mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 12:36:15 +00:00
Compare commits
2
Commits
v2
...
plugin-updates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80ec7adc0 | ||
|
|
93042ddaf1 |
@@ -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(),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
@@ -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()),
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -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
@@ -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">,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {},
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user