opencode-overclock 0.3.0 → 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 +206 -111
  2. package/package.json +2 -2
  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 +52 -18
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +96 -67
  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 -66
  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 -244
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -73
  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 -197
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # opencode-overclock
2
2
 
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.
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.
5
5
 
6
6
  Everything is a separate module you can turn off individually, so you can take one feature and
7
7
  ignore the rest. When opencode ships a native equivalent, the matching module goes away.
@@ -13,18 +13,18 @@ opencode plugin -g opencode-overclock # every project
13
13
 
14
14
  ## What you get
15
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. | — |
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
25
 
26
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`.
27
+ needs you, plus `/oc-tasks`, `/oc-usage`, `/oc-schedules`, `/oc-buddy` (pet), `/oc-buddy-switch` (choose species), and `/oc-buddy-cycle` (next species).
28
28
 
29
29
  **Please read this before installing:** overclock gives the agent the ability to run shell
30
30
  commands in the background (`task_run`) and to schedule recurring prompts (`schedule_create`).
@@ -54,142 +54,239 @@ a plugin's `dispose` hook, without which this plugin's timers and watchers are n
54
54
 
55
55
  ## Configuration
56
56
 
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:
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.
60
59
 
61
- ```json
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
62
132
  {
63
- "features": {
64
- "guard": {
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 }
75
- }
133
+ "guard": {
134
+ "hooks": [
135
+ {
136
+ "name": "lint",
137
+ "tools": ["edit", "write"],
138
+ "pathFilter": "**/*.py",
139
+ "run": "ruff check .",
140
+ },
141
+ ],
142
+ },
76
143
  }
77
144
  ```
78
145
 
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.
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
+ ```
82
162
 
83
- Each entry is `true`, `false`, or an object of options (which also means "on"). To turn
84
- something off:
163
+ ##### Go
85
164
 
86
- ```json
87
- { "features": { "buddy": false } }
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
+ }
88
178
  ```
89
179
 
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` | — |
180
+ ##### Generic / Make
97
181
 
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.
182
+ ```jsonc
183
+ {
184
+ "guard": {
185
+ "hooks": [
186
+ {
187
+ "name": "check",
188
+ "tools": ["edit", "write"],
189
+ "run": "make check",
190
+ },
191
+ ],
192
+ },
193
+ }
194
+ ```
101
195
 
102
- ### Quality gate options (`guard`)
196
+ #### Hook options
103
197
 
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).
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"` |
109
209
 
110
- ### Restricting tool names
210
+ ### Restricting and remapping tool names
111
211
 
112
212
  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.
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
+ ```
115
227
 
116
- ```json
228
+ `toolNames` maps this plugin's tools onto custom names you want the model to see:
229
+
230
+ ```jsonc
117
231
  {
118
- "toolAllowlist": ["TaskCreate", "TaskList", "TaskOutput", "TaskStop", "MyExtraTool"],
119
- "toolNames": { "task_run": "TaskCreate", "task_status": "TaskList" }
232
+ "plugin": [
233
+ [
234
+ "opencode-overclock",
235
+ {
236
+ "toolNames": { "task_run": "run_background_task" },
237
+ },
238
+ ],
239
+ ],
120
240
  }
121
241
  ```
122
242
 
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
243
+ ### Running OpenCode V2 plugins on OpenCode V1
244
+
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:
246
+
247
+ ```jsonc
160
248
  {
161
- "toolAllowlist": "claude-code",
162
- "toolNames": { "usage_report": "StructuredOutput" }
249
+ "plugin": [
250
+ [
251
+ "opencode-overclock",
252
+ {
253
+ "plugins": ["./plugins/custom-agent.ts", ["opencode-plugin-review", { "strict": true }]],
254
+ },
255
+ ],
256
+ ],
163
257
  }
164
258
  ```
165
259
 
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.
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.
261
+
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.
168
264
 
169
265
  ## Notes on the TUI surface
170
266
 
171
267
  The TUI plugin sends a desktop notification (with sound) when a turn completes or the agent
172
268
  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
269
+ options (`notifyIdle`, `notifyPermission`, `notifyQuestion`, `notifyError`, `buddy` in `tui.json`).
270
+ Its slash commands read the state files under `.opencode/overclock/`, so they work
174
271
  without going through the model.
175
272
 
176
273
  The buddy hatches once per install with a random species, rarity and name, persists in the
177
274
  TUI's key-value store, hides itself below 100 columns, and needs `@opentui/solid` resolvable at
178
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).
179
278
 
180
279
  ## Contributing
181
280
 
182
281
  ```
183
282
  src/
184
- index.ts entry: load config, init modules, merge hooks
283
+ index.ts entry: init enabled modules, merge hooks, hybrid V1/V2 export
185
284
  tui.ts TUI plugin (notifications + slash commands), separate export
186
- types.ts FeatureModule contract
187
- config.ts config loader
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
191
- lib/ state dir + json, session inject + toast
192
- 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)
193
290
  test/ bun test
194
291
  ```
195
292
 
@@ -197,8 +294,6 @@ Adding a feature:
197
294
 
198
295
  1. Write `src/features/<name>.ts` exporting a `FeatureModule`.
199
296
  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.
202
297
 
203
298
  Background reading:
204
299
  [docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) maps opencode's
@@ -243,7 +338,7 @@ somewhere else.
243
338
  ### Headless end-to-end
244
339
 
245
340
  ```sh
246
- 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
247
342
  ```
248
343
 
249
344
  - `< /dev/null` is required; an open stdin hangs.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-overclock",
3
- "version": "0.3.0",
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",
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