Compare commits

..
64 changed files with 2517 additions and 400 deletions
+7 -10
View File
@@ -27,7 +27,6 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
cli: ${{ steps.packages.outputs.cli }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -43,20 +42,18 @@ jobs:
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
echo "cli=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); for (const name of ["app", "cli"]) console.log(`${name}=${result.packages.items.some((item) => item.name === `@opencode-ai/${name}`)}`)' >> "$GITHUB_OUTPUT"
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
needs: affected
strategy:
fail-fast: false
matrix:
@@ -122,7 +119,7 @@ jobs:
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
@@ -140,7 +137,7 @@ jobs:
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
@@ -154,13 +151,13 @@ jobs:
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: needs.affected.outputs.cli == 'true'
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: needs.affected.outputs.cli == 'true'
if: always()
timeout-minutes: 15
working-directory: packages/cli
env:
+2
View File
@@ -986,6 +986,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -995,6 +996,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:",
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
}
}
+27
View File
@@ -88,8 +88,35 @@ 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
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.UpdateInfo> }
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 name: string
}
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: Plugin.UpdateResult }
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export type PluginUpdateAllInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type PluginUpdateAllOutput = {
readonly location: Location.Info
readonly data: ReadonlyArray<Plugin.UpdateResult>
}
export type PluginUpdateAllOperation<E = never> = (
input?: PluginUpdateAllInput,
) => Effect.Effect<PluginUpdateAllOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
readonly updateAll: PluginUpdateAllOperation<E>
}
export type SessionListInput = {
+29 -1
View File
@@ -15,6 +15,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginUpdateAllInput,
PluginUpdateAllOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -313,7 +319,29 @@ 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"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { name: input["name"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdateAll = (raw: RawClient["server.plugin"]) => (input?: PluginUpdateAllInput) =>
preserveEffect<PluginUpdateAllOutput>()(
raw["plugin.updateAll"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
updateAll: EndpointPluginUpdateAll(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginUpdateAllInput,
PluginUpdateAllOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -457,6 +463,43 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "GET",
path: `/api/plugin/update`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { name: input["name"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
updateAll: (input?: PluginUpdateAllInput, requestOptions?: RequestOptions) =>
request<PluginUpdateAllOutput>(
{
method: "POST",
path: `/api/plugin/update-all`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+53 -4
View File
@@ -426,6 +426,24 @@ export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type PluginUpdateInfo = {
name: string
source: PluginSource
status: "not-updateable" | "pinned" | "up-to-date" | "available" | "failed"
currentVersion?: string
latestVersion?: string
error?: string
}
export type PluginUpdateResult = {
name: string
source: PluginSource
status: "not-updateable" | "pinned" | "up-to-date" | "updated" | "failed"
previousVersion?: string
version?: string
error?: string
}
export type SessionMessageLocationSwitched = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -476,7 +494,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
@@ -2460,6 +2478,40 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginUpdateInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly name: { readonly name: string }["name"]
}
export type PluginUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginUpdateResult
}
export type PluginUpdateAllInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PluginUpdateAllOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginUpdateResult>
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
@@ -2740,7 +2792,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3016,7 +3067,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3292,7 +3342,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
+1 -1
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source, UpdateInfo, UpdateResult } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -1,6 +1,7 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -9,6 +10,15 @@ import { Context, Effect } from "effect"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
readonly check: () => Effect.Effect<Plugin.UpdateInfo[]>
readonly update: (name: string) => Effect.Effect<Plugin.UpdateResult>
readonly updateAll: () => Effect.Effect<Plugin.UpdateResult[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
export const noUpdates = {
check: () => Effect.succeed([]),
update: () => Effect.die("Plugin updates unavailable"),
updateAll: () => Effect.succeed([]),
} satisfies Omit<Interface, "flush">
+146 -13
View File
@@ -1,9 +1,9 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
export { Service, type Interface, noUpdates } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Schema, Semaphore, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -106,25 +106,30 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
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 local = path.isAbsolute(operation.target)
const installed = local
? { entrypoint: pathToFileURL(operation.target).href, revision: operation.mtime?.toString() }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.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 source =
operation.mtime === undefined
const source = local
? operation.mtime === undefined
? entrypoint
: typeof Bun !== "undefined"
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
: installed.revision
? `${entrypoint}?revision=${encodeURIComponent(installed.revision)}`
: entrypoint
yield* Effect.log({ msg: "loading plugin", local })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
version: `${JSON.stringify(operation)}:${installed.revision ?? ""}`,
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
@@ -134,15 +139,16 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const ready = yield* Latch.make()
const activationLock = Semaphore.makeUnsafe(1)
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()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
@@ -159,6 +165,73 @@ export const layer = Layer.effect(
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
})
const checkOne = Effect.fn("PluginSupervisor.checkOne")(function* (info: Plugin.Info) {
const name = pluginName(info)
if (info.source.type !== "package") {
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateInfo
}
const source = info.source
return yield* npm.check(source.package).pipe(
Effect.map(
(update): Plugin.UpdateInfo => ({
name,
source: info.source,
status: update.pinned
? "pinned"
: !update.updateable
? "not-updateable"
: update.updateAvailable
? "available"
: "up-to-date",
currentVersion: update.currentVersion,
latestVersion: update.latestVersion,
}),
),
Effect.catchCause(() =>
Effect.succeed({
name,
source: info.source,
status: "failed",
error: "Failed to check plugin update",
} satisfies Plugin.UpdateInfo),
),
)
})
const check = Effect.fn("PluginSupervisor.check")(function* () {
return yield* Effect.forEach(yield* registry.list(), checkOne, { concurrency: "unbounded" })
})
const updateOne = Effect.fn("PluginSupervisor.updateOne")(function* (info: Plugin.Info) {
const name = pluginName(info)
if (info.source.type !== "package") {
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateResult
}
const source = info.source
return yield* npm.update(source.package).pipe(
Effect.map(
(update): Plugin.UpdateResult => ({
name,
source: info.source,
status: update.pinned
? "pinned"
: !update.updateable
? "not-updateable"
: update.updated
? "updated"
: "up-to-date",
previousVersion: update.previousVersion,
version: update.latestVersion ?? update.currentVersion,
}),
),
Effect.catchCause(() =>
Effect.succeed({
name,
source: info.source,
status: "failed",
error: "Failed to update plugin",
} satisfies Plugin.UpdateResult),
),
)
})
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(() =>
@@ -169,19 +242,73 @@ export const layer = Layer.effect(
}),
),
)
const reload = (packages: readonly string[]) =>
activationLock.withPermit(
Effect.gen(function* () {
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
const failed = (yield* registry.list()).flatMap((info) =>
info.status === "failed" && info.source.type === "package" && packages.includes(info.source.package)
? [info.source.package]
: [],
)
if (failed.length === 0) return failed
yield* Effect.forEach(failed, (pkg) =>
npm
.rollback(pkg)
.pipe(
Effect.catchCause((cause) => Effect.logError("failed to restore plugin package revision", { cause })),
),
)
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
return failed
}),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
yield* activationLock.withPermit(activate())
if (observed === target) yield* ready.open
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({
flush: ready.await,
check,
update: Effect.fn("PluginSupervisor.update")(function* (name) {
const info = (yield* registry.list()).find((info) => pluginName(info) === name || info.id === name)
if (!info) {
return {
name,
source: { type: "package", package: name },
status: "failed",
error: "Plugin not found",
}
}
const result = yield* updateOne(info)
if (result.status === "updated" && result.source.type === "package") {
const failed = yield* reload([result.source.package])
if (failed.length > 0)
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
}
return result
}),
updateAll: Effect.fn("PluginSupervisor.updateAll")(function* () {
const results = yield* Effect.forEach(yield* registry.list(), updateOne)
const packages = results.flatMap((result) =>
result.status === "updated" && result.source.type === "package" ? [result.source.package] : [],
)
if (packages.length === 0) return results
const failed = yield* reload(packages)
return results.map((result) => {
if (result.source.type !== "package" || !failed.includes(result.source.package)) return result
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
})
}),
})
}),
)
@@ -199,4 +326,10 @@ function pluginSource(target: string): Plugin.Source {
return { type: "package", package: target }
}
function pluginName(info: Plugin.Info) {
if (info.source.type === "package") return info.source.package
if (info.source.type === "local") return info.source.path
return info.id ?? info.source.type
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+12 -22
View File
@@ -596,11 +596,7 @@ const layer = Layer.effect(
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
@@ -722,7 +718,7 @@ const layer = Layer.effect(
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = yield* skills.get(input.skill)
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
@@ -974,22 +970,16 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
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,
}
}),
)
const available = yield* skillService.list()
return yield* Effect.forEach(requested, (attachment) => {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
return Effect.succeed({
id: skill.id,
name: skill.name,
mention: attachment.mention,
})
})
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
})
+1 -5
View File
@@ -139,11 +139,7 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
const skills =
message.skills?.flatMap((skill) =>
skill.text === undefined ? [] : [`[Skill activated: ${skill.name}]\n${skill.text}`],
) ?? []
return [...skills, `[User]: ${message.text}`, ...files].join("\n")
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -236,7 +236,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]
-1
View File
@@ -220,7 +220,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: skill.text === undefined ? undefined : redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,
-20
View File
@@ -1,7 +1,6 @@
export * as Skill from "./skill.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { FSUtil } from "@opencode-ai/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
@@ -53,21 +52,6 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, 10)
: []
return {
directory,
output: toModelOutput(skill, files),
}
})
export type Data = {
skills: Map<ID, Types.DeepMutable<Info>>
}
@@ -80,7 +64,6 @@ export type Draft = {
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -115,9 +98,6 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
get: Effect.fn("Skill.get")(function* (id) {
return state.get().skills.get(id)
}),
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
}),
+1
View File
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+17 -2
View File
@@ -1,6 +1,7 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -8,6 +9,7 @@ import { Skill } from "../../skill.js"
import { Permission } from "../../permission.js"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
@@ -45,7 +47,8 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const skill = yield* skills.get(input.id)
const current = yield* skills.list()
const skill = current.find((skill) => skill.id === input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
@@ -56,7 +59,19 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: Skill.toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}).pipe(
Effect.map((output) => ({
+24
View File
@@ -37,6 +37,30 @@ const staticIt = testEffect(
)
describe("PluginSupervisor config", () => {
it.live("reports local and builtin plugins as not updateable", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin] },
Effect.gen(function* () {
yield* ready()
const supervisor = yield* PluginSupervisor.Service
const updates = yield* supervisor.check()
expect(updates.find((update) => update.name === plugin)).toEqual({
name: plugin,
source: { type: "local", path: plugin },
status: "not-updateable",
})
expect(updates.find((update) => update.source.type === "builtin")?.status).toBe("not-updateable")
expect(yield* supervisor.update(plugin)).toEqual({
name: plugin,
source: { type: "local", path: plugin },
status: "not-updateable",
})
}),
)
})
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
@@ -25,13 +25,18 @@ import { host } from "../plugin/host"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const pluginUpdates = {
check: () => Effect.succeed([]),
update: () => Effect.die("unused"),
updateAll: () => Effect.succeed([]),
}
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
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, PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })),
deps: [],
})
@@ -314,7 +319,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 PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
+178
View File
@@ -0,0 +1,178 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Effect } 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"
const npmLayer = (cache: string) =>
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
describe("Npm plugin updates", () => {
test("checks a moving Git ref without installing and explicitly updates its cached revision", async () => {
await using tmp = await tmpdir()
const repository = path.join(tmp.path, "plugin")
const cache = path.join(tmp.path, "cache")
await fs.mkdir(repository)
await Bun.write(
path.join(repository, "package.json"),
JSON.stringify({ name: "fixture-plugin", version: "1.0.0", exports: "./index.js" }),
)
await Bun.write(path.join(repository, "index.js"), "export default 'first'\n")
await Bun.$`git init -q -b main ${repository}`
await commit(repository, "first")
const first = await revision(repository)
const spec = `git+${pathToFileURL(repository).href}#main`
const layer = npmLayer(cache)
const installed = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(installed.revision).toBe(first)
await Bun.write(path.join(repository, "index.js"), "export default 'second'\n")
await commit(repository, "second")
const second = await revision(repository)
const checked = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.check(spec)
}).pipe(Effect.provide(layer), Effect.runPromise)
expect(checked).toMatchObject({
currentVersion: first,
latestVersion: second,
updateAvailable: true,
})
const updated = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.update(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(updated).toMatchObject({
previousVersion: first,
currentVersion: second,
updated: true,
})
const current = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(current.directory).not.toBe(installed.directory)
expect(current.revision).toBe(second)
if (!current.entrypoint) throw new Error("Updated plugin entrypoint missing")
expect(
await Bun.file(
current.entrypoint.startsWith("file:") ? fileURLToPath(current.entrypoint) : current.entrypoint,
).text(),
).toContain("second")
const unchanged = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.update(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(unchanged).toMatchObject({
previousVersion: second,
currentVersion: second,
latestVersion: second,
updateAvailable: false,
updated: false,
})
expect(
await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec)
}).pipe(Effect.provide(layer), Effect.runPromise),
).toMatchObject({ directory: current.directory, revision: second })
await Effect.gen(function* () {
const npm = yield* Npm.Service
yield* npm.rollback(spec)
}).pipe(Effect.provide(layer), Effect.runPromise)
expect(
await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec)
}).pipe(Effect.provide(layer), Effect.runPromise),
).toMatchObject({ directory: installed.directory, revision: first })
})
test("resolves a Git semver selector without installing HEAD outside the configured range", async () => {
await using tmp = await tmpdir()
const repository = path.join(tmp.path, "plugin")
const cache = path.join(tmp.path, "cache")
await fs.mkdir(repository)
await writePlugin(repository, "1.0.0", "one")
await Bun.$`git init -q -b main ${repository}`
await commit(repository, "one")
await Bun.$`git -C ${repository} tag v1.0.0`
const first = await revision(repository)
const spec = `git+${pathToFileURL(repository).href}#semver:^1`
const layer = npmLayer(cache)
const installed = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(installed.revision).toBe(first)
await writePlugin(repository, "1.1.0", "one-one")
await commit(repository, "one-one")
await Bun.$`git -C ${repository} tag v1.1.0`
const latest = await revision(repository)
await writePlugin(repository, "2.0.0", "two")
await commit(repository, "two")
await Bun.$`git -C ${repository} tag v2.0.0`
expect(await revision(repository)).not.toBe(latest)
const checked = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.check(spec)
}).pipe(Effect.provide(layer), Effect.runPromise)
expect(checked).toMatchObject({ currentVersion: first, latestVersion: latest, updateAvailable: true })
const updated = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.update(spec)
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
expect(updated).toMatchObject({ currentVersion: latest, latestVersion: latest, updated: true })
})
test("fails checks for missing Git refs", async () => {
await using tmp = await tmpdir()
const repository = path.join(tmp.path, "plugin")
await fs.mkdir(repository)
await writePlugin(repository, "1.0.0", "one")
await Bun.$`git init -q -b main ${repository}`
await commit(repository, "one")
const layer = npmLayer(path.join(tmp.path, "cache"))
await expect(
Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.check(`git+${pathToFileURL(repository).href}#missing`)
}).pipe(Effect.provide(layer), Effect.runPromise),
).rejects.toMatchObject({ _tag: "NpmInstallFailedError" })
})
})
async function writePlugin(repository: string, version: string, value: string) {
await Bun.write(
path.join(repository, "package.json"),
JSON.stringify({ name: "fixture-plugin", version, exports: "./index.js" }),
)
await Bun.write(path.join(repository, "index.js"), `export default '${value}'\n`)
}
async function commit(repository: string, message: string) {
await Bun.$`git -C ${repository} add .`
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${message}`
}
async function revision(repository: string) {
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
}
-42
View File
@@ -1,6 +1,5 @@
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -222,47 +221,6 @@ describe("Npm.add", () => {
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
})
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const repository = pathToFileURL(fixture.repository).href
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
const second = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
})
})
describe("Npm.resolve", () => {
+3
View File
@@ -35,7 +35,10 @@ const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
rollback: () => Effect.void,
which: () => Effect.undefined,
}),
)
@@ -23,7 +23,10 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
rollback: () => Effect.void,
which: () => Effect.undefined,
})
}
@@ -14,7 +14,10 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
rollback: () => Effect.void,
which: () => Effect.undefined,
})
@@ -23,7 +23,6 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -232,13 +231,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Manual compaction should include this short conversation.",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Use Effect services and generators.",
},
],
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
@@ -269,7 +261,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
+1 -13
View File
@@ -5,7 +5,6 @@ import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@@ -1179,13 +1178,6 @@ describe("SessionTransfer", () => {
id: sourceMessageID,
type: "user",
text: "Imported message",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Private skill instructions from /private/project",
},
],
time: { created: DateTime.makeUnsafe(100) },
},
{
@@ -1215,11 +1207,7 @@ describe("SessionTransfer", () => {
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{
id: sourceMessageID,
text: `[redacted:text:${sourceMessageID}]`,
skills: [{ id: "effect", name: "[redacted:skill-name:0]", text: "[redacted:skill:0]" }],
},
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
+1 -1
View File
@@ -110,7 +110,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, { ...PluginSupervisor.noUpdates, flush: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
+5 -3
View File
@@ -76,12 +76,14 @@ 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)) }),
PluginSupervisor.Service.of({
...PluginSupervisor.noUpdates,
flush: Effect.sync(() => (ready = true)),
}),
),
)
}),
@@ -205,44 +205,6 @@ Recent work
})
})
test("lowers each prepared skill once before the prompt", () => {
const effect = SkillAttachment.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "<skill_content>Use Effect</skill_content>",
})
const api = SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "<skill_content>Design APIs</skill_content>",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill-content"),
type: "user",
text: "Use @effect and @api-design",
skills: [effect, api, SkillAttachment.make({ id: effect.id, name: effect.name })],
time: { created },
}),
],
model,
)
expect(messages).toEqual([
Message.make({
id: id("user-skill-content"),
role: "user",
content: [
{ type: "text", text: "<skill_content>Use Effect</skill_content>" },
{ type: "text", text: "<skill_content>Design APIs</skill_content>" },
{ type: "text", text: "Use @effect and @api-design" },
],
metadata: {},
}),
])
})
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
@@ -85,7 +85,10 @@ 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,
PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -374,6 +374,7 @@ let pluginFlushHook = Effect.void
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
...PluginSupervisor.noUpdates,
flush: Effect.suspend(() => pluginFlushHook),
}),
)
+17 -57
View File
@@ -9,7 +9,6 @@ import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
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 { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -24,20 +23,18 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const info = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
}),
]),
})
const skills = Layer.merge(
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
@@ -59,7 +56,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("materializes mentioned skills on their owning prompt", () =>
it.effect("keeps skill mentions as references on a normal prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -70,63 +67,26 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply @effect and @effect",
skills: [
{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } },
{ id: Skill.ID.make("effect"), mention: { start: 18, end: 25, text: "@effect" } },
],
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([])
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({
id,
type: "user",
text: "Apply @effect and @effect",
text: "Apply @effect",
skills: [
{
id: "effect",
name: "Effect",
text: Skill.toModelOutput(info, []),
mention: { start: 6, end: 13, text: "@effect" },
},
{
id: "effect",
name: "Effect",
mention: { start: 18, end: 25, text: "@effect" },
},
],
}),
])
}),
)
it.effect("excludes mentioned skills when forking before their prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const session = yield* sessions.create({ location })
const initial = SessionMessage.ID.make("msg_before_skill_attachment")
const selected = SessionMessage.ID.make("msg_fork_skill_attachment")
yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false })
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
yield* sessions.prompt({
id: selected,
sessionID: session.id,
text: "Apply @effect",
skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
expect.objectContaining({ type: "user", text: "Before the skill" }),
])
)
}),
)
-2
View File
@@ -31,8 +31,6 @@ describe("Skill", () => {
})
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
expect(yield* skill.get(Skill.ID.make("review"))).toEqual(info("review", "Second"))
expect(yield* skill.get(Skill.ID.make("missing"))).toBeUndefined()
}),
)
@@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+3 -1
View File
@@ -125,7 +125,9 @@ 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(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
),
),
deps: [
Config.node,
-1
View File
@@ -74,7 +74,6 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
get: (id) => Effect.succeed(current.find((skill) => skill.id === id)),
list: () => Effect.succeed(current),
}),
)
+3 -1
View File
@@ -100,7 +100,9 @@ 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(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+1 -1
View File
@@ -31,7 +31,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: PluginApi<unknown>
readonly plugin: Pick<PluginApi<unknown>, "list">
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
+1 -1
View File
@@ -30,7 +30,7 @@ export interface Context {
readonly mcp: MCPDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
readonly plugin: PluginApi
readonly plugin: Pick<PluginApi, "list">
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
+358 -3
View File
@@ -486,6 +486,306 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"get": {
"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": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.UpdateInfo"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check configured plugins for explicitly available updates.",
"summary": "Check plugin updates"
},
"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": {
"$ref": "#/components/schemas/Plugin.UpdateResult"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update-all": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.updateAll",
"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.UpdateResult"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
"summary": "Update plugins"
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -14846,6 +15146,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15659,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15989,6 +16295,58 @@
}
]
},
"Plugin.UpdateInfo": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
},
"currentVersion": {
"type": "string"
},
"latestVersion": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Plugin.UpdateResult": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
},
"previousVersion": {
"type": "string"
},
"version": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16200,9 +16558,6 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+43
View File
@@ -19,6 +19,49 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.get("plugin.check", "/api/plugin/update", {
query: LocationQuery,
success: Location.response(Schema.Array(Plugin.UpdateInfo)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin updates",
description: "Check configured plugins for explicitly available updates.",
}),
),
)
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
payload: Schema.Struct({ name: Schema.String }),
query: LocationQuery,
success: Location.response(Plugin.UpdateResult),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
summary: "Update plugin",
description: "Explicitly update one configured plugin and reload plugins when it changes.",
}),
),
)
.add(
HttpApiEndpoint.post("plugin.updateAll", "/api/plugin/update-all", {
query: LocationQuery,
success: Location.response(Schema.Array(Plugin.UpdateResult)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.updateAll",
summary: "Update plugins",
description: "Explicitly update every updateable configured plugin and reload changed plugins.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
+20
View File
@@ -32,6 +32,26 @@ export const Info = Schema.Union([
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export interface UpdateInfo extends Schema.Schema.Type<typeof UpdateInfo> {}
export const UpdateInfo = Schema.Struct({
name: Schema.String,
source: Source,
status: Schema.Literals(["not-updateable", "pinned", "up-to-date", "available", "failed"]),
currentVersion: Schema.String.pipe(optional),
latestVersion: Schema.String.pipe(optional),
error: Schema.String.pipe(optional),
}).annotate({ identifier: "Plugin.UpdateInfo" })
export interface UpdateResult extends Schema.Schema.Type<typeof UpdateResult> {}
export const UpdateResult = Schema.Struct({
name: Schema.String,
source: Source,
status: Schema.Literals(["not-updateable", "pinned", "up-to-date", "updated", "failed"]),
previousVersion: Schema.String.pipe(optional),
version: Schema.String.pipe(optional),
error: Schema.String.pipe(optional),
}).annotate({ identifier: "Plugin.UpdateResult" })
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
-1
View File
@@ -57,7 +57,6 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String.pipe(optional),
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
@@ -6,7 +6,6 @@ import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
import { SkillAttachment } from "../src/prompt.js"
import { Provider } from "../src/provider.js"
import { Pty } from "../src/pty.js"
import { Session } from "../src/session.js"
@@ -80,16 +79,6 @@ describe("contract hygiene", () => {
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
})
test("skill attachments retain legacy references while accepting prepared instructions", () => {
const reference = { id: Skill.ID.make("effect"), name: Skill.Name.make("Effect") }
expect(Schema.decodeUnknownSync(SkillAttachment)(reference)).toEqual(reference)
expect(Schema.encodeSync(SkillAttachment)({ ...reference, text: undefined })).toEqual(reference)
expect(Schema.decodeUnknownSync(SkillAttachment)({ ...reference, text: "Use Effect" })).toEqual({
...reference,
text: "Use Effect",
})
})
test("session inbox items omit the internal enqueue sequence", () => {
expect(
Schema.encodeSync(SessionInbox.Info)(
+8 -6
View File
@@ -1,13 +1,15 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { Effect } from "effect"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
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", () => response(Plugin.Service.use((plugin) => plugin.list())))
.handle("plugin.check", () => response(PluginSupervisor.Service.use((plugins) => plugins.check())))
.handle("plugin.update", (ctx) =>
response(PluginSupervisor.Service.use((plugins) => plugins.update(ctx.payload.name))),
)
.handle("plugin.updateAll", () => response(PluginSupervisor.Service.use((plugins) => plugins.updateAll()))),
)
+29
View File
@@ -96,6 +96,35 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("serves plugin update operations through the HttpApi", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const check = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/plugin/update")))
expect(check.status).toBe(200)
expect(yield* Effect.promise(() => check.json())).toMatchObject({ data: expect.any(Array) })
const update = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/plugin/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "missing-plugin" }),
}),
),
)
expect(update.status).toBe(200)
expect(yield* Effect.promise(() => update.json())).toMatchObject({
data: { name: "missing-plugin", status: "failed", error: "Plugin not found" },
})
const updateAll = yield* Effect.promise(() =>
handler(new Request("http://opencode.local/api/plugin/update-all", { method: "POST" })),
)
expect(updateAll.status).toBe(200)
expect(yield* Effect.promise(() => updateAll.json())).toMatchObject({ data: expect.any(Array) })
}).pipe(Effect.scoped),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
Effect.gen(function* () {
const requests: string[] = []
@@ -50,9 +50,12 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
check: () => Effect.succeed([]),
flush: Effect.sync(() => {
flushes++
}),
update: () => Effect.die("unused"),
updateAll: () => Effect.succeed([]),
})
expect(flushes).toBe(0)
+2
View File
@@ -258,6 +258,8 @@ export const Definitions = {
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+r", "Update selected plugin"),
"dialog.plugins.update_all": keybind("ctrl+shift+r", "Update all plugins"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
@@ -0,0 +1,19 @@
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
export type UpdateEntry = PluginUpdateInfo | PluginUpdateResult
export function matchesPluginUpdate(plugin: PluginInfo, update: UpdateEntry) {
if (plugin.source.type !== update.source.type) return false
if (plugin.source.type === "package" && update.source.type === "package") {
return plugin.source.package === update.source.package
}
if (plugin.source.type === "local" && update.source.type === "local") return plugin.source.path === update.source.path
return plugin.id === update.name
}
export function pluginServerKey(plugin: PluginInfo) {
if (plugin.id) return `server:${plugin.id}`
if (plugin.source.type === "package") return `server:package:${plugin.source.package}`
if (plugin.source.type === "local") return `server:local:${plugin.source.path}`
return `server:${plugin.source.type}`
}
@@ -1,15 +1,17 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, 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 { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
import { matchesPluginUpdate, pluginServerKey, type UpdateEntry } from "./plugins-model"
const id = "opencode.plugins"
type Entry =
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo; readonly update?: UpdateEntry }
| {
readonly key: string
readonly runtime: "tui"
@@ -19,21 +21,47 @@ type Entry =
readonly error?: string
}
type PluginUpdateOperations = {
check(): Promise<readonly PluginUpdateInfo[]>
update(name: string): Promise<PluginUpdateResult>
updateAll(): Promise<readonly PluginUpdateResult[]>
}
export type PluginRegistry = Pick<ReturnType<typeof usePlugin>, "registered" | "list" | "activate" | "deactivate">
export function PluginsDialog(props: {
context: Plugin.Context
plugins: ReturnType<typeof usePlugin>
plugins: PluginRegistry
server?: () => readonly PluginInfo[]
updates?: PluginUpdateOperations
}) {
const dialog = useDialog()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
const [checking, setChecking] = createSignal(true)
const [operationError, setOperationError] = createSignal(false)
const [updateEntries, setUpdateEntries] = createSignal<readonly UpdateEntry[]>([])
const [updating, setUpdating] = createSignal<string>()
const location = () => props.context.location ?? props.context.data.location.default()
const operations: PluginUpdateOperations = props.updates ?? {
check: () => props.context.client.plugin.check({ location: location() }).then((result) => result.data),
update: (name) => props.context.client.plugin.update({ name, location: location() }).then((result) => result.data),
updateAll: () => props.context.client.plugin.updateAll({ location: location() }).then((result) => result.data),
}
const [server, { refetch: refetchServer }] = createResource(
() => (props.server ? undefined : location()),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onMount(() => {
dialog.setSize("medium")
void operations
.check()
.then(setUpdateEntries)
.catch(() => setOperationError(true))
.finally(() => setChecking(false))
})
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -56,10 +84,12 @@ export function PluginsDialog(props: {
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
runtime: "server" as const,
const runtime = props.server?.() ?? server() ?? []
const serverEntries: Entry[] = runtime.map((plugin) => ({
key: pluginServerKey(plugin),
runtime: "server",
plugin,
update: updateEntries().find((update) => matchesPluginUpdate(plugin, update)),
}))
return [
...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
@@ -81,11 +111,15 @@ 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),
footer: statusLabel(entry, checking(), updating()),
footerColor:
status(entry) === "failed"
status(entry) === "failed" || updateStatus(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
: updateStatus(entry) === "updated" || updateStatus(entry) === "up-to-date"
? props.context.theme.text.feedback.success.default
: updateStatus(entry) === "available"
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
@@ -96,6 +130,11 @@ export function PluginsDialog(props: {
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedUpdate = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "server") return
return entry.update
})
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
@@ -119,11 +158,38 @@ export function PluginsDialog(props: {
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
message: errorMessage(cause),
})
})
.finally(() => setLocked(false))
}
const runUpdate = (updating: string, operation: () => Promise<void>) => {
if (locked()) return
setLocked(true)
setUpdating(updating)
setOperationError(false)
void operation()
.catch((cause) => {
setOperationError(true)
props.context.ui.toast.show({ variant: "error", message: errorMessage(cause) })
})
.finally(() => {
setUpdating()
setLocked(false)
})
}
const update = (name: string) =>
runUpdate(name, async () => {
const result = await operations.update(name)
setUpdateEntries((current) => current.map((entry) => (entry.name === name ? result : entry)))
if (!props.server && result.status === "updated") await refetchServer()
})
const updateAll = () =>
runUpdate("*", async () => {
const results = await operations.updateAll()
setUpdateEntries(results)
if (!props.server && results.some((result) => result.status === "updated")) await refetchServer()
})
return (
<box>
@@ -141,19 +207,47 @@ export function PluginsDialog(props: {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
}}
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)),
},
]
: []
}
: []),
...(focusedUpdate()?.status === "available"
? [
{
title: "update",
command: "dialog.plugins.update",
onTrigger: (_option: DialogSelectOption<string>) => update(focusedUpdate()!.name),
},
]
: []),
...(updateEntries().some((entry) => entry.status === "available")
? [
{
title: "update all",
command: "dialog.plugins.update_all",
selection: "none" as const,
side: "right" as const,
onTrigger: updateAll,
},
]
: []),
]}
footer={
<Show when={pluginError(focusedEntry())}>
<Show
when={pluginError(focusedEntry())}
fallback={
<Show when={operationError()}>
<text fg={props.context.theme.text.feedback.error.default}>Plugin update failed</text>
</Show>
}
>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -196,8 +290,35 @@ function status(entry: Entry) {
return entry.status
}
function updateStatus(entry: Entry) {
if (entry.runtime !== "server") return
return entry.update?.status
}
function statusLabel(entry: Entry, checking: boolean, updating: string | undefined) {
if (entry.runtime === "tui") return status(entry) === "active" ? undefined : status(entry)
const update = entry.update
if (updating === "*" || (update && updating === update.name)) return "updating …"
if (entry.plugin.status === "failed") return "failed"
if (!update) return checking ? "checking …" : undefined
if (update.status === "not-updateable") return update.source.type === "local" ? "local" : undefined
const currentVersion =
"currentVersion" in update ? update.currentVersion : "version" in update ? update.version : undefined
if (update.status === "pinned") return currentVersion ? `${currentVersion} pinned` : "pinned"
if (update.status === "up-to-date") return currentVersion ? `${currentVersion} up to date` : "up to date"
if (update.status === "available") {
return `${update.currentVersion ?? "installed"}${update.latestVersion ?? "update available"}`
}
if (update.status === "updated") return `${update.previousVersion ?? "installed"}${update.version ?? "updated"}`
return "failed"
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
if (entry?.runtime === "server") {
if (entry.plugin.status === "failed") return entry.plugin.error
if (entry.update?.status === "failed") return entry.update.error
return
}
return entry?.error
}
@@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, type JSX } from "solid-js"
import { StoryFooter } from "./footer"
import { mermanLayoutsStory } from "./merman-layouts"
import { pluginUpdatesStory } from "./plugin-updates"
import { sessionTabsStory } from "./session-tabs"
import { sessionLocationMissingStory } from "./session-location-missing"
@@ -16,7 +17,7 @@ export type Story = {
render: (context: Plugin.Context) => JSX.Element
}
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, pluginUpdatesStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -0,0 +1,184 @@
import type { PluginInfo, PluginUpdateInfo, PluginUpdateResult } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { onMount } from "solid-js"
import { PluginsDialog, type PluginRegistry } from "../plugins"
import type { Story } from "./index"
import { StoryFooter } from "./footer"
const server: PluginInfo[] = [
{
id: "fixture.analytics",
source: { type: "package", package: "@fixture/analytics@latest" },
status: "active",
tui: false,
},
{
id: "fixture.theme",
source: { type: "package", package: "@fixture/theme@2.4.0" },
status: "active",
tui: false,
},
{
id: "fixture.local",
source: { type: "local", path: "/fixture/plugins/local.ts" },
status: "active",
tui: false,
},
{
source: { type: "package", package: "@fixture/broken@latest" },
status: "failed",
error: "Plugin entrypoint could not be loaded",
tui: false,
},
]
const initial = (): PluginUpdateInfo[] => [
{
name: "@fixture/analytics@latest",
source: { type: "package", package: "@fixture/analytics@latest" },
status: "available",
currentVersion: "1.8.0",
latestVersion: "1.9.0",
},
{
name: "@fixture/theme@2.4.0",
source: { type: "package", package: "@fixture/theme@2.4.0" },
status: "pinned",
currentVersion: "2.4.0",
},
{
name: "/fixture/plugins/local.ts",
source: { type: "local", path: "/fixture/plugins/local.ts" },
status: "not-updateable",
},
{
name: "@fixture/broken@latest",
source: { type: "package", package: "@fixture/broken@latest" },
status: "failed",
error: "Registry request failed with status 503",
},
]
const plugins: PluginRegistry = {
registered: () => [{ id: "fixture.ui", source: "builtin", active: true }],
list: () => [],
activate: async () => true,
deactivate: async () => true,
}
function PluginUpdatesStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
let fixture = initial()
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, 500))
const open = () =>
props.context.ui.dialog.show(() => (
<PluginsDialog
context={props.context}
plugins={plugins}
server={() => server}
updates={{
async check() {
await wait()
return fixture
},
async update(name) {
await wait()
const current = fixture.find((entry) => entry.name === name)
const result: PluginUpdateResult = {
name,
source: current?.source ?? { type: "package", package: name },
status: "updated",
previousVersion: current?.currentVersion,
version: current?.latestVersion,
}
fixture = fixture.map((entry) =>
entry.name === name ? { ...entry, status: "up-to-date", currentVersion: entry.latestVersion } : entry,
)
return result
},
async updateAll() {
await wait()
const results: PluginUpdateResult[] = fixture.map((entry) =>
entry.status === "available"
? {
name: entry.name,
source: entry.source,
status: "updated",
previousVersion: entry.currentVersion,
version: entry.latestVersion,
}
: {
name: entry.name,
source: entry.source,
status: entry.status,
version: entry.currentVersion,
error: entry.error,
},
)
fixture = initial().map((entry) =>
entry.status === "available"
? { ...entry, status: "up-to-date", currentVersion: entry.latestVersion }
: entry,
)
return results
},
}}
/>
))
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: "Back to storybook",
group: "Storybook",
run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
},
{ bind: "return", title: "Open plugin controls", group: "Storybook", run: open },
{
bind: "r",
title: "Reset plugin controls",
group: "Storybook",
run() {
fixture = initial()
open()
},
},
],
}))
onMount(open)
return (
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<box flexGrow={1} paddingLeft={2} paddingTop={1}>
<text fg={theme.text.default}>Plugin update controls fixture</text>
<text fg={theme.text.subdued}>The production Plugins dialog opens automatically.</text>
</box>
<StoryFooter
context={props.context}
title="storybook / plugin updates"
status="fixture API · 500ms operations"
controls={[
{ shortcut: "enter", label: "open" },
{ shortcut: "r", label: "reset" },
{ shortcut: "esc", label: "back" },
]}
/>
</box>
)
}
export const pluginUpdatesStory: Story = {
id: "plugin-updates",
title: "Plugin update controls",
render: (context) => <PluginUpdatesStory context={context} />,
}
+13 -21
View File
@@ -190,9 +190,6 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
messageID: item.id,
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
delivery: item.delivery,
...(item.payload.skills?.length
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
: {}),
}
}
@@ -391,12 +388,6 @@ function skillCommit(messageID: string, name: string, skillID = messageID): Stre
}
}
function skillCommits(messageID: string, skills: FooterQueuedPrompt["skills"] = []) {
return Array.from(new Map(skills.map((skill) => [skill.id, skill])).values(), (skill) =>
skillCommit(messageID, skill.name, skill.id),
)
}
function compactionCommit(messageID: string): StreamCommit {
return {
kind: "system",
@@ -676,7 +667,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...skillCommits(message.id, message.skills),
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
@@ -965,16 +956,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncPending()
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
if (!waiting && pending && !visible)
commits.push({
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
})
write(commits, { phase: "running", status: "waiting for assistant" })
if (!waiting && pending && !visible) {
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
])
}
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.inbox.delivery.changed") {
@@ -986,7 +979,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.messageIDs.has(event.data.inboxID)) return
state.messageIDs.add(event.data.inboxID)
write([
...skillCommits(event.data.inboxID, pending.skills),
{
kind: "user",
source: "system",
-1
View File
@@ -93,7 +93,6 @@ export type FooterQueuedPrompt = {
messageID: string
prompt: RunPrompt
delivery: RunDelivery
skills?: ReadonlyArray<{ id: string; name: string }>
}
export type QueuedPromptAction = "steer" | "cancel"
@@ -0,0 +1,62 @@
import type { PluginInfo, PluginUpdateInfo } from "@opencode-ai/client"
import { describe, expect, test } from "bun:test"
import { matchesPluginUpdate, pluginServerKey } from "../../src/feature-plugins/system/plugins-model"
const builtin = (id: string): PluginInfo => ({ id, source: { type: "builtin" }, status: "active", tui: false })
describe("plugin update row identity", () => {
test("does not join unrelated built-in plugins by source type", () => {
const update: PluginUpdateInfo = {
name: "fixture.second",
source: { type: "builtin" },
status: "not-updateable",
}
expect(matchesPluginUpdate(builtin("fixture.first"), update)).toBe(false)
expect(matchesPluginUpdate(builtin("fixture.second"), update)).toBe(true)
})
test("joins package and local plugins only by their exact source", () => {
const pkg = {
id: "fixture.package",
source: { type: "package", package: "@fixture/package@latest" },
status: "active",
tui: false,
} satisfies PluginInfo
const local = {
id: "fixture.local",
source: { type: "local", path: "/fixture/local.ts" },
status: "active",
tui: false,
} satisfies PluginInfo
expect(
matchesPluginUpdate(pkg, {
name: "@fixture/package@latest",
source: { type: "package", package: "@fixture/package@latest" },
status: "available",
}),
).toBe(true)
expect(
matchesPluginUpdate(pkg, {
name: "fixture.package",
source: { type: "package", package: "@fixture/other@latest" },
status: "available",
}),
).toBe(false)
expect(
matchesPluginUpdate(local, {
name: "/fixture/other.ts",
source: { type: "local", path: "/fixture/other.ts" },
status: "not-updateable",
}),
).toBe(false)
})
test("derives stable keys only from runtime identity", () => {
const plugin = builtin("fixture.stable")
expect(pluginServerKey(plugin)).toBe("server:fixture.stable")
expect(pluginServerKey(plugin)).toBe(pluginServerKey({ ...plugin }))
})
})
@@ -667,13 +667,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: {
text: "follow up",
skills: [
{ id: "effect", name: "Effect", text: "Use Effect services" },
{ id: "effect", name: "Effect" },
],
},
payload: { text: "follow up" },
delivery: "queue",
},
{
@@ -712,10 +706,9 @@ describe("V2 mini transport", () => {
})
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toEqual([
expect.objectContaining({ kind: "system", partID: "skill:effect", text: '→ Skill "Effect"' }),
expect.objectContaining({ kind: "user", text: "follow up" }),
])
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({
id: "evt_queued",
@@ -746,7 +739,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1", inboxID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
+2
View File
@@ -53,6 +53,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -62,6 +63,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:"
}
}
+234 -35
View File
@@ -1,7 +1,7 @@
export * as Npm from "./npm.js"
import path from "path"
import { createHash } from "node:crypto"
import { createHash, randomUUID } from "node:crypto"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { FSUtil } from "./fs-util.js"
import { Global } from "./global.js"
@@ -22,14 +22,31 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
}
export interface UpdateInfo {
readonly updateable: boolean
readonly pinned: boolean
readonly currentVersion?: string
readonly latestVersion?: string
readonly updateAvailable: boolean
}
export interface UpdateResult extends UpdateInfo {
readonly previousVersion?: string
readonly updated: boolean
}
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (pkg: string) => Effect.Effect<UpdateInfo, InstallFailedError | EffectFlock.LockError>
readonly update: (pkg: string) => Effect.Effect<UpdateResult, InstallFailedError | EffectFlock.LockError>
readonly rollback: (pkg: string) => Effect.Effect<void, InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -62,16 +79,33 @@ export async function isInstallablePackage(pkg: string) {
}
}
export async function updatePolicy(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
const parsed = npa(pkg)
const registry = ["version", "range", "tag"].includes(parsed.type)
const git = ["git", "hosted"].includes(parsed.type)
if (parsed.type === "version" || (git && /^[a-f0-9]{40,64}$/i.test(parsed.gitCommittish ?? ""))) {
return "pinned" as const
}
if (registry || git) return "mutable" as const
return "unsupported" as const
} catch {
return "unsupported" as const
}
}
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-${createHash("sha256").update(pkg).digest("hex")}`
}
} 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 entrypoint = subpaths
.map((subpath) => {
@@ -91,6 +125,8 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[
interface ArboristNode {
name: string
path: string
version?: string
resolved?: string
}
interface ArboristTree {
@@ -98,9 +134,20 @@ interface ArboristTree {
}
const PackageJson = Schema.Struct({
version: Schema.optional(Schema.String),
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(
Schema.Record(
Schema.String,
Schema.Struct({
version: Schema.optional(Schema.String),
resolved: Schema.optional(Schema.String),
}),
),
),
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -113,6 +160,15 @@ const layer = Layer.effect(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
const activeDirectory = Effect.fnUntraced(function* (dir: string) {
const active = yield* afs
.readFileStringSafe(path.join(dir, ".opencode-active"))
.pipe(Effect.orElseSucceed(() => undefined))
if (!active) return dir
const resolved = path.resolve(active.trim())
if (!resolved.startsWith(`${dir}-revision-`)) return dir
return resolved
})
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
@@ -124,14 +180,36 @@ const layer = Layer.effect(
}
return pkg
})
const refreshed = new Set<string>()
const installed = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string, git = false) {
const name = yield* installedName(pkg, dir, parsedName)
const directory = path.join(dir, "node_modules", name)
const manifest = yield* afs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
const version = Option.isSome(manifest) ? manifest.value.version : undefined
if (version && !git) return { name, directory, version }
const lock = yield* afs
.readJson(path.join(dir, "package-lock.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
const entry = Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`] : undefined
return {
name,
directory,
version: packageVersion({
name,
path: directory,
version: version ?? entry?.version,
resolved: entry?.resolved,
}),
}
})
const reify = (input: { dir: string; add?: string[]; update?: 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 options = input.update ? { ...npmOptions, preferOnline: true } : npmOptions
const arborist = new Arborist({
...options,
path: input.dir,
@@ -162,11 +240,125 @@ const layer = Layer.effect(
}),
)
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const parse = Effect.fnUntraced(function* (pkg: string) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
return yield* Effect.try({
try: () => npa(pkg),
catch: (cause) => new InstallFailedError({ cause, dir: path.join(global.cache, "packages") }),
})
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const parsed = yield* parse(pkg)
const policy = yield* Effect.promise(() => updatePolicy(pkg))
if (policy !== "mutable") {
const version =
parsed.type === "version"
? (parsed.fetchSpec ?? undefined)
: policy === "pinned"
? (parsed.gitCommittish ?? undefined)
: undefined
return {
updateable: false,
pinned: policy === "pinned",
currentVersion: version,
latestVersion: version,
updateAvailable: false,
}
}
const root = yield* directory(pkg)
const dir = yield* activeDirectory(root)
const current = yield* installed(pkg, dir, parsed.name ?? undefined, ["git", "hosted"].includes(parsed.type))
const npmOptions = yield* NpmConfig.load(root)
const { default: pacote } = yield* Effect.promise(() => import("pacote"))
const latestVersion = yield* Effect.tryPromise({
try: async () => {
if (["git", "hosted"].includes(parsed.type)) {
const resolved = await pacote.resolve(pkg, {
...npmOptions,
preferOnline: true,
noGitRevCache: true,
})
const revision = resolved.match(/#([a-f0-9]{40,64})$/i)?.[1]
if (!revision) throw new Error("Resolved Git package did not include a commit")
return revision
}
const manifest = await pacote.manifest(pkg, { ...npmOptions, preferOnline: true })
return manifest.version
},
catch: (cause) => new InstallFailedError({ cause, add: [pkg], dir }),
})
return {
updateable: true,
pinned: false,
currentVersion: current.version,
latestVersion,
updateAvailable: latestVersion !== undefined && current.version !== latestVersion,
}
})
const update = Effect.fn("Npm.update")(function* (pkg: string) {
const checked = yield* check(pkg)
if (!checked.updateAvailable) {
return { ...checked, previousVersion: checked.currentVersion, updated: false }
}
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
yield* flock.acquire(`npm-install:${root}`)
const dir = `${root}-revision-${randomUUID()}`
const target =
["git", "hosted"].includes(parsed.type) && checked.latestVersion ? pinGitSpec(pkg, checked.latestVersion) : pkg
const tree = yield* reify({ dir, add: [target], update: true })
const node = parsed.name ? tree.edgesOut.get(parsed.name)?.to : tree.edgesOut.values().next().value?.to
const version = packageVersion(node)
if (!version || version !== checked.latestVersion) {
return yield* new InstallFailedError({
cause: new Error(`Installed package identity did not match checked update: ${version ?? "missing"}`),
add: [target],
dir,
})
}
const previousDirectory = yield* activeDirectory(root)
yield* afs
.writeWithDirs(path.join(root, ".opencode-previous"), previousDirectory)
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
yield* afs
.writeWithDirs(path.join(root, ".opencode-active"), dir)
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
return {
updateable: true,
pinned: false,
currentVersion: version,
latestVersion: checked.latestVersion,
updateAvailable: false,
previousVersion: checked.currentVersion,
updated: version !== checked.currentVersion,
}
}, Effect.scoped)
const rollback = Effect.fn("Npm.rollback")(function* (pkg: string) {
const root = yield* directory(pkg)
const previous = yield* afs
.readFileStringSafe(path.join(root, ".opencode-previous"))
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
if (!previous) return
const resolved = path.resolve(previous.trim())
if (resolved !== root && !resolved.startsWith(`${root}-revision-`)) return
if (resolved === root) {
yield* fs
.remove(path.join(root, ".opencode-active"))
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
return
}
yield* afs
.writeWithDirs(path.join(root, ".opencode-active"), resolved)
.pipe(Effect.mapError((cause) => new InstallFailedError({ cause, dir: root })))
})
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const root = yield* directory(pkg)
const dir = yield* activeDirectory(root)
const parsed = (() => {
try {
return npa(pkg)
@@ -175,21 +367,14 @@ const layer = Layer.effect(
}
})()
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)
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 (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
return {
...result,
revision: (yield* installed(pkg, dir, parsedName, ["git", "hosted"].includes(parsed?.type ?? ""))).version,
}
}
const tree = yield* reify({ dir, add: [pkg] })
@@ -200,7 +385,7 @@ const layer = Layer.effect(
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path, options?.subpaths)
return { ...resolveEntryPoint(first.name, first.path, options?.subpaths), revision: packageVersion(first) }
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
@@ -208,22 +393,26 @@ const layer = Layer.effect(
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
const parsed = (() => {
try {
return npa(pkg).name ?? undefined
return npa(pkg)
} catch {
return undefined
}
})()
const root = yield* directory(pkg)
const parsedName = parsed?.name ?? undefined
const root = yield* activeDirectory(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)
return {
...resolveEntryPoint(name, dir, options?.subpaths),
revision: (yield* installed(pkg, root, parsedName, ["git", "hosted"].includes(parsed?.type ?? ""))).version,
}
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = yield* directory(pkg)
const dir = yield* activeDirectory(yield* directory(pkg))
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
@@ -275,7 +464,10 @@ const layer = Layer.effect(
return Service.of({
add,
check,
resolve,
rollback,
update,
which,
})
}),
@@ -301,9 +493,16 @@ export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
if (parsed.type !== "git") return false
return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "")
function packageVersion(node: ArboristNode | undefined) {
if (!node) return undefined
const commit = node.resolved?.match(/#([a-f0-9]{40,64})$/i)?.[1]
return commit ?? node.version ?? node.resolved
}
function pinGitSpec(pkg: string, commit: string) {
const subdirectory = pkg.indexOf("::")
const source = subdirectory === -1 ? pkg : pkg.slice(0, subdirectory)
const suffix = subdirectory === -1 ? "" : pkg.slice(subdirectory)
const hash = source.lastIndexOf("#")
return `${hash === -1 ? `${source}#` : source.slice(0, hash + 1)}${commit}${suffix}`
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { cacheKey, updatePolicy } from "../src/npm.js"
describe("Npm.updatePolicy", () => {
test.each([
["example-plugin", "mutable"],
["example-plugin@latest", "mutable"],
["example-plugin@next", "mutable"],
["example-plugin@^1.2.3", "mutable"],
["example-plugin@1.x", "mutable"],
["github:example/plugin", "mutable"],
["github:example/plugin#main", "mutable"],
["git+https://example.com/plugin.git#release", "mutable"],
["example-plugin@1.2.3", "pinned"],
[`github:example/plugin#${"a".repeat(40)}`, "pinned"],
[`git+https://example.com/plugin.git#${"b".repeat(64)}`, "pinned"],
["./plugin.ts", "unsupported"],
["file:./plugin.ts", "unsupported"],
] as const)("classifies %s as %s", async (spec, expected) => {
expect(await updatePolicy(spec)).toBe(expected)
})
})
test("hashes Git specs before using them as global cache paths", async () => {
const key = await cacheKey("git+https://user:secret@example.com/plugin.git#main")
expect(key).toMatch(/^git-[a-f0-9]{64}$/)
expect(key).not.toContain("secret")
})
+358 -3
View File
@@ -486,6 +486,306 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"get": {
"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": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.UpdateInfo"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check configured plugins for explicitly available updates.",
"summary": "Check plugin updates"
},
"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": {
"$ref": "#/components/schemas/Plugin.UpdateResult"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update-all": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.updateAll",
"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.UpdateResult"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
"summary": "Update plugins"
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -14846,6 +15146,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15659,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15989,6 +16295,58 @@
}
]
},
"Plugin.UpdateInfo": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
},
"currentVersion": {
"type": "string"
},
"latestVersion": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Plugin.UpdateResult": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
},
"previousVersion": {
"type": "string"
},
"version": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16200,9 +16558,6 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+358 -3
View File
@@ -486,6 +486,306 @@
"summary": "List plugins"
}
},
"/api/plugin/update": {
"get": {
"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": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.UpdateInfo"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check configured plugins for explicitly available updates.",
"summary": "Check plugin updates"
},
"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": {
"$ref": "#/components/schemas/Plugin.UpdateResult"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update one configured plugin and reload plugins when it changes.",
"summary": "Update plugin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update-all": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.updateAll",
"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.UpdateResult"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Explicitly update every updateable configured plugin and reload changed plugins.",
"summary": "Update plugins"
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -14846,6 +15146,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15356,6 +15659,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15989,6 +16295,58 @@
}
]
},
"Plugin.UpdateInfo": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "available", "failed"]
},
"currentVersion": {
"type": "string"
},
"latestVersion": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Plugin.UpdateResult": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["not-updateable", "pinned", "up-to-date", "updated", "failed"]
},
"previousVersion": {
"type": "string"
},
"version": {
"type": "string"
},
"error": {
"type": "string"
}
},
"required": ["name", "source", "status"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -16200,9 +16558,6 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+25 -23
View File
@@ -308,29 +308,31 @@ Unknown command IDs are rejected.
## Dialogs And Autocomplete
| ID | Default | Description |
| ------------------------------ | ------------- | ----------------------------------- |
| `dialog.select.prev` | `up,ctrl+p` | Move to previous dialog item |
| `dialog.select.next` | `down,ctrl+n` | Move to next dialog item |
| `dialog.select.page_up` | `pageup` | Move up one page in dialog |
| `dialog.select.page_down` | `pagedown` | Move down one page in dialog |
| `dialog.select.home` | `home` | Move to first dialog item |
| `dialog.select.end` | `end` | Move to last dialog item |
| `dialog.select.submit` | `return` | Submit selected dialog item |
| `dialog.prompt.submit` | `return` | Submit dialog prompt |
| `dialog.worktree.generate` | `tab` | Generate worktree name |
| `dialog.move_session.new` | `ctrl+m` | New worktree |
| `dialog.move_session.delete` | `ctrl+d` | Delete worktree |
| `dialog.move_session.refresh` | `ctrl+r` | Refresh worktrees |
| `prompt.autocomplete.prev` | `up,ctrl+p` | Move to previous autocomplete item |
| `prompt.autocomplete.next` | `down,ctrl+n` | Move to next autocomplete item |
| `prompt.autocomplete.hide` | `escape` | Hide autocomplete |
| `prompt.autocomplete.select` | `return` | Select autocomplete item |
| `prompt.autocomplete.complete` | `tab` | Complete autocomplete item |
| `permission.prompt.fullscreen` | `ctrl+f` | Toggle permission prompt fullscreen |
| `plugins.toggle` | `space` | Toggle plugin |
| `dialog.mcp.toggle` | `space` | Toggle MCP server |
| `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog |
| ID | Default | Description |
| ------------------------------ | -------------- | ----------------------------------- |
| `dialog.select.prev` | `up,ctrl+p` | Move to previous dialog item |
| `dialog.select.next` | `down,ctrl+n` | Move to next dialog item |
| `dialog.select.page_up` | `pageup` | Move up one page in dialog |
| `dialog.select.page_down` | `pagedown` | Move down one page in dialog |
| `dialog.select.home` | `home` | Move to first dialog item |
| `dialog.select.end` | `end` | Move to last dialog item |
| `dialog.select.submit` | `return` | Submit selected dialog item |
| `dialog.prompt.submit` | `return` | Submit dialog prompt |
| `dialog.worktree.generate` | `tab` | Generate worktree name |
| `dialog.move_session.new` | `ctrl+m` | New worktree |
| `dialog.move_session.delete` | `ctrl+d` | Delete worktree |
| `dialog.move_session.refresh` | `ctrl+r` | Refresh worktrees |
| `prompt.autocomplete.prev` | `up,ctrl+p` | Move to previous autocomplete item |
| `prompt.autocomplete.next` | `down,ctrl+n` | Move to next autocomplete item |
| `prompt.autocomplete.hide` | `escape` | Hide autocomplete |
| `prompt.autocomplete.select` | `return` | Select autocomplete item |
| `prompt.autocomplete.complete` | `tab` | Complete autocomplete item |
| `permission.prompt.fullscreen` | `ctrl+f` | Toggle permission prompt fullscreen |
| `plugins.toggle` | `space` | Toggle plugin |
| `dialog.mcp.toggle` | `space` | Toggle MCP server |
| `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog |
| `dialog.plugins.update` | `ctrl+r` | Update selected plugin |
| `dialog.plugins.update_all` | `ctrl+shift+r` | Update all plugins |
## Terminal And Plugins
@@ -6,6 +6,16 @@ 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 installed plugins
Run `/plugins`, or open **Plugins** from the command palette, to inspect server and CLI plugins. Select an installed server
package or Git plugin, then use the actions in the dialog footer:
- `ctrl+r` updates the selected plugin when an update is available.
- `ctrl+shift+r` updates every plugin with an available update.
The dialog marks pinned and local plugins that cannot be updated automatically.
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:
+3 -3
View File
@@ -93,9 +93,9 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
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.
Changes under watched config directories reload automatically. Package and Git plugins refresh only when you explicitly
request an update; OpenCode installs the matching revision and reloads the plugin without a restart. Changes to unwatched
local dependencies may still require restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts