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.
package/README.md CHANGED
@@ -43,7 +43,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
43
43
  {
44
44
  "plugins": [
45
45
  {
46
- "package": "opencode-plugboard@0.20.0",
46
+ "package": "opencode-plugboard@0.21.0",
47
47
  "options": {
48
48
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
49
49
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -64,7 +64,7 @@ Keep the version pinned. A bare `"opencode-plugboard"` means `latest`, so a cach
64
64
  opencode api get /api/agent
65
65
  ```
66
66
 
67
- For an always-on server, run that from the service unit. See [warm-up](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/warm-up.md).
67
+ For an always-on server, run that from the service unit. See [warm-up][warm-up].
68
68
 
69
69
  **4. Call it.** The listener is on `127.0.0.1:4010`. This prints `pong`:
70
70
 
@@ -107,7 +107,7 @@ Give every tool its own key. Traffic, history and usage are then separated by ap
107
107
  }
108
108
  ```
109
109
 
110
- Callers send the key as `Authorization: Bearer <key>` (or `x-api-key` on the Anthropic routes). They never need the OpenCode server password. Removing a key takes effect on the running server at once.
110
+ Callers send the key as `Authorization: Bearer <key>` (or `x-api-key` on the Anthropic routes). They never need the OpenCode server password. Removing a key takes effect on the running server at once; see [reloads and restarts][configuration-reload].
111
111
 
112
112
  #### Model allowlist
113
113
 
@@ -390,7 +390,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
390
390
  {
391
391
  "plugins": [
392
392
  {
393
- "package": "opencode-plugboard@0.20.0",
393
+ "package": "opencode-plugboard@0.21.0",
394
394
  "options": {
395
395
  // Reachable from the LAN. Non-loopback hosts refuse to start without keys.
396
396
  "listen": { "host": "10.0.1.50", "port": 4010 },
@@ -468,8 +468,8 @@ The protocol-level results are in the [compatibility matrix][compatibility]. It
468
468
 
469
469
  ## Known gaps
470
470
 
471
- - **Responses is stateless.** Every request carries the whole conversation, and nothing is stored. Clients that default to `store: true` fail on a second turn ([#147](https://github.com/jjjermiah/opencode-plugboard/issues/147)). Set `store: false`. For n8n, turn "Use Responses API" off.
472
- - **Stop sequences** are removed from the answer only on `/v1/messages` ([#145](https://github.com/jjjermiah/opencode-plugboard/issues/145)). Other endpoints return the matched text.
471
+ - **Responses keeps no conversation.** Every request carries the whole conversation, and `previous_response_id` and `conversation` are rejected. The output items of recent answers are held in memory for an hour, so the `item_reference`s that the Vercel AI SDK sends back resolve; a restart clears them ([#147](https://github.com/jjjermiah/opencode-plugboard/issues/147)). For n8n, turn "Use Responses API" off.
472
+ - **Stop sequences** are removed from the answer when they end it, on the Anthropic, OpenAI and Gemini routes. The host reports only that the turn stopped, so a sequence the provider ended on without returning it is not removed.
473
473
  - **Anthropic structured outputs** (`output_config.format`) are refused with a 400. Use `json_schema` on the OpenAI or Gemini endpoints.
474
474
  - **Gemini `candidateCount` above 1** is refused. The OpenAI endpoints serve `n`.
475
475
  - **Gemini model ids with a `/`** sent by the AI SDK's Google provider or LangChain's `google-genai` lack the `models/` prefix and get a 401. The `@google/genai` SDK works.
@@ -485,7 +485,7 @@ Open work is tracked in the [v0.1 milestone](https://github.com/jjjermiah/openco
485
485
  ## Security
486
486
 
487
487
  - **Bind address.** The listener binds `127.0.0.1` by default. A non-loopback `listen.host` refuses to start without `keys`. On a loopback bind, a `Host` header that is not `localhost`, `127.0.0.1` or `[::1]` gets a 403.
488
- - **Keys cross the network in clear text.** The endpoint speaks plain HTTP. For remote access, allow only known addresses in the firewall, tunnel over SSH, or put a TLS-terminating proxy in front. See [reaching plugboard from another host or container][reach].
488
+ - **Keys cross the network in clear text.** The endpoint speaks plain HTTP. For remote access, allow only known addresses in the firewall, tunnel over SSH, or put a TLS-terminating proxy in front. See [reaching plugboard from another host or container][reach] and [remote access][remote-access].
489
489
  - **Keep keys out of the config file.** Use `{env:VAR}` and keep the values in the environment or a secret store. Every app needs its own key.
490
490
  - **`adminKey` reads usage only.** It cannot call the model routes, and it must differ from every app key.
491
491
  - **Keyless mode is loopback only.** Without `keys`, the one app is `default`, and only local callers can reach it.
@@ -510,28 +510,32 @@ The plugin swaps the caller's prompt, messages and tools in for OpenCode's own.
510
510
 
511
511
  MIT
512
512
 
513
- [configuration]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md
514
- [configuration-apps]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#apps-and-keys
515
- [configuration-aliases]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#aliases-and-fallbacks
516
- [configuration-limits]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#concurrency-and-rate-limits
517
- [configuration-usage]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#usage-statistics
518
- [responses]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md
519
- [messages]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/messages.md
520
- [gemini]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/gemini.md
521
- [completions]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/completions.md
522
- [history]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/history.md
523
- [warm-up]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/warm-up.md
524
- [architecture]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md
525
- [development]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/development.md
526
- [compatibility]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/compatibility.md
513
+ Not affiliated with or endorsed by OpenAI, Anthropic, Google, GitHub or Microsoft. Product names are trademarks of their owners.
514
+
515
+ [configuration]: https://jjjermiah.github.io/opencode-plugboard/configuration
516
+ [configuration-apps]: https://jjjermiah.github.io/opencode-plugboard/configuration#apps-and-keys
517
+ [configuration-aliases]: https://jjjermiah.github.io/opencode-plugboard/configuration#aliases-and-fallbacks
518
+ [configuration-limits]: https://jjjermiah.github.io/opencode-plugboard/configuration#concurrency-and-rate-limits
519
+ [configuration-usage]: https://jjjermiah.github.io/opencode-plugboard/configuration#usage-statistics
520
+ [configuration-reload]: https://jjjermiah.github.io/opencode-plugboard/configuration#reloads-and-restarts
521
+ [responses]: https://jjjermiah.github.io/opencode-plugboard/responses
522
+ [messages]: https://jjjermiah.github.io/opencode-plugboard/messages
523
+ [gemini]: https://jjjermiah.github.io/opencode-plugboard/gemini
524
+ [completions]: https://jjjermiah.github.io/opencode-plugboard/completions
525
+ [history]: https://jjjermiah.github.io/opencode-plugboard/history
526
+ [warm-up]: https://jjjermiah.github.io/opencode-plugboard/warm-up
527
+ [architecture]: https://jjjermiah.github.io/opencode-plugboard/architecture
528
+ [development]: https://jjjermiah.github.io/opencode-plugboard/development
529
+ [compatibility]: https://jjjermiah.github.io/opencode-plugboard/compatibility
527
530
  [release-candidate]: https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/results/release-candidate.md
528
- [reach]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md#reaching-plugboard-from-another-host-or-container
529
- [client-openai-python]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-python.md
530
- [client-openai-js]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-js.md
531
- [client-vercel-ai-sdk]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/vercel-ai-sdk.md
532
- [client-langchain-python]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-python.md
533
- [client-langchain-js]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-js.md
534
- [client-pydantic-ai]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/pydantic-ai.md
535
- [client-open-webui]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/open-webui.md
536
- [client-n8n]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md
537
- [client-any-app]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md
531
+ [reach]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app#reaching-plugboard-from-another-host-or-container
532
+ [remote-access]: https://jjjermiah.github.io/opencode-plugboard/remote-access
533
+ [client-openai-python]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-python
534
+ [client-openai-js]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-js
535
+ [client-vercel-ai-sdk]: https://jjjermiah.github.io/opencode-plugboard/clients/vercel-ai-sdk
536
+ [client-langchain-python]: https://jjjermiah.github.io/opencode-plugboard/clients/langchain-python
537
+ [client-langchain-js]: https://jjjermiah.github.io/opencode-plugboard/clients/langchain-js
538
+ [client-pydantic-ai]: https://jjjermiah.github.io/opencode-plugboard/clients/pydantic-ai
539
+ [client-open-webui]: https://jjjermiah.github.io/opencode-plugboard/clients/open-webui
540
+ [client-n8n]: https://jjjermiah.github.io/opencode-plugboard/clients/n8n
541
+ [client-any-app]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app
package/dist/tui.js ADDED
@@ -0,0 +1,334 @@
1
+ // Generated by .github/scripts/build-tui.ts from src/tui.tsx. Do not edit.
2
+ import { effect as _$effect } from "@opentui/solid";
3
+ import { createComponent as _$createComponent } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { memo as _$memo } from "@opentui/solid";
6
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
7
+ import { insertNode as _$insertNode } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { createElement as _$createElement } from "@opentui/solid";
10
+ /** @jsxImportSource @opentui/solid */
11
+ /**
12
+ * The `/plugboard` usage page in OpenCode's terminal UI (package entry `./tui`). OpenCode loads it
13
+ * for every server that runs plugboard with the TUI feature: it reads the usage report over the
14
+ * server's own RPC (`context.client.rpc(UsageRpc).get`, src/rpc.ts), so it sees every app there.
15
+ * Nothing is served over HTTP, and nothing here runs on the server.
16
+ *
17
+ * Page keys: t, w, m, q pick the range (today, 7, 30, 90 UTC days); r reloads; esc goes back.
18
+ * Setup only registers the page and the command (no awaits). Shaping and formats: src/tui-format.ts.
19
+ */
20
+
21
+ import { Plugin } from "@opencode/plugin/tui";
22
+ import { TextAttributes } from "@opentui/core";
23
+ import { createResource, createSignal, For, Show } from "solid-js";
24
+ import { UsageRpc } from "../src/rpc.js";
25
+ import { APP_COLUMNS, appRows, appValues, barCells, formatLine, MODEL_COLUMNS, modelRows, modelValues, presetRange, RANGE_PRESETS, totalItems, unavailableMessage } from "../src/tui-format.js";
26
+ const PRESET_LABEL = preset => RANGE_PRESETS.find(item => item.preset === preset)?.label ?? preset;
27
+ function UsagePage(props) {
28
+ const theme = props.context.theme;
29
+ const [query, setQuery] = createSignal({
30
+ preset: "month",
31
+ reload: 0
32
+ });
33
+ const [report] = createResource(query, async current => {
34
+ const range = presetRange(current.preset, Date.now());
35
+ try {
36
+ return await props.context.client.rpc(UsageRpc).get({
37
+ from: range.from,
38
+ to: range.to
39
+ });
40
+ } catch (error) {
41
+ props.context.ui.toast.show({
42
+ variant: "error",
43
+ title: "Plugboard",
44
+ message: unavailableMessage(error)
45
+ });
46
+ throw error;
47
+ }
48
+ });
49
+ props.context.keymap.layer(() => ({
50
+ commands: [{
51
+ bind: "escape",
52
+ title: "back",
53
+ run: props.onClose
54
+ }, ...RANGE_PRESETS.map(item => ({
55
+ bind: item.key,
56
+ title: `range ${item.label}`,
57
+ run: () => setQuery(current => ({
58
+ preset: item.preset,
59
+ reload: current.reload
60
+ }))
61
+ })), {
62
+ bind: "r",
63
+ title: "refresh",
64
+ run: () => setQuery(current => ({
65
+ ...current,
66
+ reload: current.reload + 1
67
+ }))
68
+ }]
69
+ }));
70
+ return (() => {
71
+ var _el$ = _$createElement("box"),
72
+ _el$2 = _$createElement("scrollbox"),
73
+ _el$3 = _$createElement("box"),
74
+ _el$4 = _$createElement("text"),
75
+ _el$6 = _$createElement("text"),
76
+ _el$7 = _$createTextNode(` · t today · w 7 days · m 30 days · q 90 days · r refresh · esc back`);
77
+ _$insertNode(_el$, _el$2);
78
+ _$setProp(_el$, "width", "100%");
79
+ _$setProp(_el$, "height", "100%");
80
+ _$insertNode(_el$2, _el$3);
81
+ _$setProp(_el$2, "flexGrow", 1);
82
+ _$insertNode(_el$3, _el$4);
83
+ _$insertNode(_el$3, _el$6);
84
+ _$setProp(_el$3, "flexDirection", "column");
85
+ _$setProp(_el$3, "gap", 1);
86
+ _$setProp(_el$3, "paddingLeft", 2);
87
+ _$setProp(_el$3, "paddingRight", 2);
88
+ _$setProp(_el$3, "paddingTop", 1);
89
+ _$setProp(_el$3, "paddingBottom", 1);
90
+ _$insertNode(_el$4, _$createTextNode(`Plugboard usage`));
91
+ _$insertNode(_el$6, _el$7);
92
+ _$insert(_el$6, (() => {
93
+ var _c$ = _$memo(() => !!report());
94
+ return () => _c$() ? `${report().from} to ${report().to} (UTC)` : PRESET_LABEL(query().preset);
95
+ })(), _el$7);
96
+ _$insert(_el$3, _$createComponent(Show, {
97
+ get when() {
98
+ return !report.error;
99
+ },
100
+ get fallback() {
101
+ return (() => {
102
+ var _el$9 = _$createElement("text");
103
+ _$insert(_el$9, () => unavailableMessage(report.error));
104
+ _$effect(_$p => _$setProp(_el$9, "fg", theme.text.feedback.error.base, _$p));
105
+ return _el$9;
106
+ })();
107
+ },
108
+ get children() {
109
+ return _$createComponent(Show, {
110
+ get when() {
111
+ return report();
112
+ },
113
+ get fallback() {
114
+ return (() => {
115
+ var _el$0 = _$createElement("text");
116
+ _$insertNode(_el$0, _$createTextNode(`Loading usage…`));
117
+ _$effect(_$p => _$setProp(_el$0, "fg", theme.text.muted, _$p));
118
+ return _el$0;
119
+ })();
120
+ },
121
+ get children() {
122
+ return _$createComponent(UsageBody, {
123
+ get report() {
124
+ return report();
125
+ },
126
+ theme: theme
127
+ });
128
+ }
129
+ });
130
+ }
131
+ }), null);
132
+ _$effect(_p$ => {
133
+ var _v$ = theme.background.base,
134
+ _v$2 = theme.text.base,
135
+ _v$3 = TextAttributes.BOLD,
136
+ _v$4 = theme.text.muted;
137
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "backgroundColor", _v$, _p$.e));
138
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
139
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$4, "attributes", _v$3, _p$.a));
140
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$6, "fg", _v$4, _p$.o));
141
+ return _p$;
142
+ }, {
143
+ e: undefined,
144
+ t: undefined,
145
+ a: undefined,
146
+ o: undefined
147
+ });
148
+ return _el$;
149
+ })();
150
+ }
151
+ function UsageBody(props) {
152
+ const apps = () => appRows(props.report);
153
+ const models = () => modelRows(props.report, 10);
154
+ return (() => {
155
+ var _el$10 = _$createElement("box"),
156
+ _el$11 = _$createElement("box"),
157
+ _el$12 = _$createElement("text"),
158
+ _el$15 = _$createElement("text");
159
+ _$insertNode(_el$10, _el$11);
160
+ _$insertNode(_el$10, _el$12);
161
+ _$insertNode(_el$10, _el$15);
162
+ _$setProp(_el$10, "flexDirection", "column");
163
+ _$setProp(_el$10, "gap", 1);
164
+ _$setProp(_el$11, "flexDirection", "row");
165
+ _$setProp(_el$11, "flexWrap", "wrap");
166
+ _$setProp(_el$11, "gap", 2);
167
+ _$insert(_el$11, _$createComponent(For, {
168
+ get each() {
169
+ return totalItems(props.report.totals);
170
+ },
171
+ children: item => (() => {
172
+ var _el$18 = _$createElement("box"),
173
+ _el$19 = _$createElement("text"),
174
+ _el$20 = _$createElement("text");
175
+ _$insertNode(_el$18, _el$19);
176
+ _$insertNode(_el$18, _el$20);
177
+ _$setProp(_el$18, "flexDirection", "row");
178
+ _$setProp(_el$18, "gap", 1);
179
+ _$insert(_el$19, () => item.label);
180
+ _$insert(_el$20, () => item.value);
181
+ _$effect(_p$ => {
182
+ var _v$9 = props.theme.text.muted,
183
+ _v$0 = props.theme.text.base,
184
+ _v$1 = TextAttributes.BOLD;
185
+ _v$9 !== _p$.e && (_p$.e = _$setProp(_el$19, "fg", _v$9, _p$.e));
186
+ _v$0 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$0, _p$.t));
187
+ _v$1 !== _p$.a && (_p$.a = _$setProp(_el$20, "attributes", _v$1, _p$.a));
188
+ return _p$;
189
+ }, {
190
+ e: undefined,
191
+ t: undefined,
192
+ a: undefined
193
+ });
194
+ return _el$18;
195
+ })()
196
+ }));
197
+ _$insertNode(_el$12, _$createTextNode(`Apps`));
198
+ _$insert(_el$10, _$createComponent(Show, {
199
+ get when() {
200
+ return apps().length > 0;
201
+ },
202
+ get fallback() {
203
+ return (() => {
204
+ var _el$21 = _$createElement("text");
205
+ _$insertNode(_el$21, _$createTextNode(`No requests in this range.`));
206
+ _$effect(_$p => _$setProp(_el$21, "fg", props.theme.text.muted, _$p));
207
+ return _el$21;
208
+ })();
209
+ },
210
+ get children() {
211
+ return [(() => {
212
+ var _el$14 = _$createElement("text");
213
+ _$insert(_el$14, () => formatLine(APP_COLUMNS.map(column => column.title), APP_COLUMNS));
214
+ _$effect(_$p => _$setProp(_el$14, "fg", props.theme.text.muted, _$p));
215
+ return _el$14;
216
+ })(), _$createComponent(For, {
217
+ get each() {
218
+ return apps();
219
+ },
220
+ children: row => (() => {
221
+ var _el$23 = _$createElement("box"),
222
+ _el$24 = _$createElement("text"),
223
+ _el$25 = _$createElement("text");
224
+ _$insertNode(_el$23, _el$24);
225
+ _$insertNode(_el$23, _el$25);
226
+ _$setProp(_el$23, "flexDirection", "row");
227
+ _$insert(_el$24, () => formatLine(appValues(row), APP_COLUMNS));
228
+ _$insert(_el$25, () => ` ${barCells(row.share, 24)}`);
229
+ _$effect(_p$ => {
230
+ var _v$10 = props.theme.text.base,
231
+ _v$11 = props.theme.categorical[0][200];
232
+ _v$10 !== _p$.e && (_p$.e = _$setProp(_el$24, "fg", _v$10, _p$.e));
233
+ _v$11 !== _p$.t && (_p$.t = _$setProp(_el$25, "fg", _v$11, _p$.t));
234
+ return _p$;
235
+ }, {
236
+ e: undefined,
237
+ t: undefined
238
+ });
239
+ return _el$23;
240
+ })()
241
+ })];
242
+ }
243
+ }), _el$15);
244
+ _$insertNode(_el$15, _$createTextNode(`Models`));
245
+ _$insert(_el$10, _$createComponent(Show, {
246
+ get when() {
247
+ return models().length > 0;
248
+ },
249
+ get fallback() {
250
+ return (() => {
251
+ var _el$26 = _$createElement("text");
252
+ _$insertNode(_el$26, _$createTextNode(`No model calls in this range.`));
253
+ _$effect(_$p => _$setProp(_el$26, "fg", props.theme.text.muted, _$p));
254
+ return _el$26;
255
+ })();
256
+ },
257
+ get children() {
258
+ return [(() => {
259
+ var _el$17 = _$createElement("text");
260
+ _$insert(_el$17, () => formatLine(MODEL_COLUMNS.map(column => column.title), MODEL_COLUMNS));
261
+ _$effect(_$p => _$setProp(_el$17, "fg", props.theme.text.muted, _$p));
262
+ return _el$17;
263
+ })(), _$createComponent(For, {
264
+ get each() {
265
+ return models();
266
+ },
267
+ children: row => (() => {
268
+ var _el$28 = _$createElement("text");
269
+ _$insert(_el$28, () => formatLine(modelValues(row), MODEL_COLUMNS));
270
+ _$effect(_$p => _$setProp(_el$28, "fg", props.theme.text.base, _$p));
271
+ return _el$28;
272
+ })()
273
+ })];
274
+ }
275
+ }), null);
276
+ _$effect(_p$ => {
277
+ var _v$5 = props.theme.text.muted,
278
+ _v$6 = TextAttributes.BOLD,
279
+ _v$7 = props.theme.text.muted,
280
+ _v$8 = TextAttributes.BOLD;
281
+ _v$5 !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$5, _p$.e));
282
+ _v$6 !== _p$.t && (_p$.t = _$setProp(_el$12, "attributes", _v$6, _p$.t));
283
+ _v$7 !== _p$.a && (_p$.a = _$setProp(_el$15, "fg", _v$7, _p$.a));
284
+ _v$8 !== _p$.o && (_p$.o = _$setProp(_el$15, "attributes", _v$8, _p$.o));
285
+ return _p$;
286
+ }, {
287
+ e: undefined,
288
+ t: undefined,
289
+ a: undefined,
290
+ o: undefined
291
+ });
292
+ return _el$10;
293
+ })();
294
+ }
295
+ export default Plugin.define({
296
+ id: "opencode-plugboard.tui",
297
+ setup(context) {
298
+ const [previous, setPrevious] = createSignal({
299
+ ...context.ui.router.current()
300
+ });
301
+ context.ui.router.register({
302
+ name: "plugboard.usage",
303
+ render: () => _$createComponent(UsagePage, {
304
+ context: context,
305
+ onClose: () => context.ui.router.navigate(previous())
306
+ })
307
+ });
308
+ context.keymap.layer(() => ({
309
+ mode: "global",
310
+ commands: [{
311
+ id: "plugboard.usage.open",
312
+ title: "Plugboard usage",
313
+ group: "Plugboard",
314
+ slash: {
315
+ name: "plugboard"
316
+ },
317
+ palette: true,
318
+ run() {
319
+ const current = context.ui.router.current();
320
+ if (current.type === "plugin" && current.name === "plugboard.usage") return;
321
+ // The router exposes a mutable store; keep the route before navigating.
322
+ setPrevious({
323
+ ...current
324
+ });
325
+ context.ui.dialog.clear();
326
+ context.ui.router.navigate({
327
+ type: "plugin",
328
+ name: "plugboard.usage"
329
+ });
330
+ }
331
+ }]
332
+ }));
333
+ }
334
+ });
package/index.ts CHANGED
@@ -27,7 +27,7 @@ import { withFallbacks } from "./src/fallback.js"
27
27
  import { withBudgets } from "./src/budgets.js"
28
28
  import { withLimits } from "./src/limits.js"
29
29
  import { createOpenAiRoute } from "./src/openai/route.js"
30
- import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
30
+ import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, setConfigError, sessionStore, withResponseCache } from "./src/runtime.js"
31
31
  import { createSessionCaller } from "./src/session/caller.js"
32
32
  import { installSessionHooks } from "./src/session/context.js"
33
33
  import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
@@ -43,10 +43,16 @@ export default Plugin.define({
43
43
  try {
44
44
  options = parseOptions(ctx.options)
45
45
  } catch (error) {
46
- // Invalid options: stay inert and say why, instead of failing the Location's plugin load.
47
- logError("options.invalid", { error: errorMessage(error) })
48
- return
46
+ // Invalid options fail this setup, so the host lists the plugin as `failed` with the message
47
+ // (opencode packages/core/src/plugin.ts: a failed setup becomes slot.error, then the state).
48
+ // Nothing is registered, so the Location stays inert. The same message goes to the error log,
49
+ // and to `/v1/status` and `/readyz` while a listener from an earlier good generation serves (#159).
50
+ const message = errorMessage(error)
51
+ setConfigError(message)
52
+ logError("options.invalid", { error: message })
53
+ return yield* Effect.die(new Error(`plugboard options are invalid: ${message}`))
49
54
  }
55
+ setConfigError(undefined)
50
56
 
51
57
  // ---- every Location: join the shared listener (sync registration only).
52
58
  const sessions = sessionStore()
@@ -82,12 +88,13 @@ export default Plugin.define({
82
88
  // Usage is outermost, so it records the answer the client gets (src/usage.ts).
83
89
  // Caps are outside the fallbacks, so one request takes one slot even when it falls back (src/limits.ts).
84
90
  // Aliases written as a list retry the next target before the first output (src/fallback.ts).
85
- // The response cache sits outside the fallbacks, so a hit never reaches a provider (src/cache.ts).
91
+ // The response cache is outside budgets and limits: a hit spends nothing, takes no slot and counts toward no
92
+ // rate window, and never reaches a provider (src/cache.ts). Only a miss is budgeted and limited.
86
93
  caller: withUsage(
87
- // Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
88
- withBudgets(
89
- withLimits(
90
- withResponseCache(
94
+ withResponseCache(
95
+ // Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
96
+ withBudgets(
97
+ withLimits(
91
98
  withFallbacks(createSessionCaller(callerDeps), {
92
99
  options,
93
100
  loadCatalog: servingCatalog,
@@ -96,26 +103,26 @@ export default Plugin.define({
96
103
  usage?.fallback(app)
97
104
  },
98
105
  }),
99
- options,
106
+ () => limits,
107
+ processLimits(),
108
+ {
109
+ onLimited: (app, reason) => {
110
+ if (options.metrics) processMetrics().limited(app, reason)
111
+ usage?.limited(app)
112
+ },
113
+ },
100
114
  ),
101
- () => limits,
102
- processLimits(),
115
+ options.budgets,
116
+ usage,
117
+ processBudgets(),
103
118
  {
104
- onLimited: (app, reason) => {
105
- if (options.metrics) processMetrics().limited(app, reason)
119
+ onRefused: (app) => {
120
+ if (options.metrics) processMetrics().limited(app, "budget")
106
121
  usage?.limited(app)
107
122
  },
108
123
  },
109
124
  ),
110
- options.budgets,
111
- usage,
112
- processBudgets(),
113
- {
114
- onRefused: (app) => {
115
- if (options.metrics) processMetrics().limited(app, "budget")
116
- usage?.limited(app)
117
- },
118
- },
125
+ options,
119
126
  ),
120
127
  usage,
121
128
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
@@ -18,9 +18,9 @@
18
18
  "exports": {
19
19
  ".": { "types": "./index.ts", "default": "./index.ts" },
20
20
  "./rpc": { "types": "./rpc.ts", "default": "./rpc.ts" },
21
- "./tui": { "types": "./tui.ts", "default": "./tui.ts" }
21
+ "./tui": { "types": "./tui.ts", "default": "./dist/tui.js" }
22
22
  },
23
- "files": ["index.ts", "rpc.ts", "tui.ts", "src/", "README.md", "LICENSE"],
23
+ "files": ["index.ts", "rpc.ts", "tui.ts", "src/", "dist/", "README.md", "LICENSE"],
24
24
  "engines": {
25
25
  "bun": ">=1.4.2"
26
26
  },
@@ -31,6 +31,9 @@
31
31
  "mutations": "bun .github/scripts/mutation-check.ts",
32
32
  "flake-hunt": "bun .github/scripts/flake-hunt.ts",
33
33
  "coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
34
+ "live-matrix": "bun test/live/matrix.ts",
35
+ "build:tui": "bun .github/scripts/build-tui.ts",
36
+ "prepack": "bun run build:tui",
34
37
  "smoke:pack": "bun .github/scripts/smoke-pack.ts",
35
38
  "docs:dev": "vitepress dev docs",
36
39
  "docs:build": "vitepress build docs",
@@ -38,6 +41,7 @@
38
41
  },
39
42
  "dependencies": {
40
43
  "@prometheus-io/client": "0.16.1",
44
+ "@types/escape-html": "1.0.4",
41
45
  "escape-html": "1.0.3",
42
46
  "ipaddr.js": "2.5.0",
43
47
  "lru-cache": "11.5.3",
@@ -52,6 +56,8 @@
52
56
  "@ai-sdk/openai": "4.0.91",
53
57
  "@ai-sdk/openai-compatible": "3.0.67",
54
58
  "@anthropic-ai/sdk": "0.133.0",
59
+ "@babel/core": "7.29.7",
60
+ "@babel/preset-typescript": "7.27.1",
55
61
  "@commitlint/cli": "21.2.3",
56
62
  "@commitlint/config-conventional": "21.2.3",
57
63
  "@google/genai": "2.28.0",
@@ -62,12 +68,12 @@
62
68
  "@opentui/core": "0.5.17",
63
69
  "@opentui/solid": "0.5.17",
64
70
  "@types/bun": "1.4.2",
65
- "@types/escape-html": "^1.0.4",
66
71
  "@types/lcov-parse": "1.0.2",
67
72
  "@types/node": "^26.6.2",
68
73
  "@types/semver": "7.8.0",
69
74
  "ai": "7.0.137",
70
75
  "ajv": "8.20.0",
76
+ "babel-preset-solid": "1.9.12",
71
77
  "conventional-commits-parser": "7.1.3",
72
78
  "effect": "4.0.0-rc.112",
73
79
  "eventsource-parser": "4.1.1",
@@ -114,6 +114,8 @@ export async function runMessage(
114
114
  emit: (event: MessagesStreamEvent) => void,
115
115
  ): Promise<MessageRun> {
116
116
  const id = newId("msg")
117
+ // Only a non-streamed body needs the aggregate: a stream is already on the wire, so nothing grows (#153).
118
+ const aggregate = !exchange.stream
117
119
  const content: Block[] = []
118
120
  /** Blocks opened so far: the next block's `index`. */
119
121
  let nextIndex = 0
@@ -155,7 +157,7 @@ export async function runMessage(
155
157
  emit({ type: "content_block_stop", index: open.index })
156
158
  // The signature is always empty: the host does not expose the provider's thinking signature (#146), so a
157
159
  // replayed thinking block cannot carry the real one. Clients that replay thinking are told by the docs.
158
- content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
160
+ if (aggregate) content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
159
161
  open = undefined
160
162
  }
161
163
  const releaseHeld = () => delta("text", holdback.release())
@@ -173,7 +175,7 @@ export async function runMessage(
173
175
  })
174
176
  ping()
175
177
  }
176
- open.text += text
178
+ if (aggregate) open.text += text
177
179
  emit({
178
180
  type: "content_block_delta",
179
181
  index: open.index,
@@ -192,7 +194,7 @@ export async function runMessage(
192
194
  ping()
193
195
  emit({ type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: JSON.stringify(input) } })
194
196
  emit({ type: "content_block_stop", index })
195
- content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
197
+ if (aggregate) content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
196
198
  calls++
197
199
  }
198
200