opencode-overclock 0.2.1 → 0.3.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 +180 -71
- package/package.json +2 -2
- package/src/buddy/sprites.ts +1 -1
- package/src/buddy/tui.ts +12 -1
- package/src/features/tasks.ts +2 -2
- package/src/index.ts +31 -17
- package/src/merge.ts +34 -3
- package/src/tools.ts +244 -0
- package/src/types.ts +34 -0
- package/src/validate.ts +66 -12
package/README.md
CHANGED
|
@@ -1,106 +1,208 @@
|
|
|
1
1
|
# opencode-overclock
|
|
2
2
|
|
|
3
|
-
Power-ups for [opencode](https://opencode.ai)
|
|
3
|
+
Power-ups for [opencode](https://opencode.ai): background tasks, cron-style scheduling,
|
|
4
|
+
sandboxed bash, quality-gate hooks, cost telemetry, checkpoints — and an ASCII pet.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Everything is a separate module you can turn off individually, so you can take one feature and
|
|
7
|
+
ignore the rest. When opencode ships a native equivalent, the matching module goes away.
|
|
6
8
|
|
|
7
9
|
```sh
|
|
8
10
|
opencode plugin opencode-overclock # this project
|
|
9
|
-
opencode plugin -g opencode-overclock #
|
|
11
|
+
opencode plugin -g opencode-overclock # every project
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
| Module | What it does | Tools it adds |
|
|
17
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
18
|
+
| `tasks` | Run shell commands in the background. The agent gets the result posted back into the session when they finish, and a nudge if one blocks on a prompt. | `task_run` `task_status` `task_output` `task_kill` |
|
|
19
|
+
| `sched` | Recurring prompts on a cron expression or an interval (`"5m"`). Survives restarts; an interval on the current session makes a loop. | `schedule_create` `schedule_list` `schedule_delete` |
|
|
20
|
+
| `guard` | Your own quality gates: run a command after the agent edits files, and feed failures back to it once the session goes idle. | — |
|
|
21
|
+
| `usage` | Per-day and per-session cost and token totals, collected from the event bus. | `usage_report` |
|
|
22
|
+
| `checkpoints` | Rewind a session to any earlier message, files included, on top of opencode's own snapshots. Reverting asks for permission first. | `checkpoint_list` `checkpoint_revert` `checkpoint_restore` |
|
|
23
|
+
| `sandbox` | Wrap every bash call in bwrap: read-only `/`, writable project and `/tmp`, network off by default. Off unless you enable it. | `bash_unsandboxed` (escape hatch) |
|
|
24
|
+
| `buddy` | An ASCII pet next to the prompt that reacts to what the session is doing. Purely cosmetic. | — |
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
On top of the tools, the TUI side adds desktop notifications when a turn finishes or the agent
|
|
27
|
+
needs you, plus `/oc-tasks`, `/oc-usage`, `/oc-schedules` and `/oc-buddy`.
|
|
28
|
+
|
|
29
|
+
**Please read this before installing:** overclock gives the agent the ability to run shell
|
|
30
|
+
commands in the background (`task_run`) and to schedule recurring prompts (`schedule_create`).
|
|
31
|
+
That is the point of the plugin, but it is worth an explicit yes rather than a surprise. It
|
|
32
|
+
tells you what it enabled on a project's first run. The tool definitions cost roughly 800
|
|
33
|
+
tokens of context.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
The command at the top of this page is the reliable way to install, because one package ships
|
|
38
|
+
**two** surfaces that register in two different config files:
|
|
15
39
|
|
|
16
40
|
```jsonc
|
|
17
|
-
// opencode.json -> server surface
|
|
41
|
+
// opencode.json -> server surface: the tools and hooks
|
|
18
42
|
{ "plugin": ["opencode-overclock"] }
|
|
19
|
-
// tui.json -> TUI surface
|
|
43
|
+
// tui.json -> TUI surface: notifications, slash commands, the buddy
|
|
20
44
|
{ "plugin": ["opencode-overclock"] }
|
|
21
45
|
```
|
|
22
46
|
|
|
23
|
-
|
|
47
|
+
Adding only the `opencode.json` entry by hand is the most common mistake: the tools work and
|
|
48
|
+
the notifications silently never load.
|
|
49
|
+
|
|
50
|
+
Requires **opencode >= 1.18.4**. That floor comes from the buddy sprite, which renders against
|
|
51
|
+
the `@opentui/solid` version opencode bundles from 1.18.4 onward. If you don't care about the
|
|
52
|
+
buddy, the server surface alone works back to 1.15.11 — the first release where opencode calls
|
|
53
|
+
a plugin's `dispose` hook, without which this plugin's timers and watchers are never cleaned up.
|
|
24
54
|
|
|
25
|
-
##
|
|
55
|
+
## Configuration
|
|
26
56
|
|
|
27
|
-
|
|
57
|
+
Everything is optional. With no config file you get every module except `sandbox`, and `guard`
|
|
58
|
+
sits inert until you give it hooks — so the one thing worth configuring on day one is a quality
|
|
59
|
+
gate. A reasonable `.opencode/overclock.json` to start from:
|
|
28
60
|
|
|
29
61
|
```json
|
|
30
62
|
{
|
|
31
63
|
"features": {
|
|
32
|
-
"tasks": true,
|
|
33
|
-
"sched": true,
|
|
34
|
-
"sandbox": { "net": false },
|
|
35
64
|
"guard": {
|
|
36
|
-
"hooks": [
|
|
37
|
-
|
|
65
|
+
"hooks": [
|
|
66
|
+
{
|
|
67
|
+
"name": "typecheck",
|
|
68
|
+
"tools": ["edit", "write"],
|
|
69
|
+
"run": "npm run typecheck",
|
|
70
|
+
"pathFilter": "src/**/*.ts"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"tasks": { "killOnExit": true }
|
|
38
75
|
}
|
|
39
76
|
}
|
|
40
77
|
```
|
|
41
78
|
|
|
42
|
-
|
|
79
|
+
That gives you a typecheck after every edit (reported back to the agent when the session goes
|
|
80
|
+
idle), background tasks that don't outlive the session, plus scheduling, telemetry, checkpoints
|
|
81
|
+
and the buddy on their defaults. Swap `run` for whatever your project uses.
|
|
82
|
+
|
|
83
|
+
Each entry is `true`, `false`, or an object of options (which also means "on"). To turn
|
|
84
|
+
something off:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "features": { "buddy": false } }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Module | Options |
|
|
91
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `tasks` | `killOnExit` bool · `stallDetection` bool · `stallThresholdMs` num · `stallCheckIntervalMs` num |
|
|
93
|
+
| `sched` | `skipIfBusy` bool |
|
|
94
|
+
| `sandbox` | `net` bool |
|
|
95
|
+
| `guard` | `hooks` array |
|
|
96
|
+
| `usage`, `checkpoints`, `buddy` | — |
|
|
97
|
+
|
|
98
|
+
Typos are reported at startup with a "did you mean", because a misspelled key like
|
|
99
|
+
`killOnExist` would otherwise read as "not set" and quietly run the default. A bad config never
|
|
100
|
+
takes the plugin down; the affected setting falls back to its default.
|
|
101
|
+
|
|
102
|
+
### Quality gate options (`guard`)
|
|
43
103
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
104
|
+
Each hook runs a command after the agent uses one of the tools it watches, and reports failures
|
|
105
|
+
back to the agent. `name`, `tools` and `run` are required. Also available: `pathFilter` (glob), `mode` (`inject`,
|
|
106
|
+
the default, waits for the session to be idle before reporting; `append` reports immediately),
|
|
107
|
+
`debounceMs` (2000), `timeoutMs` (60000), `onSuccess` (`silent` or `notify`), and `maxDeferMs`
|
|
108
|
+
(300000, how long `inject` waits for an idle session before reporting anyway).
|
|
47
109
|
|
|
48
|
-
|
|
49
|
-
| ---------------------- | ----------------------------------------------------------------------------------------------- |
|
|
50
|
-
| `tasks` | `killOnExit` bool · `stallDetection` bool · `stallThresholdMs` num · `stallCheckIntervalMs` num |
|
|
51
|
-
| `sched` | `skipIfBusy` bool |
|
|
52
|
-
| `sandbox` | `net` bool |
|
|
53
|
-
| `guard` | `hooks` array |
|
|
54
|
-
| `usage`, `checkpoints` | — |
|
|
55
|
-
| `buddy` | — (TUI surface; `features.buddy: false` hides it) |
|
|
110
|
+
### Restricting tool names
|
|
56
111
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
before reporting anyway).
|
|
112
|
+
If your setup only accepts certain tool names, list them in `toolAllowlist`. Any tool whose
|
|
113
|
+
name isn't permitted is withheld from the model rather than offered and refused, since a single
|
|
114
|
+
unrecognised name can fail an entire request.
|
|
61
115
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"toolAllowlist": ["TaskCreate", "TaskList", "TaskOutput", "TaskStop", "MyExtraTool"],
|
|
119
|
+
"toolNames": { "task_run": "TaskCreate", "task_status": "TaskList" }
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`toolNames` maps this plugin's tools onto names you allow. Keys are declared names (the table
|
|
124
|
+
under [What you get](#what-you-get)); values are what the model sees.
|
|
125
|
+
|
|
126
|
+
Startup tells you exactly where you stand: what was renamed, what was withheld and which of
|
|
127
|
+
your allowed names are still free to use for it, and any name that collides with an opencode
|
|
128
|
+
built-in (`bash`, `task`, …) or differs from one only by capitalisation — the first replaces
|
|
129
|
+
that built-in, the second reads as a duplicate to anything matching case-insensitively.
|
|
130
|
+
Descriptions mentioning a renamed tool are rewritten too, so the agent never gets instructions
|
|
131
|
+
naming a tool it wasn't given. Permission ids keep their declared names, so existing permission
|
|
132
|
+
config still applies.
|
|
133
|
+
|
|
134
|
+
#### Named lists
|
|
135
|
+
|
|
136
|
+
`toolAllowlist` entries can also name a bundled list, which expands to every name it permits.
|
|
137
|
+
Mix and match freely — `["claude-code", "MyExtraTool"]` is a bundled list plus one of your own.
|
|
138
|
+
|
|
139
|
+
`claude-code` is the tool set Claude Code registers. opencode's ids are snake_case and Claude
|
|
140
|
+
Code's are PascalCase, so the two vocabularies don't overlap and these names are free to use.
|
|
141
|
+
A bundled list also supplies default names for tools where it contains the same operation:
|
|
142
|
+
|
|
143
|
+
| Module | Declared | Sent as |
|
|
144
|
+
| ------- | ----------------- | ------------ |
|
|
145
|
+
| `tasks` | `task_run` | `TaskCreate` |
|
|
146
|
+
| `tasks` | `task_status` | `TaskList` |
|
|
147
|
+
| `tasks` | `task_output` | `TaskOutput` |
|
|
148
|
+
| `tasks` | `task_kill` | `TaskStop` |
|
|
149
|
+
| `sched` | `schedule_create` | `CronCreate` |
|
|
150
|
+
| `sched` | `schedule_list` | `CronList` |
|
|
151
|
+
| `sched` | `schedule_delete` | `CronDelete` |
|
|
152
|
+
|
|
153
|
+
That's the whole table, and it stops there on purpose. Nothing in the `claude-code` set means
|
|
154
|
+
"revert a session checkpoint" or "report token spend", so `checkpoints`, `usage` and
|
|
155
|
+
`bash_unsandboxed` get no default name: handing them an unrelated one would tell the model the
|
|
156
|
+
wrong thing about what they do. They're withheld until you choose a name yourself, and startup
|
|
157
|
+
says which names are free:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"toolAllowlist": "claude-code",
|
|
162
|
+
"toolNames": { "usage_report": "StructuredOutput" }
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`toolNames` overrides any row above too, if a different name reads better for you. The table is
|
|
167
|
+
checked against the source by a test, so the two can't drift apart.
|
|
65
168
|
|
|
66
|
-
##
|
|
169
|
+
## Notes on the TUI surface
|
|
67
170
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
| `sandbox` | `bash_unsandboxed` (escape hatch) | bwrap-wrap every bash call: `/` ro, project + `/tmp` rw, net configurable. Opt-in |
|
|
73
|
-
| `guard` | — | user hooks: after matching tool calls, run configured cmds (debounced), failures fed back to model once the session idles |
|
|
74
|
-
| `usage` | `usage_report` | per-day + per-session cost/token telemetry off `message.updated` events |
|
|
75
|
-
| `checkpoints` | `checkpoint_list` `checkpoint_revert` `checkpoint_restore` | session revert/unrevert over opencode's shadow-git snapshots; revert gated by permission ask |
|
|
76
|
-
| `buddy` | — | ASCII pet beside the prompt (TUI): hatches once per install, idles/blinks, reacts to session events; `/oc-buddy` pets it |
|
|
171
|
+
The TUI plugin sends a desktop notification (with sound) when a turn completes or the agent
|
|
172
|
+
needs permission, asks a question, or errors — each individually switchable through plugin
|
|
173
|
+
options. Its slash commands read the state files under `.opencode/overclock/`, so they work
|
|
174
|
+
without going through the model.
|
|
77
175
|
|
|
78
|
-
|
|
176
|
+
The buddy hatches once per install with a random species, rarity and name, persists in the
|
|
177
|
+
TUI's key-value store, hides itself below 100 columns, and needs `@opentui/solid` resolvable at
|
|
178
|
+
runtime. If it isn't, the buddy quietly sits out and the rest of the TUI plugin still loads.
|
|
79
179
|
|
|
80
|
-
##
|
|
180
|
+
## Contributing
|
|
81
181
|
|
|
82
182
|
```
|
|
83
183
|
src/
|
|
84
|
-
index.ts entry: config
|
|
184
|
+
index.ts entry: load config, init modules, merge hooks
|
|
85
185
|
tui.ts TUI plugin (notifications + slash commands), separate export
|
|
86
186
|
types.ts FeatureModule contract
|
|
87
187
|
config.ts config loader
|
|
88
188
|
merge.ts hook composition (many modules, same hook -> sequential)
|
|
189
|
+
tools.ts gateway tool policy: alias presets, allowlists, rename/withhold
|
|
190
|
+
validate.ts overclock.json checks + startup summary
|
|
89
191
|
lib/ state dir + json, session inject + toast
|
|
90
192
|
features/ one file per module + registry
|
|
91
193
|
test/ bun test
|
|
92
194
|
```
|
|
93
195
|
|
|
94
|
-
|
|
196
|
+
Adding a feature:
|
|
95
197
|
|
|
96
|
-
1. `src/features/<name>.ts
|
|
97
|
-
2. Register in `src/features/index.ts
|
|
198
|
+
1. Write `src/features/<name>.ts` exporting a `FeatureModule`.
|
|
199
|
+
2. Register it in `src/features/index.ts`.
|
|
200
|
+
3. If it adds tools, add each one to the `claude-code` table in `src/tools.ts`. A test fails
|
|
201
|
+
otherwise, since an unmapped tool is invisible behind a whitelisting gateway.
|
|
98
202
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
## Dev
|
|
203
|
+
Background reading:
|
|
204
|
+
[docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) maps opencode's
|
|
205
|
+
plugin/hook/event surface and tracks upstream drift.
|
|
104
206
|
|
|
105
207
|
```sh
|
|
106
208
|
bun install
|
|
@@ -118,31 +220,38 @@ npm publish
|
|
|
118
220
|
bun run verify:published # runtime load, by name, from the registry
|
|
119
221
|
```
|
|
120
222
|
|
|
121
|
-
`verify`
|
|
122
|
-
|
|
123
|
-
|
|
223
|
+
`verify` can't exercise the runtime load path: opencode resolves `plugin` entries by npm name
|
|
224
|
+
from the registry, and a miss is silent. `verify:published` is the only check that proves an
|
|
225
|
+
installed-from-npm session actually gets the tools, so run it after every publish.
|
|
124
226
|
|
|
125
|
-
###
|
|
227
|
+
### Working on it locally
|
|
126
228
|
|
|
127
|
-
`.opencode/plugins/dev.ts` re-exports `src/index.ts
|
|
229
|
+
`.opencode/plugins/dev.ts` re-exports `src/index.ts` and `dev-tui.ts` re-exports `src/tui.ts`,
|
|
230
|
+
so an opencode session in this repo runs both surfaces from source. Note that only this
|
|
231
|
+
auto-loaded directory accepts a path — `plugin` array entries resolve by npm name from the
|
|
232
|
+
registry, and an unpublished name fails silently.
|
|
128
233
|
|
|
129
|
-
1. `opencode` here.
|
|
130
|
-
2. Edit `src/`.
|
|
131
|
-
3.
|
|
234
|
+
1. Run `opencode` here. The plugin is live.
|
|
235
|
+
2. Edit `src/`. There's no hot reload, so restart opencode.
|
|
236
|
+
3. Inspect state under `.opencode/overclock/` (gitignored).
|
|
132
237
|
|
|
133
238
|
Gotcha: if `~/.config/opencode/tui.json` also loads `opencode-overclock` from npm, that copy
|
|
134
|
-
wins the `overclock-tui` id and
|
|
135
|
-
|
|
136
|
-
|
|
239
|
+
wins the `overclock-tui` id and your local dev TUI plugin (along with any unpublished feature)
|
|
240
|
+
silently never loads. Remove the global entry while developing, or point `XDG_CONFIG_HOME`
|
|
241
|
+
somewhere else.
|
|
137
242
|
|
|
138
|
-
### Headless
|
|
243
|
+
### Headless end-to-end
|
|
139
244
|
|
|
140
245
|
```sh
|
|
141
246
|
timeout 90 opencode run -m anthropic/claude-sonnet-5 "Use task_run to run 'echo hi' ..." < /dev/null
|
|
142
247
|
```
|
|
143
248
|
|
|
144
|
-
|
|
249
|
+
- `< /dev/null` is required; an open stdin hangs.
|
|
250
|
+
- A dev build can hang on exit after the work is done, so wrap it in `timeout` and judge by
|
|
251
|
+
artifacts (`.opencode/overclock/`, log tails) rather than the exit code.
|
|
252
|
+
- Plugin stderr goes to `opencode run --print-logs` or `~/.local/share/opencode/log/`. Grep for
|
|
253
|
+
`[overclock]`.
|
|
254
|
+
|
|
255
|
+
## License
|
|
145
256
|
|
|
146
|
-
|
|
147
|
-
- Dev build hang on exit AFTER work done -> wrap in `timeout`, judge by artifacts (`.opencode/overclock/`, log tails), not exit code.
|
|
148
|
-
- Plugin stderr: `opencode run --print-logs` or `~/.local/share/opencode/log/`. Grep `[overclock]`.
|
|
257
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-overclock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Power-ups for opencode: background tasks, scheduling, sandboxed bash, tool hooks, usage telemetry, checkpoints. Modular, toggleable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"prepublishOnly": "tsc --noEmit && bun test"
|
|
36
36
|
},
|
|
37
37
|
"engines": {
|
|
38
|
-
"opencode": ">=1.18.
|
|
38
|
+
"opencode": ">=1.18.4"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@opencode-ai/plugin": "1.18.9",
|
package/src/buddy/sprites.ts
CHANGED
|
@@ -149,7 +149,7 @@ export function withBubble(frame: string, bubble: string | undefined): string {
|
|
|
149
149
|
export function spriteFrame(species: Species, state: SpriteState, tick: number): string {
|
|
150
150
|
const art = ART[species]
|
|
151
151
|
const t = ((tick % 1000) + 1000) % 1000
|
|
152
|
-
const body = art.idle[t
|
|
152
|
+
const body = art.idle[art.beat(t)]!
|
|
153
153
|
const rest = art.idle[0]!
|
|
154
154
|
|
|
155
155
|
switch (state) {
|
package/src/buddy/tui.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
2
|
-
import { rollCompanion, describeCompanion } from "./companion.ts"
|
|
2
|
+
import { rollCompanion, describeCompanion, migrateSpecies } from "./companion.ts"
|
|
3
3
|
import type { Companion, Rarity } from "./types.ts"
|
|
4
4
|
import { spriteFrame, withBubble, spriteHeight, SPRITE_WIDTH, type SpriteState } from "./sprites.ts"
|
|
5
5
|
import { pickReaction, createReactionGate, type ReactionKind } from "./reactions.ts"
|
|
@@ -52,6 +52,17 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
|
52
52
|
companion = rollCompanion()
|
|
53
53
|
api.kv.set(KV_KEY, companion)
|
|
54
54
|
api.ui.toast({ message: `a buddy hatched: ${describeCompanion(companion)}` })
|
|
55
|
+
} else {
|
|
56
|
+
// A companion persisted under a species we have since retired has no art:
|
|
57
|
+
// every lookup into ART would throw from inside the slot render, where the
|
|
58
|
+
// caller's try/catch can't reach, and the buddy would silently not paint.
|
|
59
|
+
// Write the migration back so it settles once instead of re-rolling per launch.
|
|
60
|
+
const migrated = migrateSpecies(companion)
|
|
61
|
+
if (migrated) {
|
|
62
|
+
companion = migrated
|
|
63
|
+
api.kv.set(KV_KEY, migrated)
|
|
64
|
+
api.ui.toast({ message: `${migrated.name} is a ${migrated.species} now` })
|
|
65
|
+
}
|
|
55
66
|
}
|
|
56
67
|
const hatched: Companion = companion
|
|
57
68
|
|
package/src/features/tasks.ts
CHANGED
|
@@ -245,7 +245,7 @@ export const tasks: FeatureModule = {
|
|
|
245
245
|
},
|
|
246
246
|
defaultEnabled: true,
|
|
247
247
|
requires: ["session.promptAsync", "session.messages"],
|
|
248
|
-
async init(ctx, options) {
|
|
248
|
+
async init(ctx, options, shared) {
|
|
249
249
|
const logDir = await ensureStateDir(ctx.directory, "tasks")
|
|
250
250
|
const stateDir = await ensureStateDir(ctx.directory)
|
|
251
251
|
const killOnExit = options.killOnExit !== false
|
|
@@ -284,7 +284,7 @@ export const tasks: FeatureModule = {
|
|
|
284
284
|
task.sessionID,
|
|
285
285
|
`[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
|
|
286
286
|
`last output:\n${tail.trimEnd()}\n\n` +
|
|
287
|
-
`The command is likely blocked on a prompt. Kill it with task_kill and re-run non-interactively ` +
|
|
287
|
+
`The command is likely blocked on a prompt. Kill it with ${shared.toolName("task_kill")} and re-run non-interactively ` +
|
|
288
288
|
`(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
|
|
289
289
|
)
|
|
290
290
|
},
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { toast } from "./lib/inject.ts"
|
|
|
7
7
|
import { firstRun } from "./lib/state.ts"
|
|
8
8
|
import { validateConfig, summarise } from "./validate.ts"
|
|
9
9
|
import { createBusyTracker } from "./lib/busy.ts"
|
|
10
|
+
import { EMPTY_POLICY, resolveToolPolicy, type ToolPolicy } from "./tools.ts"
|
|
10
11
|
import type { FeatureModule, SharedDeps } from "./types.ts"
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -16,22 +17,17 @@ import type { FeatureModule, SharedDeps } from "./types.ts"
|
|
|
16
17
|
export const Overclock: Plugin = async (ctx) => {
|
|
17
18
|
const config = await loadConfig(ctx.directory)
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
// user believes their setting took effect. Warn, never throw: bad config degrades to
|
|
21
|
-
// defaults rather than taking the plugin down.
|
|
20
|
+
// Collected now, reported once the tool policy is known so a single pass covers both.
|
|
22
21
|
const issues = validateConfig(config, features)
|
|
23
|
-
for (const issue of issues) {
|
|
24
|
-
console.warn(`[overclock] config: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`)
|
|
25
|
-
}
|
|
26
|
-
if (issues.length) {
|
|
27
|
-
void toast(
|
|
28
|
-
ctx.client,
|
|
29
|
-
`overclock: ${issues.length} config issue${issues.length > 1 ? "s" : ""} (see logs) -- using defaults for those`,
|
|
30
|
-
"warning",
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
22
|
|
|
34
|
-
|
|
23
|
+
// Resolved after the init loop, against the modules that actually loaded -- warning about a
|
|
24
|
+
// tool belonging to a disabled feature would be noise. Modules only call `toolName` at
|
|
25
|
+
// runtime (a hook or timer, long after init), so reading it through this binding is safe.
|
|
26
|
+
let policy: ToolPolicy = EMPTY_POLICY
|
|
27
|
+
const shared: SharedDeps = {
|
|
28
|
+
busy: createBusyTracker(),
|
|
29
|
+
toolName: (declared) => policy.rename[declared] ?? declared,
|
|
30
|
+
}
|
|
35
31
|
// First part, so the tracker is current before any module's own event hook reads it.
|
|
36
32
|
const parts: Partial<Hooks>[] = [{ event: async ({ event }) => shared.busy.onEvent(event) }]
|
|
37
33
|
const skipped: string[] = []
|
|
@@ -57,15 +53,33 @@ export const Overclock: Plugin = async (ctx) => {
|
|
|
57
53
|
}
|
|
58
54
|
}
|
|
59
55
|
|
|
56
|
+
const resolved = resolveToolPolicy(config, enabled)
|
|
57
|
+
policy = resolved.policy
|
|
58
|
+
issues.push(...resolved.issues)
|
|
59
|
+
|
|
60
|
+
// A mistyped key is otherwise a silent no-op -- the feature runs with defaults and the
|
|
61
|
+
// user believes their setting took effect. Warn, never throw: bad config degrades to
|
|
62
|
+
// defaults rather than taking the plugin down.
|
|
63
|
+
for (const issue of issues) {
|
|
64
|
+
console.warn(`[overclock] config: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`)
|
|
65
|
+
}
|
|
66
|
+
if (issues.length) {
|
|
67
|
+
void toast(
|
|
68
|
+
ctx.client,
|
|
69
|
+
`overclock: ${issues.length} config issue${issues.length > 1 ? "s" : ""} (see logs)`,
|
|
70
|
+
"warning",
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
// Say what was added. This plugin grants the agent background shell execution and
|
|
61
75
|
// recurring scheduling; that should not be discovered by accident. Log every start
|
|
62
76
|
// (stderr, invisible unless you look), toast only on a project's first run.
|
|
63
|
-
console.warn(`[overclock] ${summarise(enabled, skipped)}`)
|
|
77
|
+
console.warn(`[overclock] ${summarise(enabled, skipped, policy)}`)
|
|
64
78
|
if (await firstRun(ctx.directory)) {
|
|
65
|
-
void toast(ctx.client, `overclock active: ${summarise(enabled, skipped)}`, "info")
|
|
79
|
+
void toast(ctx.client, `overclock active: ${summarise(enabled, skipped, policy)}`, "info")
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
if (skipped.length)
|
|
69
83
|
void toast(ctx.client, `overclock: ${skipped.join(", ")} disabled (SDK drift)`, "warning")
|
|
70
|
-
return mergeHooks(parts)
|
|
84
|
+
return mergeHooks(parts, policy)
|
|
71
85
|
}
|
package/src/merge.ts
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { EMPTY_POLICY, type ToolPolicy } from "./tools.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rewrite declared tool names appearing inside a description ("reversible via
|
|
6
|
+
* checkpoint_restore", "Kill it with task_kill"). Left alone, a remap leaves the model
|
|
7
|
+
* reading instructions that name a tool it was never offered. Word-anchored so a name
|
|
8
|
+
* that is a substring of a longer identifier is not clobbered.
|
|
9
|
+
*/
|
|
10
|
+
export function renameInText(text: string, rename: Record<string, string>): string {
|
|
11
|
+
let out = text
|
|
12
|
+
for (const [from, to] of Object.entries(rename)) {
|
|
13
|
+
if (from === to) continue
|
|
14
|
+
out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
|
|
15
|
+
}
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
2
18
|
|
|
3
19
|
/**
|
|
4
20
|
* Compose many Partial<Hooks> into one Hooks.
|
|
5
21
|
* - fn hooks: call sequentially, module order. Each sees prior mutations of `output`.
|
|
6
22
|
* - `tool` map: shallow merge. Name collision -> later module wins, warn.
|
|
23
|
+
* - `policy`: declared tool name -> model-visible name, plus tools to withhold. Applied here
|
|
24
|
+
* because every module's tool map funnels through this one merge, so one pass covers the
|
|
25
|
+
* whole surface and a module never has to know a policy exists.
|
|
7
26
|
*/
|
|
8
|
-
export function mergeHooks(parts: Partial<Hooks>[]): Hooks {
|
|
27
|
+
export function mergeHooks(parts: Partial<Hooks>[], policy: ToolPolicy = EMPTY_POLICY): Hooks {
|
|
9
28
|
const merged: Record<string, unknown> = {}
|
|
10
29
|
const tools: Record<string, unknown> = {}
|
|
30
|
+
const { rename, withheld } = policy
|
|
11
31
|
|
|
12
32
|
for (const part of parts) {
|
|
13
33
|
for (const [key, value] of Object.entries(part)) {
|
|
14
34
|
if (value === undefined) continue
|
|
15
35
|
if (key === "tool") {
|
|
16
|
-
|
|
36
|
+
const renaming = Object.keys(rename).length > 0
|
|
37
|
+
for (const [declared, def] of Object.entries(value as Record<string, unknown>)) {
|
|
38
|
+
// Withheld = no allowlist slot. Dropping it here is the point: one unlisted name
|
|
39
|
+
// makes the gateway reject the whole request, so the rest of the plugin still works.
|
|
40
|
+
if (withheld.has(declared)) continue
|
|
41
|
+
const name = rename[declared] ?? declared
|
|
17
42
|
if (tools[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
|
|
18
|
-
|
|
43
|
+
const d = def as { description?: unknown }
|
|
44
|
+
// Clone rather than mutate: the module owns its tool objects and may hold the
|
|
45
|
+
// same reference elsewhere.
|
|
46
|
+
tools[name] =
|
|
47
|
+
renaming && typeof d.description === "string"
|
|
48
|
+
? { ...d, description: renameInText(d.description, rename) }
|
|
49
|
+
: def
|
|
19
50
|
}
|
|
20
51
|
continue
|
|
21
52
|
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { ConfigIssue, FeatureModule, OverclockConfig } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool ids opencode registers itself (observed on 1.18.4 via `/experimental/tool/ids`).
|
|
5
|
+
*
|
|
6
|
+
* Only used to warn: a tool registered under one of these *replaces* the built-in in the final
|
|
7
|
+
* tool map, and a name that differs only by case (`Task` vs `task`) is worse still -- the host
|
|
8
|
+
* offers both, and a consumer that matches case-insensitively sees a duplicate. Drift in this
|
|
9
|
+
* list only makes the warning less complete, never wrong.
|
|
10
|
+
*/
|
|
11
|
+
export const HOST_TOOL_IDS: readonly string[] = [
|
|
12
|
+
"apply_patch",
|
|
13
|
+
"bash",
|
|
14
|
+
"edit",
|
|
15
|
+
"glob",
|
|
16
|
+
"grep",
|
|
17
|
+
"invalid",
|
|
18
|
+
"question",
|
|
19
|
+
"read",
|
|
20
|
+
"skill",
|
|
21
|
+
"task",
|
|
22
|
+
"todowrite",
|
|
23
|
+
"webfetch",
|
|
24
|
+
"websearch",
|
|
25
|
+
"write",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export interface KnownAllowlist {
|
|
29
|
+
/** every name the list permits */
|
|
30
|
+
names: readonly string[]
|
|
31
|
+
/**
|
|
32
|
+
* declared tool name -> the name from this list it is offered under. Only for names that
|
|
33
|
+
* mean the same operation; nothing is invented, so this table stays short.
|
|
34
|
+
*/
|
|
35
|
+
aliases: Readonly<Record<string, string>>
|
|
36
|
+
/**
|
|
37
|
+
* Tools with no honest alias in this list, and why. Recorded rather than left blank so the
|
|
38
|
+
* absence is a decision someone made, not an oversight -- a test requires every tool to be
|
|
39
|
+
* in `aliases` or here, and the reason is shown when such a tool gets withheld.
|
|
40
|
+
*/
|
|
41
|
+
unaliased: Readonly<Record<string, string>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Named allowlists, usable anywhere a tool name is accepted in `toolAllowlist`.
|
|
46
|
+
*
|
|
47
|
+
* `claude-code` is Claude Code's registered tool set. opencode's own ids are snake_case and
|
|
48
|
+
* disjoint from it, so every name here is free for this plugin to use.
|
|
49
|
+
*
|
|
50
|
+
* `aliases` only covers tools where a name in the list denotes the same operation, so nothing
|
|
51
|
+
* here is a guess: background tasks and cron already exist in this vocabulary. Anything else is
|
|
52
|
+
* listed in `unaliased` with the reason, because picking an unrelated name on a user's behalf
|
|
53
|
+
* would mislead the model about what the tool does. Map those yourself with `toolNames`.
|
|
54
|
+
*
|
|
55
|
+
* Adding a tool: give it an alias or an `unaliased` reason in the same commit. A test fails
|
|
56
|
+
* otherwise, so the decision surfaces in CI rather than as a rejected request mid-session.
|
|
57
|
+
*/
|
|
58
|
+
export const KNOWN_ALLOWLISTS: Readonly<Record<string, KnownAllowlist>> = {
|
|
59
|
+
"claude-code": {
|
|
60
|
+
names: [
|
|
61
|
+
"Read",
|
|
62
|
+
"Write",
|
|
63
|
+
"Edit",
|
|
64
|
+
"MultiEdit",
|
|
65
|
+
"NotebookEdit",
|
|
66
|
+
"Glob",
|
|
67
|
+
"Grep",
|
|
68
|
+
"Bash",
|
|
69
|
+
"Agent",
|
|
70
|
+
"Task",
|
|
71
|
+
"Workflow",
|
|
72
|
+
"TodoWrite",
|
|
73
|
+
"TaskCreate",
|
|
74
|
+
"TaskGet",
|
|
75
|
+
"TaskList",
|
|
76
|
+
"TaskUpdate",
|
|
77
|
+
"TaskStop",
|
|
78
|
+
"TaskOutput",
|
|
79
|
+
"TeamCreate",
|
|
80
|
+
"TeamDelete",
|
|
81
|
+
"SendMessage",
|
|
82
|
+
"EnterPlanMode",
|
|
83
|
+
"ExitPlanMode",
|
|
84
|
+
"EnterWorktree",
|
|
85
|
+
"ExitWorktree",
|
|
86
|
+
"ListMcpResourcesTool",
|
|
87
|
+
"WaitForMcpServers",
|
|
88
|
+
"ToolSearch",
|
|
89
|
+
"Skill",
|
|
90
|
+
"CronCreate",
|
|
91
|
+
"CronDelete",
|
|
92
|
+
"CronList",
|
|
93
|
+
"ScheduleWakeup",
|
|
94
|
+
"AskUserQuestion",
|
|
95
|
+
"StructuredOutput",
|
|
96
|
+
"ValidationResult",
|
|
97
|
+
"ReportFindings",
|
|
98
|
+
"LSP",
|
|
99
|
+
],
|
|
100
|
+
aliases: {
|
|
101
|
+
task_run: "TaskCreate",
|
|
102
|
+
task_status: "TaskList",
|
|
103
|
+
task_output: "TaskOutput",
|
|
104
|
+
task_kill: "TaskStop",
|
|
105
|
+
schedule_create: "CronCreate",
|
|
106
|
+
schedule_list: "CronList",
|
|
107
|
+
schedule_delete: "CronDelete",
|
|
108
|
+
},
|
|
109
|
+
unaliased: {
|
|
110
|
+
bash_unsandboxed: "the only fitting name is `Bash`, which is also an opencode built-in",
|
|
111
|
+
checkpoint_list: "no name in this list denotes session checkpoints",
|
|
112
|
+
checkpoint_revert: "no name in this list denotes session checkpoints",
|
|
113
|
+
checkpoint_restore: "no name in this list denotes session checkpoints",
|
|
114
|
+
usage_report: "no name in this list denotes cost/token telemetry",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ToolPolicy {
|
|
120
|
+
/** declared name -> model-visible name */
|
|
121
|
+
rename: Record<string, string>
|
|
122
|
+
/** declared names withheld from the model entirely (no allowed name to use) */
|
|
123
|
+
withheld: Set<string>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const EMPTY_POLICY: ToolPolicy = { rename: {}, withheld: new Set() }
|
|
127
|
+
|
|
128
|
+
/** Entries like "claude-code" name a bundled list; a literal tool id would not look like this. */
|
|
129
|
+
function looksLikeListName(entry: string): boolean {
|
|
130
|
+
return /^[a-z0-9]+(-[a-z0-9]+)+$/.test(entry)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Expand `toolAllowlist` into the names it permits plus the aliases any named list brings.
|
|
135
|
+
* Entries are either a known list name or a literal tool name, so extending a bundled list is
|
|
136
|
+
* `["claude-code", "MyExtraTool"]` -- no separate key, no re-listing what the bundle covers.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveAllowlist(value: unknown): {
|
|
139
|
+
names?: string[]
|
|
140
|
+
aliases: Record<string, string>
|
|
141
|
+
/** declared tool -> why no bundled list offered a name for it */
|
|
142
|
+
unaliased: Record<string, string>
|
|
143
|
+
issues: ConfigIssue[]
|
|
144
|
+
} {
|
|
145
|
+
const aliases: Record<string, string> = {}
|
|
146
|
+
const unaliased: Record<string, string> = {}
|
|
147
|
+
if (value === undefined) return { aliases, unaliased, issues: [] }
|
|
148
|
+
|
|
149
|
+
const entries = typeof value === "string" ? [value] : value
|
|
150
|
+
if (!Array.isArray(entries) || !entries.every((v) => typeof v === "string")) {
|
|
151
|
+
return {
|
|
152
|
+
aliases,
|
|
153
|
+
unaliased,
|
|
154
|
+
issues: [
|
|
155
|
+
{
|
|
156
|
+
path: "toolAllowlist",
|
|
157
|
+
message: `must be a name or an array of names (a known list is ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}), got ${Array.isArray(value) ? "array with non-strings" : typeof value}`,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const names: string[] = []
|
|
164
|
+
const issues: ConfigIssue[] = []
|
|
165
|
+
for (const entry of entries as string[]) {
|
|
166
|
+
const known = KNOWN_ALLOWLISTS[entry]
|
|
167
|
+
if (known) {
|
|
168
|
+
names.push(...known.names)
|
|
169
|
+
Object.assign(aliases, known.aliases)
|
|
170
|
+
Object.assign(unaliased, known.unaliased)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
// A typo'd list name would otherwise pass as a literal tool name, withhold everything, and
|
|
174
|
+
// suggest the typo itself as a free name. Cheap to catch, confusing to debug.
|
|
175
|
+
if (looksLikeListName(entry)) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: "toolAllowlist",
|
|
178
|
+
message: `"${entry}" looks like a known list but is not one (known: ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}) -- treating it as a literal tool name`,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
names.push(entry)
|
|
182
|
+
}
|
|
183
|
+
return { names, aliases, unaliased, issues }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fold the allowlist's aliases + explicit renames into one policy, and report what a human
|
|
188
|
+
* needs to act on. Every check here exists because the failure it catches is otherwise
|
|
189
|
+
* invisible until a request comes back rejected:
|
|
190
|
+
* - a tool with no permitted name is withheld, and the still-free names are listed so picking
|
|
191
|
+
* one is a single config line;
|
|
192
|
+
* - a name that collides with a host built-in (or its case-twin) is called out, since that
|
|
193
|
+
* replaces the built-in or reads as a duplicate.
|
|
194
|
+
*/
|
|
195
|
+
export function resolveToolPolicy(
|
|
196
|
+
config: Pick<OverclockConfig, "toolNames" | "toolAllowlist">,
|
|
197
|
+
features: readonly FeatureModule[],
|
|
198
|
+
): { policy: ToolPolicy; issues: ConfigIssue[] } {
|
|
199
|
+
const { names: allowlist, aliases, unaliased, issues } = resolveAllowlist(config.toolAllowlist)
|
|
200
|
+
|
|
201
|
+
// Explicit names win: a bundled list is a starting point, not a straitjacket.
|
|
202
|
+
const rename: Record<string, string> = { ...aliases, ...(config.toolNames ?? {}) }
|
|
203
|
+
|
|
204
|
+
const final = new Map<string, string>() // declared -> model-visible
|
|
205
|
+
for (const name of features.flatMap((f) => f.tools ?? [])) {
|
|
206
|
+
final.set(name, rename[name] ?? name)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [name, visible] of final) {
|
|
210
|
+
const twin = HOST_TOOL_IDS.find((id) => id.toLowerCase() === visible.toLowerCase())
|
|
211
|
+
if (!twin) continue
|
|
212
|
+
issues.push({
|
|
213
|
+
path: `tool "${name}"`,
|
|
214
|
+
message:
|
|
215
|
+
twin === visible
|
|
216
|
+
? `"${visible}" is an opencode built-in -- registering it replaces that built-in`
|
|
217
|
+
: `"${visible}" differs from opencode's built-in "${twin}" only by case; anything matching case-insensitively sees one name twice`,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const withheld = new Set<string>()
|
|
222
|
+
if (allowlist) {
|
|
223
|
+
const allowed = new Set(allowlist)
|
|
224
|
+
const taken = new Set([...final.values()].filter((v) => allowed.has(v)))
|
|
225
|
+
const builtin = new Set(HOST_TOOL_IDS.map((id) => id.toLowerCase()))
|
|
226
|
+
// Suggesting a name that would immediately earn a built-in collision warning is worse than
|
|
227
|
+
// suggesting nothing, so case-twins of opencode's own ids are not offered.
|
|
228
|
+
const free = allowlist.filter((n) => !taken.has(n) && !builtin.has(n.toLowerCase()))
|
|
229
|
+
for (const [name, visible] of final) {
|
|
230
|
+
if (allowed.has(visible)) continue
|
|
231
|
+
withheld.add(name)
|
|
232
|
+
const why = unaliased[name] ? ` (${unaliased[name]})` : ""
|
|
233
|
+
issues.push({
|
|
234
|
+
path: `tool "${name}"`,
|
|
235
|
+
message:
|
|
236
|
+
`"${visible}" is not in toolAllowlist${why} -- withheld from the model. ` +
|
|
237
|
+
`Pick a name for it via toolNames (free: ${free.slice(0, 4).join(", ") || "none left"}), ` +
|
|
238
|
+
`or add one to toolAllowlist`,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { policy: { rename, withheld }, issues }
|
|
244
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -9,16 +9,50 @@ import type { BusyTracker } from "./lib/busy.ts"
|
|
|
9
9
|
export interface SharedDeps {
|
|
10
10
|
/** live per-session busy/idle state; the entry owns the subscription that feeds it */
|
|
11
11
|
busy: BusyTracker
|
|
12
|
+
/**
|
|
13
|
+
* Declared tool name -> the name the model was actually offered (see `toolNames` config).
|
|
14
|
+
* Needed wherever a module names one of its own tools in text the model reads: under a
|
|
15
|
+
* remap the declared name is not a tool the model has.
|
|
16
|
+
*/
|
|
17
|
+
toolName(declared: string): string
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
/** Per-feature config from .opencode/overclock.json. `false` = off, object = options. */
|
|
15
21
|
export type FeatureConfig = boolean | Record<string, unknown>
|
|
16
22
|
|
|
23
|
+
/** A problem found in overclock.json. Lives here so config consumers need not import validate. */
|
|
24
|
+
export interface ConfigIssue {
|
|
25
|
+
/** dotted location in overclock.json, e.g. "features.tasks.killOnExit" */
|
|
26
|
+
path: string
|
|
27
|
+
message: string
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
/** Option value kinds a module declares, so a typo in overclock.json can be caught. */
|
|
18
31
|
export type OptionType = "boolean" | "number" | "string" | "array" | "object"
|
|
19
32
|
|
|
20
33
|
export interface OverclockConfig {
|
|
21
34
|
features?: Record<string, FeatureConfig>
|
|
35
|
+
/**
|
|
36
|
+
* Model-visible tool ids: declared name -> replacement. The key of the `tool` hook map is
|
|
37
|
+
* literally the name sent to the provider, so this is the whole remap. Exists for hosts
|
|
38
|
+
* behind a proxy that whitelists tool names and rejects unknown ones.
|
|
39
|
+
*
|
|
40
|
+
* A replacement that collides with a built-in tool *overrides* that built-in in the final
|
|
41
|
+
* tool map -- borrow a name you do not mind losing. Permission ids are deliberately not
|
|
42
|
+
* remapped: they key the user's opencode permission config, not the wire format.
|
|
43
|
+
*/
|
|
44
|
+
toolNames?: Record<string, string>
|
|
45
|
+
/**
|
|
46
|
+
* The only tool names the model may be offered. Entries are literal names or the name of a
|
|
47
|
+
* bundled list (see KNOWN_ALLOWLISTS), so extending one is `["claude-code", "MyExtraTool"]`.
|
|
48
|
+
* A bundled list also supplies default aliases for this plugin's tools; `toolNames` overrides
|
|
49
|
+
* those per tool.
|
|
50
|
+
*
|
|
51
|
+
* Any tool whose final name is not permitted is withheld from the model rather than offered
|
|
52
|
+
* and rejected -- a single unrecognised name can fail a whole request, so a loud gap at
|
|
53
|
+
* startup beats a session that cannot reach the provider.
|
|
54
|
+
*/
|
|
55
|
+
toolAllowlist?: string[] | string
|
|
22
56
|
}
|
|
23
57
|
|
|
24
58
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import type { FeatureModule, OptionType } from "./types.ts"
|
|
1
|
+
import type { ConfigIssue, FeatureModule, OptionType } from "./types.ts"
|
|
2
|
+
import type { ToolPolicy } from "./tools.ts"
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
/** dotted location in overclock.json, e.g. "features.tasks.killOnExit" */
|
|
5
|
-
path: string
|
|
6
|
-
message: string
|
|
7
|
-
}
|
|
4
|
+
export type { ConfigIssue }
|
|
8
5
|
|
|
9
6
|
/** Levenshtein, capped -- only used to turn a typo into a "did you mean". */
|
|
10
7
|
function distance(a: string, b: string): number {
|
|
@@ -52,6 +49,48 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
52
49
|
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
53
50
|
}
|
|
54
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Check the `toolNames` remap. A rename that silently does nothing is the worst outcome
|
|
54
|
+
* here: the proxy keeps rejecting the tool and the config looks correct. So an unknown
|
|
55
|
+
* source name is an issue, and two sources aiming at one target is an issue -- the merge
|
|
56
|
+
* would keep only the last.
|
|
57
|
+
*/
|
|
58
|
+
function validateToolNames(toolNames: unknown, features: readonly FeatureModule[]): ConfigIssue[] {
|
|
59
|
+
if (toolNames === undefined) return []
|
|
60
|
+
if (!isPlainObject(toolNames)) {
|
|
61
|
+
return [{ path: "toolNames", message: `"toolNames" must be an object, got ${typeOf(toolNames)}` }]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const issues: ConfigIssue[] = []
|
|
65
|
+
const declared = features.flatMap((f) => f.tools ?? [])
|
|
66
|
+
const targets = new Map<string, string>()
|
|
67
|
+
|
|
68
|
+
for (const [from, to] of Object.entries(toolNames)) {
|
|
69
|
+
if (!declared.includes(from)) {
|
|
70
|
+
issues.push({ path: `toolNames.${from}`, message: unknownKey(from, declared, "tool") })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (typeof to !== "string" || to.trim() === "") {
|
|
74
|
+
issues.push({
|
|
75
|
+
path: `toolNames.${from}`,
|
|
76
|
+
message: `must be a non-empty string, got ${typeOf(to)}`,
|
|
77
|
+
})
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const prior = targets.get(to)
|
|
81
|
+
if (prior) {
|
|
82
|
+
issues.push({
|
|
83
|
+
path: `toolNames.${from}`,
|
|
84
|
+
message: `"${to}" is already the target of "${prior}" -- only one would survive the merge`,
|
|
85
|
+
})
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
targets.set(to, from)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return issues
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
/**
|
|
56
95
|
* Check overclock.json against the feature registry.
|
|
57
96
|
*
|
|
@@ -66,11 +105,13 @@ export function validateConfig(config: unknown, features: readonly FeatureModule
|
|
|
66
105
|
return [{ path: "", message: `config must be an object, got ${typeOf(config)}` }]
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
const TOP = ["features"]
|
|
108
|
+
const TOP = ["features", "toolNames", "toolAllowlist"]
|
|
70
109
|
for (const key of Object.keys(config)) {
|
|
71
110
|
if (!TOP.includes(key)) issues.push({ path: key, message: unknownKey(key, TOP, "top-level key") })
|
|
72
111
|
}
|
|
73
112
|
|
|
113
|
+
issues.push(...validateToolNames(config.toolNames, features))
|
|
114
|
+
|
|
74
115
|
const { features: featuresCfg } = config
|
|
75
116
|
if (featuresCfg === undefined) return issues
|
|
76
117
|
if (!isPlainObject(featuresCfg)) {
|
|
@@ -130,14 +171,27 @@ export function validateConfig(config: unknown, features: readonly FeatureModule
|
|
|
130
171
|
* capability: installing overclock hands the agent background shell execution and
|
|
131
172
|
* recurring scheduling, and that should not be something a user discovers by accident.
|
|
132
173
|
*/
|
|
133
|
-
export function summarise(
|
|
134
|
-
|
|
174
|
+
export function summarise(
|
|
175
|
+
enabled: readonly FeatureModule[],
|
|
176
|
+
skipped: readonly string[],
|
|
177
|
+
policy: ToolPolicy = { rename: {}, withheld: new Set() },
|
|
178
|
+
): string {
|
|
179
|
+
const { rename, withheld } = policy
|
|
180
|
+
const offered = enabled.flatMap((f) => (f.tools ?? []).filter((t) => !withheld.has(t)))
|
|
181
|
+
// Report the name the model is actually offered, not the declared one -- under a remap the
|
|
182
|
+
// declared name appears nowhere on the wire, so listing it would misdescribe the session.
|
|
135
183
|
const parts = enabled.map((f) => {
|
|
136
|
-
const
|
|
137
|
-
return `${f.name}${
|
|
184
|
+
const names = (f.tools ?? []).filter((t) => !withheld.has(t)).map((t) => rename[t] ?? t)
|
|
185
|
+
return `${f.name}${names.length ? ` (${names.join(", ")})` : ""}`
|
|
138
186
|
})
|
|
139
187
|
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
140
|
-
let line = `${plural(enabled.length, "module")}, ${plural(
|
|
188
|
+
let line = `${plural(enabled.length, "module")}, ${plural(offered.length, "tool")}: ${parts.join(" · ")}`
|
|
189
|
+
const applied = offered.filter((t) => rename[t] && rename[t] !== t).map((t) => `${t}->${rename[t]}`)
|
|
190
|
+
if (applied.length) line += ` | renamed: ${applied.join(", ")}`
|
|
191
|
+
// Withheld tools are the one case where the session is quietly less capable than the config
|
|
192
|
+
// implies, so they are named here rather than left to the issue log alone.
|
|
193
|
+
const held = enabled.flatMap((f) => (f.tools ?? []).filter((t) => withheld.has(t)))
|
|
194
|
+
if (held.length) line += ` | withheld: ${held.join(", ")}`
|
|
141
195
|
if (skipped.length) line += ` | skipped: ${skipped.join(", ")}`
|
|
142
196
|
return line
|
|
143
197
|
}
|