Compare commits

...
12 changed files with 164 additions and 5 deletions
+2
View File
@@ -25,6 +25,7 @@ import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginInstructions } from "./plugin/instructions.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
@@ -207,6 +208,7 @@ export const node = makeLocationNode({
Tool.node,
Vcs.node,
PluginHooks.node,
PluginInstructions.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
+6
View File
@@ -31,6 +31,7 @@ import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import { PluginInstructions } from "./instructions.js"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -60,6 +61,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const generate = yield* Generate.Service
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const instructions = yield* PluginInstructions.Service
const runtime = yield* PluginRuntime.Service
const locationInfo = () =>
new Location.Info({
@@ -213,6 +215,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
),
},
experimental: {
instructions: {
transform: instructions.transform,
reload: instructions.reload,
},
terminal: {
read: (input) => runtime.persistentPty.read(input.sessionID, input.lines),
},
+62
View File
@@ -0,0 +1,62 @@
export * as PluginInstructions from "./instructions.js"
import { Instruction } from "@opencode-ai/plugin/instructions"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Instructions } from "../instructions/index.js"
import { State } from "../state.js"
type Source = (context: Instruction.Context) => Instructions.Source
export interface Interface extends State.Transformable<Instruction.EffectDraft> {
readonly load: (context: Instruction.Context) => Instructions.List
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginInstructions") {}
export const layer = Layer.sync(Service, () => {
const state = State.create<Map<Instructions.Key, Source>, Instruction.EffectDraft>({
name: "plugin.instructions",
initial: () => new Map(),
draft: (sources) => ({
add: (definition) => {
const key = Schema.decodeUnknownSync(Instructions.Key)(definition.key)
if (sources.has(key)) throw new Instructions.DuplicateKeyError({ key })
sources.set(key, (context) => {
const [source] = Instructions.make({
key,
codec: definition.codec,
read: Effect.suspend(() => definition.read(context)).pipe(
Effect.orDie,
Effect.map((value) => {
if (value === Instruction.removed) return Instructions.removed
if (value === Instruction.unavailable) return Instructions.unavailable
return value
}),
),
render: definition.render,
})
return {
...source,
read: source.read.pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() =>
Effect.logWarning("plugin instruction source unavailable", { key }).pipe(
Effect.as(Instructions.unavailable),
),
),
),
}
})
},
}),
})
return Service.of({
transform: state.transform,
reload: state.reload,
load: (context) => [...state.get().values()].map((make) => make(context)),
})
})
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+12
View File
@@ -9,6 +9,7 @@ import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { InstructionDiscovery } from "../instruction-discovery.js"
import { Instructions } from "../instructions/index.js"
import { PluginInstructions } from "../plugin/instructions.js"
import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
@@ -86,6 +87,7 @@ const layer = Layer.effect(
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* PluginSupervisor.Service
const pluginInstructions = yield* PluginInstructions.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -152,6 +154,15 @@ const layer = Layer.effect(
loaded.references,
loaded.mcp,
loaded.entries,
pluginInstructions.load({
sessionID: session.id,
agent: agent.id,
location: Location.Info.make({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
}),
]),
tools: loaded.tools,
}
@@ -191,6 +202,7 @@ export const node = makeLocationNode({
McpInstructions.node,
McpTool.node,
PluginSupervisor.node,
PluginInstructions.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
+1 -1
View File
@@ -55,7 +55,7 @@ describe("Plugin", () => {
},
}
expect(Object.keys(host.experimental)).toEqual(["terminal"])
expect(Object.keys(host.experimental)).toEqual(["instructions", "terminal"])
expect(Object.keys(host.experimental.terminal)).toEqual(["read"])
expect(yield* pending).toBe(terminal)
expect(yield* host.experimental.terminal.read({ sessionID })).toBe(terminal)
+2
View File
@@ -19,6 +19,7 @@ import { Mcp } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginInstructions } from "@opencode-ai/core/plugin/instructions"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
@@ -81,6 +82,7 @@ export const PluginTestLayer = LayerNode.compile(
PluginRuntime.node,
Permission.node,
PluginHooks.node,
PluginInstructions.node,
Reference.node,
Rpc.node,
Skill.node,
+8 -3
View File
@@ -12,8 +12,9 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
type Overrides = Partial<Omit<Plugin.Context, "options" | "session" | "experimental">> & {
readonly session?: Partial<Plugin.Context["session"]>
readonly experimental?: Partial<Plugin.Context["experimental"]>
}
export function host(overrides: Overrides = {}): Plugin.Context {
return {
@@ -66,8 +67,12 @@ export function host(overrides: Overrides = {}): Plugin.Context {
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
experimental: overrides.experimental ?? {
terminal: {
experimental: {
instructions: overrides.experimental?.instructions ?? {
transform: () => Effect.die("unused experimental.instructions.transform"),
reload: () => Effect.die("unused experimental.instructions.reload"),
},
terminal: overrides.experimental?.terminal ?? {
read: () => Effect.die("unused experimental.terminal.read"),
},
},
+1 -1
View File
@@ -58,7 +58,7 @@ describe("fromPromise", () => {
define({
id: "promise-terminal-read",
setup: async (ctx) => {
expect(Object.keys(ctx.experimental)).toEqual(["terminal"])
expect(Object.keys(ctx.experimental).sort()).toEqual(["instructions", "terminal"])
expect(Object.keys(ctx.experimental.terminal)).toEqual(["read"])
for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) {
await expect(
+6
View File
@@ -8,6 +8,8 @@ import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { Instruction } from "../instructions.js"
import type { Transform } from "./registration.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
@@ -31,6 +33,10 @@ export interface Context {
readonly command: CommandDomain
readonly event: EventDomain
readonly experimental: {
readonly instructions: {
readonly transform: Transform<Instruction.EffectDraft>
readonly reload: () => Effect.Effect<void>
}
readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
}
readonly integration: IntegrationDomain
+39
View File
@@ -0,0 +1,39 @@
export * as Instruction from "./instructions.js"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Location } from "@opencode-ai/schema/location"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect, Schema } from "effect"
/** An observed absence. Global symbols preserve identity across separately installed plugin copies. */
export const removed: unique symbol = Symbol.for("@opencode-ai/plugin/instructions/removed")
export type Removed = typeof removed
/** The source cannot currently be read; its last stored value stands. */
export const unavailable: unique symbol = Symbol.for("@opencode-ai/plugin/instructions/unavailable")
export type Unavailable = typeof unavailable
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly location: Location.Info
}
interface Definition<A, Read> {
readonly key: string
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: (context: Context) => Read
readonly render: {
readonly initial: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
}
export interface EffectDraft {
add<A>(source: Definition<A, Effect.Effect<NoInfer<A> | Removed | Unavailable, unknown>>): void
}
export interface PromiseDraft {
add<A>(source: Definition<A, NoInfer<A> | Removed | Unavailable | Promise<NoInfer<A> | Removed | Unavailable>>): void
}
+19
View File
@@ -336,6 +336,25 @@ export function fromPromise(plugin: Plugin) {
),
},
experimental: {
instructions: {
transform: (callback) =>
register(
host.experimental.instructions.transform((draft) =>
callback({
add: (source) =>
draft.add({
...source,
read: (context) =>
Effect.tryPromise({
try: () => Promise.resolve(source.read(context)),
catch: (cause) => cause,
}),
}),
}),
),
),
reload: () => run(host.experimental.instructions.reload()),
},
terminal: {
read: adaptApiMethod(ExperimentalEndpoints["persistentPty.read"], host.experimental.terminal.read),
},
+6
View File
@@ -8,6 +8,8 @@ import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { Instruction } from "../instructions.js"
import type { Transform } from "./registration.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
@@ -31,6 +33,10 @@ export interface Context {
readonly command: CommandDomain
readonly event: EventDomain
readonly experimental: {
readonly instructions: {
readonly transform: Transform<Instruction.PromiseDraft>
readonly reload: () => Promise<void>
}
readonly terminal: Pick<OpenCodeClient["experimental"]["persistentPty"], "read">
}
readonly integration: IntegrationDomain