opencode-plugboard 0.19.0 → 0.21.0

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.
package/src/runtime.ts CHANGED
@@ -53,6 +53,7 @@ import { Cause, Effect, Stream } from "effect"
53
53
  import stableStringify from "safe-stable-stringify"
54
54
  import { raceAbort } from "./abort.js"
55
55
  import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
56
+ import { personaOf } from "./models.js"
56
57
  import type { PlugboardOptions } from "./config.js"
57
58
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
58
59
  import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
@@ -63,6 +64,7 @@ import type { HistoryPort } from "./session/host.js"
63
64
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
64
65
  import { createSessionStore, type SessionStore } from "./session/state.js"
65
66
  import { createBudgetState, type BudgetState } from "./budgets.js"
67
+ import { createKeyState, type KeyState } from "./keys.js"
66
68
  import { createUsageState, type UsageState } from "./usage.js"
67
69
 
68
70
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -116,8 +118,8 @@ const BACKEND_POLL_MS = 25
116
118
  const BIND_RETRY_MS = 15_000
117
119
  const BIND_REMINDER_MS = 5 * 60_000
118
120
 
119
- /** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
120
- const VERSION = 13
121
+ /** 15: the last options error (`configError`, #159) and `catalogLoaded` for `/readyz` (#160). 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
122
+ const VERSION = 15
121
123
  const KEY = Symbol.for("opencode-plugboard.runtime")
122
124
 
123
125
  interface Listener {
@@ -154,6 +156,12 @@ interface Pump {
154
156
  stop(): void
155
157
  }
156
158
 
159
+ /** A plugin options error and when it was recorded (epoch ms). */
160
+ export interface ConfigError {
161
+ readonly message: string
162
+ readonly at: number
163
+ }
164
+
157
165
  interface Runtime {
158
166
  readonly version: number
159
167
  readonly sessions: SessionStore
@@ -183,6 +191,12 @@ interface Runtime {
183
191
  readonly usage: UsageState
184
192
  /** The budget warnings already logged (src/budgets.ts). */
185
193
  readonly budgets: BudgetState
194
+ /** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
195
+ readonly runtimeKeys: KeyState
196
+ /** The last plugin options error (`index.ts`), cleared by the next Location whose options parse (#159). */
197
+ configError?: ConfigError
198
+ /** Set once the serving catalog has loaded successfully (`loadServingCatalog`); `/readyz` reads it (#160). */
199
+ catalogLoaded?: boolean
186
200
  listener?: Listener
187
201
  /** Present while a fiber is subscribed to the server event bus. */
188
202
  pump?: Pump
@@ -197,6 +211,7 @@ const detachedStartedAt = Date.now()
197
211
  const detachedLimits = createLimitState()
198
212
  const detachedUsage = createUsageState()
199
213
  const detachedBudgets = createBudgetState()
214
+ const detachedKeys = createKeyState()
200
215
  let foreignVersionLogged = false
201
216
 
202
217
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -215,6 +230,7 @@ function runtime(): Runtime | undefined {
215
230
  limits: createLimitState(),
216
231
  usage: createUsageState(),
217
232
  budgets: createBudgetState(),
233
+ runtimeKeys: createKeyState(),
218
234
  startedAt: Date.now(),
219
235
  }
220
236
  slot[KEY] = created
@@ -299,6 +315,9 @@ export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
299
315
  /** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
300
316
  export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
301
317
 
318
+ /** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
319
+ export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
320
+
302
321
  /**
303
322
  * The options authentication reads: `keys` is the live key set, not the serving generation's own
304
323
  * (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
@@ -336,7 +355,9 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
336
355
  if (rt) rt.cache = held
337
356
  }
338
357
  const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
339
- return cachedCaller(caller, config, held.store, count)
358
+ // One bypass predicate: a persona model, or an app whose answers depend on live OpenCode tool output (#82).
359
+ const serverToolApps = new Set(Object.entries(options.apps).filter(([, policy]) => policy.serverTools !== undefined).map(([app]) => app))
360
+ return cachedCaller(caller, config, held.store, count, (model, app) => personaOf(options, model) !== undefined || serverToolApps.has(app))
340
361
  }
341
362
  return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
342
363
  }
@@ -348,12 +369,16 @@ export interface RuntimeStatus extends RuntimeSnapshot {
348
369
  readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
349
370
  /** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
350
371
  readonly servingConfirmed: boolean
372
+ /** The last plugin options error, when its Location was the latest to load the plugin (#159). */
373
+ readonly configError?: ConfigError
374
+ /** The serving catalog has loaded at least once in this process (`/readyz`, #160). */
375
+ readonly catalogLoaded: boolean
351
376
  }
352
377
 
353
378
  /** A synchronous snapshot of the runtime: no host call, no filesystem. */
354
379
  export function processStatus(): RuntimeStatus {
355
380
  const rt = runtime()
356
- if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
381
+ if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false, catalogLoaded: false }
357
382
  const serving = preferred(rt)
358
383
  return {
359
384
  runtimeVersion: rt.version,
@@ -362,10 +387,23 @@ export function processStatus(): RuntimeStatus {
362
387
  guards: rt.sessions.size(),
363
388
  startedAt: rt.startedAt,
364
389
  servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
390
+ catalogLoaded: rt.catalogLoaded === true,
365
391
  ...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
392
+ ...(rt.configError ? { configError: rt.configError } : {}),
366
393
  }
367
394
  }
368
395
 
396
+ /**
397
+ * Records the plugin options error of the Location that just failed to parse its options, or clears
398
+ * it (`undefined`) once options parse. Synchronous, so plugin setup may call it. The status and
399
+ * `/readyz` show it while a listener from an earlier good generation still serves (#159).
400
+ */
401
+ export function setConfigError(message: string | undefined): void {
402
+ const rt = runtime()
403
+ if (!rt) return
404
+ rt.configError = message === undefined ? undefined : { message, at: Date.now() }
405
+ }
406
+
369
407
  /** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
370
408
  function preferred(rt: Runtime): Generation | undefined {
371
409
  for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
@@ -376,9 +414,11 @@ function preferred(rt: Runtime): Generation | undefined {
376
414
  * The catalog of the preferred accepted generation. `fallback` (the caller's own loader) is only
377
415
  * used when no generation is registered, which a live request does not normally see.
378
416
  */
379
- export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
417
+ export async function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
380
418
  const rt = runtime()
381
- return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
419
+ const models = await (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
420
+ if (rt) rt.catalogLoaded = true
421
+ return models
382
422
  }
383
423
 
384
424
  /** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
@@ -512,7 +552,13 @@ export function registerGeneration(input: GenerationInput): Registration {
512
552
  } else if (canonicalJson(keys) !== canonicalJson(rt.keys ?? {})) {
513
553
  const live = rt.keys ?? {}
514
554
  if (rt.generations.length === 0) rt.keys = keys
515
- else if (revokesOrRotatesOnly(live, keys)) {
555
+ else if (Object.keys(keys).length === 0) {
556
+ // #152: removing the last key fails closed. The old key must not keep working, and the set must not go keyless.
557
+ if (canonicalJson(live) !== canonicalJson(REVOKED_KEYS)) {
558
+ log("listener.keys_updated", { removed: Object.keys(live).length, rotated: 0 })
559
+ rt.keys = REVOKED_KEYS
560
+ }
561
+ } else if (revokesOrRotatesOnly(live, keys)) {
516
562
  log("listener.keys_updated", {
517
563
  removed: Object.keys(live).filter((app) => !Object.hasOwn(keys, app)).length,
518
564
  rotated: Object.keys(keys).filter((app) => Object.hasOwn(live, app) && live[app] !== keys[app]).length,
@@ -562,6 +608,13 @@ export function registerGeneration(input: GenerationInput): Registration {
562
608
  }
563
609
  }
564
610
 
611
+ /**
612
+ * #152: the live key set once the last key is revoked while a generation serves. An empty key set means
613
+ * keyless, so this one keeps an entry no request can match: `*` is not a valid app name, and its key is
614
+ * empty, which `Bearer` (it needs a non-empty token) never presents. Every request gets 401 until restart.
615
+ */
616
+ const REVOKED_KEYS: Readonly<Record<string, string>> = { "*": "" }
617
+
565
618
  /** #90: `next` names no app that `live` lacks, and names at least one app: it only removes or changes keys. */
566
619
  function revokesOrRotatesOnly(live: Readonly<Record<string, string>>, next: Readonly<Record<string, string>>): boolean {
567
620
  const apps = Object.keys(next)
@@ -18,15 +18,15 @@
18
18
 
19
19
  import { randomUUID } from "node:crypto"
20
20
  import { raceAbort } from "../abort.js"
21
- import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
21
+ import { APP_NAME_PATTERN, DEFAULT_APP, DEFAULT_SERVER_TOOL_STEPS, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
22
22
  import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
23
- import { log, logError } from "../log.js"
24
- import { checkAppModel } from "../models.js"
23
+ import { log, logError, logWarn } from "../log.js"
24
+ import { checkAppModel, type Persona } from "../models.js"
25
25
  import { RequestCollector } from "./events.js"
26
26
  import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
27
27
  import { describeError, type SessionPort } from "./host.js"
28
28
  import { ImageError, prepareImages, type RemoteImages } from "./media.js"
29
- import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
29
+ import { requestPermissions, seedToolsFor } from "./seeds.js"
30
30
  import type { SessionStore } from "./state.js"
31
31
 
32
32
  export interface SessionCallerDeps {
@@ -44,6 +44,11 @@ export interface SessionCallerDeps {
44
44
  * called right before `session.create`. An error ends the request without creating anything.
45
45
  */
46
46
  readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
47
+ /**
48
+ * What the requested name stands for beyond a plain model (`personaOf` in src/models.ts): a skill alias adds
49
+ * the skill's content to the system prompt, an agent runs its own system prompt and OpenCode agent.
50
+ */
51
+ readonly persona?: (model: string) => Persona | undefined
47
52
  }
48
53
 
49
54
  /** How long the request guard may outlive the response while the host has not reported the end of the execution. */
@@ -54,6 +59,8 @@ const failure = (status: number, message: string, code: string): PlugEvent => ({
54
59
  error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
55
60
  })
56
61
 
62
+ const notFound = (message: string): PlugError => ({ status: 404, kind: "not_found", message, code: "model_not_found", param: "model" })
63
+
57
64
  const countMedia = (request: PlugRequest): number =>
58
65
  request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
59
66
 
@@ -99,6 +106,33 @@ function checkAppTools(request: PlugRequest, app: string, policy: AppPolicy | un
99
106
  message: `Request has ${request.tools.length} tools; app ${app} allows ${max} (option \`apps.${app}.maxTools\`)`,
100
107
  }
101
108
  }
109
+ /** A caller tool may not share a name with one of the app's server tools: the model would see two tools under one name. */
110
+ function checkServerToolNames(request: PlugRequest, policy: AppPolicy | undefined): PlugError | undefined {
111
+ const clash = request.tools.find((tool) => policy?.serverTools?.includes(tool.name))
112
+ if (!clash) return undefined
113
+ return {
114
+ status: 400,
115
+ kind: "invalid_request",
116
+ code: "server_tool_collision",
117
+ param: "tools",
118
+ message: `Tool \`${clash.name}\` is one of this app's OpenCode server tools (option \`apps.<name>.serverTools\`); rename the caller's tool`,
119
+ }
120
+ }
121
+
122
+ /** Every server tool must be a direct tool of this location right now (fail closed; checked per request, never at setup). */
123
+ async function checkServerToolsAvailable(port: SessionPort, policy: AppPolicy | undefined, signal: AbortSignal): Promise<PlugError | undefined> {
124
+ if (!policy?.serverTools) return undefined
125
+ const offered = new Set(await raceAbort(port.directTools(), signal))
126
+ const missing = policy.serverTools.filter((tool) => !offered.has(tool))
127
+ if (missing.length === 0) return undefined
128
+ return {
129
+ status: 400,
130
+ kind: "invalid_request",
131
+ code: "server_tool_unavailable",
132
+ message: `OpenCode tool(s) not available in this location: ${missing.join(", ")} (option \`apps.<name>.serverTools\`). CodeMode tools, including MCP tools by default, cannot be listed; set codemode: false on the tool`,
133
+ }
134
+ }
135
+
102
136
  export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
103
137
  const { port, sessions, maxTools, ensureBackend } = deps
104
138
  const apps = deps.apps ?? {}
@@ -113,9 +147,12 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
113
147
  const requestID = randomUUID()
114
148
  const startedAt = Date.now()
115
149
  let outcome = "cancelled"
150
+ // Whether the model produced anything at all (text, reasoning or a tool call), for `answer.empty` (#172).
151
+ let produced = false
116
152
  const note = (event: PlugEvent): PlugEvent => {
117
153
  if (event.type === "finish") outcome = `finish:${event.reason}`
118
154
  else if (event.type === "error") outcome = `error:${event.error.status}${event.error.code ? `:${event.error.code}` : ""}`
155
+ else produced = true
119
156
  return event
120
157
  }
121
158
 
@@ -123,9 +160,11 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
123
160
  const policy = appPolicy({ apps }, app)
124
161
  const outOfScope = checkAppModel(policy, called.model, called.ref)
125
162
  if (outOfScope) return yield note({ type: "error", error: outOfScope })
126
- const { request, stripped } = applyToolPolicy(called, policy)
163
+ let { request, stripped } = applyToolPolicy(called, policy)
127
164
  const tooMany = checkAppTools(request, app, policy)
128
165
  if (tooMany) return yield note({ type: "error", error: tooMany })
166
+ const clash = checkServerToolNames(request, policy)
167
+ if (clash) return yield note({ type: "error", error: clash })
129
168
  if (stripped) {
130
169
  try {
131
170
  context.onPolicy?.(["tools-stripped"])
@@ -165,24 +204,60 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
165
204
  const unavailable = await ensureBackend?.(app, context.signal)
166
205
  if (unavailable) return yield note({ type: "error", error: unavailable })
167
206
  if (context.signal.aborted) return
207
+ const missingServerTools = await checkServerToolsAvailable(port, policy, context.signal)
208
+ if (missingServerTools) return yield note({ type: "error", error: missingServerTools })
209
+ if (context.signal.aborted) return
210
+
211
+ // 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
212
+ // caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
213
+ const persona = deps.persona?.(called.model)
214
+ let prelude: ReadonlyArray<string> = []
215
+ if (persona?.kind === "skill") {
216
+ const content = await port.skill(directory, persona.skill)
217
+ if (content === undefined) return yield note({ type: "error", error: notFound(`Skill ${persona.skill} is not available`) })
218
+ prelude = [content]
219
+ } else if (persona?.kind === "agent") {
220
+ const agent = await port.agent(directory, persona.agent)
221
+ if (agent === undefined) return yield note({ type: "error", error: notFound(`Agent ${persona.agent} is not available`) })
222
+ request = { ...request, system: agent.system ? [agent.system] : [] }
223
+ }
224
+ if (context.signal.aborted) return
225
+
226
+ collector = new RequestCollector({
227
+ stopTurn: interrupt,
228
+ requestID,
229
+ app,
230
+ ...(policy?.serverTools
231
+ ? { serverTools: new Set(policy.serverTools), maxServerToolSteps: policy.maxServerToolSteps ?? DEFAULT_SERVER_TOOL_STEPS }
232
+ : {}),
233
+ })
168
234
 
169
- collector = new RequestCollector({ stopTurn: interrupt, requestID })
170
- const created = await raceAbort(port.create({
235
+ const creating = port.create({
171
236
  directory,
172
237
  model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
238
+ ...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
173
239
  title: makeTitle(request),
174
240
  metadata: { plugboard: { app, model: request.model, requestID } },
175
- permissions: REQUEST_PERMISSIONS,
176
- }), context.signal)
241
+ permissions: requestPermissions(policy?.serverTools ?? []),
242
+ })
243
+ const created = await raceAbort(creating, context.signal).catch((error) => {
244
+ // The host keeps creating after the abort. A session that lands now is in no index, so it is removed.
245
+ if (context.signal.aborted)
246
+ void creating.then(
247
+ (late) => port.remove(late.id).catch((removeError) => logError("session.late_remove_failed", { requestID, app, sessionID: late.id, error: describeError(removeError) })),
248
+ () => {},
249
+ )
250
+ throw error
251
+ })
177
252
  sessionID = String(created.id)
178
- sessions.register({ sessionID, app, requestID, request, collector })
253
+ sessions.register({ sessionID, app, requestID, request, collector, prelude })
179
254
 
180
255
  context.signal.addEventListener("abort", onAbort, { once: true })
181
256
  if (context.signal.aborted) return onAbort()
182
257
 
183
258
  // The history id is known from here on: tell the front-end before the first event.
184
259
  try {
185
- context.onRecord?.(sessionID)
260
+ context.onRecord?.(sessionID, directory)
186
261
  } catch (error) {
187
262
  logError("request.on_record_failed", { requestID, app, error: describeError(error) })
188
263
  }
@@ -217,6 +292,16 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
217
292
  if (promptStarted) sessions.retire(sessionID, { timeoutMs: CLEANUP_TIMEOUT_MS, onTimeout: interrupt })
218
293
  else sessions.release(sessionID)
219
294
  }
295
+ const serverToolCalls = collector?.serverCallCount ?? 0
296
+ if (serverToolCalls > 0 && context.onServerTools) {
297
+ try {
298
+ context.onServerTools(serverToolCalls)
299
+ } catch (error) {
300
+ logError("request.on_server_tools_failed", { requestID, app, error: describeError(error) })
301
+ }
302
+ }
303
+ // A turn that stopped with nothing in it is kept as a session for inspection: the session id finds it.
304
+ if (outcome === "finish:stop" && !produced) logWarn("answer.empty", { requestID, app, model: request.model, sessionID, tools: request.tools.length })
220
305
  log("request.finished", { requestID, app, model: request.model, durationMs: Date.now() - startedAt, outcome })
221
306
  }
222
307
  },
@@ -59,10 +59,13 @@ function generationOptions(request: PlugRequest): Record<string, unknown> {
59
59
  * Returns how many caller tools got no seed (zero unless the seed pool is smaller than the request
60
60
  * needs, or two caller tools share a name and would collapse into one key).
61
61
  *
62
+ * The app's `serverTools` (option `apps`) are kept as the host offered them: their descriptors were
63
+ * never rewritten, so the host runs the real tool. Not offered when the caller set `tool_choice` none.
64
+ *
62
65
  * The map is built with `Object.fromEntries`: a tool named `__proto__` becomes an own key like any
63
66
  * other instead of assigning the prototype and vanishing.
64
67
  */
65
- function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number {
68
+ function remapSeedTools(session: ContextHookEvent, request: PlugRequest, serverTools: ReadonlyArray<string>): number {
66
69
  const seeds = Object.entries(session.tools ?? {}).filter(([name]) => name.startsWith(SEED_PREFIX))
67
70
  const tools = seedToolsFor(request)
68
71
  const next = new Map<string, HostToolDescriptor>()
@@ -77,8 +80,16 @@ function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number
77
80
  descriptor.input = tool.parameters
78
81
  next.set(tool.name, descriptor)
79
82
  }
83
+ const missing = tools.length - next.size
84
+ if (request.toolChoice !== "none") {
85
+ const offered = session.tools ?? {}
86
+ for (const name of serverTools) {
87
+ if (!Object.hasOwn(offered, name) || next.has(name)) continue
88
+ next.set(name, offered[name] as HostToolDescriptor)
89
+ }
90
+ }
80
91
  session.tools = Object.fromEntries(next)
81
- return tools.length - next.size
92
+ return missing
82
93
  }
83
94
 
84
95
  /**
@@ -109,6 +120,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
109
120
  const state = sessions.lookup(String(session.sessionID))
110
121
  if (!state) return
111
122
  const { request } = state
123
+ const policy = appPolicy({ apps }, state.app)
112
124
 
113
125
  // Swap media placeholders for the Asset-carrying parts the host built from the prompt files.
114
126
  const hostMedia = (session.messages ?? [])
@@ -123,7 +135,9 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
123
135
  return text("[image unavailable]")
124
136
  }
125
137
 
126
- const system = systemFor(request.system, appPolicy({ apps }, state.app)).map(text)
138
+ // Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
139
+ // then the tool-choice steering below.
140
+ const system = [...(state.prelude ?? []), ...systemFor(request.system, policy)].map(text)
127
141
  // The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
128
142
  const choice = request.toolChoice
129
143
  if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
@@ -132,7 +146,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
132
146
  session.messages = toHostMessages(request.messages).map((message) => ({ ...message, content: message.content.map(swap) }))
133
147
  Object.assign(session.options ?? (session.options = {}), generationOptions(request))
134
148
 
135
- const missingTools = remapSeedTools(session, request)
149
+ const missingTools = remapSeedTools(session, request, policy?.serverTools ?? [])
136
150
 
137
151
  // The caller validates both before dispatch; this only fires if the host disagrees. The request is
138
152
  // already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
@@ -6,13 +6,18 @@
6
6
  * ModelCaller streams out through `events()` (not buffered beyond the queue).
7
7
  *
8
8
  * The turn is stopped from inside the model loop (see `session/seeds.ts`): once the step has ended
9
- * (`session.step.ended`) with at least one tool call, the owner is asked to interrupt the
9
+ * (`session.step.ended`) with at least one caller tool call, the owner is asked to interrupt the
10
10
  * session before a second model step can start, and the request finishes as `tool-calls`.
11
11
  * The collector never calls the host itself; the owner passes `stopTurn`.
12
+ *
13
+ * A call to one of the app's `serverTools` is not the caller's: OpenCode runs it, the runner starts
14
+ * the next step with its result, and the collector neither emits it nor stops the turn. A step with
15
+ * only server calls continues; a step that also holds a caller call is returned to the caller as usual.
16
+ * Server steps are counted and capped (`maxServerToolSteps`); each server call is logged once.
12
17
  */
13
18
 
14
19
  import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
15
- import { logError } from "../log.js"
20
+ import { log, logError } from "../log.js"
16
21
  import type { BusEvent } from "./host.js"
17
22
  import { FAILURE_EVENT } from "./state.js"
18
23
 
@@ -145,6 +150,18 @@ const toUsage = (tokens?: RawUsage): PlugUsage | undefined =>
145
150
  const CONTEXT_OVERFLOW =
146
151
  /context[_ ](?:length|window)|maximum context|prompt (?:is )?too long|too many tokens|token limit|input length.*exceeds|exceeds the (?:available )?context|reduce the length of the messages/i
147
152
 
153
+ /**
154
+ * Provider wording that the status does not settle (#171). A ChatGPT subscription at its limit is a
155
+ * `provider.invalid-request` with `subscription_sharing_usage_limit_exceeded` and no status. These
156
+ * are matched on the message and the raw body, and only ever decide a class; the text never leaves.
157
+ */
158
+ const USAGE_LIMIT = /usage[_ ]limit|quota/i
159
+ const RATE_LIMIT = /rate[_ ]limit|too many requests/i
160
+ const AUTH_FAILURE = /unauthori[sz]ed|invalid[_ ]api[_ ]key|incorrect api key|not logged in|log in again|sign in|permission denied/i
161
+ const MODEL_NOT_FOUND = /model[^\n]{0,60}(?:not found|does not exist|not supported|unknown)|unknown model|model_not_found/i
162
+
163
+ const AUTH_MESSAGE = "The model provider rejected its credentials; the plugboard host needs to sign in again"
164
+
148
165
  /**
149
166
  * Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. Provider and host
150
167
  * error text can carry endpoints, account or organization ids, so it never reaches the caller:
@@ -167,10 +184,9 @@ function toPlugError(error: RawError | undefined, fallback: string): PlugError {
167
184
  return { status: 400, kind: "invalid_request", code: "context_length_exceeded", param: "messages", message: "The request is longer than the model's context window" }
168
185
  }
169
186
  if (type === "provider.auth" || status === 401 || status === 403) {
170
- const message = "The model provider rejected its credentials; the plugboard host needs to sign in again"
171
- if (status === 401) return { status, kind: "authentication", code: "provider_auth_failed", message }
172
- if (status === 403) return { status, kind: "permission", code: "provider_auth_failed", message }
173
- return { status: 502, kind: "server", code: "provider_auth_failed", message }
187
+ if (status === 401) return { status, kind: "authentication", code: "provider_auth_failed", message: AUTH_MESSAGE }
188
+ if (status === 403) return { status, kind: "permission", code: "provider_auth_failed", message: AUTH_MESSAGE }
189
+ return { status: 502, kind: "server", code: "provider_auth_failed", message: AUTH_MESSAGE }
174
190
  }
175
191
  if (type === "provider.content-filter") {
176
192
  return { status: 400, kind: "invalid_request", code: "content_filter", message: "The model provider's content filter blocked the request" }
@@ -179,6 +195,11 @@ function toPlugError(error: RawError | undefined, fallback: string): PlugError {
179
195
  return { status: 504, kind: "timeout", code: "provider_timeout", message: "The model provider timed out" }
180
196
  }
181
197
  if (type.startsWith("provider.")) {
198
+ const detail = `${error?.message ?? ""}\n${error?.response?.body ?? ""}`
199
+ if (USAGE_LIMIT.test(detail)) return { status: 429, kind: "rate_limit", code: "quota_exceeded", message: "The model provider's usage quota is exhausted" }
200
+ if (RATE_LIMIT.test(detail)) return { status: 429, kind: "rate_limit", code: "rate_limited", message: "The model provider is rate limiting requests; retry later" }
201
+ if (MODEL_NOT_FOUND.test(detail)) return { status: 404, kind: "not_found", code: "model_not_found", param: "model", message: "The model provider does not know the requested model" }
202
+ if (AUTH_FAILURE.test(detail)) return { status: 502, kind: "server", code: "provider_auth_failed", message: AUTH_MESSAGE }
182
203
  if (status === 404) return { status, kind: "not_found", code: "provider_rejected_request", message: "The model provider rejected the request" }
183
204
  if (status !== undefined && status < 500) return { status, kind: "invalid_request", code: "provider_rejected_request", message: "The model provider rejected the request" }
184
205
  return { status: status ?? 502, kind: "server", code: "provider_unavailable", message: "The model provider is unavailable or returned an error" }
@@ -191,6 +212,18 @@ export interface CollectorHooks {
191
212
  readonly stopTurn: () => void
192
213
  /** The request's id, for the log line that carries a failure's raw host text. */
193
214
  readonly requestID?: string
215
+ /** The app, for the `server_tool.call` log lines. */
216
+ readonly app?: string
217
+ /** The app's `serverTools` (option `apps`): OpenCode runs these calls, the caller never sees them. */
218
+ readonly serverTools?: ReadonlySet<string>
219
+ /** Steps that may run server tools before the turn is stopped. The owner passes it with `serverTools`. */
220
+ readonly maxServerToolSteps?: number
221
+ }
222
+
223
+ interface ServerCall {
224
+ readonly name: string
225
+ readonly startedAt: number
226
+ done: boolean
194
227
  }
195
228
 
196
229
  export class RequestCollector {
@@ -198,6 +231,9 @@ export class RequestCollector {
198
231
  private readonly emittedText = new Map<number, number>()
199
232
  private readonly toolNames = new Map<string, string>()
200
233
  private readonly calls = new Set<string>()
234
+ private readonly serverCalls = new Map<string, ServerCall>()
235
+ private stepServerCalls = 0
236
+ private serverSteps = 0
201
237
  private usage?: RawUsage
202
238
  private finished = false
203
239
  private executionDone = false
@@ -221,6 +257,11 @@ export class RequestCollector {
221
257
  return this.settled
222
258
  }
223
259
 
260
+ /** How many server tool calls OpenCode ran in this session (each counted when it started). */
261
+ get serverCallCount(): number {
262
+ return this.serverCalls.size
263
+ }
264
+
224
265
  /** True once the owner cancelled the request (client gone or timeout). */
225
266
  get cancelled(): boolean {
226
267
  return this.wasCancelled
@@ -274,23 +315,48 @@ export class RequestCollector {
274
315
  case "session.tool.called": {
275
316
  if (this.finished) return
276
317
  const id = String(item.id)
277
- if (this.calls.has(id)) return
318
+ if (this.calls.has(id) || this.serverCalls.has(id)) return
319
+ const name = String(item.name ?? this.toolNames.get(id) ?? "")
320
+ if (this.hooks.serverTools?.has(name)) {
321
+ this.serverCalls.set(id, { name, startedAt: Date.now(), done: false })
322
+ this.stepServerCalls++
323
+ return
324
+ }
278
325
  this.calls.add(id)
279
- this.emit({ type: "tool-call", id, name: String(item.name ?? this.toolNames.get(id) ?? ""), input: item.input })
326
+ this.emit({ type: "tool-call", id, name, input: item.input })
327
+ return
328
+ }
329
+ case "session.tool.success":
330
+ case "session.tool.failed": {
331
+ const call = this.serverCalls.get(String(item.id))
332
+ if (!call || call.done) return
333
+ call.done = true
334
+ log("server_tool.call", {
335
+ requestID: this.hooks.requestID,
336
+ app: this.hooks.app,
337
+ tool: call.name,
338
+ durationMs: Date.now() - call.startedAt,
339
+ ok: item.type === "session.tool.success",
340
+ })
280
341
  return
281
342
  }
282
343
  case "session.usage.updated":
283
344
  // Assumed invariant: `tokens` is the host's token-usage record (RawUsage shape).
284
345
  this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
285
346
  return
286
- case "session.step.ended":
347
+ case "session.step.ended": {
287
348
  this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
349
+ const serverCalls = this.stepServerCalls
350
+ this.stepServerCalls = 0
288
351
  // Also after the response finished: a later step that ended with calls means the first
289
352
  // interrupt lost the race or failed; ask again (the guard stays until the execution ends).
290
353
  if (this.calls.size > 0 && !this.executionDone) this.hooks.stopTurn()
291
- if (this.calls.size === 0 && toFinish(item.finish) === "stop") this.settled = true
292
- this.finishWith(withCalls(toFinish(item.finish), this.calls.size > 0))
354
+ const reason = toFinish(item.finish)
355
+ if (this.calls.size === 0 && serverCalls > 0) return this.serverStepEnded(reason)
356
+ if (this.calls.size === 0 && reason === "stop") this.settled = true
357
+ this.finishWith(withCalls(reason, this.calls.size > 0))
293
358
  return
359
+ }
294
360
  case FAILURE_EVENT:
295
361
  // Synthetic, from inside the process (lost event stream, preparation mismatch).
296
362
  if (isPlugError(item.error)) this.abort(item.error)
@@ -308,12 +374,14 @@ export class RequestCollector {
308
374
  }
309
375
  case "session.execution.failed":
310
376
  this.executionDone = true
377
+ this.logUnfinishedServerCalls()
311
378
  if (!this.finished) this.failFromHost(item.error as RawError | undefined, "Session execution failed")
312
379
  this.end()
313
380
  return
314
381
  case "session.execution.interrupted":
315
382
  case "session.execution.succeeded":
316
383
  this.executionDone = true
384
+ this.logUnfinishedServerCalls()
317
385
  if (!this.finished) {
318
386
  if (this.calls.size > 0) this.finishWith("tool-calls")
319
387
  else if (this.wasCancelled) this.end()
@@ -346,16 +414,52 @@ export class RequestCollector {
346
414
  this.end()
347
415
  }
348
416
 
417
+ /**
418
+ * A step that ran only server tools: the runner starts the next step with their results. The cap is
419
+ * checked on each such step; past it the request fails and the turn is stopped.
420
+ */
421
+ private serverStepEnded(reason: PlugFinish): void {
422
+ this.serverSteps++
423
+ if (this.serverSteps > (this.hooks.maxServerToolSteps ?? 0)) {
424
+ this.abort({
425
+ status: 502,
426
+ kind: "server",
427
+ code: "server_tool_steps_exceeded",
428
+ message: "The model used more OpenCode tool steps than this app allows",
429
+ })
430
+ return
431
+ }
432
+ if (reason === "stop" || reason === "tool-calls") return
433
+ this.finishWith(reason)
434
+ }
435
+
436
+ /** Server calls the execution ended before they reported back are logged as not ok, once each. */
437
+ private logUnfinishedServerCalls(): void {
438
+ for (const call of this.serverCalls.values()) {
439
+ if (call.done) continue
440
+ call.done = true
441
+ log("server_tool.call", {
442
+ requestID: this.hooks.requestID,
443
+ app: this.hooks.app,
444
+ tool: call.name,
445
+ durationMs: Date.now() - call.startedAt,
446
+ ok: false,
447
+ })
448
+ }
449
+ }
450
+
349
451
  /** Fail with the caller-safe mapping of a host error; the raw text is logged here and goes nowhere else. */
350
452
  private failFromHost(raw: RawError | undefined, fallback: string): void {
351
453
  if (this.finished) return
454
+ const error = toPlugError(raw, fallback)
352
455
  logError("model.failed", {
353
456
  requestID: this.hooks.requestID,
354
457
  type: raw?.type,
355
458
  status: raw?.status,
459
+ code: error.code,
356
460
  message: raw?.message?.slice(0, 500),
357
461
  })
358
- this.fail(toPlugError(raw, fallback))
462
+ this.fail(error)
359
463
  }
360
464
 
361
465
  private fail(error: PlugError): void {