opencode-plugboard 0.20.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.
@@ -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 {
@@ -32,10 +32,19 @@ export interface SessionPort {
32
32
  }): Promise<{ id: string }>
33
33
  prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
34
34
  interrupt(sessionID: string): Promise<void>
35
+ /** Deletes a host session. Used only for a session that was created after its request was aborted (#155). */
36
+ remove(sessionID: string): Promise<void>
35
37
  /** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
36
38
  skill(directory: string, id: string): Promise<string | undefined>
37
39
  /** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
38
40
  agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
41
+ /**
42
+ * The ids of the OpenCode tools this location offers directly to a model (`codemode: false`), the only
43
+ * ones a request session can call by name. CodeMode-wrapped tools are reached through `execute`, which a
44
+ * request session denies, so they are not listed. A tool whose permission name differs from its id is
45
+ * not listed either: the session's permission rules are written by id.
46
+ */
47
+ directTools(): Promise<ReadonlyArray<string>>
39
48
  }
40
49
 
41
50
  type HostSession = HostContext["session"]
@@ -112,6 +121,11 @@ export function createSessionPort(ctx: HostContext): SessionPort {
112
121
  ),
113
122
  interrupt: (sessionID) =>
114
123
  run(ctx.session.interrupt({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["interrupt"]>[0])).then(() => undefined),
124
+ remove: (sessionID) => run(ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0])).then(() => undefined),
125
+ directTools: () =>
126
+ run(ctx.tool.list()).then((tools) =>
127
+ tools.filter((tool) => tool.options?.codemode === false && (tool.options?.permission ?? tool.id) === tool.id).map((tool) => tool.id),
128
+ ),
115
129
  }
116
130
  }
117
131
 
@@ -39,6 +39,16 @@ export const REQUEST_PERMISSIONS: ReadonlyArray<{ action: string; resource: stri
39
39
  { action: `${SEED_PREFIX}*`, resource: "*", effect: "allow" },
40
40
  ]
41
41
 
42
+ /**
43
+ * Session permissions of a request session of an app with option `serverTools`: the same deny-all, then
44
+ * each listed OpenCode tool id allowed by its exact name (no wildcard). The host takes the last matching
45
+ * rule (`Permission.evaluate`, findLast), so the later allows win over the deny. An app without
46
+ * `serverTools` gets exactly `REQUEST_PERMISSIONS`.
47
+ */
48
+ export function requestPermissions(serverTools: ReadonlyArray<string>): ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }> {
49
+ return [...REQUEST_PERMISSIONS, ...serverTools.map((tool) => ({ action: tool, resource: "*", effect: "allow" as const }))]
50
+ }
51
+
42
52
  /** The caller tools that get a seed in the model request (`tool_choice` none offers none, a named tool only that one). */
43
53
  export function seedToolsFor(request: PlugRequest): ReadonlyArray<PlugTool> {
44
54
  const choice = request.toolChoice
package/tui.ts CHANGED
@@ -1,4 +1,5 @@
1
- // The `./tui` entrypoint. OpenCode resolves a package's TUI entry through `exports["./tui"]`, but a
2
- // plugin configured by local path is resolved as `<directory>/tui` with no `exports` lookup
3
- // (packages/plugin/src/host.ts in OpenCode 2.0.26), so the entry lives at the package root.
4
- export { default } from "./src/tui.tsx"
1
+ // The `./tui` entrypoint for a plugin configured by local path. OpenCode resolves that as
2
+ // `<directory>/tui` with no `exports` lookup (packages/plugin/src/host.ts in OpenCode 2.0.26), so the
3
+ // entry lives at the package root. It re-exports the compiled file (`bun run build:tui`), not
4
+ // src/tui.tsx: a local path under node_modules is never Solid-compiled (see build-tui.ts).
5
+ export { default } from "./dist/tui.js"