Compare commits

...
71 changed files with 6448 additions and 542 deletions
+11 -3
View File
@@ -966,6 +966,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -975,6 +976,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -5812,7 +5814,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6642,8 +6644,6 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -7450,6 +7450,12 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8156,6 +8162,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
@@ -84,12 +84,15 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true) =>
resolve: (spec, install = true, revision) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
install
? npm.add(spec, { subpaths: ["tui"], revision })
: npm.resolve(spec, { subpaths: ["tui"], revision }),
),
check: (spec) => runPromise(npm.check(spec)),
update: (spec) => runPromise(npm.update(spec, { subpaths: ["tui"] })),
reload: (spec, options) => runPromise(npm.reload(spec, { subpaths: ["tui"], ...options })),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
+24
View File
@@ -88,8 +88,32 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export type PluginReloadInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginReloadOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginReloadOperation<E = never> = (input: PluginReloadInput) => Effect.Effect<PluginReloadOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
readonly reload: PluginReloadOperation<E>
}
export type SessionListInput = {
+33 -1
View File
@@ -15,6 +15,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -316,7 +322,33 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginReload = (raw: RawClient["server.plugin"]) => (input: PluginReloadInput) =>
preserveEffect<PluginReloadOutput>()(
raw["plugin.reload"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
reload: EndpointPluginReload(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -460,6 +466,45 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
reload: (input: PluginReloadInput, requestOptions?: RequestOptions) =>
request<PluginReloadOutput>(
{
method: "POST",
path: `/api/plugin/reload`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+60 -2
View File
@@ -16,6 +16,8 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -433,8 +435,24 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
| {
id: string
source: PluginSource
status: "active"
tui: boolean
revision?: string
generation?: string
error?: string
}
| {
id?: string
source: PluginSource
status: "failed"
error: string
tui: boolean
revision?: string
generation?: string
}
export type SessionMessageLocationSwitched = {
id: string
@@ -2330,6 +2348,10 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginOperationError = { readonly _tag: "PluginOperationError"; readonly message: string }
export const isPluginOperationError = (value: unknown): value is PluginOperationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginOperationError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2540,6 +2562,42 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginReloadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginReloadOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+2
View File
@@ -23,6 +23,7 @@ import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginExecution } from "./plugin/execution.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
@@ -67,6 +68,7 @@ const nodes = [
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginExecution.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
+107 -44
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -26,6 +26,7 @@ import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
import { PluginExecution } from "./plugin/execution.js"
export interface Interface {
readonly activate: (
@@ -38,6 +39,8 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly revision?: string
readonly generation?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -47,8 +50,12 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const execution = yield* PluginExecution.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<
Plugin.ID,
{ readonly plugin: Versioned; readonly scope: Scope.Closeable; readonly error?: string }
>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
@@ -75,7 +82,7 @@ const layer = Layer.effect(
"plugin.id": plugin.id,
cause: loaded.cause,
})
return { error: Cause.pretty(loaded.cause) } as const
return { error: errorMessage(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -98,55 +105,84 @@ const layer = Layer.effect(
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
const nextInventory = withFailures(
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
failures,
)
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
yield* execution.exclusive(
State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
// Registrations are append-ordered. Preserve the unchanged prefix, then rebuild the suffix.
const current = Array.from(active.values(), (entry) => entry.plugin)
const prefix = definitions.findIndex(
(definition, index) =>
definition.id !== current[index]?.id || definition.version !== current[index]?.version,
)
const keep = prefix === -1 ? definitions.length : prefix
const previousEntries = new Map(active)
const closing = Array.from(active.entries()).slice(keep).toReversed()
yield* Effect.forEach(
closing,
([id, entry]) =>
Effect.gen(function* () {
yield* Scope.close(entry.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to clean up plugin", { "plugin.id": id, cause }),
),
)
active.delete(id)
}),
{ discard: true },
)
for (const [index, definition] of definitions.entries()) {
if (index < keep) {
nextInventory.push(activeInfo(definition, active.get(definition.id)?.error))
continue
}
const previous = previousEntries.get(definition.id)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
const failed: Plugin.Info = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
...(definition.revision === undefined ? {} : { revision: definition.revision }),
...(definition.generation === undefined ? {} : { generation: definition.generation }),
}
if (!previous) {
nextInventory.push(failed)
continue
}
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope, error: loaded.error })
nextInventory.push({ ...activeInfo(previous.plugin), error: loaded.error })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
nextInventory.push(failed)
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
inventory = withFailures(nextInventory, failures)
}),
).pipe(Effect.uninterruptible),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
@@ -171,15 +207,41 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
...(plugin.generation === undefined ? {} : { generation: plugin.generation }),
...(error === undefined ? {} : { error }),
}
}
/** Inventory errors must not expose credentials or unbounded import/setup traces. */
export function errorMessage(cause: Cause.Cause<unknown>) {
return Cause.pretty(cause)
.replace(/https?:\/\/[^\s"'<>]+/g, (value) => {
if (!URL.canParse(value)) return "[URL]"
const url = new URL(value)
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return url.href
})
.slice(0, 2000)
}
function withFailures(active: Plugin.Info[], failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]) {
return failures.reduce((inventory, failure) => {
const index = inventory.findIndex((entry) => JSON.stringify(entry.source) === JSON.stringify(failure.source))
if (index === -1) return [...inventory, failure]
return inventory.map((entry, current) => (current === index ? { ...entry, error: failure.error } : entry))
}, active)
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -203,5 +265,6 @@ export const node = makeLocationNode({
WebSearch.node,
Generate.node,
Permission.node,
PluginExecution.node,
],
})
+74
View File
@@ -0,0 +1,74 @@
export * as PluginExecution from "./execution.js"
import { Context, Effect, Latch, Layer, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
export interface Interface {
readonly lease: <A, E, R>(
session: { readonly id: string; readonly parentID?: string; readonly admission?: boolean },
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R>
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginExecution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const admission = yield* Latch.make(true)
const idle = yield* Latch.make(true)
const mutations = yield* Semaphore.make(1)
const readers = new Map<string, number>()
let leases = 0
const acquire = (session: {
readonly id: string
readonly parentID?: string
readonly admission?: boolean
}): Effect.Effect<void> =>
Effect.suspend(() => {
// Child execution must finish the parent's tool call even while activation waits.
if (
!admission.isOpen() &&
!readers.has(session.id) &&
!(session.parentID && readers.has(session.parentID)) &&
!(session.admission && leases > 0)
)
return Effect.interruptible(admission.await).pipe(Effect.andThen(acquire(session)))
// Admission protects callbacks but does not establish Session execution ownership.
if (!session.admission) readers.set(session.id, (readers.get(session.id) ?? 0) + 1)
leases++
return idle.close
})
return Service.of({
lease: (session, effect) =>
Effect.acquireUseRelease(
acquire(session),
() => effect,
() =>
Effect.suspend(() => {
leases--
if (!session.admission) {
const count = (readers.get(session.id) ?? 1) - 1
if (count === 0) readers.delete(session.id)
else readers.set(session.id, count)
}
return leases === 0 ? idle.open : Effect.void
}),
),
exclusive: (effect) =>
mutations.withPermit(
Effect.uninterruptibleMask((restore) =>
admission.close.pipe(
Effect.andThen(restore(idle.await).pipe(Effect.andThen(restore(effect)))),
Effect.ensuring(admission.open),
),
),
),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+5
View File
@@ -4,6 +4,7 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { OperationError } from "@opencode-ai/schema/plugin"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -327,6 +328,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
// In-process management can wait on its own setup/tool lease. Use an external client instead.
check: () => Effect.fail(new OperationError({ message: "Check plugin updates through an external client." })),
update: () => Effect.fail(new OperationError({ message: "Update plugins through an external client." })),
reload: () => Effect.fail(new OperationError({ message: "Reload plugins through an external client." })),
},
reference: {
list: () => response(reference.list()),
+15 -6
View File
@@ -36,15 +36,22 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
fresh?: Npm.EntryPoint,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
const resolved =
fresh ??
(path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined, generation: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true }))
const entrypoint = resolved.entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`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 source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
const target =
typeof Bun !== "undefined" && path.isAbsolute(operation.target)
? operation.target.replaceAll("\\", "/")
: entrypoint
const source = fresh || 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 value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
@@ -53,7 +60,9 @@ export const load = Effect.fn("PluginModule.load")(function* (
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
version: JSON.stringify([operation, resolved.generation]),
revision: resolved.revision,
generation: resolved.generation,
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,6 +1,7 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -9,6 +10,11 @@ import { Context, Effect } from "effect"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
/** Initial reconciliation barrier; it never closes again after startup settles. */
readonly initialized: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.OperationError>
readonly update: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
readonly reload: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+203 -19
View File
@@ -2,8 +2,10 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Option, Scope, Semaphore, Stream } from "effect"
import path from "path"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -15,10 +17,13 @@ import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
type Cached = { readonly operation: string; readonly plugin: Plugin.Versioned; readonly error?: string }
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
cache: Map<string, Cached>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -26,6 +31,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const previousSources = new Map(cache)
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -48,13 +54,23 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
const cached = previousSources.get(operation.target)
const key = JSON.stringify(operation)
const plugin =
cached?.operation === key
? cached.plugin
: yield* PluginModule.load(operation).pipe(
Effect.flatMap((plugin) =>
cached && plugin.id !== cached.plugin.id
? Effect.fail(new Error(`Plugin ID changed from ${cached.plugin.id} to ${plugin.id}`))
: Effect.succeed(plugin),
),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Plugin.errorMessage(cause) }),
),
),
)
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -62,9 +78,24 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
error: plugin.error,
tui: false,
})
if (cached) {
cache.set(operation.target, { ...cached, operation: key, error: plugin.error })
packages.set(operation.target, cached.plugin)
enabled.add(cached.plugin.id)
}
continue
}
failures.delete(operation.target)
if (cached?.operation === key && cached.error) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: cached.error,
tui: plugin.tui ?? false,
})
} else {
failures.delete(operation.target)
cache.set(operation.target, { operation: key, plugin })
}
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
@@ -77,7 +108,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
failures: [...failures.entries()]
.filter(([target]) => {
const plugin = packages.get(target)
return !plugin || enabled.has(plugin.id)
})
.map(([, failure]) => failure),
}
})
@@ -89,12 +125,20 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const scope = yield* Scope.Scope
const ready = yield* Latch.make()
const initialized = yield* Latch.make()
const mutations = yield* Semaphore.make(1)
const cache = new Map<string, Cached>()
// Internal definitions capture stable Location services; SDK contributions remain dynamic.
const internal = yield* PluginInternal.list()
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
const activate = Effect.fn("PluginSupervisor.activate")(function* (
operations: readonly ConfigPluginSource.Operation[],
previous: ReadonlyMap<string, Cached> = new Map(cache),
) {
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
@@ -108,12 +152,117 @@ export const layer = Layer.effect(
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations, cache).pipe(Effect.provideService(Npm.Service, npm))
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
const activated = yield* registry.activate(resolved.plugins, resolved.failures).pipe(Effect.exit)
if (activated._tag === "Failure") {
cache.clear()
previous.forEach((entry, target) => cache.set(target, entry))
return yield* Effect.failCause(activated.cause)
}
const inventory = yield* registry.list()
for (const [target, entry] of cache) {
if (!operations.some((operation) => operation.type === "add" && operation.target === target)) {
cache.delete(target)
continue
}
const active = inventory.find((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
)
if (active?.status !== "active" || !active.error) continue
const old = previous.get(target)
if (old) cache.set(target, { ...old, operation: entry.operation, error: active.error })
}
return inventory
})
const operation = Effect.fnUntraced(function* (target: string) {
const operations = yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope))
const entry = operations.findLast((entry) => entry.type === "add" && entry.target === target)
const inventory = yield* registry.list()
if (
!entry ||
entry.type !== "add" ||
target === "*" ||
target.endsWith(".*") ||
target.startsWith("opencode.") ||
(!cache.has(target) &&
!inventory.some((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
))
)
return yield* new Plugin.OperationError({ message: `Not a configured plugin source: ${target}` })
return { entry, operations }
})
const mutate = (target: string, action: "update" | "reload") =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
const selected = yield* operation(target)
if (action === "update") {
if (path.isAbsolute(target) || !(yield* npm.inspect(target)).mutable)
return yield* new Plugin.OperationError({ message: `Plugin source cannot be updated: ${target}` })
}
const previous = new Map(cache)
const generation = randomUUID()
const staged = path.isAbsolute(target)
? {
directory: path.dirname(target),
generation,
entrypoint: `${typeof Bun !== "undefined" ? target.replaceAll("\\", "/") : pathToFileURL(target).href}?reload=${generation}`,
}
: yield* action === "update"
? npm.update(target, { subpaths: ["server", ""] })
: npm.reload(target, {
subpaths: ["server", ""],
revision: cache.get(target)?.plugin.revision,
generation: cache.get(target)?.plugin.generation,
})
const loaded = yield* PluginModule.load(selected.entry, staged).pipe(
Effect.provideService(Npm.Service, npm),
Effect.exit,
)
if (loaded._tag === "Failure") {
const old = cache.get(target)
if (!old) return yield* Effect.failCause(loaded.cause)
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: Plugin.errorMessage(loaded.cause),
})
return yield* activate(selected.operations, previous)
}
const old = cache.get(target)
if (old && loaded.value.id !== old.plugin.id) {
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: `Plugin ID changed from ${old.plugin.id} to ${loaded.value.id}`,
})
return yield* activate(selected.operations, previous)
}
cache.set(target, { operation: JSON.stringify(selected.entry), plugin: loaded.value })
return yield* activate(selected.operations, previous)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: `Unable to ${action} plugin source.` }),
)
}),
)
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
@@ -130,13 +279,48 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
yield* mutations
.withPermit(
Effect.gen(function* () {
return yield* activate(yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope)))
}),
)
.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) {
yield* initialized.open
yield* ready.open
}
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({
flush: ready.await,
initialized: initialized.await,
check: (target) =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
yield* operation(target)
if (path.isAbsolute(target)) return { mutable: false }
return yield* npm.check(target)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: "Unable to check plugin source." }),
)
}),
),
update: (target) => mutate(target, "update"),
reload: (target) => mutate(target, "reload"),
})
}),
)
+114 -86
View File
@@ -41,6 +41,7 @@ import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginExecution } from "./plugin/execution.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -690,30 +691,39 @@ const layer = Layer.effect(
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
return yield* gate.lease({ id: session.id, admission: true }, generate.generate(input))
}).pipe(Effect.provide(locations.get(session.location)))
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const commands = yield* Command.Service
// Commands may await child Sessions, so they retain ownership that lets those children drain.
return yield* gate.lease(
session,
commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery: input.delivery ?? "steer",
},
}),
)
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -723,16 +733,20 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const shell = yield* Shell.Service
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
return yield* gate.lease(
{ id: input.sessionID, admission: true },
shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie),
)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -964,10 +978,14 @@ const layer = Layer.effect(
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
@@ -976,8 +994,12 @@ const layer = Layer.effect(
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
)
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
@@ -1019,59 +1041,65 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: request.sessionID, admission: true },
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
)
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
yield* plugins.initialized
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
+11 -2
View File
@@ -26,6 +26,7 @@ import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
import { PluginExecution } from "../../plugin/execution.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -40,6 +41,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const pluginExecution = yield* PluginExecution.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
@@ -89,7 +91,6 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -313,7 +314,14 @@ const layer = Layer.effect(
}
})
return Service.of({ drain })
return Service.of({
drain: Effect.fnUntraced(function* (input) {
yield* plugins.initialized
const session = yield* store.get(input.sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${input.sessionID}`))
return yield* pluginExecution.lease(session, drain(input))
}),
})
}),
)
@@ -332,5 +340,6 @@ export const node = makeLocationNode({
Snapshot.node,
ToolOutput.node,
Database.node,
PluginExecution.node,
],
})
+8 -2
View File
@@ -17,6 +17,7 @@ import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
import { PluginExecution } from "../plugin/execution.js"
const MAX_LENGTH = 100
const MAX_CONTEXT_LENGTH = 8_000
@@ -165,9 +166,14 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const gate = yield* PluginExecution.Service
const title = make({ bus, llm, context, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
generate: Effect.fnUntraced(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return
return yield* gate.lease({ id: session.id, admission: true }, title.generate(database.db, sessionID))
}),
})
}),
)
@@ -175,5 +181,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node, PluginExecution.node],
})
+106 -1
View File
@@ -19,13 +19,15 @@ import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { git } from "../fixture/git"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Npm.node]), [
[Global.node, tempGlobalLayer],
]),
)
@@ -37,6 +39,85 @@ const staticIt = testEffect(
)
describe("PluginSupervisor config", () => {
it.live(
"updates a real package graph and retains its active revision after failed import and setup",
() =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const location = yield* Location.Service
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
const agents = yield* Agent.Service
const npm = yield* Npm.Service
const repository = path.join(location.directory, "package")
const target = `git+${pathToFileURL(repository).href}#fixture-branch`
const initial = (yield* registry.list())[0]
expect(initial?.revision).toMatch(/^[0-9a-f]{40}$/)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("first")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "value.js"), `export const value = "second"`))
yield* commitPackage(repository)
const check = yield* supervisor.check(target)
expect(check.mutable).toBe(true)
expect(check.installed).toBe(initial?.revision)
expect(check.available).not.toBe(initial?.revision)
const updated = (yield* supervisor.update(target))[0]
expect(updated?.revision).toBe(check.available)
expect(updated?.generation).not.toBe(initial?.generation)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "index.js"), packagePlugin(true)))
yield* commitPackage(repository)
const failedSetup = (yield* supervisor.update(target))[0]
expect(failedSetup).toEqual({ ...updated, error: expect.stringContaining("package setup failed") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
expect((yield* npm.inspect(target)).installed).not.toBe(updated?.revision)
yield* Effect.promise(() =>
fs.writeFile(path.join(repository, "index.js"), `import "./missing.js"; ${packagePlugin()}`),
)
yield* commitPackage(repository)
const failedImport = (yield* supervisor.update(target))[0]
expect(failedImport).toEqual({ ...updated, error: expect.stringContaining("missing.js") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
const reloaded = (yield* supervisor.reload(target))[0]
expect(reloaded?.status).toBe("active")
expect(reloaded?.revision).toBe(updated?.revision)
expect(reloaded?.error).toBeUndefined()
expect(reloaded?.generation).not.toBe(updated?.generation)
}),
false,
async (directory) => {
const repository = path.join(directory, "package")
await fs.mkdir(repository)
await fs.writeFile(
path.join(repository, "package.json"),
JSON.stringify({
name: "fixture-core-plugin",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
)
await fs.writeFile(path.join(repository, "index.js"), packagePlugin())
await fs.writeFile(path.join(repository, "value.js"), `export const value = "first"`)
await git(repository, "init", "-q", "-b", "fixture-branch")
await Effect.runPromise(commitPackage(repository))
await fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
plugins: [
"-*",
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "first" } },
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "last" } },
],
}),
)
},
),
30_000,
)
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
@@ -505,6 +586,30 @@ export default Plugin.define({
`
}
function packagePlugin(fail = false) {
return `import { value } from "./value.js"
export default { id: "package-plugin", tui: true, async setup(ctx) {
await ctx.agent.transform((draft) => draft.update("package-agent", (agent) => { agent.description = value }))
${fail ? 'throw new Error("package setup failed")' : ""}
} }`
}
const commitPackage = (repository: string) =>
Effect.promise(async () => {
await git(repository, "add", ".")
await git(
repository,
"-c",
"user.name=fixture",
"-c",
"user.email=fixture@example.com",
"commit",
"-q",
"-m",
"fixture",
)
})
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
@@ -22,6 +22,7 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
import { noopPluginSupervisor } from "../fixture/plugin-supervisor"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -31,7 +32,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
deps: [],
})
@@ -314,7 +315,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return noopPluginSupervisor()
}),
),
deps: [LocationWatcherPolicy.node],
@@ -0,0 +1,11 @@
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
export const noopPluginSupervisor = (flush: Effect.Effect<unknown> = Effect.void) =>
PluginSupervisor.Service.of({
flush: Effect.asVoid(flush),
initialized: Effect.asVoid(flush),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
})
@@ -2,17 +2,20 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./plugin-supervisor"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer, LayerMap } from "effect"
import { Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
PluginExecution.layer,
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
) as Layer.Layer<LocationServices>,
),
)
+12 -11
View File
@@ -304,7 +304,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("reruns activation for Config updates during startup", () =>
itWithSdk.live("reconciles Config updates during startup without restarting unchanged plugins", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -316,18 +316,14 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.writeFile(file, "{}"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-config-reload",
effect: () =>
Effect.sync(() => ++activations.count).pipe(
Effect.flatMap((activation) =>
activation === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
Effect.andThen(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
),
),
}),
@@ -355,12 +351,17 @@ describe("LocationServiceMap", () => {
Effect.provide(context),
Effect.forkChild,
)
const initialFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.initialized).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(initialFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
expect(activations.count).toBe(2)
yield* Fiber.join(initialFiber)
expect(activations.count).toBe(1)
const inventory = yield* Plugin.Service.use((plugins) => plugins.list()).pipe(Effect.provide(context))
expect(inventory.map((plugin) => plugin.id)).toContain(Plugin.ID.make("config-effect-plugin"))
}),
),
),
+576 -7
View File
@@ -2,11 +2,12 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, withTempDir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const win = process.platform === "win32"
@@ -27,12 +28,27 @@ async function createGitFixture(directory: string) {
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
await writePackage(repository, {
name: "fixture-git-plugin",
exports: "./index.js",
type: "module",
exports: { ".": "./index.js", "./cjs": "./index.cjs" },
dependencies: { "fixture-dependency": "file:./dependency" },
})
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
await writePackage(path.join(repository, "dependency"), {
name: "fixture-dependency",
type: "module",
exports: { import: "./index.js", require: "./index.cjs" },
})
await Bun.write(
path.join(repository, "index.js"),
'import { dependency } from "fixture-dependency"\nimport { relative } from "./relative.js"\nexport default { root: true }\nexport { dependency, relative }\n',
)
await Bun.write(path.join(repository, "relative.js"), 'export const relative = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.js"), 'export const dependency = { value: "first" }\n')
await Bun.write(
path.join(repository, "index.cjs"),
'module.exports = { relative: require("./relative.cjs"), dependency: require("fixture-dependency") }\n',
)
await Bun.write(path.join(repository, "relative.cjs"), 'module.exports = { value: "first" }\n')
await Bun.write(path.join(repository, "dependency", "index.cjs"), 'module.exports = { value: "first" }\n')
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
@@ -55,6 +71,123 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function advanceGitFixture(repository: string, value: string) {
await Bun.write(path.join(repository, "relative.js"), `export const relative = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.js"),
`export const dependency = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.write(path.join(repository, "relative.cjs"), `module.exports = { value: ${JSON.stringify(value)} }\n`)
await Bun.write(
path.join(repository, "dependency", "index.cjs"),
`module.exports = { value: ${JSON.stringify(value)} }\n`,
)
await Bun.$`git -C ${repository} add .`
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${value}`
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0", "1.2.0"]) {
const root = path.join(directory, version)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, {
name: "@fixture/registry-plugin",
version,
type: "module",
exports: { ".": "./index.js", "./tui": "./index.js" },
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
})
await Bun.write(
path.join(pkg, "index.js"),
'export { relative } from "./relative.js"\nexport { dependency } from "@fixture/registry-dependency"\n',
)
await Bun.write(path.join(pkg, "relative.js"), `export const relative = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, `dependency-${version}`)
const pkg = path.join(root, "package")
await fs.mkdir(pkg, { recursive: true })
await writePackage(pkg, { name: "@fixture/registry-dependency", version, type: "module", exports: "./index.js" })
await Bun.write(path.join(pkg, "index.js"), `export const dependency = { value: ${JSON.stringify(version)} }\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(`dependency-${version}`, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = {
latest: "1.0.0",
dependency: "1.0.0",
requests: 0,
offline: false,
beforeTarball: () => Promise.resolve(),
}
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
if (request.headers.get("authorization") !== "Bearer fixture-only-token")
return new Response("unauthorized", { status: 401 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-dependency")
return Response.json({
name: "@fixture/registry-dependency",
"dist-tags": { latest: state.dependency },
versions: Object.fromEntries(
["1.0.0", ...(state.dependency === "1.1.0" ? ["1.1.0"] : [])].map((version) => [
version,
{
name: "@fixture/registry-dependency",
version,
dist: { tarball: `${url.origin}/tarball/dependency-${version}.tgz` },
},
]),
),
})
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
["1.0.0", "1.1.0", "1.2.0", "1.3.0"].map((version) => [
version,
{
name: "@fixture/registry-plugin",
version,
dependencies: { "@fixture/registry-dependency": "^1.0.0" },
dist: { tarball: `${url.origin}/tarball/${version}.tgz` },
},
]),
),
})
const tarball = tarballs.get(url.pathname.replace("/tarball/", "").replace(".tgz", ""))
if (tarball) await state.beforeTarball()
return tarball ? new Response(tarball) : new Response("missing tarball", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`registry=${server.url}\n@fixture:registry=${server.url}\n//${server.url.host}/:_authToken=fixture-only-token\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
const it = testEffect(Layer.empty)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -203,7 +336,10 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
expect(entries.added.directory).toContain(
path.join("packages", await Npm.cacheKey(spec), "generations", entries.added.generation, "node_modules"),
)
expect(entries.added.revision).toBe(fixture.commit)
}
})
@@ -221,6 +357,15 @@ describe("Npm.add", () => {
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
await advanceGitFixture(fixture.repository, "second")
const exact = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(`git+file://${fixture.repository}#fixture-branch::path:packages/subdirectory-plugin`, {
revision: fixture.commit,
})
}).pipe(Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(exact.revision).toBe(fixture.commit)
expect(exact.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
@@ -293,3 +438,427 @@ describe("Npm.resolve", () => {
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
describe("Npm generations", () => {
it.live(
"loads the cached immutable graph while an updater holds the install lock",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
fixture.state.latest = "1.1.0"
fixture.state.dependency = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.gen(function* () {
yield* Effect.promise(() => started.promise).pipe(Effect.timeout("5 seconds"))
const adding = yield* Effect.all([
npm.add(spec),
npm.add(spec, { revision: first.revision, subpaths: ["tui"] }),
npm.add(spec, { refresh: true }),
]).pipe(Effect.forkScoped)
// Timeout the join, not uninterruptible lock acquisition; release the updater before scoped cleanup.
expect(yield* Fiber.join(adding).pipe(Effect.timeout("5 seconds"))).toEqual([first, first, first])
expect(updating.pollUnsafe()).toBeUndefined()
}).pipe(Effect.ensuring(Effect.sync(() => resume.resolve())))
const updated = yield* Fiber.join(updating)
expect(updated.revision).toBe("1.1.0")
expect(updated.generation).not.toBe(first.generation)
expect(yield* npm.add(spec)).toEqual(updated)
const retained = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(retained.relative.value).toBe("1.0.0")
expect(retained.dependency.value).toBe("1.0.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"reloads the retained local generation when the same npm revision acquired a different dependency graph",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
const loaded = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(loaded.dependency.value).toBe("1.0.0")
fixture.state.dependency = "1.1.0"
const second = yield* npm.update(spec)
expect(second.revision).toBe(first.revision)
expect(second.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).dependency.value).toBe("1.1.0")
expect(yield* npm.resolve(spec, { revision: first.revision })).toEqual(second)
const foreign = yield* npm.add(pinned)
return { first, second, foreign, loaded }
}).pipe(Effect.provide(npmLayer(cache)))
fixture.state.offline = true
const requests = fixture.state.requests
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.second)
expect((yield* npm.reload(spec, { generation: "../../outside" }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect((yield* npm.reload(spec, { generation: entries.foreign.generation }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(
(yield* npm.reload(spec, { generation: entries.first.generation, revision: "1.1.0" }).pipe(Effect.flip))
._tag,
).toBe("NpmInstallFailedError")
expect(yield* npm.resolve(spec)).toEqual(entries.second)
const reloaded = yield* npm.reload(spec, {
generation: entries.first.generation,
revision: entries.first.revision,
})
expect(reloaded.revision).toBe(entries.first.revision)
expect(reloaded.generation).not.toBe(entries.first.generation)
const fresh = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(fresh.dependency.value).toBe("1.0.0")
expect(fresh.dependency).not.toBe(entries.loaded.dependency)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(fixture.state.requests).toBe(requests)
expect(
yield* Effect.promise(() =>
Bun.file(path.join(root, "generations", entries.first.generation, "generation.json")).json(),
),
).toEqual({ name: "@fixture/registry-plugin", generation: entries.first.generation, revision: "1.0.0" })
expect(
yield* Effect.promise(() =>
Bun.file(path.join(path.dirname(entries.second.directory), "registry-dependency", "index.js")).text(),
),
).toContain('"1.1.0"')
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"defers cancellation through publication without removing the committed generation",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec)
fixture.state.latest = "1.1.0"
const started = Promise.withResolvers<void>()
const resume = Promise.withResolvers<void>()
fixture.state.beforeTarball = () => {
started.resolve()
return resume.promise
}
const updating = yield* npm.update(spec).pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
updating.interruptUnsafe()
expect(updating.pollUnsafe()).toBeUndefined()
resume.resolve()
const exit = yield* Fiber.await(updating)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
// The pending interrupt is observed after publication, outside staging's cleanup boundary.
const published = yield* npm.resolve(spec)
expect(published.revision).toBe("1.1.0")
expect(published.generation).not.toBe(first.generation)
expect((yield* Effect.promise(() => import(published.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
expect((yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).length).toBe(2)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"finds binaries in published generations and keeps their old files usable after reload",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = path.join(tmp.path, "binary")
yield* Effect.promise(async () => {
await fs.mkdir(fixture)
await writePackage(fixture, {
name: "fixture-bin",
bin: { "fixture-bin": "./cli.js", "fixture-other": "./other.js" },
})
await Bun.write(path.join(fixture, "cli.js"), '#!/usr/bin/env node\nconsole.log(require("./value.cjs"))\n')
await Bun.write(path.join(fixture, "value.cjs"), 'module.exports = "fixture"\n')
await Bun.write(path.join(fixture, "other.js"), '#!/usr/bin/env node\nconsole.log("other")\n')
})
const spec = `fixture-bin@file:${fixture}`
const cache = path.join(tmp.path, "cache")
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const binary = yield* npm.which(spec)
expect(binary).toEndWith(path.join(".bin", "fixture-bin"))
expect(yield* npm.which(spec, "fixture-other")).toEndWith(path.join(".bin", "fixture-other"))
expect(yield* npm.which(spec, "missing")).toBeUndefined()
const original = yield* npm.resolve(spec)
const reloaded = yield* npm.reload(spec)
expect(reloaded.generation).not.toBe(original.generation)
const fresh = yield* npm.which(spec)
expect(fresh).not.toBe(binary)
expect(fresh).toContain(reloaded.generation)
expect(yield* Effect.promise(() => Bun.file(binary ?? "").text())).toContain("value.cjs")
expect(yield* Effect.promise(() => Bun.file(fresh ?? "").text())).toContain("value.cjs")
if (!win)
yield* Effect.promise(async () => {
const child = Bun.spawn([process.execPath, fresh ?? ""], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
expect(await new Response(child.stdout).text()).toBe("fixture\n")
})
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
)
it.live(
"checks without changing files, updates repeatedly, preserves pins, and reloads the whole Git graph offline",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const pinned = `git+${pathToFileURL(fixture.repository).href}#${fixture.commit}`
const entries = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
expect(firstModule.relative.value).toBe("first")
expect(firstModule.dependency.value).toBe("first")
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
expect(yield* npm.check(spec)).toEqual({ installed: fixture.commit, available: secondCommit, mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
expect(yield* npm.add(spec, { refresh: true })).toEqual(first)
const second = yield* npm.update(spec)
expect(second.revision).toBe(secondCommit)
const secondModule = yield* Effect.promise(() => import(second.entrypoint ?? ""))
expect(secondModule.relative.value).toBe("second")
expect(secondModule.dependency.value).toBe("second")
expect(second.generation).not.toBe(first.generation)
const thirdCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
const third = yield* npm.update(spec)
expect(third.revision).toBe(thirdCommit)
const thirdModule = yield* Effect.promise(() => import(third.entrypoint ?? ""))
expect(thirdModule.relative.value).toBe("third")
expect(thirdModule.dependency.value).toBe("third")
const thirdCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const thirdCommonJs = yield* Effect.promise(() => import(thirdCjs.entrypoint ?? ""))
expect(thirdCommonJs.default.relative.value).toBe("third")
expect(thirdCommonJs.default.dependency.value).toBe("third")
expect(yield* npm.check(pinned)).toEqual({
installed: fixture.commit,
available: fixture.commit,
mutable: false,
})
expect(yield* npm.update(pinned)).toEqual(pin)
const companionCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "companion"))
const companion = yield* npm.add(spec, { revision: companionCommit })
expect(companion.revision).toBe(companionCommit)
expect((yield* Effect.promise(() => import(companion.entrypoint ?? ""))).dependency.value).toBe("companion")
expect(yield* npm.resolve(spec)).toEqual(third)
expect(yield* npm.resolve(spec, { revision: companionCommit })).toEqual(companion)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(third)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe(thirdCommit)
expect(reloaded.generation).not.toBe(third.generation)
const reloadedModule = yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))
expect(reloadedModule.relative).toEqual(thirdModule.relative)
expect(reloadedModule.relative).not.toBe(thirdModule.relative)
expect(reloadedModule.dependency).not.toBe(thirdModule.dependency)
const freshCjs = yield* npm.resolve(spec, { subpaths: ["cjs"] })
const freshCommonJs = yield* Effect.promise(() => import(freshCjs.entrypoint ?? ""))
expect(freshCommonJs.default.relative).not.toBe(thirdCommonJs.default.relative)
expect(freshCommonJs.default.dependency).not.toBe(thirdCommonJs.default.dependency)
const exactReload = yield* npm.reload(spec, { revision: companionCommit })
expect(exactReload.revision).toBe(companionCommit)
expect(yield* npm.resolve(spec)).toEqual(reloaded)
expect(yield* npm.update(pinned)).toEqual(pin)
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"first"',
)
expect(yield* Effect.promise(() => Bun.file(path.join(second.directory, "relative.js")).text())).toContain(
'"second"',
)
return { first, third, reloaded, exactReload, thirdCjs, freshCjs }
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(entries.reloaded)
expect(yield* npm.inspect(spec)).toEqual({ installed: entries.third.revision, mutable: true })
expect(yield* npm.resolve(spec, { revision: entries.exactReload.revision })).toEqual(entries.exactReload)
}).pipe(Effect.provide(npmLayer(cache)))
// Exercise the same realpaths in Node's ESM loader as well as Bun's loader above.
const node = Bun.which("node")
if (node)
yield* Effect.promise(async () => {
const script = `
import assert from "node:assert/strict";
const first = await import(${JSON.stringify(entries.first.entrypoint)});
const third = await import(${JSON.stringify(entries.third.entrypoint)});
const fresh = await import(${JSON.stringify(entries.reloaded.entrypoint)});
assert.equal(first.relative.value, "first");
assert.equal(third.relative.value, "third");
assert.equal(third.dependency.value, "third");
assert.notEqual(fresh.relative, third.relative);
assert.notEqual(fresh.dependency, third.dependency);
const thirdCjs = await import(${JSON.stringify(entries.thirdCjs.entrypoint)});
const freshCjs = await import(${JSON.stringify(entries.freshCjs.entrypoint)});
assert.equal(thirdCjs.default.relative.value, "third");
assert.equal(thirdCjs.default.dependency.value, "third");
assert.notEqual(freshCjs.default.relative, thirdCjs.default.relative);
assert.notEqual(freshCjs.default.dependency, thirdCjs.default.dependency);
`
const child = Bun.spawn([node, "--input-type=module", "-e", script], { stdout: "pipe", stderr: "pipe" })
expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
})
}),
),
30_000,
)
it.live(
"auto-refreshes a legacy Git install once without overwriting it",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.promise(() => createGitFixture(tmp.path))
const cache = path.join(tmp.path, "cache")
const spec = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const root = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(spec)))
const initial = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.promise(async () => {
// Recreate the shipped pre-generation cache layout, including its real npm lockfile.
await fs.cp(path.join(root, "generations", initial.generation), root, { recursive: true, dereference: true })
await fs.rm(path.join(root, "current.json"))
await fs.rm(path.join(root, "revisions"), { recursive: true })
await fs.rm(path.join(root, "generations"), { recursive: true })
})
const secondCommit = yield* Effect.promise(() => advanceGitFixture(fixture.repository, "second"))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
const legacy = yield* npm.resolve(spec)
expect(legacy.revision).toBe(fixture.commit)
expect(legacy.generation).toStartWith("legacy-")
const updated = yield* npm.add(spec, { refresh: true })
expect(updated.revision).toBe(secondCommit)
expect(updated.directory).not.toBe(legacy.directory)
yield* Effect.promise(() => advanceGitFixture(fixture.repository, "third"))
expect(yield* npm.add(spec, { refresh: true })).toEqual(updated)
expect(yield* Effect.promise(() => Bun.file(path.join(legacy.directory, "relative.js")).text())).toContain(
'"first"',
)
yield* Effect.promise(() => fs.rename(fixture.repository, `${fixture.repository}-offline`))
const reloaded = yield* npm.reload(spec, { generation: legacy.generation, revision: legacy.revision })
expect(reloaded.revision).toBe(fixture.commit)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).dependency.value).toBe("first")
expect(yield* npm.resolve(spec)).toEqual(updated)
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
it.live(
"uses npm configuration, stages exact registry revisions, and retains the old graph after a failed tarball",
withTempDir((tmp) =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(
Effect.promise(() => createRegistryFixture(tmp.path)),
(fixture) => Effect.promise(() => fixture[Symbol.asyncDispose]()),
)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = yield* Effect.promise(() => fixture.configure(cache, spec))
yield* Effect.promise(() => fixture.configure(cache, pinned))
const updated = yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.inspect(spec)).toEqual({ installed: undefined, mutable: true })
expect(yield* npm.check(spec)).toEqual({ installed: undefined, available: "1.0.0", mutable: true })
expect((yield* npm.resolve(spec)).entrypoint).toBeUndefined()
const first = yield* npm.add(spec, { refresh: true })
const pin = yield* npm.add(pinned)
const firstModule = yield* Effect.promise(() => import(first.entrypoint ?? ""))
fixture.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.resolve(spec)).toEqual(first)
const second = yield* npm.update(spec, { subpaths: [""] })
expect(second.revision).toBe("1.1.0")
expect((yield* Effect.promise(() => import(second.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect((yield* npm.update(spec, { subpaths: ["missing"] }).pipe(Effect.flip))._tag).toBe(
"NpmInstallFailedError",
)
expect(yield* npm.resolve(spec)).toEqual(second)
const companion = yield* npm.add(spec, { revision: "1.2.0", subpaths: ["tui"] })
expect(companion.revision).toBe("1.2.0")
expect(yield* npm.resolve(spec)).toEqual(second)
fixture.state.latest = "1.3.0"
expect((yield* npm.check(spec)).available).toBe("1.3.0")
const before = yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))
expect((yield* npm.update(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect(yield* Effect.promise(() => fs.readdir(path.join(root, "generations")))).toEqual(before)
expect(yield* npm.resolve(spec)).toEqual(second)
expect((yield* npm.add(spec, { revision: "latest" }).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
const requests = fixture.state.requests
fixture.state.offline = true
expect(yield* npm.check(pinned)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: false })
expect(yield* npm.update(pinned)).toEqual(pin)
const reloaded = yield* npm.reload(spec)
expect(reloaded.revision).toBe("1.1.0")
expect(fixture.state.requests).toBe(requests)
expect((yield* Effect.promise(() => import(reloaded.entrypoint ?? ""))).relative.value).toBe("1.1.0")
expect(firstModule.relative.value).toBe("1.0.0")
expect(yield* Effect.promise(() => Bun.file(path.join(first.directory, "relative.js")).text())).toContain(
'"1.0.0"',
)
return reloaded
}).pipe(Effect.provide(npmLayer(cache)))
yield* Effect.gen(function* () {
const npm = yield* Npm.Service
expect(yield* npm.resolve(spec)).toEqual(updated)
expect((yield* npm.resolve(spec, { revision: "1.2.0" })).revision).toBe("1.2.0")
}).pipe(Effect.provide(npmLayer(cache)))
}),
),
30_000,
)
})
+181 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Cause, Context, Effect, Exit, Fiber, Latch, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -8,6 +8,8 @@ import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,6 +27,178 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("closes the entire suffix after cleanup defects without leaving old hooks behind", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* PluginHooks.Service
const closed: string[] = []
const observed: string[] = []
const definition = (id: string, version: string, broken = false) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
yield* ctx.session.hook("prompt", () =>
Effect.sync(() => {
observed.push(`${id}${version}`)
}),
)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed.push(`${id}${version}`)
if (broken) throw new Error("cleanup failed")
}),
)
}),
})
const first = [definition("a", "1"), definition("b", "1"), definition("c", "1", true)]
yield* plugins.activate(first)
yield* plugins.activate([definition("a", "2"), ...first.slice(1)])
expect(closed).toEqual(["c1", "b1", "a1"])
expect((yield* plugins.list()).map((plugin) => [plugin.id, plugin.status])).toEqual(
["a", "b", "c"].map((id) => [Plugin.ID.make(id), "active"]),
)
yield* plugins.activate([definition("a", "2"), definition("b", "2"), definition("c", "2")])
yield* registry.trigger("session", "prompt", {
sessionID: Session.ID.make("ses_cleanup"),
messageID: SessionMessage.ID.make("msg_cleanup"),
prompt: { text: "hello" },
delivery: "steer",
metadata: undefined,
})
expect(observed).toEqual(["a2", "b2", "c2"])
expect(closed).toEqual(["c1", "b1", "a1", "c1", "b1"])
yield* plugins.activate([])
expect(closed.slice(-3)).toEqual(["c2", "b2", "a2"])
expect(yield* plugins.list()).toEqual([])
}),
)
it.effect("does not gate an unchanged generation while a Session lease is held", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const gate = yield* PluginExecution.Service
const definitions = [{ id: "unchanged", version: "1", effect: () => Effect.void }]
yield* plugins.activate(definitions)
yield* gate.lease({ id: "running" }, plugins.activate(definitions))
expect((yield* plugins.list())[0]?.status).toBe("active")
}),
)
it.effect("bounds inventory errors and removes URL credentials and query strings", () =>
Effect.sync(() => {
const error = Plugin.errorMessage(
Cause.die(new Error(`https://user:password@example.com/plugin?token=secret ${"x".repeat(3000)}`)),
)
expect(error.length).toBeLessThanOrEqual(2000)
expect(error).toContain("https://example.com/plugin")
expect(error).not.toContain("password")
expect(error).not.toContain("token=secret")
}),
)
it.effect("keeps the unchanged registration prefix and preserves suffix ordering", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const loads: string[] = []
const closed: string[] = []
const definition = (id: string, version: string) => ({
id,
version,
effect: (ctx: EffectPlugin.Context) =>
Effect.gen(function* () {
loads.push(`${id}:${version}`)
yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(id)))
yield* ctx.agent.transform((draft) =>
draft.update("ordered", (agent) => {
agent.description = `${agent.description ?? ""}${id}:${version};`
}),
)
}),
})
const prefix = definition("prefix", "1")
const suffix = definition("suffix", "1")
yield* plugins.activate([prefix, definition("target", "1"), suffix])
yield* plugins.activate([prefix, definition("target", "2"), suffix])
expect(loads).toEqual(["prefix:1", "target:1", "suffix:1", "target:2", "suffix:1"])
expect(closed).toEqual(["suffix", "target"])
expect((yield* agents.get(Agent.ID.make("ordered")))?.description).toBe("prefix:1;target:2;suffix:1;")
}),
)
it.effect("does not close plugin resources while a leased snapshot executes", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const gate = yield* PluginExecution.Service
const started = yield* Latch.make()
const finish = yield* Latch.make()
let closed = false
yield* plugins.activate([
{
id: "leased",
version: "1",
revision: "1.0.0",
generation: "first",
tui: true,
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
}),
)
yield* ctx.tool.transform((draft) =>
draft.add({
name: "leased",
description: "Leased tool",
input: Schema.Struct({}),
options: { codemode: false },
execute: () =>
Effect.gen(function* () {
yield* started.open
yield* finish.await
expect(closed).toBe(false)
return { content: "first" }
}),
}),
)
}),
},
])
const running = yield* gate
.lease(
{ id: "ses_running" },
Effect.gen(function* () {
const snapshot = yield* tools.snapshot()
return yield* snapshot.execute({
sessionID: Session.ID.make("ses_running"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_message"),
call: { type: "tool-call", id: "call", name: "leased", input: {} },
})
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* started.await
const replacement = yield* plugins
.activate([
{
id: "leased",
version: "2",
revision: "2.0.0",
generation: "second",
effect: () => Effect.void,
},
])
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(closed).toBe(false)
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* finish.open
yield* Fiber.join(running)
yield* Fiber.join(replacement)
expect(closed).toBe(true)
expect((yield* plugins.list())[0]?.revision).toBe("2.0.0")
}),
)
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -400,16 +574,18 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
yield* plugins.activate([{ ...versioned(previous), revision: "1.0.0", generation: "previous", tui: true }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0", generation: "replacement" }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
status: "active",
error: expect.stringContaining("replacement failed"),
tui: false,
tui: true,
revision: "1.0.0",
generation: "previous",
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
+152
View File
@@ -0,0 +1,152 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch } from "effect"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginExecution.layer)
it.effect("defers activation through awaited children and titles, blocking unrelated selections", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const childAllowed = yield* Latch.make()
const titleFinished = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease(
{ id: "parent" },
Effect.gen(function* () {
yield* childAllowed.await
yield* gate.lease(
{ id: "child", parentID: "parent" },
Effect.gen(function* () {
yield* gate.lease(
{ id: "grandchild", parentID: "child" },
Effect.sync(() => events.push("child")),
)
}),
)
yield* gate.lease(
{ id: "new-prompt", admission: true },
Effect.sync(() => events.push("admitted")),
)
events.push("parent")
}),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const title = yield* gate
.lease(
{ id: "parent", admission: true },
titleFinished.await.pipe(Effect.andThen(Effect.sync(() => events.push("title")))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const activation = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("selection")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* childAllowed.open
yield* Fiber.join(parent)
expect(events).toEqual(["child", "admitted", "parent"])
yield* titleFinished.open
yield* Fiber.join(title)
yield* Fiber.join(activation)
yield* Fiber.join(unrelated)
expect(events).toEqual(["child", "admitted", "parent", "title", "activation", "selection"])
}),
)
it.effect("releases reader leases and pending admission on interruption", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const reader = yield* gate.lease({ id: "reader" }, Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate.exclusive(Effect.never).pipe(Effect.forkScoped({ startImmediately: true }))
const waiting = yield* gate
.lease({ id: "waiting" }, Effect.never)
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.interrupt(waiting)
yield* Fiber.interrupt(writer)
expect(yield* gate.lease({ id: "new" }, Effect.succeed("open"))).toBe("open")
yield* Fiber.interrupt(reader)
expect(yield* gate.exclusive(Effect.succeed("idle"))).toBe("idle")
}),
)
it.effect("does not let admission-only work establish same-Session or child execution ownership", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishParent = yield* Latch.make()
const finishAdmission = yield* Latch.make()
const events: string[] = []
const parent = yield* gate
.lease({ id: "parent" }, finishParent.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const admission = yield* gate
.lease({ id: "unrelated", admission: true }, finishAdmission.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const unrelated = yield* gate
.lease(
{ id: "unrelated" },
Effect.sync(() => events.push("unrelated")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "unrelated" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishParent.open
yield* Fiber.join(parent)
expect(events).toEqual([])
yield* finishAdmission.open
yield* Fiber.join(admission)
yield* Fiber.join(writer)
yield* Fiber.join(unrelated)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "unrelated"])
}),
)
it.effect("a title-only helper protects its resources without allowing an idle Session or child to drain", () =>
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
const finishTitle = yield* Latch.make()
const events: string[] = []
const title = yield* gate
.lease({ id: "idle", admission: true }, finishTitle.await)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const session = yield* gate
.lease(
{ id: "idle" },
Effect.sync(() => events.push("session")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const child = yield* gate
.lease(
{ id: "child", parentID: "idle" },
Effect.sync(() => events.push("child")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* finishTitle.open
yield* Fiber.join(title)
yield* Fiber.join(writer)
yield* Fiber.join(session)
yield* Fiber.join(child)
expect(events[0]).toBe("activation")
expect(events.slice(1).toSorted()).toEqual(["child", "session"])
}),
)
+8 -2
View File
@@ -19,6 +19,7 @@ import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
@@ -35,8 +36,12 @@ 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: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
which: () => Effect.undefined,
}),
)
@@ -78,6 +83,7 @@ export const PluginTestLayer = LayerNode.compile(
PluginRuntime.node,
Permission.node,
PluginHooks.node,
PluginExecution.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
+3
View File
@@ -98,6 +98,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
@@ -22,8 +22,12 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
add: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
which: () => Effect.undefined,
})
}
@@ -13,8 +13,12 @@ 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: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined, generation: "test" }),
inspect: () => Effect.succeed({ mutable: false }),
check: () => Effect.succeed({ mutable: false }),
update: () => Effect.die("unused npm.update"),
reload: () => Effect.die("unused npm.reload"),
which: () => Effect.undefined,
})
@@ -0,0 +1,129 @@
import { expect } from "bun:test"
import { Effect, Fiber, Latch, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { App } from "@opencode-ai/core/app"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Session.node,
SessionProjector.node,
LocationServiceMap.node,
PluginRuntime.providerNode,
PluginRuntime.node,
KV.node,
App.node,
]),
[
[Global.node, tempGlobalLayer],
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, SessionExecution.noopLayer],
[
SessionGenerateNode.node,
Layer.succeed(
SessionGenerate.Service,
SessionGenerate.Service.of({
generate: () => Effect.succeed("generated"),
}),
),
],
],
),
)
for (const mode of ["Effect", "Promise"]) {
it.live(`${mode} prompt hooks can await auxiliary Session generation without granting drain ownership`, () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-reentry-")))
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const session = yield* sessions.create({ location: ref })
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const registry = yield* Plugin.Service
const host = yield* PluginHost.make(registry)
const entered = yield* Latch.make()
const proceed = yield* Latch.make()
const events: string[] = []
if (mode === "Effect") {
yield* host.session.hook(
"prompt",
(event) =>
Effect.gen(function* () {
yield* entered.open
yield* proceed.await
events.push(
(yield* host.session
.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" })
.pipe(Effect.orDie)).text,
)
}),
undefined,
)
}
if (mode === "Promise") {
yield* PluginPromise.fromPromise({
id: "nested-generation",
setup: async (ctx) => {
await ctx.session.hook("prompt", async (event) => {
await Effect.runPromise(entered.open)
await Effect.runPromise(proceed.await)
events.push(
(await ctx.session.generate({ sessionID: event.sessionID, prompt: "Generate inside admission" }))
.text,
)
})
},
}).effect(host)
}
const preparing = yield* sessions
.prompt({ sessionID: session.id, text: "hello", resume: false })
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => proceed.open)
yield* entered.await
const writer = yield* gate
.exclusive(Effect.sync(() => events.push("activation")))
.pipe(Effect.forkScoped({ startImmediately: true }))
const drain = yield* gate
.lease(
{ id: session.id },
Effect.sync(() => events.push("drain")),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(events).toEqual([])
yield* proceed.open
yield* Fiber.join(preparing)
yield* Fiber.join(writer)
yield* Fiber.join(drain)
expect(events).toEqual(["generated", "activation", "drain"])
}).pipe(Effect.provide(locations.get(ref)))
}),
)
}
+26 -2
View File
@@ -46,9 +46,10 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Tool } from "@opencode-ai/core/tool"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { Effect, Fiber, Latch, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
@@ -111,7 +112,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.never, initialized: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
@@ -139,6 +140,7 @@ const it = testEffect(
Agent.node,
InstructionBuiltIns.node,
PluginHooks.node,
PluginExecution.node,
SessionGenerateNode.node,
]),
[
@@ -225,6 +227,28 @@ const setup = Effect.gen(function* () {
return { db, bus, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates under a held Session lease while config reconciliation is pending", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
yield* setup
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
const proceed = yield* Latch.make()
const running = yield* gate
.lease(
{ id: sessionID },
proceed.await.pipe(Effect.andThen(generate.generate({ sessionID, prompt: "Generate with active plugins" }))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const writer = yield* gate.exclusive(Effect.void).pipe(Effect.forkScoped({ startImmediately: true }))
yield* proceed.open
expect(yield* Fiber.join(running)).toBe("Transient answer")
yield* Fiber.join(writer)
expect(requests).toHaveLength(1)
}),
)
it.effect(
"generates from fresh settled Session context without durable mutation",
() =>
+5 -6
View File
@@ -1,4 +1,6 @@
import { describe, expect } from "bun:test"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { tmpdir } from "os"
@@ -68,6 +70,7 @@ const locations = Layer.effect(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
PluginExecution.layer,
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
@@ -78,13 +81,9 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor(Effect.sync(() => (ready = true)))),
)
}),
) as unknown as Layer.Layer<LocationServices>,
@@ -33,6 +33,7 @@ import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
@@ -85,7 +86,7 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor())
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
+45 -6
View File
@@ -48,6 +48,8 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
import { SessionUsage } from "@opencode-ai/core/session/usage"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
@@ -206,6 +208,7 @@ const makeRunnerState = () => {
systemLoadHook: Effect.void,
skillBaselines: new Map<Agent.ID, string>(),
pluginFlushHook: Effect.void,
pluginConfigFlushHook: Effect.void,
authorizations: new Array<Tool.Context>(),
executions: new Array<string>(),
closedTransports: new Array<Session.ID>(),
@@ -386,12 +389,10 @@ const layer = Layer.unwrap(
}),
}),
])
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
}),
)
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, {
...noopPluginSupervisor(Effect.suspend(() => state.pluginFlushHook)),
flush: Effect.suspend(() => state.pluginConfigFlushHook),
})
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -464,6 +465,7 @@ const layer = Layer.unwrap(
Catalog.node,
Tool.node,
PluginHooks.node,
PluginExecution.node,
echoNode,
SessionRunnerModel.node,
InstructionBuiltIns.node,
@@ -4306,6 +4308,43 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
scenario("defers plugin activation through retry backoff and blocks a new Session drain", function* (s) {
const gate = yield* PluginExecution.Service
yield* s.admit("Retry before updating plugins")
yield* insertSession(otherSessionID)
yield* s.session.prompt({ sessionID: otherSessionID, text: "Wait for replacement", resume: false })
yield* s.llm.push(
Stream.fail(providerUnavailable()),
TestLLM.text("Recovered", "text-retry"),
TestLLM.text("New generation", "text-new"),
)
const scheduled = yield* s.bus
.subscribe(SessionEvent.RetryScheduled)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const running = yield* s.resume.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.join(scheduled)
const reconciled = yield* Deferred.make<void>()
s.pluginConfigFlushHook = Deferred.await(reconciled)
let activated = false
const activation = yield* gate
.exclusive(
Effect.sync(() => {
activated = true
}).pipe(Effect.andThen(Deferred.succeed(reconciled, undefined))),
)
.pipe(Effect.forkScoped({ startImmediately: true }))
const waiting = yield* s.session.resume(otherSessionID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(activated).toBe(false)
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("3 seconds")
yield* Fiber.join(running)
yield* Fiber.join(activation)
yield* Fiber.join(waiting)
expect(activated).toBe(true)
expect(s.requests).toHaveLength(3)
})
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
yield* s.admit("Interrupt retry backoff")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
+4 -1
View File
@@ -11,6 +11,8 @@ import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -33,12 +35,13 @@ const info = Skill.Info.make({
content: "Use Effect",
})
const skills = Layer.mergeAll(
PluginExecution.layer,
LayerNode.compile(PluginHooks.node),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+1 -1
View File
@@ -130,7 +130,7 @@ const it = testEffect(
[Catalog.node, catalog],
[SessionRunnerModel.node, models],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void, initialized: Effect.void })],
],
),
)
+2 -1
View File
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
@@ -127,7 +128,7 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(noopPluginSupervisor())),
),
deps: [
Config.node,
+2 -1
View File
@@ -25,6 +25,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { noopPluginSupervisor } from "./fixture/plugin-supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
@@ -100,7 +101,7 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(noopPluginSupervisor())),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+3
View File
@@ -283,6 +283,9 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
update: adaptApiMethod(PluginEndpoints["plugin.update"], host.plugin.update),
reload: adaptApiMethod(PluginEndpoints["plugin.reload"], host.plugin.reload),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
+515 -3
View File
@@ -486,6 +486,363 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check the configured plugin source for a newer compatible revision without activating it.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Update a mutable configured plugin source and activate it after running work finishes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/reload": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.reload",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Reload a configured plugin source without network access after running work finishes.",
"summary": "Reload plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -977,6 +1334,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1355,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -10074,6 +10441,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15414,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15927,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16307,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -16042,6 +16506,15 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["id", "source", "status", "tui"],
@@ -16065,6 +16538,12 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
}
},
"required": ["source", "status", "error", "tui"],
@@ -16072,6 +16551,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16126,6 +16621,20 @@
}
]
},
"PluginOperationErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginOperationError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16222,7 +16731,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -17040,6 +17549,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
+45 -1
View File
@@ -1,7 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PluginGroup = HttpApiGroup.make("server.plugin")
@@ -19,6 +19,50 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Plugin.PackageStatus),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin updates",
description: "Check the configured plugin source for a newer compatible revision without activating it.",
}),
),
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Schema.Array(Plugin.Info)),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Update a mutable configured plugin source and activate it after running work finishes.",
}),
),
HttpApiEndpoint.post("plugin.reload", "/api/plugin/reload", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Schema.Array(Plugin.Info)),
error: Plugin.OperationError.pipe(HttpApiSchema.status(400)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.reload",
summary: "Reload plugin",
description: "Reload a configured plugin source without network access after running work finishes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
+22
View File
@@ -21,6 +21,13 @@ export const Info = Schema.Union([
source: Source,
status: Schema.Literal("active"),
tui: Schema.Boolean,
revision: optional(Schema.String).annotate({ description: "Loaded root package version or full Git commit." }),
generation: optional(Schema.String).annotate({
description: "Opaque loaded module graph identity, local to this runtime.",
}),
error: optional(Schema.String).annotate({
description: "The last replacement failed; the reported revision is still active.",
}),
}),
Schema.Struct({
id: ID.pipe(optional),
@@ -28,10 +35,25 @@ export const Info = Schema.Union([
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
revision: optional(Schema.String).annotate({ description: "Registered root package version or full Git commit." }),
generation: optional(Schema.String).annotate({
description: "Opaque registered module graph identity, local to this runtime.",
}),
}),
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
export const PackageStatus = Schema.Struct({
installed: optional(Schema.String),
available: optional(Schema.String),
mutable: Schema.Boolean,
}).annotate({ identifier: "Plugin.PackageStatus" })
export class OperationError extends Schema.TaggedError<OperationError>()("PluginOperationError", {
message: Schema.String,
}) {}
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("plugin inventory distinguishes active code from a failed replacement", () => {
const info = Schema.decodeUnknownSync(Plugin.Info)({
id: "example.plugin",
source: { type: "package", package: "example-plugin@latest" },
status: "active",
tui: true,
revision: "1.0.0",
generation: "generation-one",
error: "Replacement setup failed",
})
expect(info.status).toBe("active")
expect(info.revision).toBe("1.0.0")
expect(info.error).toBe("Replacement setup failed")
expect(Schema.encodeSync(Plugin.Info)(info)).toEqual(info)
})
test("plugin revision fields remain optional for existing inventories", () => {
expect(
Schema.encodeSync(Plugin.Info)({
id: Plugin.ID.make("example.plugin"),
source: { type: "local", path: "/plugins/example.ts" },
status: "active",
tui: false,
revision: undefined,
generation: undefined,
error: undefined,
}),
).toEqual({
id: "example.plugin",
source: { type: "local", path: "/plugins/example.ts" },
status: "active",
tui: false,
})
})
test("package checks omit unknown revisions rather than guessing", () => {
expect(
Schema.encodeSync(Plugin.PackageStatus)({
installed: undefined,
available: undefined,
mutable: true,
}),
).toEqual({ mutable: true })
expect(
Schema.decodeUnknownSync(Plugin.PackageStatus)({
installed: "1.0.0",
available: "1.0.0",
mutable: false,
}),
).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: false })
})
test("plugin operation errors expose a readable wire message", () => {
expect(
Schema.encodeSync(Plugin.OperationError)(new Plugin.OperationError({ message: "Unknown plugin source" })),
).toEqual({ _tag: "PluginOperationError", message: "Unknown plugin source" })
})
+5 -1
View File
@@ -47,7 +47,11 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
return {
...plugins,
flush: release.open.pipe(Effect.andThen(plugins.flush)),
initialized: release.open.pipe(Effect.andThen(plugins.initialized)),
}
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+4 -2
View File
@@ -1,4 +1,5 @@
import { Generate } from "@opencode-ai/core/generate"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -27,8 +28,9 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
Effect.fn("server.generate.text")(function* (request) {
yield* flushPlugins
const generate = yield* Generate.Service
const text = yield* generate
.text(request.payload)
const gate = yield* PluginExecution.Service
const text = yield* gate
.lease({ id: "generate", admission: true }, generate.text(request.payload))
.pipe(
Effect.mapError((error) =>
error._tag === "Generate.ModelSelectionError"
@@ -4,7 +4,8 @@ import { Effect } from "effect"
export function pluginReadiness(error: () => ServiceUnavailableError) {
return PluginSupervisor.Service.pipe(
Effect.flatMap((plugins) => plugins.flush),
// Reads may keep observing the coherent active graph while replacement waits for running tools.
Effect.flatMap((plugins) => plugins.initialized),
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(error()),
+16 -5
View File
@@ -1,13 +1,24 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.check(payload.target))),
)
.handle("plugin.update", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.update(payload.target))),
)
.handle("plugin.reload", ({ payload }) =>
response(PluginSupervisor.Service.use((plugins) => plugins.reload(payload.target))),
),
)
+20 -13
View File
@@ -2,6 +2,7 @@ import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -41,19 +42,25 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
return yield* response(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: {
...ctx.payload.env,
...(yield* environment.get({ directory: location.directory, cwd })),
},
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: "pty", admission: true },
Effect.gen(function* () {
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
return yield* response(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: {
...ctx.payload.env,
...(yield* environment.get({ directory: location.directory, cwd })),
},
}),
)
}),
)
}),
+7 -2
View File
@@ -1,6 +1,7 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -21,11 +22,15 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
gate.lease(
{ id: "shell", admission: true },
shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
),
)
}),
)
+3 -1
View File
@@ -1,4 +1,5 @@
import { WebSearch } from "@opencode-ai/core/websearch"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -30,8 +31,9 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
Effect.fn("server.websearch.query")(function* (request) {
yield* awaitPlugins
const websearch = yield* WebSearch.Service
const gate = yield* PluginExecution.Service
return yield* response(
websearch.query(request.payload).pipe(
gate.lease({ id: "websearch", admission: true }, websearch.query(request.payload)).pipe(
Effect.catchTags({
"WebSearch.ProviderRequired": () =>
new InvalidRequestError({
+10 -2
View File
@@ -49,8 +49,8 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
const readiness = pluginReadiness(
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
const layer = Layer.mock(PluginSupervisor.Service, {
initialized: Effect.sync(() => {
flushes++
}),
})
@@ -59,3 +59,11 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
await Effect.runPromise(Effect.all([readiness, readiness], { concurrency: 1 }).pipe(Effect.provide(layer)))
expect(flushes).toBe(2)
})
test("plugin state reads do not wait for a pending replacement", async () => {
const readiness = pluginReadiness(
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.mock(PluginSupervisor.Service, { initialized: Effect.void, flush: Effect.never })
expect(await Effect.runPromise(readiness.pipe(Effect.as("active"), Effect.provide(layer)))).toBe("active")
})
@@ -0,0 +1,154 @@
import { expect } from "bun:test"
import { Context, Deferred, Effect, Fiber, Latch, Layer } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "@opencode-ai/core/bus"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Generate } from "@opencode-ai/core/generate"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WebSearch } from "@opencode-ai/core/websearch"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
for (const endpoint of ["websearch", "generate"]) {
it.live(`${endpoint} HTTP execution retains plugin resources through completion`, () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-execution-")))
const sdkReady = yield* Deferred.make<SdkPlugins.Interface>()
const queryStarted = yield* Latch.make()
const finishQuery = yield* Latch.make()
const activationRequested = yield* Latch.make()
const admissionRequested = yield* Latch.make()
const activationStarted = yield* Latch.make()
const finishActivation = yield* Latch.make()
const events: string[] = []
const definition = (version: number) =>
define({
id: "executor-resource",
effect: (ctx) =>
Effect.gen(function* () {
const resource = { closed: false }
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
resource.closed = true
events.push(`closed:${version}`)
}),
)
if (version === 2) {
yield* activationStarted.open
yield* finishActivation.await
}
yield* ctx.websearch.transform((draft) =>
draft.add({
id: "leased-provider",
name: "Leased provider",
execute: () =>
Effect.gen(function* () {
if (version === 1) {
yield* queryStarted.open
yield* finishQuery.await
}
expect(resource.closed).toBe(false)
events.push(`query:${version}`)
return []
}),
}),
)
}),
})
const sdkNode = makeGlobalNode({
service: SdkPlugins.Service,
layer: SdkPlugins.layer.pipe(
Layer.tap((context) => {
const sdk = Context.get(context, SdkPlugins.Service)
return Deferred.succeed(sdkReady, sdk).pipe(Effect.andThen(sdk.register(definition(1))))
}),
),
deps: [Bus.node],
})
const executionNode = makeLocationNode({
service: PluginExecution.Service,
layer: Layer.effect(
PluginExecution.Service,
Effect.gen(function* () {
const gate = yield* PluginExecution.Service
return PluginExecution.Service.of({
lease: (session, effect) => admissionRequested.open.pipe(Effect.andThen(gate.lease(session, effect))),
exclusive: (effect) => activationRequested.open.pipe(Effect.andThen(gate.exclusive(effect))),
})
}),
).pipe(Layer.provide(PluginExecution.layer)),
deps: [],
})
// The Generate handler executes a real plugin provider without contacting an LLM.
const generateNode = makeLocationNode({
service: Generate.Service,
layer: Layer.effect(
Generate.Service,
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
return Generate.Service.of({
text: () =>
websearch
.query({
providerID: WebSearch.ID.make("leased-provider"),
query: "generate",
})
.pipe(Effect.as("generated"), Effect.orDie),
})
}),
),
deps: [WebSearch.node],
})
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: tmp.path, project: false },
fs: { filewatcher: false },
},
{
overrides: [
[SdkPlugins.node, sdkNode],
[PluginExecution.node, executionNode],
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Generate.node, generateNode],
],
},
)
const sdk = yield* Deferred.await(sdkReady)
const request = Effect.promise(() => {
const url = new URL(`http://opencode.local/api/${endpoint}`)
url.searchParams.set("location[directory]", tmp.path)
return handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
endpoint === "generate" ? { prompt: "hello" } : { providerID: "leased-provider", query: "hello" },
),
}),
)
})
const first = yield* request.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.addFinalizer(() => finishQuery.open.pipe(Effect.andThen(finishActivation.open)))
yield* queryStarted.await
yield* activationRequested.close
yield* sdk.register(definition(2))
yield* activationRequested.await
expect(events).toEqual([])
yield* finishQuery.open
expect((yield* Fiber.join(first)).status).toBe(200)
yield* activationStarted.await
expect(events).toEqual(["query:1", "closed:1"])
yield* admissionRequested.close
const second = yield* request.pipe(Effect.forkScoped({ startImmediately: true }))
yield* admissionRequested.await
expect(events).toEqual(["query:1", "closed:1"])
yield* finishActivation.open
expect((yield* Fiber.join(second)).status).toBe(200)
expect(events).toEqual(["query:1", "closed:1", "query:2"])
}),
)
}
+113
View File
@@ -0,0 +1,113 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Location } from "@opencode-ai/schema/location"
import { Plugin } from "@opencode-ai/schema/plugin"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live("checks and reloads only configured sources, preserving working code after import and setup failures", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-plugin-endpoint-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const target = path.join(project, "plugin.ts")
const disabled = path.join(project, "disabled.ts")
yield* Effect.promise(async () => {
await fs.mkdir(global)
await fs.mkdir(project)
await fs.writeFile(target, `export default { id: "managed", tui: true, setup() {} }`)
await fs.writeFile(
disabled,
`export default { id: "disabled", setup() { throw new Error("must remain disabled") } }`,
)
await fs.writeFile(
path.join(project, "opencode.json"),
JSON.stringify({ plugins: ["-*", target, disabled, "-disabled"] }),
)
})
const server = yield* startServer(global)
const invoke = (action: string, source: string) =>
Effect.promise(async () => {
const url = new URL(`/api/plugin/${action}`, server.base)
url.searchParams.set("location[directory]", project)
const response = await fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ target: source }),
})
const body: unknown = await response.json()
return { status: response.status, body }
})
const check = yield* invoke("check", target)
expect(check.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Plugin.PackageStatus))(check.body).data).toEqual({
mutable: false,
})
const first = yield* invoke("reload", target)
expect(first.status).toBe(200)
const initial = Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(first.body).data
expect(initial).toEqual([
expect.objectContaining({ id: "managed", status: "active", tui: true, generation: expect.any(String) }),
])
yield* Effect.promise(() =>
fs.writeFile(target, `export default { id: "managed", setup() { throw new Error("setup failed") } }`),
)
const setup = yield* invoke("reload", target)
expect(setup.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(setup.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("setup failed") },
])
yield* Effect.promise(() =>
fs.writeFile(target, `import "./missing.ts"; export default { id: "managed", setup() {} }`),
)
const imported = yield* invoke("reload", target)
expect(imported.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(imported.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("missing.ts") },
])
yield* Effect.promise(() =>
fs.writeFile(
target,
`export default { id: "changed-id", setup() { throw new Error("new setup must not run") } }`,
),
)
const renamed = yield* invoke("reload", target)
expect(renamed.status).toBe(200)
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(renamed.body).data).toEqual([
{ ...initial[0], error: expect.stringContaining("Plugin ID changed from managed to changed-id") },
])
const off = yield* invoke("reload", disabled)
expect(off.status).toBe(200)
expect(
Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(off.body).data.map((item) => item.id),
).toEqual([Plugin.ID.make("managed")])
yield* Effect.promise(() => fs.writeFile(target, `export default { id: "managed", tui: true, setup() {} }`))
const replacements = yield* Effect.all([invoke("reload", target), invoke("reload", target)], {
concurrency: "unbounded",
})
const generations = replacements.map((result) => {
expect(result.status).toBe(200)
const info = Schema.decodeUnknownSync(Location.response(Schema.Array(Plugin.Info)))(result.body).data[0]
expect(info?.status).toBe("active")
expect(info?.error).toBeUndefined()
return info?.generation
})
expect(new Set(generations).size).toBe(2)
expect(generations).not.toContain(initial[0]?.generation)
for (const operation of [
{ action: "update", source: target },
{ action: "reload", source: "opencode.agent" },
{ action: "update", source: "not-configured@latest" },
{ action: "check", source: "managed" },
]) {
const rejected = yield* invoke(operation.action, operation.source)
expect(rejected.status).toBe(400)
expect(Schema.decodeUnknownSync(Plugin.OperationError)(rejected.body)._tag).toBe("PluginOperationError")
}
}),
)
+1
View File
@@ -264,6 +264,7 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.check": keybind("ctrl+r", "Check plugin updates"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
@@ -1,10 +1,14 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -17,6 +21,7 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
}
export function PluginsDialog(props: {
@@ -25,15 +30,24 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [busy, setBusy] = createSignal<string>()
const [list, setList] = createSignal<DialogSelectRef<string>>()
const [detail, setDetail] = createSignal<string>()
const [errorDetail, setErrorDetail] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string>>({})
const [checks, setChecks] = props.context.storage.memory("updates", {
initial: { packages: {} as Record<string, PackageStatus> },
})
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
const [server, { refetch }] = createResource(
() => (props.server ? undefined : 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()))
onCleanup(props.context.data.on("server.connected", () => void refetch()))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -54,7 +68,8 @@ export function PluginsDialog(props: {
id: plugin.id,
target: plugin.target,
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
error: plugin.error,
revision: plugin.revision,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
@@ -66,12 +81,129 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const management = (entry: Entry | undefined) => {
if (!entry) return undefined
if (entry.runtime === "server") {
if (entry.plugin.source.type === "package")
return { runtime: "server" as const, target: entry.plugin.source.package, package: true }
if (entry.plugin.source.type === "local")
return { runtime: "server" as const, target: entry.plugin.source.path, package: false }
return undefined
}
if (props.plugins.registered().some((plugin) => plugin.id === entry.id && plugin.source === "builtin"))
return undefined
const companion = (props.server?.() ?? server() ?? []).find(
(plugin) => plugin.source.type === "package" && plugin.source.package === entry.target,
)
return {
runtime: companion ? ("server" as const) : ("tui" as const),
target: entry.target,
package: !localSource(entry.target, "."),
}
}
const checkKey = (entry: Entry | undefined) => {
const owner = management(entry)
if (!owner) return ""
const location = props.context.data.location.default()
return JSON.stringify([owner.runtime, location, owner.target])
}
const checked = (entry: Entry | undefined): PackageStatus | undefined => checks.packages[checkKey(entry)]
const revision = (entry: Entry) => (entry.runtime === "server" ? entry.plugin.revision : entry.revision)
const available = (entry: Entry | undefined) => {
const value = checked(entry)
return Boolean(
entry && value?.mutable && value.available && value.available !== (revision(entry) ?? value.installed),
)
}
const entryError = (entry: Entry | undefined) => errors()[checkKey(entry)] ?? pluginError(entry)
const detailEntry = createMemo(() => entries().find((entry) => entry.key === detail()))
const manage = (entry: Entry | undefined, action: "check" | "update" | "reload") => {
const owner = management(entry)
if (locked() || !owner || !entry || (action !== "reload" && !owner.package)) return
const key = checkKey(entry)
const location = props.context.data.location.default()
setLocked(true)
setBusy(
action === "check"
? "Checking for updates..."
: action === "update"
? "Updating; waiting for running work..."
: "Reloading; waiting for running work...",
)
setErrors((items) => {
const next = { ...items }
delete next[key]
return next
})
const task = async () => {
if (action === "check") {
const result =
owner.runtime === "server"
? (await props.context.client.plugin.check({ target: owner.target, location })).data
: await props.plugins.check(owner.target)
setChecks((draft) => {
draft.packages[key] = result
})
return
}
if (owner.runtime === "server") {
const result = await props.context.client.plugin[action]({ target: owner.target, location })
await Promise.all([refetch(), props.plugins.sync()])
const companion = props.plugins.list().find((plugin) => plugin.target === owner.target)
if (companion && companion.status !== "unsupported" && companion.error) throw new Error(companion.error)
const attempted = result.data.find((plugin) =>
plugin.source.type === "package"
? plugin.source.package === owner.target
: plugin.source.type === "local" && plugin.source.path === owner.target,
)
if (attempted?.error) throw new Error(attempted.error)
if (action === "update" && attempted?.revision && checks.packages[key])
setChecks((draft) => {
draft.packages[key] = {
mutable: draft.packages[key].mutable,
installed: attempted.revision,
available: attempted.revision,
}
})
} else {
await props.plugins[action](owner.target)
const loaded = props.plugins.list().find((plugin) => plugin.target === owner.target)
if (action === "update" && loaded && loaded.status !== "unsupported" && loaded.revision && checks.packages[key])
setChecks((draft) => {
draft.packages[key] = {
mutable: draft.packages[key].mutable,
installed: loaded.revision,
available: loaded.revision,
}
})
}
props.context.ui.toast.show({
variant: "success",
message: action === "update" ? "Plugin update applied" : "Plugin reloaded",
})
}
void task()
.catch((cause) => {
const message = errorMessage(cause)
setErrors((items) => ({ ...items, [key]: message }))
props.context.ui.toast.show({
variant: "error",
message:
action === "check" ? "Could not check plugin updates" : `Plugin ${action} failed; view error details`,
})
})
.finally(() => {
setBusy()
setLocked(false)
})
}
createEffect(() => {
if (initial()) return
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const options = createMemo(() =>
@@ -81,9 +213,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: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
footer: available(entry)
? "↑ update"
: entryError(entry) && status(entry) === "active"
? "previous active"
: status(entry) === "active"
? undefined
: status(entry),
footerColor: available(entry)
? props.context.theme.text.feedback.info.default
: entryError(entry)
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
@@ -95,11 +234,10 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
return entry
return entry?.runtime === "tui" && entry.id ? entry : undefined
})
const toggleTitle = createMemo(() => {
const entry = focusedTui()
@@ -125,10 +263,16 @@ export function PluginsDialog(props: {
.finally(() => setLocked(false))
}
const back = () => {
setDetail()
setErrorDetail(false)
dialog.setSize("medium")
}
return (
<box>
<Show
when={detail()}
when={detailEntry()}
fallback={
<DialogSelect
title="Plugins"
@@ -136,45 +280,179 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
ref={setList}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
if (entry) setDetail(entry.key)
}}
actions={
focusedTui()
actions={[
...(focusedTui()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option: DialogSelectOption<string>) =>
toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []
}
: []),
...(management(focusedEntry())?.package
? [
{
title: "check",
command: "plugins.check",
onTrigger: (option: DialogSelectOption<string>) =>
manage(
entries().find((entry) => entry.key === option.value),
"check",
),
},
]
: []),
]}
footer={
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
</text>
<Show when={!busy()} fallback={<text fg={props.context.theme.text.subdued}>{busy()}</text>}>
<Show when={dimensions().width >= 60}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> details</span>
</text>
</Show>
</Show>
}
/>
}
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
<Show
when={errorDetail()}
fallback={
<DialogSelect
title={label(entry(), props.context)}
renderFilter={false}
locked={locked()}
preserveSelection={true}
titleView={
<box flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} gap={1}>
<text fg={props.context.theme.text.default} truncate>
<b>{label(entry(), props.context)}</b>
</text>
<scrollbox
width="100%"
height={Math.min(8, Math.max(1, Math.floor(dimensions().height / 3) - 3))}
scrollbarOptions={{ visible: false }}
>
<text width="100%" fg={props.context.theme.text.subdued} wrapMode="word">
{[
`Runtime ${entry().runtime === "server" ? "Server" : "This terminal"}`,
`Status ${status(entry())}`,
...(management(entry())?.package
? [
`Loaded ${revisionLabel(revision(entry())) ?? "Unknown"}`,
`Installed ${revisionLabel(checked(entry())?.installed) ?? "Not checked"}`,
`Available ${revisionLabel(checked(entry())?.available) ?? "Not checked"}${checked(entry())?.mutable === false ? " (pinned)" : ""}`,
]
: [
management(entry())
? "Local source; reload does not fetch packages."
: "Updates with OpenCode itself.",
]),
`Scope ${props.context.ui.format.path(props.context.data.location.default().directory)}`,
`Source ${pluginSource(entry(), props.context)}`,
...(management(entry())?.package
? [
`Provides ${entries()
.filter((item) => management(item)?.target === management(entry())?.target)
.map((item) => label(item, props.context))
.join(", ")}`,
]
: []),
].join("\n")}
</text>
</scrollbox>
</box>
}
options={[
...(management(entry())?.package
? [
{
title: "Check for updates",
value: "check",
footer: checked(entry())?.mutable === false ? "pinned" : undefined,
},
...(checked(entry())?.mutable === false
? []
: [
{
title: "Update package",
value: "update",
footer: available(entry()) ? "↑ available" : undefined,
},
]),
]
: []),
...(management(entry()) ? [{ title: "Reload installed code", value: "reload" }] : []),
...("id" in entry()
? [
{
title: status(entry()) === "active" ? "Disable in this terminal" : "Enable in this terminal",
value: "toggle",
},
]
: []),
...(entryError(entry())
? [
{
title: "View error details",
value: "error",
footer: "error",
footerColor: props.context.theme.text.feedback.error.default,
},
]
: []),
{ title: "Back to plugins", value: "back" },
]}
onSelect={(option) => {
if (option.value === "back") return back()
if (option.value === "error") return setErrorDetail(true)
if (option.value === "toggle") return toggle(entry())
if (option.value === "check" || option.value === "update" || option.value === "reload")
manage(entry(), option.value)
}}
bindings={[{ bind: "escape", title: "Back", group: "Dialog", run: back }]}
footer={
<text
fg={
entryError(entry())
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued
}
>
{busy() ??
(entryError(entry())
? "Operation failed; view error details."
: available(entry())
? "↑ A newer revision is available."
: checked(entry())
? "No newer revision at last check."
: "Enter to select an action")}
</text>
}
/>
}
>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={entryError(entry()) ?? "Unknown plugin error"}
context={`Status: ${status(entry())}\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setErrorDetail(false)
dialog.setSize("medium")
}}
/>
</Show>
)}
</Show>
</box>
@@ -186,6 +464,10 @@ function label(entry: Entry, context: Plugin.Context) {
return entry.plugin.id ?? source(entry.plugin, context)
}
function revisionLabel(value: string | undefined) {
return value && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value) ? value.slice(0, 7) : value
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.target
return source(entry.plugin, context)
@@ -203,7 +485,7 @@ function status(entry: Entry) {
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
if (entry?.runtime === "server") return entry.plugin.error
return entry?.error
}
+295 -121
View File
@@ -1,5 +1,6 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
batch,
@@ -33,13 +34,35 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean, revision?: string) => Promise<PackageEntry | string | undefined>
readonly check: (spec: string) => Promise<PackageStatus>
readonly update: (spec: string) => Promise<PackageEntry>
readonly reload: (
spec: string,
options?: { readonly revision?: string; readonly generation?: string },
) => Promise<PackageEntry>
}
type State =
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
export type PackageEntry = { readonly entrypoint?: string; readonly revision?: string; readonly generation: string }
type State = {
readonly revision?: string
readonly generation?: string
} & (
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive"; readonly error?: string }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly id?: string; readonly status: "failed"; readonly error: string }
)
type Companion = Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
}
type CompanionGraph = {
readonly revision?: string
readonly generation?: string
readonly entry: PackageEntry | string | undefined
}
type RegisteredPlugin = {
readonly id: string
@@ -60,6 +83,10 @@ type Value = {
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
readonly check: (target: string) => Promise<PackageStatus>
readonly update: (target: string) => Promise<void>
readonly reload: (target: string) => Promise<void>
readonly sync: () => Promise<void>
}
type Registration = {
@@ -67,6 +94,9 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
generation?: string
serverGeneration?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -76,7 +106,10 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
type Desired = Pick<
Registration,
"plugin" | "source" | "target" | "version" | "options" | "revision" | "generation" | "serverGeneration"
> & { enabled: boolean }
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
@@ -98,11 +131,16 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
>
>([])
const [serverPlugins, setServerPlugins] = createSignal<ReadonlyArray<Companion>>([])
const [registrationOrder, setRegistrationOrder] = createSignal<string[]>([], { equals: isDeepEqual })
const disabled = new Set<string>()
const stagedPackages = new Map<string, PackageEntry>()
const companionGraphs = new Map<string, CompanionGraph>()
const managementFailures = new Map<string, string>()
// Disabled imports remain candidates until setup succeeds. Keep one working
// definition for failures when the user later enables a candidate.
const lastGood = new Map<string, Desired>()
let inventoryLoaded = false
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -123,9 +161,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}
const markdown = createMemo(() =>
combineMarkdownRenderers(
Object.values(store.registrations).flatMap((registration) =>
registration.active ? [registration.markdown] : [],
),
registrationOrder().flatMap((id) => {
const registration = store.registrations[id]
return registration?.active ? [registration.markdown] : []
}),
),
)
const clearContributions = (id: string) => {
@@ -183,10 +222,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
setStore("registrations", id, "active", true)
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "active" } : state,
"id" in state && state.id === id ? pluginState(state.target, store.registrations[id]) : state,
),
)
})
lastGood.set(id, { ...toDesired(store.registrations[id]), options: snapshotOptions(item.options) })
return true
}
@@ -205,7 +245,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "inactive" } : state,
"id" in state && state.id === id ? { ...state, id, status: "inactive" as const } : state,
),
)
}),
@@ -254,8 +294,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// core plugin registry: fold the ordered entries into a desired end state
// (importing only new or changed sources, before anything running is
// touched), no-op when the generation is unchanged, and restart only the
// plugins that differ. Membership or order changes rebuild the whole
// generation to preserve slot-order semantics.
// plugins that differ. Order is tracked separately from registration lifetime
// so an inventory change cannot unmount the built-in management dialog.
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
@@ -293,38 +333,43 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const companion = serverPlugins().find((plugin) => plugin.source.package === target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
(error) => ({
status: "failed" as const,
error: errorMessage(error),
}),
)
: await resolvePlugin(
target,
local,
options,
previous,
props.packages,
source.install,
sourceGeneration,
companion,
stagedPackages.get(target),
companionGraphs,
).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
if (resolved.status === "unsupported") {
if (previous) {
failures.push(pluginState(target, previous, "Updated package has no TUI entrypoint"))
desired.set(previous.plugin.id, toDesired(previous))
continue
}
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
if (resolved.status === "failed") {
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
failures.push(pluginState(target, previous, resolved.error))
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
failures.push(pluginState(target, previous, setupFailure.error))
desired.set(previous.plugin.id, toDesired(previous))
continue
}
@@ -334,35 +379,34 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
source: "external",
target,
version: resolved.version,
revision: resolved.revision,
generation: resolved.generation,
serverGeneration: companion?.generation,
options,
enabled: true,
})
}
for (const item of desired.values()) if (disabled.has(item.plugin.id)) item.enabled = false
// Compare: unchanged plugins are never touched, and a fully unchanged
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
const structural =
currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivateNoisily(id)),
for (const id of Object.keys(store.registrations).filter((id) => !desired.has(id))) {
await deactivateNoisily(id)
lastGood.delete(id)
setStore(
"registrations",
produce((registrations) => {
delete registrations[id]
}),
)
setStore("registrations", reconcileStore({}))
}
const changed = structural
? desiredIds
: desiredIds.filter((id) => {
const registration = store.registrations[id]!
const item = desired.get(id)!
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return !sameGeneration(registration, item) || registration.active !== item.enabled
})
setRegistrationOrder(desiredIds)
const changed = desiredIds.filter((id) => {
const registration = store.registrations[id]
const item = desired.get(id)!
return !sameGeneration(registration, item) || registration?.active !== item.enabled
})
// Swap: cleanup failures surface as a toast, never propagate, so one
// broken plugin cannot take the rest of the generation down.
@@ -371,31 +415,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced = !registration || !sameGeneration(registration, item)
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback = replaced && registration ? toDesired(registration) : undefined
const fallback = lastGood.get(id)
const wasActive = registration?.active ?? false
if (replaced) {
if (registration) await deactivateNoisily(id)
// In-place replacement keeps the registration's key position, which
// slot ordering (mode "replace" takes the last one) depends on.
// Replace only this plugin's contributions, leaving mounted built-ins
// and other plugin slots alive.
setStore("registrations", id, toRegistration(item))
}
if (!item.enabled) {
await deactivateNoisily(id)
if (item.target) managementFailures.delete(item.target)
continue
}
const error = await activate(id).then(() => undefined, errorMessage)
if (!error) continue
if (!error) {
if (item.target) managementFailures.delete(item.target)
continue
}
errors.set(id, error)
if (!fallback) continue
setStore("registrations", id, toRegistration(fallback))
if (!fallback.enabled) continue
const restored = await activate(id).then(
() => true,
() => false,
)
if (restored) errors.set(id, `${error} (previous version still active)`)
if (!wasActive) continue
await activate(id).catch(() => undefined)
}
const failedTargets = new Set(failures.map((failure) => failure.target))
@@ -404,20 +446,25 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (item.target === undefined) return []
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, id: item.plugin.id, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
const error = errors.get(item.plugin.id) ?? managementFailures.get(item.target)
return [pluginState(item.target, store.registrations[item.plugin.id], error)]
}),
...failures,
...failures.map((state) =>
"error" in state
? pluginState(
state.target,
Object.values(store.registrations).find((item) => item.target === state.target),
state.error,
)
: state,
),
]
// Surface newly failing plugins; repeated reconciles stay silent.
for (const state of states)
if (
state.status === "failed" &&
!store.states.some(
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
"error" in state &&
state.error &&
!store.states.some((prev) => "error" in prev && prev.target === state.target && prev.error === state.error)
)
host.toast.show({
variant: "error",
@@ -443,12 +490,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}),
)
}
// Claims come back in enable order: registration-store key order across
// plugins (generations preserve key positions in place), then registration
// order within one plugin. The resolver's last-wins rules depend on it.
// Claims come back in desired generation order across plugins, then
// registration order within one plugin. The resolver's last-wins rules depend on it.
const claims = createMemo(() =>
Object.entries(store.registrations).flatMap(([id, registration]) =>
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
registrationOrder().flatMap((id) => {
const registration = store.registrations[id]
if (!registration) return []
return Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
// Rows downstream diff by reference; a stable claim per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
@@ -459,47 +507,82 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
slotItems.set(slot.render, item)
return item
}),
),
})
}),
)
// Object.keys tracks the store's keys node only: refcount changes on an
// already-mounted path (a second tab's composer) skip re-resolution.
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
createEffect(
on(
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
() => JSON.stringify(config.data.plugins ?? []),
() => {
npmFailures.clear()
void enqueue(reconcile).then(
// Discover server ownership before resolving CLI config duplicates:
// otherwise startup could briefly activate a newer local companion.
void enqueue(() => (inventoryLoaded ? reconcile() : syncServerPlugins().catch(() => reconcile()))).then(
() => setStore("ready", true),
() => setStore("ready", true),
)
},
),
)
const syncServerPlugins = () =>
client.api.plugin
.list({ location: data.location.default() })
.then((response) =>
setServerPlugins(
response.data.filter(
(
plugin,
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
),
),
)
.catch(() => undefined)
createEffect(
on(
() => JSON.stringify(data.location.default()),
() => void syncServerPlugins(),
),
)
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
onCleanup(client.event.on("server.connected", syncServerPlugins))
const syncServerPlugins = async () => {
const response = await client.api.plugin.list({ location: data.location.default() })
const companions = response.data.filter(
(plugin): plugin is Companion => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
)
if (!isDeepEqual(serverPlugins(), companions)) npmFailures.clear()
setServerPlugins(companions)
inventoryLoaded = true
await reconcile()
}
const sync = () => enqueue(syncServerPlugins)
const syncQuietly = () => void sync().catch(() => undefined)
const manage = (target: string, operation: "update" | "reload") =>
enqueue(async () => {
const previous = Object.values(store.registrations).find((item) => item.target === target)
managementFailures.delete(target)
await (async () => {
if (serverPlugins().some((plugin) => plugin.source.package === target)) {
const response = await client.api.plugin[operation]({ target, location: data.location.default() })
await syncServerPlugins()
const failed = response.data.find(
(plugin) => plugin.source.type === "package" && plugin.source.package === target && plugin.error,
)
if (failed?.error) throw new Error(failed.error)
return
}
const local = localSource(target, directory)
if (local) {
if (operation === "update") throw new Error("Local plugins cannot be updated")
const entrypoint = await resolveLocal(local)
if (entrypoint) sourceVersions.delete(entrypoint)
} else {
stagedPackages.set(
target,
operation === "reload"
? await props.packages.reload(target, { revision: previous?.revision, generation: previous?.generation })
: await props.packages.update(target),
)
}
npmFailures.delete(target)
setupFailures.delete(target)
await reconcile()
})().catch((error) => {
managementFailures.set(target, errorMessage(error))
setStore("states", (states) => [
...states.filter((state) => state.target !== target),
pluginState(target, previous, errorMessage(error)),
])
throw error
})
const state = store.states.find((state) => state.target === target)
if (state && "error" in state && state.error) throw new Error(state.error)
})
createEffect(on(() => JSON.stringify(data.location.default()), syncQuietly))
onCleanup(client.event.on("plugin.updated", syncQuietly))
onCleanup(client.event.on("server.connected", syncQuietly))
onMount(() => {
let disposing: Promise<void> | undefined
const dispose = () => {
@@ -530,18 +613,47 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
ready: () => store.ready,
list: () => store.states,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({
id,
source: plugin.source,
active: plugin.active,
})),
registrationOrder().flatMap((id) => {
const plugin = store.registrations[id]
return plugin ? [{ id, source: plugin.source, active: plugin.active }] : []
}),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
activate: (id) =>
enqueue(async () => {
const fallback = lastGood.get(id)
const result = await activate(id).catch((error) => {
if (fallback) setStore("registrations", id, toRegistration(fallback))
const item = store.registrations[id]
if (item?.target)
setStore("states", (states) =>
states.map((state) =>
state.target === item.target ? pluginState(state.target, item, errorMessage(error)) : state,
),
)
throw error
})
disabled.delete(id)
return result
}),
deactivate: (id) =>
enqueue(() => {
disabled.add(id)
return deactivate(id)
}),
check: (target) =>
enqueue(async () => {
if (serverPlugins().some((plugin) => plugin.source.package === target))
return (await client.api.plugin.check({ target, location: data.location.default() })).data
if (localSource(target, directory)) return { mutable: false }
return props.packages.check(target)
}),
update: (target) => manage(target, "update"),
reload: (target) => manage(target, "reload"),
sync,
}}
>
{props.children}
@@ -576,12 +688,45 @@ async function resolvePlugin(
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
companion: Companion | undefined,
staged: PackageEntry | undefined,
companionGraphs: Map<string, CompanionGraph>,
) {
// Package entrypoints never change within a session, so a loaded previous
// 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)
// An unchanged advertised graph reuses the loaded module, never the cache's
// current pointer: a remote server and this client can have different caches.
if (
!local &&
previous &&
sameOptions(previous.options, options) &&
(companion || !staged || staged.generation === previous.generation) &&
(!companion ||
((companion.revision === undefined || companion.revision === previous.revision) &&
companion.generation === previous.serverGeneration))
)
return {
status: "unchanged" as const,
plugin: previous.plugin,
version: previous.version,
revision: previous.revision,
generation: previous.generation,
}
const cached = companionGraphs.get(spec)
const pkg = local
? undefined
: companion
? cached && cached.revision === companion.revision && cached.generation === companion.generation
? cached.entry
: previous &&
companion.generation !== previous.serverGeneration &&
(companion.revision === undefined || companion.revision === previous.revision)
? await packages.reload(spec, { revision: companion.revision ?? previous.revision })
: await packages.resolve(spec, companion.revision !== undefined, companion.revision)
: (staged ?? (await packages.resolve(spec, install)))
if (companion?.revision !== undefined && (typeof pkg === "string" || pkg?.revision !== companion.revision))
throw new Error(`TUI companion revision does not match server revision ${companion.revision}: ${spec}`)
if (companion)
companionGraphs.set(spec, { revision: companion.revision, generation: companion.generation, entry: pkg })
const entrypoint = local ? await resolveLocal(local) : typeof pkg === "string" ? pkg : pkg?.entrypoint
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.
@@ -589,7 +734,13 @@ async function resolvePlugin(
while (true) {
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 }
return {
status: "unchanged" as const,
plugin: previous.plugin,
version,
revision: previous.revision,
generation: previous.generation,
}
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -601,7 +752,16 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
if (previous && mod.default.id !== previous.plugin.id)
throw new Error(`Plugin ID cannot change for ${spec}: ${previous.plugin.id} -> ${mod.default.id}`)
return {
status: "loaded" as const,
plugin: mod.default,
version,
revision: typeof pkg === "string" ? undefined : pkg?.revision,
generation:
generation === undefined ? (typeof pkg === "string" ? undefined : pkg?.generation) : String(generation),
}
}
}
@@ -611,6 +771,9 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
generation: item.generation,
serverGeneration: item.serverGeneration,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -626,6 +789,9 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
generation: item.generation,
serverGeneration: item.serverGeneration,
options: item.options,
enabled: item.active,
}
@@ -635,6 +801,14 @@ function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
function pluginState(target: string, registration: Registration | undefined, error?: string): State {
const metadata = { revision: registration?.revision, generation: registration?.generation }
if (error && !registration?.active)
return { target, id: registration?.plugin.id, status: "failed", error, ...metadata }
if (!registration) return { target, status: "unsupported" }
return { target, id: registration.plugin.id, status: registration.active ? "active" : "inactive", error, ...metadata }
}
function sameGeneration(
a: Pick<Registration, "version" | "options"> | undefined,
b: Pick<Registration, "version" | "options">,
+4
View File
@@ -93,6 +93,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
readonly selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -526,6 +527,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
+13 -12
View File
@@ -7,6 +7,7 @@ import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { noPackages } from "./fixture/tui-packages"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -32,7 +33,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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -108,7 +109,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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -182,7 +183,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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -273,7 +274,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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -354,7 +355,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -428,7 +429,7 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: noPackages,
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -509,7 +510,7 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -597,7 +598,7 @@ test("new session inherits the active session model", async () => {
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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -687,7 +688,7 @@ test("keeps the prompt display stable while a new location catalog loads", async
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: noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -768,7 +769,7 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: noPackages,
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -822,7 +823,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
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: noPackages,
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -934,7 +935,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: noPackages,
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -0,0 +1,12 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { appendFile } from "node:fs/promises"
export default {
id: "fixture.local",
async setup(context: Plugin.Context) {
if (typeof context.options.marker !== "string") throw new Error("Missing fixture lifecycle marker")
const marker = context.options.marker
await appendFile(marker, "setup\n")
return () => appendFile(marker, "cleanup\n")
},
}
@@ -0,0 +1,6 @@
export default {
id: "fixture.package",
setup() {
throw new Error("Fixture replacement failed during setup")
},
}
@@ -0,0 +1,4 @@
export default {
id: "fixture.package",
setup() {},
}
@@ -0,0 +1,4 @@
export default {
id: "fixture.package",
setup() {},
}
+12
View File
@@ -0,0 +1,12 @@
import type { PackageResolver } from "../../src/plugin/context"
export const noPackages: PackageResolver = {
resolve: async () => undefined,
check: async () => ({ mutable: false }),
update: async () => {
throw new Error("No fixture package to update")
},
reload: async () => {
throw new Error("No fixture package to reload")
},
}
+661 -11
View File
@@ -1,12 +1,15 @@
import { expect, mock, test } from "bun:test"
import { expect, spyOn, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import { cp, mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { createEventStream, createFetch, json, type FetchHandler } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { noPackages } from "./fixture/tui-packages"
import type { PackageResolver, usePlugin } from "../src/plugin/context"
import type { Config } from "../src/config"
function lifecyclePluginSource(marker: string, id: string, version: string) {
return `
@@ -58,20 +61,31 @@ async function bootApp(
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
packages?: Partial<PackageResolver>
config?: Config.Info
fetch?: FetchHandler
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const pluginContext = await import("../src/plugin/context")
const original = pluginContext.usePlugin
let plugin: ReturnType<typeof usePlugin> | undefined
const probe = spyOn(pluginContext, "usePlugin").mockImplementation(() => {
plugin = original()
return plugin
})
let plugins = options?.plugins ?? []
const events = createEventStream()
const calls = createFetch((url) => {
const calls = createFetch(async (url, request) => {
const response = await options?.fetch?.(url, request)
if (response) return response
if (url.pathname === "/api/plugin")
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: options?.plugins ?? [],
data: plugins,
})
if (url.pathname !== "/api/fs/list") return
return json({
@@ -90,26 +104,662 @@ async function bootApp(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: options?.resolve ?? (async () => undefined) },
config: { get: async () => options?.config ?? {}, update: async () => options?.config ?? {} },
packages: { ...noPackages, resolve: options?.resolve ?? noPackages.resolve, ...options?.packages },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
Effect.provide(
Global.layerWith({
home: directory,
config: path.join(directory, ".global"),
data: path.join(directory, ".data"),
state: path.join(directory, ".state"),
cache: path.join(directory, ".cache"),
tmp: path.join(directory, ".tmp"),
bin: path.join(directory, ".bin"),
log: path.join(directory, ".log"),
repos: path.join(directory, ".repos"),
}),
),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
task,
plugin() {
if (!plugin) throw new Error("PluginProvider has not mounted")
return plugin
},
inventory(next: unknown[], notify = true) {
plugins = next
if (notify) events.emit({ id: "evt_plugin", created: Date.now(), type: "plugin.updated", data: {} })
},
async [Symbol.asyncDispose]() {
process.chdir(cwd)
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
probe.mockRestore()
},
}
}
function companion(revision: string, generation = `server-${revision}`) {
return [
{
id: "test.server",
source: { type: "package", package: "test-plugin@latest" },
status: "active",
tui: true,
revision,
generation,
},
]
}
async function fixturePackages(directory: string, marker: string) {
type Graph = { directory: string; entrypoint: string; revision: string; generation: string }
const graphs = new Map<string, Graph>()
const generations = new Map<string, Graph>()
const calls: Array<{ operation: string; spec: string; install?: boolean; revision?: string; generation?: string }> =
[]
let current = "1.0.0"
let available = "2.0.0"
let generation = 0
const graph = (revision: string) => {
const result = graphs.get(revision)
if (!result) throw new Error(`Missing fixture revision ${revision}`)
return result
}
const publish = async (revision: string, source?: string, options?: { generation?: string; dependency?: string }) => {
const root = path.join(directory, "cache", options?.generation ?? revision)
await mkdir(root, { recursive: true })
await writeFile(
path.join(root, "package.json"),
JSON.stringify({ type: "module", name: "test-plugin", version: revision }),
)
await writeFile(
path.join(root, "version.ts"),
`export const version = ${JSON.stringify(options?.dependency ?? revision)}`,
)
await writeFile(
path.join(root, "tui.ts"),
source ??
`
import { appendFile } from "node:fs/promises"
import { version } from "./version"
await appendFile(${JSON.stringify(marker)}, version + ":import\\n")
export default {
id: "test.package",
setup: async () => {
await appendFile(${JSON.stringify(marker)}, version + ":setup\\n")
return () => appendFile(${JSON.stringify(marker)}, version + ":cleanup\\n")
},
}
`,
)
const result = {
directory: root,
entrypoint: pathToFileURL(path.join(root, "tui.ts")).href,
revision,
generation: options?.generation ?? `local-${revision}`,
}
graphs.set(revision, result)
generations.set(result.generation, result)
}
await Promise.all(["1.0.0", "2.0.0", "3.0.0"].map((revision) => publish(revision)))
const packages: PackageResolver = {
resolve: async (spec, install, revision) => {
calls.push({ operation: "resolve", spec, install, revision })
return graph(revision ?? current)
},
check: async (spec) => {
calls.push({ operation: "check", spec })
return { installed: current, available, mutable: true }
},
update: async (spec) => {
calls.push({ operation: "update", spec })
current = available
return graph(current)
},
reload: async (spec, options) => {
calls.push({ operation: "reload", spec, ...options })
const previous = options?.generation ? generations.get(options.generation) : graph(options?.revision ?? current)
if (!previous) throw new Error(`Missing fixture generation ${options?.generation}`)
const root = `${previous.directory}-reload-${++generation}`
await cp(previous.directory, root, { recursive: true })
const result = {
directory: root,
entrypoint: pathToFileURL(path.join(root, "tui.ts")).href,
revision: previous.revision,
generation: `local-reload-${generation}`,
}
generations.set(result.generation, result)
return result
},
}
return {
packages,
calls,
publish,
current: (revision: string) => {
current = revision
},
available: (revision: string) => {
available = revision
},
}
}
test("companion inventory imports the exact server revision and sync awaits its new dependency graph", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
fixture.current("3.0.0")
await using app = await bootApp(tmp.path, { plugins: companion("1.0.0"), packages: fixture.packages })
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:setup") ?? false)).toBe("1.0.0:import\n1.0.0:setup\n")
expect(fixture.calls).toContainEqual({
operation: "resolve",
spec: "test-plugin@latest",
install: true,
revision: "1.0.0",
})
expect(app.plugin().list()).toContainEqual({
target: "test-plugin@latest",
id: "test.package",
status: "active",
revision: "1.0.0",
generation: "local-1.0.0",
error: undefined,
})
app.inventory(companion("2.0.0"))
expect(await until(read, (value) => value?.includes("2.0.0:setup") ?? false)).toBe(
"1.0.0:import\n1.0.0:setup\n2.0.0:import\n1.0.0:cleanup\n2.0.0:setup\n",
)
expect(app.plugin().list()[0]?.revision).toBe("2.0.0")
app.inventory(companion("3.0.0"), false)
await app.plugin().sync()
expect(await read()).toEndWith("3.0.0:import\n2.0.0:cleanup\n3.0.0:setup\n")
expect(app.plugin().list()[0]?.revision).toBe("3.0.0")
const before = await read()
await app.plugin().sync()
expect(await read()).toBe(before)
process.emit("SIGHUP")
await app.task
expect(await read()).toBe(`${before}3.0.0:cleanup\n`)
})
test("a server generation change reloads the same revision from a fresh local graph without moving ahead", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
fixture.current("3.0.0")
await using app = await bootApp(tmp.path, { plugins: companion("1.0.0"), packages: fixture.packages })
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
const unregister = app.plugin().slots.register("app")
const builtin = app
.plugin()
.slots.resolved()
.slotted.get("app")
?.append.find((claim) => claim.plugin === "opencode.plugins")
expect(builtin).toBeDefined()
app.inventory(companion("1.0.0", "server-reload"), false)
await app.plugin().sync()
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:import\n1.0.0:cleanup\n1.0.0:setup\n")
expect(fixture.calls.filter((call) => call.operation === "reload")).toEqual([
{ operation: "reload", spec: "test-plugin@latest", revision: "1.0.0" },
])
expect(app.plugin().list()[0]?.generation).toBe("local-reload-1")
expect(
app
.plugin()
.slots.resolved()
.slotted.get("app")
?.append.find((claim) => claim.plugin === "opencode.plugins"),
).toBe(builtin)
const before = await read()
await app.plugin().sync()
expect(await read()).toBe(before)
unregister()
process.emit("SIGHUP")
await app.task
})
test("resolution, import, and setup failures retain the loaded companion revision and surface its last error", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await fixture.publish("broken-import", "export default {")
await fixture.publish(
"broken-setup",
`
export default {
id: "test.package",
setup: async () => { throw new Error("setup boom") },
}
`,
)
await using app = await bootApp(tmp.path, { plugins: companion("1.0.0"), packages: fixture.packages })
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:setup") ?? false)).toBe("1.0.0:import\n1.0.0:setup\n")
for (const revision of ["missing", "broken-import", "broken-setup"]) {
app.inventory(companion(revision), false)
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "active", revision: "1.0.0", generation: "local-1.0.0" })
const state = app.plugin().list()[0]
expect(state && "error" in state && state.error).toBeTruthy()
const before = await read()
await app.plugin().sync()
expect(await read()).toBe(before)
}
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:cleanup\n1.0.0:setup\n")
app.inventory(companion("2.0.0"), false)
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "active", revision: "2.0.0", error: undefined })
process.emit("SIGHUP")
await app.task
})
test("rejects a companion resolver returning a different revision before touching the running instance", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await using app = await bootApp(tmp.path, {
plugins: companion("1.0.0"),
packages: {
...fixture.packages,
resolve: (spec, install, revision) =>
fixture.packages.resolve(spec, install, revision === "2.0.0" ? "3.0.0" : revision),
},
})
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
const before = await read()
app.inventory(companion("2.0.0"), false)
await app.plugin().sync()
expect(await read()).toBe(before)
expect(app.plugin().list()[0]).toMatchObject({
status: "active",
revision: "1.0.0",
error: "TUI companion revision does not match server revision 2.0.0: test-plugin@latest",
})
process.emit("SIGHUP")
await app.task
})
test("per-window companion disables survive revision updates and same-revision reloads", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await using app = await bootApp(tmp.path, { plugins: companion("1.0.0"), packages: fixture.packages })
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
expect(await app.plugin().deactivate("test.package")).toBe(true)
app.inventory(companion("2.0.0"), false)
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "2.0.0" })
app.inventory(companion("2.0.0", "server-reload"), false)
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "2.0.0", generation: "local-reload-1" })
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:cleanup\n2.0.0:import\n2.0.0:import\n")
expect(await app.plugin().activate("test.package")).toBe(true)
expect(await read()).toEndWith("2.0.0:setup\n")
process.emit("SIGHUP")
await app.task
})
test("config-disabled companions never activate a newer duplicate CLI package during startup or refresh", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
fixture.current("3.0.0")
await using app = await bootApp(tmp.path, {
plugins: companion("1.0.0"),
packages: fixture.packages,
config: { plugins: ["test-plugin@latest", "-test.package"] },
})
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:import") ?? false)).toBe("1.0.0:import\n")
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "1.0.0" })
app.inventory(companion("2.0.0"), false)
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "2.0.0" })
expect(await read()).toBe("1.0.0:import\n2.0.0:import\n")
expect(
fixture.calls.filter((call) => call.operation === "resolve").every((call) => call.revision !== undefined),
).toBe(true)
process.emit("SIGHUP")
await app.task
})
test("CLI-only package management reloads a fresh graph, updates locally, and retains manual disables", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await using app = await bootApp(tmp.path, { packages: fixture.packages, config: { plugins: ["test-plugin@latest"] } })
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
expect(await app.plugin().check("test-plugin@latest")).toEqual({
installed: "1.0.0",
available: "2.0.0",
mutable: true,
})
await app.plugin().reload("test-plugin@latest")
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:import\n1.0.0:cleanup\n1.0.0:setup\n")
expect(app.plugin().list()[0]?.generation).toBe("local-reload-1")
expect(fixture.calls.filter((call) => call.operation === "resolve")).toHaveLength(1)
await app.plugin().deactivate("test.package")
await app.plugin().update("test-plugin@latest")
await app.plugin().reload("test-plugin@latest")
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "2.0.0", generation: "local-reload-2" })
expect(await read()).toEndWith("1.0.0:cleanup\n2.0.0:import\n2.0.0:import\n")
expect(fixture.calls.filter((call) => call.operation === "update")).toHaveLength(1)
process.emit("SIGHUP")
await app.task
})
test("disabled replacements keep the last successfully enabled graph for failed-enable and reload recovery", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await fixture.publish(
"2.0.0",
`
import { appendFile } from "node:fs/promises"
export default {
id: "test.package",
setup: async () => {
await appendFile(${JSON.stringify(marker)}, "2.0.0:attempt\\n")
throw new Error("disabled replacement boom")
},
}
`,
)
await using app = await bootApp(tmp.path, { packages: fixture.packages, config: { plugins: ["test-plugin@latest"] } })
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:setup") ?? false)).toBe("1.0.0:import\n1.0.0:setup\n")
await app.plugin().deactivate("test.package")
await app.plugin().update("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "2.0.0", error: undefined })
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:cleanup\n")
await expect(app.plugin().activate("test.package")).rejects.toThrow("disabled replacement boom")
expect(app.plugin().list()[0]).toMatchObject({
status: "failed",
revision: "1.0.0",
generation: "local-1.0.0",
error: "disabled replacement boom",
})
expect(app.plugin().registered()).toContainEqual({ id: "test.package", source: "external", active: false })
await app.plugin().sync()
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:cleanup\n2.0.0:attempt\n")
expect(app.plugin().list()[0]).toMatchObject({ revision: "1.0.0", error: "disabled replacement boom" })
await app.plugin().reload("test-plugin@latest")
expect(fixture.calls.filter((call) => call.operation === "reload")).toEqual([
{ operation: "reload", spec: "test-plugin@latest", revision: "1.0.0", generation: "local-1.0.0" },
])
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "1.0.0", generation: "local-reload-1" })
const reloaded = app.plugin().list()[0]
expect(reloaded && "error" in reloaded ? reloaded.error : undefined).toBeUndefined()
expect(await app.plugin().activate("test.package")).toBe(true)
expect(app.plugin().list()[0]).toMatchObject({ status: "active", revision: "1.0.0" })
await app.plugin().deactivate("test.package")
fixture.available("3.0.0")
await app.plugin().update("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({ status: "inactive", revision: "3.0.0" })
expect(await app.plugin().activate("test.package")).toBe(true)
await app.plugin().deactivate("test.package")
fixture.available("2.0.0")
await app.plugin().update("test-plugin@latest")
await expect(app.plugin().activate("test.package")).rejects.toThrow("disabled replacement boom")
expect(app.plugin().list()[0]).toMatchObject({
status: "failed",
revision: "3.0.0",
generation: "local-3.0.0",
error: "disabled replacement boom",
})
expect(app.plugin().registered()).toContainEqual({ id: "test.package", source: "external", active: false })
process.emit("SIGHUP")
await app.task
})
test("rejects exported ID changes before replacing active or disabled registrations", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await fixture.publish(
"2.0.0",
`
import { appendFile } from "node:fs/promises"
export default {
id: "test.renamed",
setup: async () => {
await appendFile(${JSON.stringify(marker)}, "renamed:attempt\\n")
throw new Error("renamed setup boom")
},
}
`,
)
await using app = await bootApp(tmp.path, { packages: fixture.packages, config: { plugins: ["test-plugin@latest"] } })
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:setup") ?? false)).toBe("1.0.0:import\n1.0.0:setup\n")
const error = "Plugin ID cannot change for test-plugin@latest: test.package -> test.renamed"
await expect(app.plugin().update("test-plugin@latest")).rejects.toThrow(error)
expect(app.plugin().list()[0]).toMatchObject({ id: "test.package", status: "active", revision: "1.0.0", error })
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n")
await app.plugin().deactivate("test.package")
await expect(app.plugin().update("test-plugin@latest")).rejects.toThrow(error)
expect(app.plugin().list()[0]).toMatchObject({ id: "test.package", revision: "1.0.0", error })
expect(app.plugin().registered()).toContainEqual({ id: "test.package", source: "external", active: false })
expect(
app
.plugin()
.registered()
.some((plugin) => plugin.id === "test.renamed"),
).toBe(false)
expect(await read()).toBe("1.0.0:import\n1.0.0:setup\n1.0.0:cleanup\n")
process.emit("SIGHUP")
await app.task
})
test("manual package reload copies the retained generation after a same-revision dependency update fails", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await using app = await bootApp(tmp.path, { packages: fixture.packages, config: { plugins: ["test-plugin@latest"] } })
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value?.includes("1.0.0:setup") ?? false)).toBe("1.0.0:import\n1.0.0:setup\n")
await fixture.publish(
"1.0.0",
`
import { appendFile } from "node:fs/promises"
import { version } from "./version"
await appendFile(${JSON.stringify(marker)}, version + ":import\\n")
export default {
id: "test.package",
setup: async () => {
await appendFile(${JSON.stringify(marker)}, version + ":attempt\\n")
throw new Error("dependency setup boom")
},
}
`,
{ generation: "new-dependencies", dependency: "dependency-1.1.0" },
)
fixture.available("1.0.0")
await expect(app.plugin().update("test-plugin@latest")).rejects.toThrow("dependency setup boom")
expect(app.plugin().list()[0]).toMatchObject({
status: "active",
revision: "1.0.0",
generation: "local-1.0.0",
error: "dependency setup boom",
})
expect(await read()).toBe(
"1.0.0:import\n1.0.0:setup\ndependency-1.1.0:import\n1.0.0:cleanup\ndependency-1.1.0:attempt\n1.0.0:setup\n",
)
await app.plugin().reload("test-plugin@latest")
expect(fixture.calls.filter((call) => call.operation === "reload")).toEqual([
{ operation: "reload", spec: "test-plugin@latest", revision: "1.0.0", generation: "local-1.0.0" },
])
expect(app.plugin().list()[0]).toMatchObject({
status: "active",
revision: "1.0.0",
generation: "local-reload-1",
error: undefined,
})
expect(await read()).toEndWith("1.0.0:import\n1.0.0:cleanup\n1.0.0:setup\n")
const before = await read()
await app.plugin().sync()
expect(await read()).toBe(before)
process.emit("SIGHUP")
await app.task
})
test("unchanged inventory syncs retain failed package resolutions until an explicit update", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
let attempts = 0
await using app = await bootApp(tmp.path, {
config: { plugins: ["test-plugin@latest"] },
packages: {
...fixture.packages,
resolve: async () => {
attempts++
throw new Error("fixture package unavailable")
},
},
})
expect(
await until(
async () => String(app.plugin().ready()),
(value) => value === "true",
),
).toBe("true")
await app.plugin().sync()
await app.plugin().sync()
expect(attempts).toBe(1)
expect(app.plugin().list()[0]).toMatchObject({ status: "failed", error: "fixture package unavailable" })
await app.plugin().update("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({ status: "active", revision: "2.0.0", error: undefined })
expect(await readFile(marker, "utf8")).toContain("2.0.0:setup")
process.emit("SIGHUP")
await app.task
})
test("a failed CLI-only update reports failure while keeping its old graph and can recover on retry", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
await using app = await bootApp(tmp.path, { packages: fixture.packages, config: { plugins: ["test-plugin@latest"] } })
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
const before = await read()
fixture.available("missing")
await expect(app.plugin().update("test-plugin@latest")).rejects.toThrow("Missing fixture revision missing")
await app.plugin().sync()
expect(app.plugin().list()[0]).toMatchObject({
status: "active",
revision: "1.0.0",
error: "Missing fixture revision missing",
})
expect(await read()).toBe(before)
await app.plugin().reload("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({
status: "active",
revision: "1.0.0",
generation: "local-reload-1",
error: undefined,
})
expect(fixture.calls.filter((call) => call.operation === "reload")).toEqual([
{ operation: "reload", spec: "test-plugin@latest", revision: "1.0.0", generation: "local-1.0.0" },
])
fixture.available("2.0.0")
await app.plugin().update("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({ status: "active", revision: "2.0.0", error: undefined })
process.emit("SIGHUP")
await app.task
})
test("companion management delegates check/update/reload to the server and then awaits exact local reconciliation", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const fixture = await fixturePackages(tmp.path, marker)
fixture.current("3.0.0")
const operations: Array<{ path: string; body: unknown }> = []
const location = { directory: tmp.path, project: { id: "proj_test", directory: tmp.path, canonical: tmp.path } }
await using app = await bootApp(tmp.path, {
plugins: companion("1.0.0"),
packages: fixture.packages,
fetch: async (url, request) => {
if (!["/api/plugin/check", "/api/plugin/update", "/api/plugin/reload"].includes(url.pathname)) return
operations.push({ path: url.pathname, body: await request.json() })
if (url.pathname.endsWith("/check"))
return json({ location, data: { installed: "1.0.0", available: "2.0.0", mutable: true } })
const plugins = companion("2.0.0", url.pathname.endsWith("/reload") ? "server-reload" : "server-2.0.0")
app.inventory(plugins)
return json({ location, data: plugins })
},
})
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("1.0.0:setup") ?? false)
expect(await app.plugin().check("test-plugin@latest")).toEqual({
installed: "1.0.0",
available: "2.0.0",
mutable: true,
})
await app.plugin().update("test-plugin@latest")
expect(app.plugin().list()[0]?.revision).toBe("2.0.0")
await app.plugin().reload("test-plugin@latest")
expect(app.plugin().list()[0]).toMatchObject({ revision: "2.0.0", generation: "local-reload-1" })
expect(operations.map((operation) => operation.path)).toEqual([
"/api/plugin/check",
"/api/plugin/update",
"/api/plugin/reload",
])
expect(operations.every((operation) => JSON.stringify(operation.body).includes("test-plugin@latest"))).toBe(true)
expect(fixture.calls.some((call) => call.operation === "check" || call.operation === "update")).toBe(false)
expect(fixture.calls.filter((call) => call.operation === "reload")).toEqual([
{ operation: "reload", spec: "test-plugin@latest", revision: "2.0.0" },
])
expect(await read()).not.toContain("3.0.0")
process.emit("SIGHUP")
await app.task
})
test("manual local-file reload reruns an unchanged source under a fresh generation without package operations", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const source = path.join(tmp.path, "local.ts")
await writeFile(
source,
`
import { appendFile } from "node:fs/promises"
await appendFile(${JSON.stringify(marker)}, "import\\n")
${lifecyclePluginSource(marker, "test.local", "local")}
`,
)
await using app = await bootApp(tmp.path, { config: { plugins: [source] } })
const read = () => readFile(marker, "utf8")
await until(read, (value) => value?.includes("local:setup") ?? false)
const generation = app.plugin().list()[0]?.generation
expect(await app.plugin().check(source)).toEqual({ mutable: false })
await app.plugin().reload(source)
expect(app.plugin().list()[0]?.generation).not.toBe(generation)
expect(await read()).toBe("import\nlocal:setup\nimport\nlocal:cleanup\nlocal:setup\n")
process.emit("SIGHUP")
await app.task
})
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
@@ -0,0 +1,738 @@
import { expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { InputRenderable, type Renderable } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import type { Info } from "../src/config"
import type { PackageResolver } from "../src/plugin/context"
import { tmpdir } from "./fixture/fixture"
import { createEventStream, createFetch, json, type FetchHandler } from "./fixture/tui-client"
import { noPackages } from "./fixture/tui-packages"
const target = "fixture-plugin@latest"
const packageEntry = (revision: "1.0.0" | "2.0.0" | "broken") => ({
entrypoint: new URL(
`./fixture/plugin-management/package-${revision === "1.0.0" ? "v1" : revision === "2.0.0" ? "v2" : "broken"}.ts`,
import.meta.url,
).href,
revision: revision === "broken" ? "2.0.0" : revision,
generation: revision,
})
async function bootApp(
directory: string,
options: {
width?: number
height?: number
plugins?: Info["plugins"]
server?: () => PluginInfo[]
packages?: PackageResolver
fetch?: FetchHandler
} = {},
) {
const setup = await createTestRenderer({
width: options.width ?? 100,
height: options.height ?? 40,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const location = { directory, project: { id: "proj_fixture", directory, canonical: directory } }
const events = createEventStream()
const requests: Array<{ path: string; method: string; directory: string | null; body: unknown }> = []
const calls = createFetch(async (url, request) => {
if (/^\/api\/plugin\/(check|update|reload)$/.test(url.pathname))
requests.push({
path: url.pathname,
method: request.method,
directory: url.searchParams.get("location[directory]"),
body: await request.clone().json(),
})
const override = await options.fetch?.(url, request)
if (override) return override
if (url.pathname === "/api/plugin") return json({ location, data: options.server?.() ?? [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, plugins: options.plugins ?? [], keybinds: { "plugins.list": "f6" } }),
update: async () => ({}),
},
packages: options.packages ?? noPackages,
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(
Effect.provide(
Global.layerWith({
home: directory,
config: path.join(directory, "config"),
data: path.join(directory, "data"),
cache: path.join(directory, "cache"),
state: path.join(directory, "state"),
tmp: path.join(directory, "tmp"),
bin: path.join(directory, "bin"),
log: path.join(directory, "log"),
repos: path.join(directory, "repos"),
}),
),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
requests,
async [Symbol.asyncDispose]() {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task.finally(async () => {
process.chdir(cwd)
events.disconnect()
await server.stop(true)
})
},
}
}
async function openPlugins(app: TestRendererSetup, search: string) {
await app.waitForFrame((frame) => frame.includes("Ask anything"), { maxPasses: 100 })
app.mockInput.pressKey("F6")
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedRenderable instanceof InputRenderable)
await app.mockInput.typeText(search)
await app.waitForFrame((frame) => frame.includes(search) && !frame.includes("Search"))
}
async function click(app: TestRendererSetup, text: string) {
const frame = await app.waitForFrame((frame) => frame.includes(text))
const lines = frame.split("\n")
const y = lines.findIndex((line) => line.includes(text))
await app.mockMouse.click(lines[y].indexOf(text) + 1, y)
}
async function scrollMetadata(app: TestRendererSetup, expected: string, direction: "up" | "down" = "down") {
const lines = app.captureCharFrame().split("\n")
const y = lines.findIndex((line) =>
/^\s*(Runtime|Scope|Status|Source|Loaded|Installed|Available|Provides)\s{2,}/.test(line),
)
if (y < 0) throw new Error("Plugin metadata viewport was not found")
const x = lines[y].search(/\S/) + 1
for (let attempt = 0; attempt < 8; attempt++) {
if (app.captureCharFrame().replace(/\s+/g, " ").includes(expected)) return app.captureCharFrame()
await app.mockMouse.scroll(x, y, direction)
await app.renderOnce()
}
return app.captureCharFrame()
}
function findFilter(root: Renderable): InputRenderable | undefined {
if (root instanceof InputRenderable && root.traits.status === "FILTER") return root
return root.getChildren().map(findFilter).find(Boolean)
}
test("package checks are explicit and a busy update preserves the dialog and selected action", async () => {
await using tmp = await tmpdir()
const checked: string[] = []
const updates: string[] = []
const release = Promise.withResolvers<void>()
await using app = await bootApp(tmp.path, {
plugins: [target],
packages: {
...noPackages,
resolve: async () => packageEntry("1.0.0"),
check: async (spec) => {
checked.push(spec)
return { installed: "1.5.0", available: "2.0.0", mutable: true }
},
update: async (spec) => {
updates.push(spec)
await release.promise
return packageEntry("2.0.0")
},
},
})
try {
await openPlugins(app, "fixture.package")
expect(checked).toEqual([])
const filter = findFilter(app.renderer.root)
expect(filter).toBeDefined()
if (!filter) throw new Error("Plugin list filter was not found")
app.mockInput.pressKey("r", { ctrl: true })
const available = await app.waitForFrame((frame) => frame.includes("\u2191 update"))
expect(available).toContain("fixture.package")
expect(checked).toEqual([target])
expect(findFilter(app.renderer.root)).toBe(filter)
expect(app.renderer.currentFocusedRenderable).toBe(filter)
expect(filter.value).toBe("fixture.package")
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Check for updates"))
expect(details).toContain("Loaded 1.0.0")
expect(details).toContain("Installed 1.5.0")
expect(details).toContain("Available 2.0.0")
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Updating; waiting"))
app.mockInput.pressEnter()
await click(app, "Update package")
expect(updates).toEqual([target])
release.resolve()
const updated = await app.waitForFrame(
(frame) => frame.includes("Loaded 2.0.0") && frame.includes("No newer revision"),
)
expect(updated).toContain("Installed 2.0.0")
expect(updated).not.toContain("\u2191 available")
expect(updated).not.toContain("Updating; waiting")
// Enter repeats the selected update, rather than resetting to the check row.
app.mockInput.pressEnter()
await app.waitFor(() => updates.length === 2)
await app.waitForFrame((frame) => !frame.includes("Updating; waiting"))
expect(checked).toEqual([target])
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedRenderable instanceof InputRenderable)
await app.mockInput.typeText("fixture.package")
const list = await app.waitForFrame((frame) => frame.includes("fixture.package") && frame.includes("enter details"))
expect(list).not.toContain("\u2191 update")
expect(app.requests).toEqual([])
} finally {
release.resolve()
}
})
test.each([100, 40])("server package controls remain visible and usable at %i columns and 24 rows", async (width) => {
await using tmp = await tmpdir()
const inventory: PluginInfo[] = [
{
id: "fixture.server",
source: { type: "package", package: target },
status: "active",
tui: false,
revision: "1.0.0",
generation: "first",
},
]
await using app = await bootApp(tmp.path, {
width,
height: 24,
server: () => inventory,
fetch: (url) => {
const location = { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } }
if (url.pathname === "/api/plugin/check")
return json({ location, data: { installed: "1.5.0", available: "2.0.0", mutable: true } })
if (url.pathname === "/api/plugin/update") {
inventory[0] = { ...inventory[0], revision: "2.0.0", generation: "second" }
return json({ location, data: inventory })
}
},
})
await openPlugins(app, "fixture.server")
expect(app.requests).toEqual([])
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("\u2191 update"))
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Check for updates"))
expect(details).toContain("Reload installed code")
expect(details).toContain("Back to plugins")
expect(details).toContain("A newer revision is available.")
expect(details).not.toContain("Disable in this terminal")
const metadata = await scrollMetadata(app, "Available 2.0.0")
expect(metadata).toContain("Loaded 1.0.0")
expect(metadata).toContain("Installed 1.5.0")
expect(metadata).toContain("Available 2.0.0")
const source = await scrollMetadata(app, "Provides fixture.server")
expect(source.replace(/\s+/g, " ")).toContain("Provides fixture.server")
expect(source).toContain("Scope ~")
await click(app, "Update package")
await app.waitForFrame((frame) => frame.includes("No newer revision at last check."))
const updated = await scrollMetadata(app, "Loaded 2.0.0", "up")
expect(updated).toContain("Loaded 2.0.0")
expect(updated).not.toContain("\u2191 available")
expect(updated).toContain("No newer revision at last check.")
expect(app.requests).toEqual([
{ path: "/api/plugin/check", method: "POST", directory: tmp.path, body: { target } },
{ path: "/api/plugin/update", method: "POST", directory: tmp.path, body: { target } },
])
})
test("companion rows share one server-owned check and update the advertised terminal revision", async () => {
await using tmp = await tmpdir()
const inventory: PluginInfo[] = ["fixture.server", "fixture.second"].map((id) => ({
id,
source: { type: "package", package: target },
status: "active",
tui: true,
revision: "1.0.0",
generation: "first",
}))
const resolutions: Array<{ spec: string; install?: boolean; revision?: string }> = []
const localActions: string[] = []
const release = Promise.withResolvers<void>()
await using app = await bootApp(tmp.path, {
// The same raw source can also be configured in cli.json.
plugins: [target],
server: () => inventory,
packages: {
resolve: async (spec, install, revision) => {
resolutions.push({ spec, install, revision })
return packageEntry(revision === "2.0.0" ? "2.0.0" : "1.0.0")
},
check: async (spec) => {
localActions.push(`check:${spec}`)
return { mutable: true }
},
update: async (spec) => {
localActions.push(`update:${spec}`)
return packageEntry("2.0.0")
},
reload: async (spec) => {
localActions.push(`reload:${spec}`)
return packageEntry("2.0.0")
},
},
fetch: async (url) => {
const location = { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } }
if (url.pathname === "/api/plugin/check")
return json({ location, data: { installed: "1.0.0", available: "2.0.0", mutable: true } })
if (url.pathname === "/api/plugin/update") {
await release.promise
inventory.splice(
0,
inventory.length,
...inventory.map((plugin) => ({ ...plugin, revision: "2.0.0", generation: "second" })),
)
return json({ location, data: inventory })
}
},
})
try {
await openPlugins(app, "fixture.")
await app.waitForFrame(
(frame) =>
frame.includes("fixture.package") && frame.includes("fixture.server") && frame.includes("fixture.second"),
)
expect(app.requests).toEqual([])
app.mockInput.pressEnter()
await click(app, "Check for updates")
await app.waitForFrame((frame) => frame.includes("A newer revision is available."))
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedRenderable instanceof InputRenderable)
await app.mockInput.typeText("fixture.")
const list = await app.waitForFrame((frame) => frame.split("\u2191 update").length === 4)
expect(list.split("\n").filter((line) => line.includes("fixture.package"))).toHaveLength(1)
expect(app.requests).toEqual([{ path: "/api/plugin/check", method: "POST", directory: tmp.path, body: { target } }])
expect(localActions).toEqual([])
app.mockInput.pressEnter()
await click(app, "Update package")
await app.waitForFrame((frame) => frame.includes("Updating; waiting"))
app.mockInput.pressEnter()
await click(app, "Update package")
expect(app.requests.filter((request) => request.path === "/api/plugin/update")).toHaveLength(1)
release.resolve()
await app.waitForFrame((frame) => frame.includes("Loaded 2.0.0") && frame.includes("No newer revision"))
expect(resolutions).toContainEqual({ spec: target, install: true, revision: "2.0.0" })
expect(localActions).toEqual([])
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins"))
app.mockInput.pressEscape()
await openPlugins(app, "fixture.")
const reopened = await app.waitForFrame(
(frame) => frame.includes("fixture.server") && frame.includes("fixture.package"),
)
expect(reopened).not.toContain("\u2191 update")
expect(app.requests).toHaveLength(2)
} finally {
release.resolve()
}
})
test("a failed terminal package update retains its loaded revision and exposes the real setup error", async () => {
await using tmp = await tmpdir()
await using app = await bootApp(tmp.path, {
plugins: [target],
packages: {
...noPackages,
resolve: async () => packageEntry("1.0.0"),
check: async () => ({ installed: "2.0.0", available: "2.0.0", mutable: true }),
update: async () => packageEntry("broken"),
},
})
await openPlugins(app, "fixture.package")
app.mockInput.pressKey("r", { ctrl: true })
// A newer cached install is not evidence that this terminal activated it.
await app.waitForFrame((frame) => frame.includes("\u2191 update"))
app.mockInput.pressEnter()
await click(app, "Update package")
const failed = await app.waitForFrame(
(frame) => frame.includes("View error details") && frame.includes("Operation failed"),
)
expect(failed).toContain("Loaded 1.0.0")
expect(failed).toContain("Installed 2.0.0")
expect(failed).toContain("Status active")
expect(failed).toContain("\u2191 available")
await click(app, "View error details")
await app.waitForFrame(
(frame) => frame.includes("Fixture replacement failed during setup") && frame.includes("copy details"),
)
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Loaded 1.0.0") && frame.includes("View error details"))
expect(app.requests).toEqual([])
})
test("a failed server replacement reports the previous active revision and details", async () => {
await using tmp = await tmpdir()
const inventory: PluginInfo[] = [
{
id: "fixture.server",
source: { type: "package", package: target },
status: "active",
tui: false,
revision: "1.0.0",
generation: "first",
},
]
await using app = await bootApp(tmp.path, {
server: () => inventory,
fetch: (url) => {
if (url.pathname !== "/api/plugin/update") return
inventory[0] = { ...inventory[0], error: "Server replacement failed during setup" }
return json({
location: { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } },
data: inventory,
})
},
})
await openPlugins(app, "fixture.server")
app.mockInput.pressEnter()
await click(app, "Update package")
const failed = await app.waitForFrame(
(frame) => frame.includes("View error details") && frame.includes("Operation failed"),
)
expect(failed).toContain("Loaded 1.0.0")
expect(failed).toContain("Status active")
await click(app, "View error details")
await app.waitForFrame(
(frame) => frame.includes("Server replacement failed during setup") && frame.includes("copy details"),
)
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("View error details"))
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedRenderable instanceof InputRenderable)
await app.mockInput.typeText("fixture.server")
await app.waitForFrame((frame) => frame.includes("previous active"))
expect(app.requests).toEqual([{ path: "/api/plugin/update", method: "POST", directory: tmp.path, body: { target } }])
})
test.each(["tui", "server"] as const)(
"an exact %s reload preserves newer installed and available metadata",
async (runtime) => {
await using tmp = await tmpdir()
const location = { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } }
const inventory: PluginInfo[] = [
{
id: "fixture.server",
source: { type: "package", package: target },
status: "active",
tui: false,
revision: "1.0.0",
generation: "first",
},
]
const reloads: Array<{ target: string; options: Parameters<PackageResolver["reload"]>[1] }> = []
await using app = await bootApp(tmp.path, {
plugins: runtime === "tui" ? [target] : [],
server: () => (runtime === "server" ? inventory : []),
packages: {
...noPackages,
resolve: async () => packageEntry("1.0.0"),
check: async () => ({ installed: "2.0.0", available: "2.0.0", mutable: true }),
reload: async (target, options) => {
reloads.push({ target, options })
return { ...packageEntry("1.0.0"), generation: "reloaded" }
},
},
fetch: (url) => {
if (url.pathname === "/api/plugin/check")
return json({ location, data: { installed: "2.0.0", available: "2.0.0", mutable: true } })
if (url.pathname === "/api/plugin/reload") {
inventory[0] = { ...inventory[0], generation: "reloaded" }
return json({ location, data: inventory })
}
},
})
const label = runtime === "tui" ? "fixture.package" : "fixture.server"
await openPlugins(app, label)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("\u2191 update"))
app.mockInput.pressEnter()
const checked = await app.waitForFrame((frame) => frame.includes("Reload installed code"))
expect(checked).toContain("Loaded 1.0.0")
expect(checked).toContain("Installed 2.0.0")
expect(checked).toContain("Available 2.0.0")
await click(app, "Reload installed code")
const reloaded = await app.waitForFrame(
(frame) => frame.includes("Plugin reloaded") && !frame.includes("Reloading; waiting"),
)
expect(reloaded).toContain("Loaded 1.0.0")
expect(reloaded).toContain("Installed 2.0.0")
expect(reloaded).toContain("Available 2.0.0")
expect(reloaded).toContain("\u2191 available")
expect(reloaded).not.toContain("No newer revision")
expect(reloads).toEqual(runtime === "tui" ? [{ target, options: { revision: "1.0.0", generation: "1.0.0" } }] : [])
expect(app.requests).toEqual(
runtime === "server"
? [
{ path: "/api/plugin/check", method: "POST", directory: tmp.path, body: { target } },
{ path: "/api/plugin/reload", method: "POST", directory: tmp.path, body: { target } },
]
: [],
)
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedRenderable instanceof InputRenderable)
await app.mockInput.typeText(label)
await app.waitForFrame((frame) => frame.includes(label) && frame.includes("\u2191 update"))
},
)
test("a pinned package can be checked and reloaded but does not offer update", async () => {
await using tmp = await tmpdir()
const pinned = "fixture-plugin@1.0.0"
const actions: string[] = []
await using app = await bootApp(tmp.path, {
plugins: [pinned],
packages: {
...noPackages,
resolve: async () => packageEntry("1.0.0"),
check: async (spec) => {
actions.push(`check:${spec}`)
return { installed: "1.0.0", available: "2.0.0", mutable: false }
},
reload: async (spec) => {
actions.push(`reload:${spec}`)
return packageEntry("1.0.0")
},
},
})
await openPlugins(app, "fixture.package")
expect(actions).toEqual([])
app.mockInput.pressEnter()
await click(app, "Check for updates")
const checked = await app.waitForFrame((frame) => frame.includes("(pinned)"))
expect(checked).not.toContain("Update package")
expect(checked).not.toContain("\u2191 available")
expect(checked).toContain("Reload installed code")
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
await app.waitFor(() => actions.length === 2)
await app.waitForFrame((frame) => !frame.includes("Reloading; waiting"))
expect(actions).toEqual([`check:${pinned}`, `reload:${pinned}`])
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins"))
app.mockInput.pressEscape()
await openPlugins(app, "fixture.package")
expect(app.captureCharFrame()).not.toContain("\u2191 update")
app.mockInput.pressEnter()
const reopened = await app.waitForFrame((frame) => frame.includes("(pinned)"))
expect(reopened).not.toContain("Update package")
expect(actions).toHaveLength(2)
expect(app.requests).toEqual([])
})
test("builtin details retain enable and disable without package or reload actions", async () => {
await using tmp = await tmpdir()
await using app = await bootApp(tmp.path, { width: 40, height: 24 })
await openPlugins(app, "opencode.notifications")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Disable in this terminal"))
const details = await scrollMetadata(app, "Updates with OpenCode itself.")
expect(details.replace(/\s+/g, " ")).toContain("Updates with OpenCode itself.")
expect(details).not.toContain("Check for updates")
expect(details).not.toContain("Update package")
expect(details).not.toContain("Reload installed code")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Status inactive") && frame.includes("Enable in this terminal"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Status active") && frame.includes("Disable in this terminal"))
expect(app.requests).toEqual([])
})
test("local terminal details reload the source and retain toggle controls without package calls", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "lifecycle.txt")
const local = new URL("./fixture/plugin-management/local.ts", import.meta.url).href
await using app = await bootApp(tmp.path, {
plugins: [{ package: local, options: { marker } }],
})
await openPlugins(app, "fixture.local")
expect(await Bun.file(marker).text()).toBe("setup\n")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("inactive") && frame.includes("enable space"))
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => !frame.includes("inactive") && frame.includes("disable space"))
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Reload installed code"))
expect(details).not.toContain("Check for updates")
expect(details).not.toContain("Update package")
expect(details).toContain("Disable in this terminal")
await click(app, "Reload installed code")
await app.waitFor(async () => (await Bun.file(marker).text()) === "setup\ncleanup\nsetup\ncleanup\nsetup\n")
await app.waitForFrame((frame) => !frame.includes("Reloading; waiting"))
expect(app.requests).toEqual([])
})
test.each(["local", "builtin", "sdk"] as const)(
"Enter opens %s server details with only source-appropriate actions",
async (source) => {
await using tmp = await tmpdir()
const local = path.join(tmp.path, "server.ts")
const inventory: PluginInfo[] = [
{
id: "fixture.server",
source: source === "local" ? { type: "local", path: local } : { type: source },
status: "active",
tui: false,
},
]
await using app = await bootApp(tmp.path, {
server: () => inventory,
fetch: (url) => {
if (url.pathname === "/api/plugin/reload")
return json({
location: { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } },
data: inventory,
})
},
})
await openPlugins(app, "fixture.server")
app.mockInput.pressEnter()
const details = await app.waitForFrame(
(frame) => frame.includes("Runtime Server") && frame.includes("Back to plugins"),
)
expect(details).not.toContain("Check for updates")
expect(details).not.toContain("Update package")
expect(details).not.toContain("Disable in this terminal")
if (source !== "local") {
expect(details).not.toContain("Reload installed code")
expect(app.requests).toEqual([])
return
}
await click(app, "Reload installed code")
await app.waitFor(() => app.requests.length === 1)
await app.waitForFrame((frame) => !frame.includes("Reloading; waiting"))
expect(app.requests).toEqual([
{ path: "/api/plugin/reload", method: "POST", directory: tmp.path, body: { target: local } },
])
},
)
test("Escape returns to the same busy dialog while a server update waits", async () => {
await using tmp = await tmpdir()
const release = Promise.withResolvers<void>()
const inventory: PluginInfo[] = [
{
id: "fixture.server",
source: { type: "package", package: target },
status: "active",
tui: false,
revision: "1.0.0",
},
]
await using app = await bootApp(tmp.path, {
width: 40,
height: 24,
server: () => inventory,
fetch: async (url) => {
if (url.pathname !== "/api/plugin/update") return
await release.promise
inventory[0] = { ...inventory[0], revision: "2.0.0" }
return json({
location: { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } },
data: inventory,
})
},
})
try {
await openPlugins(app, "fixture.server")
app.mockInput.pressEnter()
await click(app, "Update package")
const busy = await app.waitForFrame((frame) => frame.includes("Updating; waiting"))
expect(busy.replace(/\s+/g, " ")).toContain("Updating; waiting for running work...")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Updating; waiting"))
const filter = findFilter(app.renderer.root)
expect(filter).toBeDefined()
app.mockInput.pressKey("r", { ctrl: true })
app.mockInput.pressEnter()
expect(app.requests).toHaveLength(1)
release.resolve()
await app.waitForFrame((frame) => frame.includes("Plugins") && !frame.includes("Updating; waiting"))
expect(findFilter(app.renderer.root)).toBe(filter)
expect(app.requests).toEqual([
{ path: "/api/plugin/update", method: "POST", directory: tmp.path, body: { target } },
])
} finally {
release.resolve()
}
})
test("a server row arriving after filtering supplies the selected row's check action", async () => {
await using tmp = await tmpdir()
const release = Promise.withResolvers<void>()
const state = { defer: false }
const location = { directory: tmp.path, project: { id: "proj_fixture", directory: tmp.path } }
await using app = await bootApp(tmp.path, {
width: 40,
height: 24,
fetch: async (url) => {
if (url.pathname === "/api/plugin") {
if (!state.defer) return json({ location, data: [] })
await release.promise
return json({
location,
data: [
{
id: "fixture.server",
source: { type: "package", package: target },
status: "active",
tui: false,
revision: "1.0.0",
},
],
})
}
if (url.pathname === "/api/plugin/check")
return json({ location, data: { installed: "1.0.0", available: "2.0.0", mutable: true } })
},
})
try {
await app.waitForFrame((frame) => frame.includes("Ask anything"), { maxPasses: 100 })
state.defer = true
await openPlugins(app, "fixture.server")
await app.waitForFrame((frame) => frame.includes("No results found"))
release.resolve()
const ready = await app.waitForFrame(
(frame) => frame.includes("check ctrl+r") && !frame.includes("No results found"),
)
expect(ready).not.toContain("disable space")
expect(app.requests).toEqual([])
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("\u2191 update"))
expect(app.requests).toEqual([{ path: "/api/plugin/check", method: "POST", directory: tmp.path, body: { target } }])
} finally {
release.resolve()
}
})
+2
View File
@@ -53,6 +53,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -62,6 +63,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:"
}
}
+308 -94
View File
@@ -1,10 +1,12 @@
export * as Npm from "./npm.js"
import path from "path"
import { createHash } from "node:crypto"
import { randomUUID } from "node:crypto"
import { cp } from "node:fs/promises"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { FSUtil } from "./fs-util.js"
import { Global } from "./global.js"
import { Hash } from "./hash.js"
import { EffectFlock } from "./effect-flock.js"
import { makeGlobalNode } from "./effect/app-node.js"
import { filesystem } from "./effect/app-node-platform.js"
@@ -22,14 +24,33 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
readonly generation: string
}
export interface Options {
readonly subpaths?: readonly string[]
readonly revision?: string
}
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
options?: Options & { readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: Options) => Effect.Effect<EntryPoint>
readonly inspect: (pkg: string) => Effect.Effect<{ installed?: string; mutable: boolean }, InstallFailedError>
readonly check: (
pkg: string,
) => Effect.Effect<{ installed?: string; available?: string; mutable: boolean }, InstallFailedError>
readonly update: (
pkg: string,
options?: Pick<Options, "subpaths">,
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly reload: (
pkg: string,
options?: Options & { readonly generation?: string },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -65,14 +86,14 @@ export async function isInstallablePackage(pkg: string) {
export async function cacheKey(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
if (npa(pkg).type === "git") return `git-${createHash("sha256").update(pkg).digest("hex")}`
if (npa(pkg).type === "git") return `git-${Hash.sha256(pkg)}`
} catch {
// Preserve the existing fallback for invalid and non-registry package strings.
}
return sanitize(pkg)
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]) => {
const entrypoint = subpaths
.map((subpath) => {
try {
@@ -88,17 +109,24 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[
}
}
interface ArboristNode {
name: string
path: string
}
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
const PackageJson = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
version: Schema.optional(Schema.String),
_resolved: Schema.optional(Schema.String),
})
const GenerationID = Schema.String.pipe(
Schema.check(Schema.isPattern(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/)),
)
const Generation = Schema.Struct({
name: Schema.String,
generation: GenerationID,
revision: Schema.optional(Schema.String),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))),
})
const layer = Layer.effect(
@@ -113,6 +141,19 @@ const layer = Layer.effect(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
const parse = (pkg: string) =>
Effect.promise(async () => {
const { default: npa } = await import("npm-package-arg")
try {
return npa(pkg)
} catch {
return undefined
}
})
const revisionFile = (root: string, revision: string) =>
path.join(root, "revisions", `${Hash.sha256(revision)}.json`)
const readGeneration = (file: string) =>
afs.readJson(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Generation)), Effect.option)
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
@@ -124,14 +165,70 @@ const layer = Layer.effect(
}
return pkg
})
const installed = Effect.fnUntraced(function* (pkg: string, revision?: string, generation?: string) {
const root = yield* directory(pkg)
const current = yield* readGeneration(path.join(root, "current.json"))
const legacy = `legacy-${Hash.sha256(root)}`
const selected =
generation !== undefined
? Schema.is(GenerationID)(generation)
? yield* readGeneration(path.join(root, "generations", generation, "generation.json"))
: Option.none()
: revision && (Option.isNone(current) || current.value.revision !== revision)
? yield* readGeneration(revisionFile(root, revision))
: current
const parsed = yield* parse(pkg)
const dir = Option.isSome(selected) ? path.join(root, "generations", selected.value.generation) : root
const name = Option.isSome(selected)
? selected.value.name
: yield* installedName(pkg, dir, parsed?.name ?? undefined)
const packageDir = path.join(dir, "node_modules", name)
const manifest = yield* afs
.readJson(path.join(packageDir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
const lock =
parsed?.type === "git" && Option.isNone(selected)
? yield* afs
.readJson(path.join(dir, "package-lock.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
: Option.none()
const resolved = Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`]?.resolved : undefined
const installedRevision = Option.isSome(selected)
? selected.value.revision
: Option.isSome(manifest)
? parsed?.type === "git"
? (gitRevision(resolved ?? manifest.value._resolved) ??
(!isMutable(parsed) ? (parsed.gitCommittish ?? undefined) : undefined))
: manifest.value.version
: undefined
return {
root,
dir,
name,
parsed,
cached:
Option.isSome(manifest) &&
(!revision || revision === installedRevision) &&
(generation === undefined || generation === (Option.isSome(selected) ? selected.value.generation : legacy)),
revision: installedRevision,
generation: Option.isSome(selected) ? selected.value.generation : legacy,
directory: packageDir,
}
})
const entry = (
state: { name: string; directory: string; revision?: string; generation: string },
options?: Options,
): EntryPoint => ({
...resolveEntryPoint(state.name, state.directory, options?.subpaths),
revision: state.revision,
generation: state.generation,
})
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
const reify = (input: { root: string; dir: string; pkg: string; online?: boolean }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* NpmConfig.load(input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const npmOptions = yield* NpmConfig.load(input.root)
const options = input.online ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const arborist = new Arborist({
...options,
path: input.dir,
@@ -139,133 +236,216 @@ const layer = Layer.effect(
progress: false,
savePrefix: "",
ignoreScripts: true,
installLinks: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
...options,
add,
update: input.update,
add: [input.pkg],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add,
add: [input.pkg],
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
})
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const add = Effect.fn("Npm.add")(function* (
const available = Effect.fnUntraced(function* (pkg: string, root: string) {
const { manifest, resolve } = yield* Effect.promise(() => import("pacote"))
const parsed = yield* parse(pkg)
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
return yield* Effect.tryPromise({
try: async () =>
parsed?.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
})
const publish = Effect.fnUntraced(function* (root: string, metadata: typeof Generation.Type, current: boolean) {
// Only pointers are replaced. Published module graphs remain at their original realpaths.
const write = Effect.fnUntraced(function* (file: string) {
const temporary = `${file}.${metadata.generation}.tmp`
yield* afs.writeWithDirs(temporary, JSON.stringify(metadata))
yield* fs.rename(temporary, file)
})
if (metadata.revision) yield* write(revisionFile(root, metadata.revision))
if (current) yield* write(path.join(root, "current.json"))
})
const stage = Effect.fnUntraced(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
options?: Options & { readonly generation?: string },
online = false,
copy = false,
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
const current = yield* installed(pkg)
const source =
options?.revision || options?.generation !== undefined
? yield* installed(pkg, options.revision, options.generation)
: current
const generation = randomUUID()
const dir = path.join(current.root, "generations", generation)
return yield* Effect.gen(function* () {
yield* afs.ensureDir(dir)
if (copy && !source.cached) return yield* new InstallFailedError({ dir: source.dir })
const requested =
options?.revision ?? (online && isMutable(current.parsed) ? yield* available(pkg, current.root) : undefined)
const target = requested && !copy ? yield* Effect.try(() => exactSpec(current.parsed, requested)) : pkg
if (requested && !copy && current.parsed?.type !== "git" && (yield* parse(target))?.type !== "version")
return yield* new InstallFailedError({ dir })
const tree = copy ? undefined : yield* reify({ root: current.root, dir, pkg: target, online })
if (copy) {
// Effect FileSystem.copy preserves symlinks; dereference them so dependencies cannot resolve into the old graph.
yield* Effect.tryPromise(() =>
cp(path.join(source.dir, "node_modules"), path.join(dir, "node_modules"), {
recursive: true,
dereference: true,
}),
)
// Bin links must keep their package realpath so scripts' relative imports still work.
const bins = yield* afs.scan("**/.bin/*", {
cwd: path.join(source.dir, "node_modules"),
absolute: true,
dot: true,
symlink: true,
})
yield* Effect.forEach(bins, (bin) =>
Effect.gen(function* () {
const link = yield* fs.readLink(bin).pipe(Effect.option)
if (Option.isNone(link)) return
const copied = path.join(dir, path.relative(source.dir, bin))
const target = path.join(dir, path.relative(source.dir, path.resolve(path.dirname(bin), link.value)))
yield* fs.remove(copied)
yield* fs.symlink(path.relative(path.dirname(copied), target), copied)
}),
)
}
})()
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name))
const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
const first = tree?.edgesOut.values().next().value?.to
const name = first?.name ?? source.name
const packageDir = path.join(dir, "node_modules", name)
const manifest = yield* afs
.readJson(path.join(packageDir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)))
const revision = copy
? source.revision
: current.parsed?.type === "git"
? (gitRevision(first?.resolved ?? undefined) ?? requested)
: manifest.version
if (requested && revision !== requested) return yield* new InstallFailedError({ dir })
const metadata = { name, generation, revision }
const result = entry({ ...metadata, directory: packageDir }, options)
if (options?.subpaths && !result.entrypoint) return yield* new InstallFailedError({ dir })
// A revision may have multiple dependency graphs; retain each generation's identity independently of pointers.
yield* afs.writeJson(path.join(dir, "generation.json"), metadata)
yield* publish(
current.root,
metadata,
!options?.revision || !current.cached || current.revision === options.revision,
)
return result
}).pipe(
Effect.mapError((cause) =>
cause instanceof InstallFailedError ? cause : new InstallFailedError({ dir, cause }),
),
Effect.onError(() => fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)),
// npm and copy I/O cannot be aborted; keep the lock through publication and never clean up a committed graph on cancellation.
Effect.uninterruptible,
)
})
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: Options & { readonly refresh?: boolean }) {
const cached = yield* installed(pkg, options?.revision)
if (cached.cached && (options?.revision || !options?.refresh || !isMutable(cached.parsed) || refreshed.has(pkg)))
return entry(cached, options)
yield* flock.acquire(`npm-install:${cached.root}`)
const state = yield* installed(pkg, options?.revision)
const refresh = !options?.revision && options?.refresh && isMutable(state.parsed) && !refreshed.has(pkg)
if (refresh) {
refreshed.add(pkg)
if (cached)
yield* reify({ dir, add: [pkg], update: true }).pipe(
Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")),
if (state.cached)
return yield* stage(pkg, options, true).pipe(
Effect.catchCause(() =>
Effect.logWarning("failed to refresh cached package; using installed version").pipe(
Effect.as(entry(state, options)),
),
),
)
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path, options?.subpaths)
if (state.cached) return entry(state, options)
return yield* stage(pkg, options)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (pkg: string, options?: Options) {
const state = yield* installed(pkg, options?.revision)
if (!state.cached) return { directory: state.directory, generation: state.generation }
return entry(state, options)
})
const inspect = Effect.fn("Npm.inspect")(function* (pkg: string) {
const state = yield* installed(pkg)
return { installed: state.cached ? state.revision : undefined, mutable: isMutable(state.parsed) }
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const state = yield* inspect(pkg)
return { ...state, available: state.mutable ? yield* available(pkg, yield* directory(pkg)) : state.installed }
})
const update = Effect.fn("Npm.update")(function* (pkg: string, options?: Pick<Options, "subpaths">) {
const root = yield* directory(pkg)
yield* flock.acquire(`npm-install:${root}`)
const state = yield* installed(pkg)
if (!isMutable(state.parsed) && state.cached) return entry(state, options)
return yield* stage(pkg, options, true)
}, Effect.scoped)
const reload = Effect.fn("Npm.reload")(function* (
pkg: string,
options?: Options & { readonly generation?: string },
) {
const root = yield* directory(pkg)
yield* flock.acquire(`npm-install:${root}`)
return yield* stage(pkg, options, false, true)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsedName)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = yield* directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const state = yield* installed(pkg)
const binDir = path.join(state.dir, "node_modules", ".bin")
const files = yield* fs.readDirectory(binDir).pipe(Effect.orElseSucceed(() => [] as string[]))
const selected = (name: string) => Option.some(path.join(binDir, name))
if (files.length === 0) return Option.none<string>()
// Caller picked a specific bin (e.g. pyright exposes both `pyright` and
// `pyright-langserver`); trust the hint if the package provides it.
if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
if (files.length === 1) return Option.some(files[0])
if (bin) return files.includes(bin) ? selected(bin) : Option.none<string>()
if (files.length === 1) return selected(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
const pkgJson = yield* afs.readJson(path.join(state.directory, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const unscoped = state.name.startsWith("@") ? state.name.split("/")[1] : state.name
const parsedBin = parsed.bin
if (typeof parsedBin === "string") return Option.some(unscoped)
if (typeof parsedBin === "string") return selected(unscoped)
const keys = Object.keys(parsedBin)
if (keys.length === 1) return Option.some(keys[0])
return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
if (keys.length === 1) return selected(keys[0])
return selected(parsedBin[unscoped] ? unscoped : keys[0])
}
}
return Option.some(files[0])
return selected(files[0])
})
return Option.getOrUndefined(
yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
if (Option.isSome(bin)) return bin
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
return yield* pick()
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
@@ -276,6 +456,10 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
inspect,
check,
update,
reload,
which,
})
}),
@@ -301,6 +485,36 @@ export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
export async function inspect(...args: Parameters<Interface["inspect"]>) {
return runPromise((svc) => svc.inspect(...args))
}
export async function check(...args: Parameters<Interface["check"]>) {
return runPromise((svc) => svc.check(...args))
}
export async function update(...args: Parameters<Interface["update"]>) {
return runPromise((svc) => svc.update(...args))
}
export async function reload(...args: Parameters<Interface["reload"]>) {
return runPromise((svc) => svc.reload(...args))
}
function gitRevision(resolved: string | undefined) {
return resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1]
}
function exactSpec(parsed: { type: string; name?: string | null; rawSpec: string } | undefined, revision: string) {
if (parsed?.type === "git") {
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(revision)) throw new Error("Expected a full Git commit")
const subdirectory = parsed.rawSpec.match(/::path:[^#]+$/)?.[0] ?? ""
return `${parsed.name ? `${parsed.name}@` : ""}${parsed.rawSpec.split("#")[0]}#${revision}${subdirectory}`
}
if (parsed?.name && ["version", "range", "tag"].includes(parsed.type)) return `${parsed.name}@${revision}`
throw new Error("Package does not support exact revisions")
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
+515 -3
View File
@@ -486,6 +486,363 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check the configured plugin source for a newer compatible revision without activating it.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Update a mutable configured plugin source and activate it after running work finishes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/reload": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.reload",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Reload a configured plugin source without network access after running work finishes.",
"summary": "Reload plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -977,6 +1334,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1355,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -10074,6 +10441,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15414,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15927,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16307,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -16042,6 +16506,15 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["id", "source", "status", "tui"],
@@ -16065,6 +16538,12 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
}
},
"required": ["source", "status", "error", "tui"],
@@ -16072,6 +16551,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16126,6 +16621,20 @@
}
]
},
"PluginOperationErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginOperationError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16222,7 +16731,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -17040,6 +17549,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
+515 -3
View File
@@ -486,6 +486,363 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check the configured plugin source for a newer compatible revision without activating it.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.update",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Update a mutable configured plugin source and activate it after running work finishes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/reload": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.reload",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginOperationError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginOperationErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Reload a configured plugin source without network access after running work finishes.",
"summary": "Reload plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -977,6 +1334,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1355,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -10074,6 +10441,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15414,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15927,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16307,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -16042,6 +16506,15 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["id", "source", "status", "tui"],
@@ -16065,6 +16538,12 @@
},
"tui": {
"type": "boolean"
},
"revision": {
"type": "string"
},
"generation": {
"type": "string"
}
},
"required": ["source", "status", "error", "tui"],
@@ -16072,6 +16551,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16126,6 +16621,20 @@
}
]
},
"PluginOperationErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginOperationError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16222,7 +16731,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -17040,6 +17549,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
@@ -6,6 +6,46 @@ Plugins configured in `opencode.json(c)` that expose a TUI component are loaded
to build plugins, see [Building plugins](/build/plugins). You do not need to add the same package to `cli.json`. The CLI
gets the active plugin list from the connected OpenCode server, so this also works when the server is remote.
## Manage Plugins
Run **/plugins** or choose **Plugins** from the command palette. Select a plugin and press **Enter** to see its source,
scope, loaded revision, and available actions. **Space** still enables or disables a TUI plugin in the current terminal.
- **Check for updates** looks up the revision allowed by the configured package source without replacing installed code.
You can also press **Ctrl+R** on a package row. A small upward arrow marks a newer revision after checking. Checks are
explicit, not a background polling loop.
- **Update package** downloads and applies the allowed revision without restarting the service or reopening the terminal.
Exact npm versions and full Git commit hashes remain pinned; changing a pin requires editing the configured source.
- **Reload installed code** reloads the installed package without downloading it. Local file plugins use the same
entrypoint reload mechanism as source watching; changes to unwatched local dependencies can still require a restart.
The **loaded** revision is the code actually registered in that runtime. Installed and available revisions come from the
last check or successful update and may differ from loaded code after a failed replacement. OpenCode retains the previous
working runtime when an update cannot load, and offers **View error details** rather than reporting a successful update.
This fallback is in-process, not a persistent rollback: restarting can try the installed revision again. Reloading while
the previous revision is still registered uses that revision, not a newer failed install.
Server actions apply to the terminal's default Location, shown as **Scope** in details. Activation waits for running
agent work in that Location to finish. New unrelated agent work waits while activation is pending; dependent child
sessions can finish with their parent. Prompt admission is not blocked.
A package's server plugin and TUI companion share an update source. Companion windows load the exact revision advertised
by their server, including when connected to a remote server. Each window applies the replacement independently; one
window's success does not confirm that every other window has updated. CLI-only packages update on the terminal's machine.
Disabled TUI plugins stay disabled. Built-in plugins update with OpenCode itself.
Plugin IDs own registrations and storage; a replacement that changes the exported ID is rejected without removing the
working plugin. Apply intentional ID changes by restarting or changing the configured source instead.
Reloading runs plugin cleanup and setup again. Plugins should keep drafts or other transient state in `ctx.storage.memory`
if that state must survive a reload; ordinary component state is recreated.
Package management is an external-client operation. Calling `check`, `update`, or `reload` through an in-process server
plugin's `ctx.plugin` returns an error directing you to the external client, so setup or an active tool cannot wait on its
own replacement.
Standalone SDK consumers that retain tool snapshots must coordinate their own execution lifecycle before updating plugins.
## Configure Plugins
Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects
to a remote server:
+5 -4
View File
@@ -57,7 +57,7 @@ explicitly or move it under `.opencode/`.
```jsonc title="opencode.jsonc"
{
"plugins": ["./plugins/local.ts"]
"plugins": ["./plugins/local.ts"],
}
```
@@ -66,7 +66,7 @@ use `.*` to match an ID prefix. A later ID re-enables a plugin.
```jsonc title="opencode.jsonc"
{
"plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"]
"plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"],
}
```
@@ -94,8 +94,9 @@ Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirect
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
Git plugins once. Use the CLI's [Plugins dialog](/cli/plugins#manage-plugins) to explicitly check, update, or reload a package
again without restarting the service or reopening the terminal. Exact npm versions and full Git commit hashes stay pinned.
Changes to unwatched local dependencies may still require restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts