opencode-plugboard 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The session-backed `ModelCaller`: one model call = one kept OpenCode session in the caller
3
+ * app's directory under the data root, one model step.
4
+ *
5
+ * Lifecycle of `stream()`: validate the app name, prepare images, create the session, register
6
+ * its request state, start the prompt, then relay the collector's events until it is terminal.
7
+ * Abort is checked before every side effect (directory, backend, create, register, prompt), and
8
+ * the awaited host call (create) is raced against the request signal; teardown never is.
9
+ * Request sessions are registered in the process-wide session store (`state.ts`) so the
10
+ * app-directory hook (`context.ts`) and the event pump can find them.
11
+ *
12
+ * The response and the host execution have different lifetimes (docs/architecture.md). When the
13
+ * response is over, `finally` interrupts unless the host already reported the end of the
14
+ * execution or the turn settled with a plain stop (a step can fail while the runner goes on) and hands the store entry, the
15
+ * request guard, to `sessions.retire`: it stays until the host reports the end of the execution
16
+ * or a bounded cleanup runs out (logged). Nothing here waits for that.
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto"
20
+ import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
21
+ import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
22
+ import { log, logError } from "../log.js"
23
+ import { RequestCollector } from "./events.js"
24
+ import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
25
+ import { describeError, type SessionPort } from "./host.js"
26
+ import { ImageError, prepareImages } from "./media.js"
27
+ import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
28
+ import type { SessionStore } from "./state.js"
29
+
30
+ export interface SessionCallerDeps {
31
+ readonly port: SessionPort
32
+ /** The process-wide store request sessions are registered in. */
33
+ readonly sessions: SessionStore
34
+ /** The seed pool size of the app directories (option `maxTools`); a request needing more tools fails before dispatch. */
35
+ readonly maxTools: number
36
+ /** Allow fetching http(s) image URLs (option `remoteImages`). */
37
+ readonly remoteImages?: boolean
38
+ /**
39
+ * Make sure the backend of `app` can run request sessions (the app directory's plugin is loaded),
40
+ * called right before `session.create`. An error ends the request without creating anything.
41
+ */
42
+ readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
43
+ }
44
+
45
+ /** How long the request guard may outlive the response while the host has not reported the end of the execution. */
46
+ const CLEANUP_TIMEOUT_MS = 30_000
47
+
48
+ const failure = (status: number, message: string, code: string): PlugEvent => ({
49
+ type: "error",
50
+ error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
51
+ })
52
+
53
+ const countMedia = (request: PlugRequest): number =>
54
+ request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
55
+
56
+ /** Everything the hook will need must exist before dispatch: one prompt file per image, one seed tool per offered tool. */
57
+ function checkPreparation(request: PlugRequest, images: number, maxTools: number): PlugError | undefined {
58
+ const wanted = countMedia(request)
59
+ if (images !== wanted) {
60
+ return { status: 500, kind: "server", code: "backend_preparation_failed", message: `Prepared ${images} attachment(s) for a request with ${wanted}` }
61
+ }
62
+ const tools = seedToolsFor(request).length
63
+ if (tools > maxTools) {
64
+ return {
65
+ status: 400,
66
+ kind: "invalid_request",
67
+ code: "backend_preparation_failed",
68
+ param: "tools",
69
+ message: `The request offers ${tools} tools but plugboard supports at most ${maxTools} (option \`maxTools\`)`,
70
+ }
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /** Settles with `promise`, or rejects as soon as `signal` aborts. Request side only: the promise itself keeps running. */
76
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
77
+ promise.catch(() => {}) // a late rejection after the race was lost must not become unhandled
78
+ if (signal.aborted) return Promise.reject(signal.reason)
79
+ return new Promise<T>((resolve, reject) => {
80
+ const onAbort = () => reject(signal.reason)
81
+ signal.addEventListener("abort", onAbort, { once: true })
82
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
83
+ })
84
+ }
85
+
86
+ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
87
+ const { port, sessions, maxTools, ensureBackend } = deps
88
+ const remoteImages = deps.remoteImages === true
89
+
90
+ return {
91
+ async *stream(request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
92
+ const app = APP_NAME_PATTERN.test(context.app) || context.app === DEFAULT_APP ? context.app : undefined
93
+ if (!app) return yield failure(400, `Invalid app name: ${context.app}`, "invalid_app")
94
+ if (context.signal.aborted) return
95
+
96
+ const requestID = randomUUID()
97
+ const startedAt = Date.now()
98
+ let outcome = "cancelled"
99
+ const note = (event: PlugEvent): PlugEvent => {
100
+ if (event.type === "finish") outcome = `finish:${event.reason}`
101
+ else if (event.type === "error") outcome = `error:${event.error.status}${event.error.code ? `:${event.error.code}` : ""}`
102
+ return event
103
+ }
104
+
105
+ let sessionID: string | undefined
106
+ let collector: RequestCollector | undefined
107
+ let promptStarted = false
108
+ let interruptRequested = false
109
+ const interrupt = () => {
110
+ if (sessionID) {
111
+ interruptRequested = true
112
+ void port.interrupt(sessionID).catch((error) => logError("session.interrupt_failed", { requestID, app, error: describeError(error) }))
113
+ }
114
+ }
115
+ const onAbort = () => {
116
+ collector?.cancel()
117
+ interrupt()
118
+ }
119
+
120
+ try {
121
+ // 1. Prompt files: images become data URLs (http(s) ones are fetched once).
122
+ const images = await prepareImages(request, context.signal, remoteImages)
123
+ const invalid = checkPreparation(request, images.length, maxTools)
124
+ if (invalid) return yield note({ type: "error", error: invalid })
125
+
126
+ // 2. The app directory under the data root; one top-level session per request.
127
+ if (context.signal.aborted) return
128
+ const directory = await ensureAppDir(app)
129
+ if (isPlugError(directory)) return yield note({ type: "error", error: directory })
130
+
131
+ // 3. The backend must be able to run the session before it is created.
132
+ if (context.signal.aborted) return
133
+ const unavailable = await ensureBackend?.(app, context.signal)
134
+ if (unavailable) return yield note({ type: "error", error: unavailable })
135
+ if (context.signal.aborted) return
136
+
137
+ collector = new RequestCollector({ stopTurn: interrupt })
138
+ const created = await raceAbort(port.create({
139
+ directory,
140
+ model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
141
+ title: makeTitle(request),
142
+ metadata: { plugboard: { app, model: request.model, requestID } },
143
+ permissions: REQUEST_PERMISSIONS,
144
+ }), context.signal)
145
+ sessionID = String(created.id)
146
+ sessions.register({ sessionID, app, requestID, request, collector })
147
+
148
+ context.signal.addEventListener("abort", onAbort, { once: true })
149
+ if (context.signal.aborted) return onAbort()
150
+
151
+ // The history id is known from here on: tell the front-end before the first event.
152
+ try {
153
+ context.onRecord?.(sessionID)
154
+ } catch (error) {
155
+ logError("request.on_record_failed", { requestID, app, error: describeError(error) })
156
+ }
157
+
158
+ // 4. Prompt concurrently; events stream through the collector while it runs.
159
+ if (context.signal.aborted) return onAbort()
160
+ const owned = collector
161
+ promptStarted = true
162
+ void port.prompt({ sessionID, text: renderTranscript(request), files: images }).catch((error) => {
163
+ logError("session.prompt_failed", { requestID, app, model: request.model, error: describeError(error) })
164
+ if (owned.terminal) return
165
+ owned.push({ type: "session.execution.failed", error: { type: "prompt_failed", message: "Could not start the model session" } })
166
+ })
167
+
168
+ for await (const event of collector.events()) yield note(event)
169
+ } catch (error) {
170
+ if (error instanceof ImageError) yield note(failure(400, error.message, error.code))
171
+ else if (!context.signal.aborted) {
172
+ logError("request.backend_failed", { requestID, app, model: request.model, error: describeError(error) })
173
+ yield note(failure(502, "plugboard session backend failed", "backend_failed"))
174
+ }
175
+ } finally {
176
+ context.signal.removeEventListener("abort", onAbort)
177
+ if (sessionID && collector) {
178
+ // The response may be over (consumer left early, error, timeout, a failed step) while the
179
+ // host keeps running the session: interrupt unless it reported the end of the execution
180
+ // or the step ended plainly (the runner stops by itself), and only if no interrupt went
181
+ // out yet (the tool-calls path asked at the step end already).
182
+ if (!collector.terminal) collector.cancel()
183
+ if (!collector.executionEnded && !collector.turnSettled && !interruptRequested) interrupt()
184
+ // The response ends here, the host execution may not have: the guard is kept until it does.
185
+ if (promptStarted) sessions.retire(sessionID, { timeoutMs: CLEANUP_TIMEOUT_MS, onTimeout: interrupt })
186
+ else sessions.release(sessionID)
187
+ }
188
+ log("request.finished", { requestID, app, model: request.model, durationMs: Date.now() - startedAt, outcome })
189
+ }
190
+ },
191
+ }
192
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Context hook and seed tools: what plugboard installs inside the app directories, and what the
3
+ * `session.hook("context")` hook does for a plugboard request session.
4
+ *
5
+ * The stored prompt is only a readable transcript. The hook swaps in the caller's real
6
+ * structured system, messages, tool definitions and generation options. Sessions that are not
7
+ * plugboard requests (not in the process-wide session store) are untouched.
8
+ */
9
+
10
+ import { Effect } from "effect"
11
+ import type { PlugError, PlugMessage, PlugRequest } from "../contract.js"
12
+ import type { ContextHookEvent, HostContext, HostPart, HostToolDescriptor } from "./host.js"
13
+ import { SEED_PREFIX, SEED_RESULT, seedToolsFor } from "./seeds.js"
14
+ import { FAILURE_EVENT, type SessionStore } from "./state.js"
15
+
16
+ const text = (value: string): HostPart => ({ type: "text", text: value })
17
+
18
+ /** Placeholder for a caller image; swapped for the host-built media part in `applyContext`. */
19
+ const mediaPlaceholder = (index: number): HostPart => ({ type: "media", __attachment: index })
20
+
21
+ /** Caller messages as host `Message` objects. Media placeholders are swapped in `applyContext`. */
22
+ function toHostMessages(messages: ReadonlyArray<PlugMessage>): Array<{ role: string; content: HostPart[] }> {
23
+ let image = 0
24
+ const part = (item: PlugMessage["content"][number]): HostPart => {
25
+ switch (item.type) {
26
+ case "text":
27
+ return text(item.text)
28
+ case "media":
29
+ return mediaPlaceholder(image++)
30
+ case "tool-call":
31
+ return { type: "tool-call", id: item.id, name: item.name, input: item.input }
32
+ case "tool-result":
33
+ return { type: "tool-result", id: item.id, name: item.name, result: { type: "text", value: item.result } }
34
+ }
35
+ }
36
+ return messages.map((message) => ({ role: message.role, content: message.content.map(part) }))
37
+ }
38
+
39
+ function generationOptions(request: PlugRequest): Record<string, unknown> {
40
+ const generation = request.generation
41
+ const options: Record<string, unknown> = {}
42
+ if (generation.maxTokens !== undefined) options.maxTokens = generation.maxTokens
43
+ if (generation.temperature !== undefined) options.temperature = generation.temperature
44
+ if (generation.topP !== undefined) options.topP = generation.topP
45
+ if (generation.stop !== undefined) options.stop = [...generation.stop]
46
+ if (generation.presencePenalty !== undefined) options.presencePenalty = generation.presencePenalty
47
+ if (generation.frequencyPenalty !== undefined) options.frequencyPenalty = generation.frequencyPenalty
48
+ return options
49
+ }
50
+
51
+ /**
52
+ * Re-key the registered seed tools onto the caller's tool names.
53
+ *
54
+ * Descriptor object identity is load-bearing: the host maps a rewritten tool back to its
55
+ * registered tool by the descriptor object it handed to the hook (upstream
56
+ * `model-request.ts`, tool mapping after the `context` hook), so the descriptors are mutated in
57
+ * place and the very same objects are re-inserted under the caller's names. Never copy them.
58
+ * Returns how many caller tools got no seed (zero unless the seed pool is smaller than the request
59
+ * needs, or two caller tools share a name and would collapse into one key).
60
+ *
61
+ * The map is built with `Object.fromEntries`: a tool named `__proto__` becomes an own key like any
62
+ * other instead of assigning the prototype and vanishing.
63
+ */
64
+ function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number {
65
+ const seeds = Object.entries(session.tools ?? {}).filter(([name]) => name.startsWith(SEED_PREFIX))
66
+ const tools = seedToolsFor(request)
67
+ const next = new Map<string, HostToolDescriptor>()
68
+ let index = 0
69
+ for (const tool of tools) {
70
+ const seed = seeds[index]
71
+ if (!seed) break
72
+ index++
73
+ if (next.has(tool.name)) continue
74
+ const descriptor = seed[1]
75
+ descriptor.description = tool.description
76
+ descriptor.input = tool.parameters
77
+ next.set(tool.name, descriptor)
78
+ }
79
+ session.tools = Object.fromEntries(next)
80
+ return tools.length - next.size
81
+ }
82
+
83
+ const preparationFailed = (message: string): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message })
84
+
85
+ /**
86
+ * `session.hook("context")` body: swap the request session's system, messages, tools and
87
+ * generation options for the caller's.
88
+ */
89
+ function applyContext(event: unknown, sessions: SessionStore): void {
90
+ // Assumed invariant: the host passes its mutable `SessionContext` (checked structurally by `HostContextMatches`).
91
+ const session = event as ContextHookEvent
92
+ const state = sessions.lookup(String(session.sessionID))
93
+ if (!state) return
94
+ const { request } = state
95
+
96
+ // Swap media placeholders for the Asset-carrying parts the host built from the prompt files.
97
+ const hostMedia = (session.messages ?? [])
98
+ .flatMap((message) => (Array.isArray(message.content) ? (message.content as unknown[]) : []))
99
+ .filter((part) => (part as HostPart | undefined)?.type === "media")
100
+ let missingMedia = 0
101
+ const swap = (part: HostPart) => {
102
+ if (typeof part.__attachment !== "number") return part
103
+ const media = hostMedia[part.__attachment]
104
+ if (media) return media
105
+ missingMedia++
106
+ return text("[image unavailable]")
107
+ }
108
+
109
+ const system = request.system.map(text)
110
+ // The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
111
+ const choice = request.toolChoice
112
+ if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
113
+ if (typeof choice === "object") system.push(text(`You must respond by calling the \`${choice.name}\` tool.`))
114
+ session.system = system
115
+ session.messages = toHostMessages(request.messages).map((message) => ({ ...message, content: message.content.map(swap) }))
116
+ Object.assign(session.options ?? (session.options = {}), generationOptions(request))
117
+
118
+ const missingTools = remapSeedTools(session, request)
119
+
120
+ // The caller validates both before dispatch; this only fires if the host disagrees. The request is
121
+ // already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
122
+ if (missingMedia > 0 || missingTools > 0) {
123
+ const detail = [missingMedia > 0 ? `${missingMedia} attachment(s) not delivered by the host` : "", missingTools > 0 ? `${missingTools} tool(s) without a seed tool` : ""]
124
+ state.collector.push({
125
+ type: FAILURE_EVENT,
126
+ error: preparationFailed(`Could not prepare the model request: ${detail.filter(Boolean).join(", ")}`),
127
+ })
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Registrations for a plugboard Location: the context hook, the agent deny for the seeds, and
133
+ * the seed tool pool.
134
+ *
135
+ * Whether the Location is inside the data root is only known after an asynchronous filesystem
136
+ * check (never during setup), so these are registered for every accepted generation and every
137
+ * one of them stays a no-op until `active()` turns true: the hook leaves sessions alone, the
138
+ * transforms add and change nothing. `active` is read synchronously on every run; the owner
139
+ * rebuilds the transforms (`ctx.agent.reload()`, `ctx.tool.reload()`) after it flips.
140
+ *
141
+ * Every agent denies the seeds; request sessions are created with a session permission that
142
+ * allows them again, so a person prompting inside an app directory is never offered them.
143
+ * Registration only: no host call that waits on readiness (see docs/architecture.md).
144
+ */
145
+ export function installSessionHooks(
146
+ ctx: HostContext,
147
+ options: { readonly maxTools: number; readonly sessions: SessionStore; readonly active: () => boolean },
148
+ ) {
149
+ return Effect.gen(function* () {
150
+ // Swap in the caller's system/messages/tools for plugboard request sessions only.
151
+ yield* ctx.session.hook("context", (event) => Effect.sync(() => (options.active() ? applyContext(event, options.sessions) : undefined)))
152
+
153
+ yield* ctx.agent.transform((editor) => {
154
+ if (!options.active()) return
155
+ for (const agent of editor.list()) {
156
+ editor.update(agent.id, (item) => {
157
+ item.permissions.push({ action: `${SEED_PREFIX}*`, resource: "*", effect: "deny" })
158
+ })
159
+ }
160
+ })
161
+ yield* ctx.tool.transform((editor) => {
162
+ if (!options.active()) return
163
+ for (let index = 0; index < options.maxTools; index++) {
164
+ editor.add({
165
+ name: `${SEED_PREFIX}${index}`,
166
+ description: "Plugboard placeholder tool.",
167
+ input: { type: "object", properties: {}, additionalProperties: true },
168
+ options: { codemode: false },
169
+ // Settles at once so history shows a completed call; plugboard ends the turn itself (see seeds.ts).
170
+ execute: () => Effect.succeed({ content: SEED_RESULT }),
171
+ })
172
+ }
173
+ })
174
+ })
175
+ }