feat(plugin): intercept user prompts before admission (#45550)

This commit is contained in:
Shoubhit Dash
2026-08-27 18:58:00 +05:30
committed by GitHub
Unverified
parent 46aa25f236
commit 23f0ee53cf
16 changed files with 549 additions and 54 deletions
+2
View File
@@ -26,6 +26,7 @@ import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
@@ -69,6 +70,7 @@ const locationServiceNodes = [
ModelResolver.node,
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginSupervisor.node,
Worktree.refreshNode,
FileSystemSearch.node,
+58 -41
View File
@@ -41,6 +41,7 @@ import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
@@ -640,39 +641,30 @@ const layer = Layer.effect(
),
),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptible(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const session = yield* result.get(input.sessionID)
// A staged revert must be committed before admitting new input so the prompt
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
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 prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "user",
payload: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: input.sessionID,
item: admittedInput,
const admitted = yield* Effect.gen(function* () {
const existing = yield* SessionInbox.reconcile(db, {
id: messageID,
sessionID: session.id,
delivery: input.delivery ?? "steer",
})
if (existing) return existing
const item = yield* restore(
preparePrompt(input, messageID).pipe(
Effect.provide(locations.get(session.location)),
Effect.provideService(FSUtil.Service, fs),
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
return yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: session.id,
item,
})
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
@@ -1018,19 +1010,34 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
}
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
@@ -1048,7 +1055,19 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
}),
)
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
@@ -1056,7 +1075,6 @@ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
image: Effect.Effect<Image.Interface>,
) {
const resolved = input.uri.startsWith("data:")
? {
@@ -1085,7 +1103,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime, image)
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
@@ -1100,10 +1118,9 @@ const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(f
input: PromptInput.FileAttachment,
data: string,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const service = yield* image
const service = yield* Image.Service
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* service.normalize(label, content).pipe(
+19 -7
View File
@@ -134,6 +134,23 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
return yield* Effect.die(new LifecycleConflict({ id }))
})
/** Reconciles pending or delivered work without preparing a new admission payload. */
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
db: DatabaseService,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly delivery: Delivery
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
@@ -143,13 +160,8 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
readonly item: Item
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
if (promoted !== undefined) return promoted
const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery })
if (existing !== undefined) return existing
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
@@ -0,0 +1,18 @@
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
),
)
@@ -0,0 +1,32 @@
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { SessionDomain } from "@opencode-ai/plugin/promise/session"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect } from "effect"
export function effectPrompt(context: Context) {
context.session.hook("prompt", (event) =>
Effect.sync(() => {
event.prompt.files ??= []
event.prompt.files.push({ uri: "file:///policy.md" })
event.delivery = "queue"
// @ts-expect-error Admission identity cannot be rewritten.
event.sessionID = Session.ID.make("ses_other")
}),
)
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
context.session.hook("prompt", () => Effect.void, { providerID: "openai" })
context.session.hook("context", () => Effect.void, { providerID: "openai" })
}
export function promisePrompt(session: SessionDomain) {
session.hook("prompt", (event) => {
event.prompt.text = "Prepared"
event.metadata = { source: "plugin" }
// @ts-expect-error Admission identity cannot be rewritten.
event.messageID = SessionMessage.ID.make("msg_other")
})
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
session.hook("prompt", () => {}, { providerID: "openai" })
session.hook("context", () => {}, { providerID: "openai" })
}
+5 -2
View File
@@ -33,6 +33,8 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { promptLocationLayer } from "./fixture/prompt-location"
import { globalProjectLayer } from "./lib/project"
import { tmpdir } from "./fixture/tmpdir"
@@ -50,6 +52,7 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
@@ -963,7 +966,7 @@ describe("Session.create", () => {
}),
)
it.live("runs a shell command and projects the started/ended shell message", () =>
liveIt.live("runs a shell command and projects the started/ended shell message", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -988,7 +991,7 @@ describe("Session.create", () => {
),
)
it.live("still emits shell ended for a failing command", () =>
liveIt.live("still emits shell ended for a failing command", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -0,0 +1,347 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Skill } from "@opencode-ai/core/skill"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const runtime = PluginRuntime.makeCell()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
Session.node,
LocationServiceMap.node,
PluginRuntime.providerNodeWithCell(runtime),
]),
[
[Bus.node, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[PluginRuntime.node, PluginRuntime.layerWithCell(runtime)],
],
),
)
const project = Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const setup = Effect.gen(function* () {
const tmp = yield* project
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const locations = yield* LocationServiceMap.Service
const services = locations.get(session.location)
const hooks = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* PluginHooks.Service
}).pipe(Effect.provide(services))
return { sessions, session, hooks, services }
})
describe("Session prompt hooks", () => {
it.live("waits for local plugin setup before admitting even a plain-text prompt", () =>
Effect.gen(function* () {
const tmp = yield* project
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, ".opencode/plugins/prompt.ts"),
`export default {
id: "prompt-readiness",
async setup(ctx) {
await ctx.session.hook("prompt", (event) => {
event.prompt.text = "Prepared by plugin"
})
},
}`,
),
)
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const admitted = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false })
expect(admitted.payload.text).toBe("Prepared by plugin")
}),
)
it.live("persists ordered draft edits and resolves added files and skills without mutating the caller", () =>
Effect.gen(function* () {
const fixture = yield* setup
const skills = yield* Skill.Service.pipe(Effect.provide(fixture.services))
const skill = Skill.Info.make({
id: Skill.ID.make("policy"),
name: Skill.Name.make("Policy"),
description: "Company policy",
location: AbsolutePath.make(path.join(fixture.session.location.directory, "policy.md")),
content: "Follow company policy.",
})
yield* skills.transform((draft) => draft.add(skill))
const input = {
sessionID: fixture.session.id,
id: SessionMessage.ID.create(),
text: "secret",
files: [
{
uri: "data:text/plain;base64,b3JpZ2luYWw=",
name: "original.txt",
mention: { start: 0, end: 6, text: "secret" },
},
],
metadata: { source: "api" },
resume: false,
}
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(input.sessionID)
expect(event.messageID).toBe(input.id)
event.prompt.text = "Redacted"
const file = event.prompt.files?.[0]
if (file) {
file.uri = "data:text/plain;base64,cG9saWN5"
file.name = "policy.txt"
delete file.mention
}
event.prompt.skills = [{ id: skill.id }]
event.prompt.agents = [{ name: "reviewer" }]
event.metadata ??= {}
event.metadata.source = "plugin"
event.delivery = "queue"
}),
)
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(event.prompt.text).toBe("Redacted")
event.prompt.text += " with policy"
}),
)
const admitted = yield* fixture.sessions.prompt(input)
expect(admitted).toMatchObject({
id: input.id,
delivery: "queue",
payload: {
text: "Redacted with policy",
metadata: { source: "plugin" },
files: [{ name: "policy.txt", data: "cG9saWN5", mime: "text/plain" }],
agents: [{ name: "reviewer" }],
skills: [{ id: skill.id, name: skill.name, text: Skill.toModelOutput(skill, []) }],
},
})
expect(input.text).toBe("secret")
expect(input.files).toEqual([
{
uri: "data:text/plain;base64,b3JpZ2luYWw=",
name: "original.txt",
mention: { start: 0, end: 6, text: "secret" },
},
])
expect(input.metadata).toEqual({ source: "api" })
const database = yield* Database.Service
const bus = yield* Bus.Service
expect(yield* SessionInbox.find(database.db, input.id)).toEqual(admitted)
const log = yield* fixture.sessions.log({ sessionID: input.sessionID }).pipe(Stream.runCollect)
expect(JSON.stringify(log)).not.toContain("secret")
yield* SessionInbox.promote(database.db, bus, input.sessionID, "input")
expect(yield* fixture.sessions.messages({ sessionID: input.sessionID })).toMatchObject([
{ id: input.id, type: "user", text: "Redacted with policy", metadata: { source: "plugin" } },
])
}),
)
it.live("skips hooks and payload resolution on pending and delivered retries, including conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
calls.push(event.prompt.text)
event.prompt.text = "First admission"
}),
)
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "Original", resume: false }
const first = yield* fixture.sessions.prompt(input)
const retry = { ...input, text: "Ignored", files: [{ uri: "file:///missing-retry-file" }] }
expect(yield* fixture.sessions.prompt(retry)).toEqual(first)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* SessionInbox.promote(database.db, bus, input.sessionID, "steer")
expect((yield* fixture.sessions.prompt(retry)).payload).toEqual(first.payload)
const other = yield* fixture.sessions.create({ location: fixture.session.location })
expect((yield* fixture.sessions.prompt({ ...retry, sessionID: other.id }).pipe(Effect.flip))._tag).toBe(
"Session.PromptConflictError",
)
const synthetic = yield* fixture.sessions.synthetic({
sessionID: input.sessionID,
text: "Synthetic",
resume: false,
})
expect((yield* fixture.sessions.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip))._tag).toBe(
"Session.PromptConflictError",
)
expect(calls).toEqual(["Original"])
}),
)
it.live("leaves a staged revert untouched on retries and failed preparation", () =>
Effect.gen(function* () {
const fixture = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const first = yield* fixture.sessions.prompt({ sessionID: fixture.session.id, text: "Boundary", resume: false })
yield* SessionInbox.promote(database.db, bus, fixture.session.id, "steer")
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: fixture.session.id,
revert: { messageID: first.id, files: [] },
})
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
expect(
(yield* fixture.sessions.prompt({
sessionID: fixture.session.id,
id: first.id,
text: "Ignored",
resume: false,
})).payload,
).toEqual(first.payload)
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
.pipe(Effect.exit))._tag,
).toBe("Failure")
expect((yield* fixture.sessions.get(fixture.session.id)).revert?.messageID).toBe(first.id)
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toMatchObject([{ id: first.id }])
yield* failing.dispose
const next = yield* fixture.sessions.prompt({
sessionID: fixture.session.id,
text: "After revert",
resume: false,
})
expect((yield* fixture.sessions.get(fixture.session.id)).revert).toBeUndefined()
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([next])
}),
)
it.live("keeps first-admission-wins for concurrent transformed submissions", () =>
Effect.gen(function* () {
const fixture = yield* setup
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.gen(function* () {
calls.push(event.prompt.text)
event.prompt.text += " transformed"
if (calls.length === 2) yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "First", resume: false }
const submissions = yield* Effect.all(
[fixture.sessions.prompt(input), fixture.sessions.prompt({ ...input, text: "Second" })],
{ concurrency: "unbounded" },
).pipe(Effect.forkChild)
yield* Deferred.await(entered)
expect(yield* fixture.sessions.inbox(input.sessionID)).toEqual([])
yield* Deferred.succeed(release, undefined)
const results = yield* Fiber.join(submissions)
expect(results[0]).toEqual(results[1])
expect(["First transformed", "Second transformed"]).toContain(results[0]?.payload.text)
expect(yield* fixture.sessions.inbox(input.sessionID)).toHaveLength(1)
expect(yield* fixture.sessions.prompt(input)).toEqual(results[0])
expect(calls).toHaveLength(2)
}),
)
it.live("does not admit failed attachment preparation or an interrupted hook", () =>
Effect.gen(function* () {
const fixture = yield* setup
const registration = yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
event.prompt.files = [{ uri: "file:///missing-hook-file" }]
}),
)
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Original", resume: false })
.pipe(Effect.flip))._tag,
).toBe("Session.AttachmentError")
yield* registration.dispose
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
.pipe(Effect.exit))._tag,
).toBe("Failure")
yield* failing.dispose
const started = yield* Deferred.make<void>()
yield* fixture.hooks.register("session", "prompt", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const submission = yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Interrupt", resume: false })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(submission)
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([])
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
}),
)
it.live("applies a Promise plugin to command-generated prompts only in its own location", () =>
Effect.gen(function* () {
const tmp = yield* project
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, ".opencode/plugins/command.ts"),
`export default {
id: "prompt-command",
async setup(ctx) {
await ctx.session.hook("prompt", (event) => {
event.prompt.text += " with plugin"
})
await ctx.command.transform((draft) => {
draft.add({
name: "review",
async execute(input) {
await ctx.session.prompt({ sessionID: input.sessionID, text: "Review", resume: false })
},
})
})
},
}`,
),
)
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const other = yield* setup
yield* sessions.command({ sessionID: session.id, command: "review", text: "" })
expect(yield* sessions.inbox(session.id)).toMatchObject([{ payload: { text: "Review with plugin" } }])
const untouched = yield* other.sessions.prompt({
sessionID: other.session.id,
text: "Other location",
resume: false,
})
expect(untouched.payload.text).toBe("Other location")
}),
)
})
@@ -28,6 +28,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { testEffect } from "./lib/effect"
@@ -67,6 +68,7 @@ const locations = Layer.effect(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
@@ -44,6 +44,8 @@ import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { promptLocationLayer } from "./fixture/prompt-location"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -155,6 +157,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationLayer],
[LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission],
[Catalog.node, promptCatalog],
@@ -81,6 +81,8 @@ import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq, sql } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { promptLocationLayer } from "./fixture/prompt-location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
@@ -467,6 +469,7 @@ const it = testEffect(
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationLayer],
[LayerNodePlatform.llmClient, client],
[Permission.node, permission],
[Catalog.node, promptCatalog],
+3 -1
View File
@@ -10,6 +10,7 @@ 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 { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -31,7 +32,8 @@ const info = Skill.Info.make({
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
const skills = Layer.merge(
const skills = Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
+1 -1
View File
@@ -21,7 +21,7 @@ export type ModelHooks<Spec, Failures extends Record<keyof Spec, unknown> = Reco
>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
options?: ModelHookOptions,
options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+12
View File
@@ -2,10 +2,21 @@ import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
export interface SessionPrompt {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
prompt: Types.DeepMutable<PromptInput.Prompt>
metadata?: Record<string, unknown>
delivery: SessionInbox.Delivery
}
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -42,6 +53,7 @@ export interface SessionHttpResponse {
}
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
+1 -1
View File
@@ -15,7 +15,7 @@ export type Hooks<Spec> = <Name extends keyof Spec>(
export type ModelHooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Promise<void> | void,
options?: ModelHookOptions,
options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never,
) => Promise<Registration>
export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>
+12
View File
@@ -2,10 +2,21 @@ import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
export interface SessionPrompt {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
prompt: Types.DeepMutable<PromptInput.Prompt>
metadata?: Record<string, unknown>
delivery: SessionInbox.Delivery
}
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -42,6 +53,7 @@ export interface SessionHttpResponse {
}
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
@@ -1011,6 +1011,33 @@ await registration.dispose()
### Sessions
Intercept incoming user prompts before attachment and skill resolution and durable inbox admission:
```ts
await ctx.session.hook("prompt", (event) => {
event.prompt.text = event.prompt.text.replaceAll("company-secret", "[redacted]")
event.prompt.files ??= []
event.prompt.files.push({ uri: "file:///project/policy.md" })
event.metadata = { ...event.metadata, source: "company-policy" }
event.delivery = "queue"
})
```
The hook receives an owned, mutable draft with `text`, `files`, agent mentions (`agents`), and selected `skills`, plus
`metadata` and `delivery` (`"steer"` by default, or `"queue"`). Files and skills added by hooks use the normal resolution
and validation path. If you rewrite text, update or remove attachment `mention` offsets that no longer match it. Agent
mentions do not switch the session's active agent. Session and message IDs are readonly and cannot redirect admission.
Hooks run in registration order after the session's location plugins are ready. Edits become the canonical persisted user
input; unlike `context`, `prompt` does not run on each model call. Commands that submit through `session.prompt` also run
this hook. Synthetic messages, shell messages, and compaction/move controls do not. Prompt hooks cannot be scoped by model
provider because admission is separate from model resolution. Use an unscoped registration.
Retries of an ID already pending or delivered return the original admission without rerunning hooks or resolving the
retried payload. Concurrent submissions can run hooks more than once before either admission commits, but only the first
successful admission wins. Keep hooks retry-safe; they are not an exactly-once side-effect boundary. A failed or interrupted
preparation admits no prompt. This hook transforms input and does not expose a typed rejection API.
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
dispatch.
@@ -1080,7 +1107,10 @@ await ctx.session.hook("http.response", (event) => {
#### Reference
```ts
import type { SessionPrompt } from "@opencode-ai/plugin/promise/session"
interface SessionHooks {
prompt: SessionPrompt
context: SessionContextHook
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
@@ -1111,7 +1141,7 @@ interface SessionHookContext {
hook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
options?: { providerID?: string },
options?: Name extends "prompt" ? never : { providerID?: string },
): Promise<Registration>
}
```