opencode-overclock 0.2.2 → 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 CHANGED
@@ -1,106 +1,208 @@
1
1
  # opencode-overclock
2
2
 
3
- Power-ups for [opencode](https://opencode.ai). Background tasks, scheduling, sandboxed bash, tool hooks, usage telemetry, checkpoints. Each = module, toggleable. Module dies when opencode ships native equal/better.
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
- ## Install
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 # global
11
+ opencode plugin -g opencode-overclock # every project
10
12
  ```
11
13
 
12
- Requires opencode >= 1.18.9.
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
- One package, two surfaces: the **server** plugin (tools + hooks) and the **TUI** plugin (notifications + slash commands). They register in _separate_ config files, so use the command above rather than editing config by hand — it writes both:
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 (omit this and notifications/slash commands silently never load)
43
+ // tui.json -> TUI surface: notifications, slash commands, the buddy
20
44
  { "plugin": ["opencode-overclock"] }
21
45
  ```
22
46
 
23
- Local dev: copy or symlink into `.opencode/plugins/` — see [Dev](#dev). Note that a plugin _path_ only works there; `plugin` array entries resolve by npm name from the registry, and an unpublished name fails silently.
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
- ## Config
55
+ ## Configuration
26
56
 
27
- `.opencode/overclock.json` (optional, missing = defaults):
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": [{ "name": "typecheck", "tools": ["edit", "write"], "run": "bun run typecheck" }]
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
- `true`/`false` toggle. Object = on + options. Defaults: all on except sandbox; guard inert without `hooks`.
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
- Unknown keys, unknown feature names, and wrong option types are reported at startup with a
45
- "did you mean" a typo like `killOnExist` would otherwise read as "not set" and silently
46
- run the default. Bad config never takes the plugin down; it falls back to defaults.
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
- | Feature | Options |
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
- Each `guard` hook takes `name` · `tools` (array) · `run` plus optional `pathFilter` (glob),
58
- `mode` (`inject` default, or `append`), `debounceMs` (2000), `timeoutMs` (60000), `onSuccess`
59
- (`silent`/`notify`), and `maxDeferMs` (300000 — how long `inject` waits for an idle session
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
- On a project's first run, overclock reports what it added. Worth knowing that installing it
63
- grants the agent **background shell execution** (`task_run`) and **recurring scheduling**
64
- (`schedule_create`). The tool definitions themselves cost ~800 tokens of context in total.
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
- ## Features
169
+ ## Notes on the TUI surface
67
170
 
68
- | Module | Tools | Does |
69
- | ------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
70
- | `tasks` | `task_run` `task_status` `task_output` `task_kill` | background shell cmds; exit -> result posted back into session; stall watchdog nudges on interactive prompts |
71
- | `sched` | `schedule_create` `schedule_list` `schedule_delete` | cron exprs or intervals ("5m"); interval + current session = loop; restart-safe |
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
- TUI plugin (`src/tui.ts`, separate surface): OS notifications on idle/permission/question/error via `attention.notify`, slash commands for tasks/usage/schedules off the `.opencode/overclock/` state mirrors, and the buddy (`src/buddy/`) in the prompt-right slots. The buddy rolls species/rarity/name/stats once (persisted in TUI kv), hides below 100 columns, and needs `@opentui/solid` resolvable at runtime -- if it isn't, the buddy silently sits this one out while the rest of the TUI plugin loads.
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
- ## Layout
180
+ ## Contributing
81
181
 
82
182
  ```
83
183
  src/
84
- index.ts entry: config -> init modules -> merge hooks
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
- ## Add feature
196
+ Adding a feature:
95
197
 
96
- 1. `src/features/<name>.ts`, export `FeatureModule`
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
- ## Docs
100
-
101
- - [docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) — opencode plugin/hook/event surface map + upstream drift watchlist (research)
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` cannot exercise the runtime load path opencode resolves `plugin` entries by npm
122
- name from the registry, and a miss is silent. `verify:published` is the only check that
123
- proves an installed-from-npm session actually gets the tools; run it after every publish.
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
- ### Live loop
227
+ ### Working on it locally
126
228
 
127
- `.opencode/plugins/dev.ts` re-exports `src/index.ts`, `dev-tui.ts` re-exports `src/tui.ts` -> opencode session in this repo runs both surfaces from source.
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. Plugin live.
130
- 2. Edit `src/`. No hot reload -> restart opencode.
131
- 3. State inspect: `.opencode/overclock/` (gitignored).
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 the local dev TUI plugin (and any unpublished feature, e.g.
135
- the buddy) silently never loads. Remove the global entry while developing, or run with
136
- `XDG_CONFIG_HOME` pointed elsewhere.
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 e2e
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
- Gotchas:
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
- - `< /dev/null` required. Open stdin -> hang.
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.2.2",
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.9"
38
+ "opencode": ">=1.18.4"
39
39
  },
40
40
  "dependencies": {
41
41
  "@opencode-ai/plugin": "1.18.9",
@@ -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
- // A mistyped key is otherwise a silent no-op -- the feature runs with defaults and the
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
- const shared: SharedDeps = { busy: createBusyTracker() }
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
- for (const [name, def] of Object.entries(value as Record<string, unknown>)) {
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
- tools[name] = def
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 interface ConfigIssue {
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(enabled: readonly FeatureModule[], skipped: readonly string[]): string {
134
- const toolCount = enabled.reduce((n, f) => n + (f.tools?.length ?? 0), 0)
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 tools = f.tools?.length ? ` (${f.tools.join(", ")})` : ""
137
- return `${f.name}${tools}`
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(toolCount, "tool")}: ${parts.join(" · ")}`
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
  }