opencode-overclock 0.2.2 → 0.4.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.
Files changed (50) hide show
  1. package/README.md +285 -81
  2. package/package.json +3 -3
  3. package/src/bridge.ts +1 -0
  4. package/src/buddy/companion.ts +104 -5
  5. package/src/buddy/sprites.ts +4 -4
  6. package/src/buddy/tui.ts +175 -65
  7. package/src/core/bridge.ts +34 -0
  8. package/src/core/lifecycle.ts +53 -0
  9. package/src/core/policy.ts +128 -0
  10. package/src/core/summary.ts +33 -0
  11. package/src/core/types.ts +164 -0
  12. package/src/features/buddy.ts +1 -2
  13. package/src/features/guard.ts +168 -30
  14. package/src/features/index.ts +6 -4
  15. package/src/features/recovery.ts +143 -0
  16. package/src/features/sched.ts +147 -89
  17. package/src/features/tasks.ts +54 -20
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +98 -55
  21. package/src/lib/busy.ts +1 -25
  22. package/src/lib/exec.ts +7 -0
  23. package/src/lib/inject.ts +10 -56
  24. package/src/lib/mirror.ts +13 -0
  25. package/src/lib/probe.ts +1 -15
  26. package/src/lib/state.ts +10 -39
  27. package/src/lib/tmux.ts +1 -0
  28. package/src/lib/ui.ts +208 -0
  29. package/src/merge.ts +2 -35
  30. package/src/platform/probe.ts +25 -0
  31. package/src/platform/process/exec.ts +76 -0
  32. package/src/platform/process/tmux.ts +60 -0
  33. package/src/platform/session/busy.ts +33 -0
  34. package/src/platform/session/inject.ts +82 -0
  35. package/src/platform/session/notify.ts +20 -0
  36. package/src/platform/storage/state.ts +77 -0
  37. package/src/platform/storage/store.ts +61 -0
  38. package/src/summary.ts +1 -0
  39. package/src/tools.ts +8 -0
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -39
  42. package/src/v2/context.ts +470 -0
  43. package/src/v2/host.ts +117 -0
  44. package/src/v2/loader.ts +150 -0
  45. package/src/buddy/reactions.ts +0 -41
  46. package/src/buddy/types.ts +0 -30
  47. package/src/config.ts +0 -19
  48. package/src/features/checkpoints.ts +0 -128
  49. package/src/features/sandbox.ts +0 -104
  50. package/src/validate.ts +0 -143
package/README.md CHANGED
@@ -1,106 +1,303 @@
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
+ The modular workflow suite and power-ups for [opencode](https://opencode.ai): background
4
+ tasks, cron-style scheduling, quality-gate hooks, cost telemetry — and an ASCII companion.
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. Built-in recipes & edit recovery. | — |
21
+ | `recovery` | Automatically heal provider errors (missing tool results, thinking block sequencing, context limit) and auto-resume sessions. | — |
22
+ | `truncator` | Context-protecting smart output truncation for high-volume tools (`task_output`, `bash`, `grep`, `glob`, `webfetch`) preserving header & tail diagnostics. | — |
23
+ | `usage` | Per-day and per-session cost and token totals, collected from the event bus (accessible via TUI `/oc-usage`). | — |
24
+ | `buddy` | An ASCII pet next to the prompt that reacts to what the session is doing. Purely cosmetic. | — |
25
+
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`, `/oc-buddy` (pet), `/oc-buddy-switch` (choose species), and `/oc-buddy-cycle` (next species).
13
28
 
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:
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.
54
+
55
+ ## Configuration
56
+
57
+ Everything is optional. With no options configured, overclock runs with sensible defaults: background
58
+ tasks, cron-style scheduling, quality gates, usage telemetry, and the buddy are active immediately.
59
+
60
+ Configure options directly in your project or global `opencode.json`:
61
+
62
+ ```jsonc
63
+ {
64
+ "plugin": [
65
+ [
66
+ "opencode-overclock",
67
+ {
68
+ "tasks": { "killOnExit": true },
69
+ "guard": { "auto": true },
70
+ },
71
+ ],
72
+ ],
73
+ }
74
+ ```
75
+
76
+ To turn an individual feature off:
77
+
78
+ ```jsonc
79
+ {
80
+ "plugin": [
81
+ [
82
+ "opencode-overclock",
83
+ {
84
+ "buddy": false,
85
+ },
86
+ ],
87
+ ],
88
+ }
89
+ ```
90
+
91
+ | Module | Options |
92
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
93
+ | `tasks` | `killOnExit` bool · `stallDetection` bool · `stallThresholdMs` num · `stallCheckIntervalMs` num · `tmux` bool |
94
+ | `sched` | `skipIfBusy` bool |
95
+ | `guard` | `hooks` array · `recipes` array (`["tsc", "eslint", "cargo", "ruff", "go"]`) · `auto` bool · `editRecovery` bool |
96
+ | `recovery` | `maxAttempts` num · `cooldownMs` num · `autoResume` bool |
97
+ | `truncator` | `maxChars` num · `tools` array · `headLines` num · `tailLines` num |
98
+ | `usage`, `buddy` | — |
99
+
100
+ ### Quality gates (`guard`)
101
+
102
+ Quality gates let you define automated feedback loops. Whenever the agent modifies code with `edit`
103
+ or `write`, the guard runs your project's verification command in the background. If the command fails,
104
+ the error output is automatically fed back into the session once the agent finishes its turn, prompting
105
+ it to self-correct.
106
+
107
+ Because verification commands vary by language and repository, configure hooks in your project's
108
+ local `opencode.json`:
109
+
110
+ #### Stack recipes
111
+
112
+ ##### TypeScript / JavaScript
113
+
114
+ ```jsonc
115
+ {
116
+ "guard": {
117
+ "hooks": [
118
+ {
119
+ "name": "typecheck",
120
+ "tools": ["edit", "write"],
121
+ "pathFilter": "src/**/*.ts",
122
+ "run": "npm run typecheck",
123
+ },
124
+ ],
125
+ },
126
+ }
127
+ ```
128
+
129
+ ##### Python (Ruff / Pytest)
130
+
131
+ ```jsonc
132
+ {
133
+ "guard": {
134
+ "hooks": [
135
+ {
136
+ "name": "lint",
137
+ "tools": ["edit", "write"],
138
+ "pathFilter": "**/*.py",
139
+ "run": "ruff check .",
140
+ },
141
+ ],
142
+ },
143
+ }
144
+ ```
145
+
146
+ ##### Rust (Cargo)
147
+
148
+ ```jsonc
149
+ {
150
+ "guard": {
151
+ "hooks": [
152
+ {
153
+ "name": "cargo-check",
154
+ "tools": ["edit", "write"],
155
+ "pathFilter": "**/*.rs",
156
+ "run": "cargo check",
157
+ },
158
+ ],
159
+ },
160
+ }
161
+ ```
162
+
163
+ ##### Go
164
+
165
+ ```jsonc
166
+ {
167
+ "guard": {
168
+ "hooks": [
169
+ {
170
+ "name": "go-test",
171
+ "tools": ["edit", "write"],
172
+ "pathFilter": "**/*.go",
173
+ "run": "go test ./...",
174
+ },
175
+ ],
176
+ },
177
+ }
178
+ ```
179
+
180
+ ##### Generic / Make
181
+
182
+ ```jsonc
183
+ {
184
+ "guard": {
185
+ "hooks": [
186
+ {
187
+ "name": "check",
188
+ "tools": ["edit", "write"],
189
+ "run": "make check",
190
+ },
191
+ ],
192
+ },
193
+ }
194
+ ```
195
+
196
+ #### Hook options
197
+
198
+ | Field | Default | Description |
199
+ | ------------ | ----------- | ----------------------------------------------------------------------------- |
200
+ | `name` | _required_ | Identifier displayed in failure reports |
201
+ | `tools` | _required_ | Tools to trigger on, e.g. `["edit", "write"]` |
202
+ | `run` | _required_ | Shell command to execute (receives `$GUARD_TOOL` and `$GUARD_FILE` in env) |
203
+ | `pathFilter` | `undefined` | Optional glob pattern to limit triggers to relevant files (e.g. `**/*.py`) |
204
+ | `mode` | `"inject"` | `"inject"` waits for the session to go idle; `"append"` reports immediately |
205
+ | `debounceMs` | `2000` | Debounce duration for rapid successive edits |
206
+ | `timeoutMs` | `60000` | Execution timeout before killing the command |
207
+ | `maxDeferMs` | `300000` | Maximum time `"inject"` will wait for an idle session before reporting anyway |
208
+ | `onSuccess` | `"silent"` | `"silent"` or `"notify"` |
209
+
210
+ ### Restricting and remapping tool names
24
211
 
25
- ## Config
212
+ If your setup only accepts certain tool names, list them in `toolAllowlist`. Any tool whose
213
+ name isn't permitted is withheld from the model rather than offered and refused:
214
+
215
+ ```jsonc
216
+ {
217
+ "plugin": [
218
+ [
219
+ "opencode-overclock",
220
+ {
221
+ "toolAllowlist": ["task_run", "task_status", "schedule_create"],
222
+ },
223
+ ],
224
+ ],
225
+ }
226
+ ```
26
227
 
27
- `.opencode/overclock.json` (optional, missing = defaults):
228
+ `toolNames` maps this plugin's tools onto custom names you want the model to see:
28
229
 
29
- ```json
230
+ ```jsonc
30
231
  {
31
- "features": {
32
- "tasks": true,
33
- "sched": true,
34
- "sandbox": { "net": false },
35
- "guard": {
36
- "hooks": [{ "name": "typecheck", "tools": ["edit", "write"], "run": "bun run typecheck" }]
37
- }
38
- }
232
+ "plugin": [
233
+ [
234
+ "opencode-overclock",
235
+ {
236
+ "toolNames": { "task_run": "run_background_task" },
237
+ },
238
+ ],
239
+ ],
39
240
  }
40
241
  ```
41
242
 
42
- `true`/`false` toggle. Object = on + options. Defaults: all on except sandbox; guard inert without `hooks`.
243
+ ### Running OpenCode V2 plugins on OpenCode V1
43
244
 
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.
245
+ OpenCode V1 distributions cannot natively load V2 plugins (which export `{ id, setup }` or `{ id, effect }` instead of a server function). Overclock provides an embedded V2 host engine that runs V2 plugins side-by-side with V1 tools:
47
246
 
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) |
247
+ ```jsonc
248
+ {
249
+ "plugin": [
250
+ [
251
+ "opencode-overclock",
252
+ {
253
+ "plugins": ["./plugins/custom-agent.ts", ["opencode-plugin-review", { "strict": true }]],
254
+ },
255
+ ],
256
+ ],
257
+ }
258
+ ```
56
259
 
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).
260
+ Overclock synthesizes a spec-compliant `PluginContext`, adapting V2 domain transforms (`agent`, `command`, `catalog`, `reference`, `skill`, `aisdk`) to live V1 config and chat hooks while keeping all V1 power tools active.
61
261
 
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.
262
+ Startup tells you what was renamed, what was withheld, and warns on collisions with opencode
263
+ built-in tools. Descriptions mentioning a renamed tool are rewritten automatically.
65
264
 
66
- ## Features
265
+ ## Notes on the TUI surface
67
266
 
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 |
267
+ The TUI plugin sends a desktop notification (with sound) when a turn completes or the agent
268
+ needs permission, asks a question, or errors — each individually switchable through plugin
269
+ options (`notifyIdle`, `notifyPermission`, `notifyQuestion`, `notifyError`, `buddy` in `tui.json`).
270
+ Its slash commands read the state files under `.opencode/overclock/`, so they work
271
+ without going through the model.
77
272
 
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.
273
+ The buddy hatches once per install with a random species, rarity and name, persists in the
274
+ TUI's key-value store, hides itself below 100 columns, and needs `@opentui/solid` resolvable at
275
+ runtime. If it isn't, the buddy quietly sits out and the rest of the TUI plugin still loads.
276
+ Switch buddy on demand via `/oc-buddy-switch` (opens an interactive species picker or rolls a fresh companion)
277
+ or `/oc-buddy-cycle` (advances directly to the next species in rotation).
79
278
 
80
- ## Layout
279
+ ## Contributing
81
280
 
82
281
  ```
83
282
  src/
84
- index.ts entry: config -> init modules -> merge hooks
283
+ index.ts entry: init enabled modules, merge hooks, hybrid V1/V2 export
85
284
  tui.ts TUI plugin (notifications + slash commands), separate export
86
- types.ts FeatureModule contract
87
- config.ts config loader
88
- merge.ts hook composition (many modules, same hook -> sequential)
89
- lib/ state dir + json, session inject + toast
90
- features/ one file per module + registry
285
+ core/ types, lifecycle/hook merging, tool policy, capability summary, bridge
286
+ platform/ host adapters: process (exec/tmux), session (busy/inject/notify), storage (state/store), probe
287
+ buddy/ ASCII companion state, sprites, and TUI slot integration
288
+ v2/ embedded V2 plugin host, synthetic context, and dynamic loader
289
+ features/ feature modules (tasks, sched, guard, recovery, truncator, usage, buddy)
91
290
  test/ bun test
92
291
  ```
93
292
 
94
- ## Add feature
95
-
96
- 1. `src/features/<name>.ts`, export `FeatureModule`
97
- 2. Register in `src/features/index.ts`
293
+ Adding a feature:
98
294
 
99
- ## Docs
295
+ 1. Write `src/features/<name>.ts` exporting a `FeatureModule`.
296
+ 2. Register it in `src/features/index.ts`.
100
297
 
101
- - [docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) — opencode plugin/hook/event surface map + upstream drift watchlist (research)
102
-
103
- ## Dev
298
+ Background reading:
299
+ [docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) maps opencode's
300
+ plugin/hook/event surface and tracks upstream drift.
104
301
 
105
302
  ```sh
106
303
  bun install
@@ -118,31 +315,38 @@ npm publish
118
315
  bun run verify:published # runtime load, by name, from the registry
119
316
  ```
120
317
 
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.
318
+ `verify` can't exercise the runtime load path: opencode resolves `plugin` entries by npm name
319
+ from the registry, and a miss is silent. `verify:published` is the only check that proves an
320
+ installed-from-npm session actually gets the tools, so run it after every publish.
124
321
 
125
- ### Live loop
322
+ ### Working on it locally
126
323
 
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.
324
+ `.opencode/plugins/dev.ts` re-exports `src/index.ts` and `dev-tui.ts` re-exports `src/tui.ts`,
325
+ so an opencode session in this repo runs both surfaces from source. Note that only this
326
+ auto-loaded directory accepts a path — `plugin` array entries resolve by npm name from the
327
+ registry, and an unpublished name fails silently.
128
328
 
129
- 1. `opencode` here. Plugin live.
130
- 2. Edit `src/`. No hot reload -> restart opencode.
131
- 3. State inspect: `.opencode/overclock/` (gitignored).
329
+ 1. Run `opencode` here. The plugin is live.
330
+ 2. Edit `src/`. There's no hot reload, so restart opencode.
331
+ 3. Inspect state under `.opencode/overclock/` (gitignored).
132
332
 
133
333
  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.
334
+ wins the `overclock-tui` id and your local dev TUI plugin (along with any unpublished feature)
335
+ silently never loads. Remove the global entry while developing, or point `XDG_CONFIG_HOME`
336
+ somewhere else.
137
337
 
138
- ### Headless e2e
338
+ ### Headless end-to-end
139
339
 
140
340
  ```sh
141
- timeout 90 opencode run -m anthropic/claude-sonnet-5 "Use task_run to run 'echo hi' ..." < /dev/null
341
+ timeout 90 opencode run -m <provider>/<model> "Use task_run to run 'echo hi' ..." < /dev/null
142
342
  ```
143
343
 
144
- Gotchas:
344
+ - `< /dev/null` is required; an open stdin hangs.
345
+ - A dev build can hang on exit after the work is done, so wrap it in `timeout` and judge by
346
+ artifacts (`.opencode/overclock/`, log tails) rather than the exit code.
347
+ - Plugin stderr goes to `opencode run --print-logs` or `~/.local/share/opencode/log/`. Grep for
348
+ `[overclock]`.
349
+
350
+ ## License
145
351
 
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]`.
352
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-overclock",
3
- "version": "0.2.2",
4
- "description": "Power-ups for opencode: background tasks, scheduling, sandboxed bash, tool hooks, usage telemetry, checkpoints. Modular, toggleable.",
3
+ "version": "0.4.0",
4
+ "description": "Power-ups for opencode: background tasks, cron scheduling, quality-gate hooks, usage telemetry, and companion. Modular, toggleable.",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -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",
package/src/bridge.ts ADDED
@@ -0,0 +1 @@
1
+ export { createHybridPlugin, type HybridPlugin, type HybridPluginDefinition } from "./core/bridge.ts"
@@ -1,4 +1,74 @@
1
- import { SPECIES, type Companion, type CompanionStats, type Rarity } from "./types.ts"
1
+ import type { SpriteState } from "./sprites.ts"
2
+
3
+ export const SPECIES = [
4
+ "cat",
5
+ "dog",
6
+ "bunny",
7
+ "owl",
8
+ "bat",
9
+ "penguin",
10
+ "duck",
11
+ "ghost",
12
+ "slime",
13
+ ] as const
14
+ export type Species = (typeof SPECIES)[number]
15
+
16
+ export type Rarity = "common" | "uncommon" | "rare" | "legendary"
17
+
18
+ export interface CompanionStats {
19
+ patience: number
20
+ chaos: number
21
+ wisdom: number
22
+ snark: number
23
+ }
24
+
25
+ export interface Companion {
26
+ species: Species
27
+ rarity: Rarity
28
+ name: string
29
+ stats: CompanionStats
30
+ hatchedAt: number
31
+ }
32
+
33
+ export type ReactionKind = "done" | "error" | "permission" | "question" | "pet"
34
+
35
+ /** Speech-bubble line + which face the sprite pulls while it shows. */
36
+ export interface Reaction {
37
+ text: string
38
+ state: SpriteState
39
+ }
40
+
41
+ // Lines render into the sprite's 12-col effect row -- keep every line <= 12 chars.
42
+ const POOLS: Record<ReactionKind, { lines: string[]; state: SpriteState }> = {
43
+ done: { lines: ["cooked", "ate that", "big W", "say less", "we ball"], state: "idle" },
44
+ error: { lines: ["bruh", "ur cooked", "skill issue", "aint no way", "big L"], state: "alarmed" },
45
+ permission: { lines: ["let me cook", "valid?", "we good?", "bet?", "vibe check"], state: "curious" },
46
+ question: { lines: ["wym?", "the move?", "u tell me", "spill", "hbu?"], state: "curious" },
47
+ pet: { lines: ["pookie!", "w rizz", "ur valid", "slay", "ily"], state: "pet" },
48
+ }
49
+
50
+ /** Pure: pick a random line for a reaction kind. */
51
+ export function pickReaction(kind: ReactionKind, rng: () => number = Math.random): Reaction {
52
+ const pool = POOLS[kind]
53
+ return { text: pool.lines[Math.floor(rng() * pool.lines.length)]!, state: pool.state }
54
+ }
55
+
56
+ export interface ReactionGate {
57
+ /** True + arms the cooldown if enough time has passed since the last fire. */
58
+ tryFire(now?: number): boolean
59
+ }
60
+
61
+ /** Debounce for event-driven reactions -- keeps a busy session from spamming the bubble. */
62
+ export function createReactionGate(cooldownMs = 8000): ReactionGate {
63
+ let last = -Infinity
64
+ return {
65
+ tryFire(now: number = Date.now()): boolean {
66
+ if (now - last < cooldownMs) return false
67
+ last = now
68
+ return true
69
+ },
70
+ }
71
+ }
2
72
 
3
73
  const RARITY_WEIGHTS: { rarity: Rarity; weight: number }[] = [
4
74
  { rarity: "common", weight: 60 },
@@ -55,16 +125,45 @@ function rollStat(rng: () => number): number {
55
125
  }
56
126
 
57
127
  /**
58
- * Companions persist in TUI kv and outlive the sprite sheet, so an install that
59
- * hatched a species we have since retired would look it up and find no art. Move
60
- * it onto a species we still draw, keeping the identity that isn't the drawing:
61
- * same name, rarity, stats and hatch date. Returns undefined when nothing to do.
128
+ * Move companion onto an active species if its hatched species was retired.
62
129
  */
63
130
  export function migrateSpecies(c: Companion, rng: () => number = Math.random): Companion | undefined {
64
131
  if ((SPECIES as readonly string[]).includes(c.species)) return undefined
65
132
  return { ...c, species: pick(rng, SPECIES) }
66
133
  }
67
134
 
135
+ /** Short character descriptions for each species based on their idle fidget. */
136
+ export const SPECIES_DESCRIPTIONS: Record<Species, string> = {
137
+ cat: "tail flick",
138
+ dog: "ear perk",
139
+ bunny: "ear wiggle",
140
+ owl: "feather ruffle",
141
+ bat: "wing beat",
142
+ penguin: "waddle",
143
+ duck: "ripples",
144
+ ghost: "float",
145
+ slime: "squash",
146
+ }
147
+
148
+ /** Pure: formatted description for dialog options. */
149
+ export function speciesDescription(species: Species, isCurrent = false): string {
150
+ const desc = SPECIES_DESCRIPTIONS[species] ?? species
151
+ return isCurrent ? `${desc} (current)` : desc
152
+ }
153
+
154
+ /** Move companion to a new species, preserving name, stats, and hatchedAt. */
155
+ export function switchSpecies(c: Companion, species: Species): Companion {
156
+ if (!(SPECIES as readonly string[]).includes(species)) return c
157
+ return { ...c, species }
158
+ }
159
+
160
+ /** Pure: get the next species in the SPECIES rotation. */
161
+ export function cycleSpecies(current: Species): Species {
162
+ const idx = SPECIES.indexOf(current)
163
+ if (idx === -1) return SPECIES[0]
164
+ return SPECIES[(idx + 1) % SPECIES.length]!
165
+ }
166
+
68
167
  /** One-line card for the /oc-buddy toast. */
69
168
  export function describeCompanion(c: Companion): string {
70
169
  const s = c.stats
@@ -1,4 +1,4 @@
1
- import type { Species } from "./types.ts"
1
+ import type { Species } from "./companion.ts"
2
2
 
3
3
  /**
4
4
  * Every frame is padded to exactly SPRITE_WIDTH columns so animation never
@@ -35,8 +35,8 @@ interface SpeciesArt {
35
35
  /**
36
36
  * Which of the two poses a tick shows. Per-species on purpose: alternating
37
37
  * every tick reads as a strobe, and it makes every species move alike. A cat
38
- * flicks its tail rarely, a dog wags nonstop, a dragon's wingbeat is slow and
39
- * held at the top, a ghost never quite lands.
38
+ * flicks its tail rarely, a dog wags nonstop, a bat's wingbeat flutters rapidly,
39
+ * a ghost never quite lands.
40
40
  */
41
41
  beat: (t: number) => 0 | 1
42
42
  }
@@ -148,7 +148,7 @@ export function withBubble(frame: string, bubble: string | undefined): string {
148
148
  /** Pure: render one frame. `tick` drives idle fidget + blink; other states are static. */
149
149
  export function spriteFrame(species: Species, state: SpriteState, tick: number): string {
150
150
  const art = ART[species]
151
- const t = ((tick % 1000) + 1000) % 1000
151
+ const t = Math.max(0, tick)
152
152
  const body = art.idle[art.beat(t)]!
153
153
  const rest = art.idle[0]!
154
154