opencode-overclock 0.1.0 → 0.2.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 +37 -9
- package/package.json +10 -1
- package/src/buddy/companion.ts +78 -0
- package/src/buddy/reactions.ts +41 -0
- package/src/buddy/sprites.ts +115 -0
- package/src/buddy/tui.ts +171 -0
- package/src/buddy/types.ts +20 -0
- package/src/features/buddy.ts +15 -0
- package/src/features/checkpoints.ts +1 -0
- package/src/features/guard.ts +42 -7
- package/src/features/index.ts +2 -1
- package/src/features/sandbox.ts +2 -0
- package/src/features/sched.ts +4 -5
- package/src/features/tasks.ts +7 -0
- package/src/features/usage.ts +1 -0
- package/src/index.ts +35 -3
- package/src/lib/state.ts +12 -0
- package/src/tui.ts +28 -0
- package/src/types.ts +19 -1
- package/src/validate.ts +143 -0
package/README.md
CHANGED
|
@@ -41,18 +41,41 @@ Local dev: copy or symlink into `.opencode/plugins/` — see [Dev](#dev). Note t
|
|
|
41
41
|
|
|
42
42
|
`true`/`false` toggle. Object = on + options. Defaults: all on except sandbox; guard inert without `hooks`.
|
|
43
43
|
|
|
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.
|
|
47
|
+
|
|
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) |
|
|
56
|
+
|
|
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).
|
|
61
|
+
|
|
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.
|
|
65
|
+
|
|
44
66
|
## Features
|
|
45
67
|
|
|
46
|
-
| Module | Tools | Does
|
|
47
|
-
| ------------- | ---------------------------------------------------------- |
|
|
48
|
-
| `tasks` | `task_run` `task_status` `task_output` `task_kill` | background shell cmds; exit -> result posted back into session; stall watchdog nudges on interactive prompts
|
|
49
|
-
| `sched` | `schedule_create` `schedule_list` `schedule_delete` | cron exprs or intervals ("5m"); interval + current session = loop; restart-safe
|
|
50
|
-
| `sandbox` | `bash_unsandboxed` (escape hatch) | bwrap-wrap every bash call: `/` ro, project + `/tmp` rw, net configurable. Opt-in
|
|
51
|
-
| `guard` | — | user hooks: after matching tool calls, run configured cmds (debounced), failures fed back to model
|
|
52
|
-
| `usage` | `usage_report` | per-day + per-session cost/token telemetry off `message.updated` events
|
|
53
|
-
| `checkpoints` | `checkpoint_list` `checkpoint_revert` `checkpoint_restore` | session revert/unrevert over opencode's shadow-git snapshots; revert gated by permission ask
|
|
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 |
|
|
54
77
|
|
|
55
|
-
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.
|
|
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.
|
|
56
79
|
|
|
57
80
|
## Layout
|
|
58
81
|
|
|
@@ -107,6 +130,11 @@ proves an installed-from-npm session actually gets the tools; run it after every
|
|
|
107
130
|
2. Edit `src/`. No hot reload -> restart opencode.
|
|
108
131
|
3. State inspect: `.opencode/overclock/` (gitignored).
|
|
109
132
|
|
|
133
|
+
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.
|
|
137
|
+
|
|
110
138
|
### Headless e2e
|
|
111
139
|
|
|
112
140
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-overclock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": {
|
|
@@ -41,7 +41,16 @@
|
|
|
41
41
|
"@opencode-ai/plugin": "1.18.9",
|
|
42
42
|
"croner": "^10.0.1"
|
|
43
43
|
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@opentui/solid": ">=0.4.5"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@opentui/solid": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
44
52
|
"devDependencies": {
|
|
53
|
+
"@opentui/solid": "0.4.5",
|
|
45
54
|
"@types/bun": "latest",
|
|
46
55
|
"prettier": "^3.9.6",
|
|
47
56
|
"typescript": "^5.7.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SPECIES, type Companion, type CompanionStats, type Rarity } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
const RARITY_WEIGHTS: { rarity: Rarity; weight: number }[] = [
|
|
4
|
+
{ rarity: "common", weight: 60 },
|
|
5
|
+
{ rarity: "uncommon", weight: 25 },
|
|
6
|
+
{ rarity: "rare", weight: 10 },
|
|
7
|
+
{ rarity: "legendary", weight: 5 },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const NAME_POOL = [
|
|
11
|
+
"Biscuit",
|
|
12
|
+
"Ember",
|
|
13
|
+
"Noodle",
|
|
14
|
+
"Pixel",
|
|
15
|
+
"Rune",
|
|
16
|
+
"Static",
|
|
17
|
+
"Widget",
|
|
18
|
+
"Zephyr",
|
|
19
|
+
"Marble",
|
|
20
|
+
"Gizmo",
|
|
21
|
+
"Puddle",
|
|
22
|
+
"Cinder",
|
|
23
|
+
"Quokka",
|
|
24
|
+
"Kernel",
|
|
25
|
+
"Sprocket",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** Deterministic PRNG for tests -- production uses Math.random via the default param. */
|
|
29
|
+
export function mulberry32(seed: number): () => number {
|
|
30
|
+
let a = seed | 0
|
|
31
|
+
return () => {
|
|
32
|
+
a = (a + 0x6d2b79f5) | 0
|
|
33
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
34
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
35
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pick<T>(rng: () => number, pool: readonly T[]): T {
|
|
40
|
+
return pool[Math.floor(rng() * pool.length)]!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rollRarity(rng: () => number): Rarity {
|
|
44
|
+
const total = RARITY_WEIGHTS.reduce((sum, e) => sum + e.weight, 0)
|
|
45
|
+
let r = rng() * total
|
|
46
|
+
for (const entry of RARITY_WEIGHTS) {
|
|
47
|
+
if (r < entry.weight) return entry.rarity
|
|
48
|
+
r -= entry.weight
|
|
49
|
+
}
|
|
50
|
+
return RARITY_WEIGHTS[RARITY_WEIGHTS.length - 1]!.rarity
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rollStat(rng: () => number): number {
|
|
54
|
+
return 1 + Math.floor(rng() * 10)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One-line card for the /oc-buddy toast. */
|
|
58
|
+
export function describeCompanion(c: Companion): string {
|
|
59
|
+
const s = c.stats
|
|
60
|
+
return `${c.name} the ${c.rarity} ${c.species} · patience ${s.patience} chaos ${s.chaos} wisdom ${s.wisdom} snark ${s.snark}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pure: roll a fresh companion. Called once at hatch, then persisted -- never re-rolled. */
|
|
64
|
+
export function rollCompanion(rng: () => number = Math.random, now: number = Date.now()): Companion {
|
|
65
|
+
const stats: CompanionStats = {
|
|
66
|
+
patience: rollStat(rng),
|
|
67
|
+
chaos: rollStat(rng),
|
|
68
|
+
wisdom: rollStat(rng),
|
|
69
|
+
snark: rollStat(rng),
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
species: pick(rng, SPECIES),
|
|
73
|
+
rarity: rollRarity(rng),
|
|
74
|
+
name: pick(rng, NAME_POOL),
|
|
75
|
+
stats,
|
|
76
|
+
hatchedAt: now,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SpriteState } from "./sprites.ts"
|
|
2
|
+
|
|
3
|
+
export type ReactionKind = "done" | "error" | "permission" | "question" | "pet"
|
|
4
|
+
|
|
5
|
+
/** Speech-bubble line + which face the sprite pulls while it shows. */
|
|
6
|
+
export interface Reaction {
|
|
7
|
+
text: string
|
|
8
|
+
state: SpriteState
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Lines render into the sprite's 12-col effect row -- keep every line <= 12 chars.
|
|
12
|
+
const POOLS: Record<ReactionKind, { lines: string[]; state: SpriteState }> = {
|
|
13
|
+
done: { lines: ["done!", "all set.", "ship it.", "*stretch*"], state: "idle" },
|
|
14
|
+
error: { lines: ["uh oh.", "*winces*", "yikes."], state: "alarmed" },
|
|
15
|
+
permission: { lines: ["can we?", "*peeks*", "please?"], state: "curious" },
|
|
16
|
+
question: { lines: ["your call.", "hmm?", "*head tilt*"], state: "curious" },
|
|
17
|
+
pet: { lines: ["<3", "*purrs*", "hi!!", "missed you."], state: "pet" },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pure: pick a random line for a reaction kind. */
|
|
21
|
+
export function pickReaction(kind: ReactionKind, rng: () => number = Math.random): Reaction {
|
|
22
|
+
const pool = POOLS[kind]
|
|
23
|
+
return { text: pool.lines[Math.floor(rng() * pool.lines.length)]!, state: pool.state }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReactionGate {
|
|
27
|
+
/** True + arms the cooldown if enough time has passed since the last fire. */
|
|
28
|
+
tryFire(now?: number): boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Debounce for event-driven reactions -- keeps a busy session from spamming the bubble. */
|
|
32
|
+
export function createReactionGate(cooldownMs = 8000): ReactionGate {
|
|
33
|
+
let last = -Infinity
|
|
34
|
+
return {
|
|
35
|
+
tryFire(now: number = Date.now()): boolean {
|
|
36
|
+
if (now - last < cooldownMs) return false
|
|
37
|
+
last = now
|
|
38
|
+
return true
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Species } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every frame is padded to exactly SPRITE_WIDTH columns so animation never
|
|
5
|
+
* causes layout reflow. Height is per-species (see spriteHeight) and is *not*
|
|
6
|
+
* padded: the buddy is mounted as a bottom-anchored absolute overlay, so a
|
|
7
|
+
* trailing blank row would lift the creature off the agent/model line.
|
|
8
|
+
*/
|
|
9
|
+
export const SPRITE_WIDTH = 12
|
|
10
|
+
/**
|
|
11
|
+
* Tallest a frame can get: 1 effect row + a 3-row body. The prompt box is 4 rows
|
|
12
|
+
* tall with an empty input, so staying within this keeps the buddy -- bubble row
|
|
13
|
+
* included -- from drawing above the box and onto the transcript.
|
|
14
|
+
*/
|
|
15
|
+
export const SPRITE_MAX_HEIGHT = 4
|
|
16
|
+
|
|
17
|
+
export type SpriteState = "idle" | "pet" | "alarmed" | "curious" | "sleep"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Frames are body templates, not full art per state: `{E}` is replaced by a
|
|
21
|
+
* 3-char eye string, and a top "effect" line floats <3 / ! / ? / z above the
|
|
22
|
+
* head. One drawing per species (plus a fidget frame) covers every state.
|
|
23
|
+
*/
|
|
24
|
+
const EYES = {
|
|
25
|
+
open: "o.o",
|
|
26
|
+
blink: "-.-",
|
|
27
|
+
pet: "^.^",
|
|
28
|
+
alarmed: "O.O",
|
|
29
|
+
sleep: "-.-",
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
interface SpeciesArt {
|
|
33
|
+
/** Two body frames, alternated while idle: tail flick, ear twitch, wing flap... */
|
|
34
|
+
idle: [string[], string[]]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ART: Record<Species, SpeciesArt> = {
|
|
38
|
+
cat: {
|
|
39
|
+
idle: [
|
|
40
|
+
[" /\\_/\\", "( {E} )", " > ^ <"],
|
|
41
|
+
[" /\\_/\\", "( {E} )", " > ^ <~"],
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
dog: {
|
|
45
|
+
idle: [
|
|
46
|
+
[" /^-^\\", "( {E} )", "/ ~ \\"],
|
|
47
|
+
[" /^-^/", "( {E} )", "/ ~ \\"],
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
dragon: {
|
|
51
|
+
idle: [
|
|
52
|
+
[" /\\~/\\", " ( {E} )", "<( v )>"],
|
|
53
|
+
[" /\\~/\\", " ( {E} )", "^( v )^"],
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
ghost: {
|
|
57
|
+
idle: [
|
|
58
|
+
[" .-.", " ({E})", " '\"'\"'"],
|
|
59
|
+
[" .-.", " ({E})", ' ~"~"~'],
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
slime: {
|
|
63
|
+
idle: [
|
|
64
|
+
[" ___", " ({E})", " (_____)"],
|
|
65
|
+
[" ___", " (({E}))", "(_______)"],
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const BLINK_EVERY = 7
|
|
71
|
+
|
|
72
|
+
/** Effect row on top, body below it, every row padded to SPRITE_WIDTH. */
|
|
73
|
+
function compose(effect: string, body: readonly string[], eyes: string): string {
|
|
74
|
+
const lines = [effect, ...body.map((l) => l.replace("{E}", eyes))]
|
|
75
|
+
return lines.map((l) => l.slice(0, SPRITE_WIDTH).padEnd(SPRITE_WIDTH)).join("\n")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rows a species' frames occupy: constant across states and ticks, so the host
|
|
80
|
+
* node can be sized once at mount and never resized mid-animation.
|
|
81
|
+
*/
|
|
82
|
+
export function spriteHeight(species: Species): number {
|
|
83
|
+
return 1 + ART[species].idle[0].length
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pure: overlay a speech line onto the frame's effect row (line 0), truncated to width. */
|
|
87
|
+
export function withBubble(frame: string, bubble: string | undefined): string {
|
|
88
|
+
if (!bubble) return frame
|
|
89
|
+
const lines = frame.split("\n")
|
|
90
|
+
lines[0] = bubble.slice(0, SPRITE_WIDTH).padEnd(SPRITE_WIDTH)
|
|
91
|
+
return lines.join("\n")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pure: render one frame. `tick` drives idle fidget + blink; other states are static. */
|
|
95
|
+
export function spriteFrame(species: Species, state: SpriteState, tick: number): string {
|
|
96
|
+
const art = ART[species]
|
|
97
|
+
const t = ((tick % 1000) + 1000) % 1000
|
|
98
|
+
const body = art.idle[t % 2]!
|
|
99
|
+
const rest = art.idle[0]!
|
|
100
|
+
|
|
101
|
+
switch (state) {
|
|
102
|
+
case "idle": {
|
|
103
|
+
const eyes = t % BLINK_EVERY === BLINK_EVERY - 1 ? EYES.blink : EYES.open
|
|
104
|
+
return compose("", body, eyes)
|
|
105
|
+
}
|
|
106
|
+
case "pet":
|
|
107
|
+
return compose(" <3", rest, EYES.pet)
|
|
108
|
+
case "alarmed":
|
|
109
|
+
return compose(" !", rest, EYES.alarmed)
|
|
110
|
+
case "curious":
|
|
111
|
+
return compose(" ?", rest, EYES.open)
|
|
112
|
+
case "sleep":
|
|
113
|
+
return compose(" z Z", rest, EYES.sleep)
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/buddy/tui.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { rollCompanion, describeCompanion } from "./companion.ts"
|
|
3
|
+
import type { Companion, Rarity } from "./types.ts"
|
|
4
|
+
import { spriteFrame, withBubble, spriteHeight, SPRITE_WIDTH, type SpriteState } from "./sprites.ts"
|
|
5
|
+
import { pickReaction, createReactionGate, type ReactionKind } from "./reactions.ts"
|
|
6
|
+
|
|
7
|
+
const KV_KEY = "buddy.companion"
|
|
8
|
+
/** Below this terminal width the buddy hides rather than crowd the prompt. */
|
|
9
|
+
const MIN_COLS = 100
|
|
10
|
+
const TICK_MS = 500
|
|
11
|
+
const BUBBLE_MS = 6000
|
|
12
|
+
const SLEEP_AFTER_MS = 120_000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Structural view of the opentui TextRenderable we mutate. The buddy is updated
|
|
16
|
+
* imperatively through refs instead of solid signals on purpose: reactivity would
|
|
17
|
+
* silently break if the plugin's solid-js instance ever differs from the host's,
|
|
18
|
+
* while direct property sets on host-created nodes always work.
|
|
19
|
+
*/
|
|
20
|
+
interface SpriteNode {
|
|
21
|
+
content: string
|
|
22
|
+
visible: boolean
|
|
23
|
+
destroyed?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rarityColor(rarity: Rarity, theme: TuiThemeCurrent): unknown {
|
|
27
|
+
switch (rarity) {
|
|
28
|
+
case "legendary":
|
|
29
|
+
return theme.accent
|
|
30
|
+
case "rare":
|
|
31
|
+
return theme.info
|
|
32
|
+
case "uncommon":
|
|
33
|
+
return theme.success
|
|
34
|
+
default:
|
|
35
|
+
return theme.textMuted
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hatch (or load) the companion and mount it in the prompt-right slots.
|
|
41
|
+
* Dynamic import of @opentui/solid: if the host doesn't map the specifier to its
|
|
42
|
+
* own instance, this throws and the caller degrades to "no buddy" -- it must
|
|
43
|
+
* never take the rest of the TUI plugin down.
|
|
44
|
+
*/
|
|
45
|
+
export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
46
|
+
const { jsx } = (await import("@opentui/solid/jsx-runtime")) as unknown as {
|
|
47
|
+
jsx: (type: string, props?: Record<string, unknown> | null) => unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let companion = api.kv.get<Companion | undefined>(KV_KEY, undefined)
|
|
51
|
+
if (!companion) {
|
|
52
|
+
companion = rollCompanion()
|
|
53
|
+
api.kv.set(KV_KEY, companion)
|
|
54
|
+
api.ui.toast({ message: `a buddy hatched: ${describeCompanion(companion)}` })
|
|
55
|
+
}
|
|
56
|
+
const hatched: Companion = companion
|
|
57
|
+
|
|
58
|
+
// One ticker drives every mounted node (home + session slots).
|
|
59
|
+
const nodes = new Set<SpriteNode>()
|
|
60
|
+
let tick = 0
|
|
61
|
+
let bubble: string | undefined
|
|
62
|
+
let bubbleUntil = 0
|
|
63
|
+
let faceOverride: SpriteState | undefined
|
|
64
|
+
let lastActivity = Date.now()
|
|
65
|
+
|
|
66
|
+
function currentState(now: number): SpriteState {
|
|
67
|
+
if (faceOverride && now < bubbleUntil) return faceOverride
|
|
68
|
+
if (now - lastActivity > SLEEP_AFTER_MS) return "sleep"
|
|
69
|
+
return "idle"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function paint(): void {
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
const frame = spriteFrame(hatched.species, currentState(now), tick)
|
|
75
|
+
const text = withBubble(frame, now < bubbleUntil ? bubble : undefined)
|
|
76
|
+
const wide = api.renderer.width >= MIN_COLS
|
|
77
|
+
for (const node of [...nodes]) {
|
|
78
|
+
try {
|
|
79
|
+
if (node.destroyed) {
|
|
80
|
+
nodes.delete(node)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
node.visible = wide
|
|
84
|
+
if (wide) node.content = text
|
|
85
|
+
} catch {
|
|
86
|
+
nodes.delete(node)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function react(kind: ReactionKind): void {
|
|
92
|
+
const reaction = pickReaction(kind)
|
|
93
|
+
bubble = reaction.text
|
|
94
|
+
bubbleUntil = Date.now() + BUBBLE_MS
|
|
95
|
+
faceOverride = reaction.state
|
|
96
|
+
lastActivity = Date.now()
|
|
97
|
+
paint()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const timer = setInterval(() => {
|
|
101
|
+
tick++
|
|
102
|
+
paint()
|
|
103
|
+
}, TICK_MS)
|
|
104
|
+
api.lifecycle.onDispose(async () => clearInterval(timer))
|
|
105
|
+
|
|
106
|
+
const gate = createReactionGate()
|
|
107
|
+
const unsubs = [
|
|
108
|
+
api.event.on("session.status", (event) => {
|
|
109
|
+
lastActivity = Date.now()
|
|
110
|
+
if (event.properties.status.type === "idle" && gate.tryFire()) react("done")
|
|
111
|
+
}),
|
|
112
|
+
api.event.on("session.error", () => {
|
|
113
|
+
if (gate.tryFire()) react("error")
|
|
114
|
+
}),
|
|
115
|
+
api.event.on("permission.asked", () => {
|
|
116
|
+
if (gate.tryFire()) react("permission")
|
|
117
|
+
}),
|
|
118
|
+
api.event.on("question.asked", () => {
|
|
119
|
+
if (gate.tryFire()) react("question")
|
|
120
|
+
}),
|
|
121
|
+
]
|
|
122
|
+
for (const unsub of unsubs) api.lifecycle.onDispose(async () => unsub())
|
|
123
|
+
|
|
124
|
+
// The host renders the slot inside a one-line flex row next to the agent/model
|
|
125
|
+
// text (prompt/index.tsx: justifyContent="space-between"). An in-flow sprite
|
|
126
|
+
// would stretch that row -- and the whole prompt box -- by its full height, so
|
|
127
|
+
// it goes out of flow instead: yoga places absolute children against their
|
|
128
|
+
// parent's edges, and bottom/right pin the creature's last row onto the
|
|
129
|
+
// agent/model line while it grows upward over the (usually empty) input area.
|
|
130
|
+
const renderSprite = (theme: TuiThemeCurrent) =>
|
|
131
|
+
jsx("text", {
|
|
132
|
+
position: "absolute",
|
|
133
|
+
bottom: 0,
|
|
134
|
+
right: 0,
|
|
135
|
+
width: SPRITE_WIDTH,
|
|
136
|
+
height: spriteHeight(hatched.species),
|
|
137
|
+
content: spriteFrame(hatched.species, "idle", tick),
|
|
138
|
+
fg: rarityColor(hatched.rarity, theme),
|
|
139
|
+
selectable: false,
|
|
140
|
+
visible: api.renderer.width >= MIN_COLS,
|
|
141
|
+
ref: (node: SpriteNode | undefined) => {
|
|
142
|
+
if (node) nodes.add(node)
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// register returns the assigned plugin id, not a disposer -- the host owns
|
|
147
|
+
// slot cleanup when the TUI plugin deactivates.
|
|
148
|
+
api.slots.register({
|
|
149
|
+
slots: {
|
|
150
|
+
home_prompt_right: (ctx) => renderSprite(ctx.theme.current),
|
|
151
|
+
session_prompt_right: (ctx) => renderSprite(ctx.theme.current),
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const uncommand = api.command?.register(() => [
|
|
157
|
+
{
|
|
158
|
+
title: "Overclock: Pet buddy",
|
|
159
|
+
value: "overclock.buddy",
|
|
160
|
+
slash: { name: "oc-buddy" },
|
|
161
|
+
onSelect: async () => {
|
|
162
|
+
react("pet")
|
|
163
|
+
api.ui.toast({ message: describeCompanion(hatched) })
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
])
|
|
167
|
+
if (uncommand) api.lifecycle.onDispose(async () => uncommand())
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.warn(`[overclock-tui] /oc-buddy command registration failed: ${e}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const SPECIES = ["cat", "dog", "dragon", "ghost", "slime"] as const
|
|
2
|
+
export type Species = (typeof SPECIES)[number]
|
|
3
|
+
|
|
4
|
+
export type Rarity = "common" | "uncommon" | "rare" | "legendary"
|
|
5
|
+
|
|
6
|
+
export interface CompanionStats {
|
|
7
|
+
patience: number
|
|
8
|
+
chaos: number
|
|
9
|
+
wisdom: number
|
|
10
|
+
snark: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Persisted in TUI kv. Rolled once at hatch, then stable for the life of the install. */
|
|
14
|
+
export interface Companion {
|
|
15
|
+
species: Species
|
|
16
|
+
rarity: Rarity
|
|
17
|
+
name: string
|
|
18
|
+
stats: CompanionStats
|
|
19
|
+
hatchedAt: number
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FeatureModule } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Buddy is a TUI-surface feature (src/buddy/, wired in src/tui.ts): an ASCII pet
|
|
5
|
+
* beside the prompt. This server module registers no hooks or tools -- it exists
|
|
6
|
+
* so `features.buddy` validates in overclock.json (one config file toggles both
|
|
7
|
+
* surfaces) and the first-run summary mentions it.
|
|
8
|
+
*/
|
|
9
|
+
export const buddy: FeatureModule = {
|
|
10
|
+
name: "buddy",
|
|
11
|
+
defaultEnabled: true,
|
|
12
|
+
async init() {
|
|
13
|
+
return {}
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -81,6 +81,7 @@ export function createCheckpoints(client: Client): Checkpoints {
|
|
|
81
81
|
*/
|
|
82
82
|
export const checkpoints: FeatureModule = {
|
|
83
83
|
name: "checkpoints",
|
|
84
|
+
tools: ["checkpoint_list", "checkpoint_revert", "checkpoint_restore"],
|
|
84
85
|
defaultEnabled: true,
|
|
85
86
|
requires: ["session.revert", "session.unrevert", "session.messages"],
|
|
86
87
|
async init(ctx) {
|
package/src/features/guard.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface GuardHook {
|
|
|
10
10
|
debounceMs: number
|
|
11
11
|
timeoutMs: number
|
|
12
12
|
onSuccess: "silent" | "notify"
|
|
13
|
+
/** inject mode: give up deferring past a busy session after this long, and report anyway. */
|
|
14
|
+
maxDeferMs: number
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
|
|
@@ -33,6 +35,7 @@ export function parseHooks(raw: unknown): GuardHook[] {
|
|
|
33
35
|
debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
|
|
34
36
|
timeoutMs: typeof e.timeoutMs === "number" ? e.timeoutMs : 60000,
|
|
35
37
|
onSuccess: e.onSuccess === "notify" ? "notify" : "silent",
|
|
38
|
+
maxDeferMs: typeof e.maxDeferMs === "number" ? e.maxDeferMs : 300000,
|
|
36
39
|
})
|
|
37
40
|
}
|
|
38
41
|
return hooks
|
|
@@ -85,12 +88,18 @@ interface HookState {
|
|
|
85
88
|
lastToolName: string
|
|
86
89
|
lastFilePath?: string
|
|
87
90
|
procs: Set<Bun.Subprocess>
|
|
91
|
+
/** when the current run of deferrals started, for the maxDeferMs cap */
|
|
92
|
+
deferredSince?: number
|
|
93
|
+
/** last payload actually injected, to avoid re-reporting a standing failure */
|
|
94
|
+
lastInjectedPayload?: string
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
export interface GuardRunnerDeps {
|
|
91
98
|
cwd: string
|
|
92
99
|
onInject: (sessionID: string, payload: string) => Promise<void>
|
|
93
100
|
onNotify: (hookName: string) => Promise<void>
|
|
101
|
+
/** omitted -> never busy, i.e. the old always-inject behaviour */
|
|
102
|
+
isBusy?: (sessionID: string) => boolean
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
export interface GuardRunner {
|
|
@@ -134,8 +143,20 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
|
|
|
134
143
|
s.timer = setTimeout(() => void fire(hook), hook.debounceMs)
|
|
135
144
|
return
|
|
136
145
|
}
|
|
137
|
-
s.running = true
|
|
138
146
|
const sessionID = s.lastSessionID
|
|
147
|
+
// Agent still mid-turn: the tree is in flux, so any verdict we reach now may already be
|
|
148
|
+
// stale by the time it's read. Re-arm instead. The recheck doubles as dedup -- if the
|
|
149
|
+
// agent fixed the fault itself, the later run exits 0 and we stay silent.
|
|
150
|
+
if (sessionID && deps.isBusy?.(sessionID)) {
|
|
151
|
+
s.deferredSince ??= Date.now()
|
|
152
|
+
if (Date.now() - s.deferredSince < hook.maxDeferMs) {
|
|
153
|
+
s.timer = setTimeout(() => void fire(hook), hook.debounceMs)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
// deferred too long -- session may be wedged. Report anyway rather than never.
|
|
157
|
+
}
|
|
158
|
+
s.deferredSince = undefined
|
|
159
|
+
s.running = true
|
|
139
160
|
const toolName = s.lastToolName
|
|
140
161
|
const filePath = s.lastFilePath
|
|
141
162
|
try {
|
|
@@ -143,10 +164,18 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
|
|
|
143
164
|
s.procs.add(p),
|
|
144
165
|
)
|
|
145
166
|
if (result.code !== 0) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
167
|
+
const payload = failurePayload(hook.name, result.code, result.combined)
|
|
168
|
+
// Backstop to the idle gate above: a fault the agent can't fix would otherwise
|
|
169
|
+
// re-inject on every idle cycle forever. Report transitions, not standing state.
|
|
170
|
+
// Exact-match is deliberately conservative -- it only ever suppresses a report
|
|
171
|
+
// that is byte-identical to the one the agent has already seen and failed to clear.
|
|
172
|
+
if (sessionID && payload !== s.lastInjectedPayload) {
|
|
173
|
+
await deps.onInject(sessionID, payload)
|
|
174
|
+
s.lastInjectedPayload = payload
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
s.lastInjectedPayload = undefined
|
|
178
|
+
if (hook.onSuccess === "notify") await deps.onNotify(hook.name)
|
|
150
179
|
}
|
|
151
180
|
} finally {
|
|
152
181
|
s.running = false
|
|
@@ -181,13 +210,18 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
|
|
|
181
210
|
/**
|
|
182
211
|
* User-configurable post-tool hooks. Run commands after matching tool calls. No config -> inert.
|
|
183
212
|
* "append": run synchronously, failure appended to tool output in place.
|
|
184
|
-
* "inject" (default): debounced per hook name, failure injected as a user turn
|
|
213
|
+
* "inject" (default): debounced per hook name, failure injected as a user turn once the
|
|
214
|
+
* session goes idle. Firing mid-turn doesn't interrupt (promptAsync queues), but each
|
|
215
|
+
* fire queues its own turn, so a burst of edits stacks several reports of a fault the
|
|
216
|
+
* agent was already fixing -- and reports it from a tree that was still being edited.
|
|
185
217
|
*/
|
|
186
218
|
export const guard: FeatureModule = {
|
|
187
219
|
name: "guard",
|
|
220
|
+
tools: [],
|
|
221
|
+
options: { hooks: "array" },
|
|
188
222
|
defaultEnabled: true,
|
|
189
223
|
requires: ["session.promptAsync", "session.messages"],
|
|
190
|
-
async init(ctx, options) {
|
|
224
|
+
async init(ctx, options, shared) {
|
|
191
225
|
if (options.hooks !== undefined && !Array.isArray(options.hooks)) {
|
|
192
226
|
console.warn(`[overclock] guard: options.hooks must be an array, got ${typeof options.hooks}`)
|
|
193
227
|
}
|
|
@@ -202,6 +236,7 @@ export const guard: FeatureModule = {
|
|
|
202
236
|
onNotify: async (hookName) => {
|
|
203
237
|
await toast(ctx.client, `guard "${hookName}" passed`, "success")
|
|
204
238
|
},
|
|
239
|
+
isBusy: (sessionID) => shared.busy.isBusy(sessionID),
|
|
205
240
|
})
|
|
206
241
|
|
|
207
242
|
return {
|
package/src/features/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { sandbox } from "./sandbox.ts"
|
|
|
5
5
|
import { guard } from "./guard.ts"
|
|
6
6
|
import { usage } from "./usage.ts"
|
|
7
7
|
import { checkpoints } from "./checkpoints.ts"
|
|
8
|
+
import { buddy } from "./buddy.ts"
|
|
8
9
|
|
|
9
10
|
/** Registry, ordered. Order = hook composition order. */
|
|
10
|
-
export const features: FeatureModule[] = [tasks, sched, sandbox, guard, usage, checkpoints]
|
|
11
|
+
export const features: FeatureModule[] = [tasks, sched, sandbox, guard, usage, checkpoints, buddy]
|
package/src/features/sandbox.ts
CHANGED
|
@@ -51,6 +51,8 @@ export function probeBwrap(): boolean {
|
|
|
51
51
|
*/
|
|
52
52
|
export const sandbox: FeatureModule = {
|
|
53
53
|
name: "sandbox",
|
|
54
|
+
tools: ["bash_unsandboxed"],
|
|
55
|
+
options: { net: "boolean" },
|
|
54
56
|
defaultEnabled: false,
|
|
55
57
|
async init(ctx, options) {
|
|
56
58
|
const policy: SandboxPolicy = {
|
package/src/features/sched.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { Cron } from "croner"
|
|
|
3
3
|
import type { FeatureModule } from "../types.ts"
|
|
4
4
|
import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
|
|
5
5
|
import { inject, toast } from "../lib/inject.ts"
|
|
6
|
-
import { createBusyTracker } from "../lib/busy.ts"
|
|
7
6
|
|
|
8
7
|
const z = tool.schema
|
|
9
8
|
|
|
@@ -34,15 +33,16 @@ interface Schedule {
|
|
|
34
33
|
*/
|
|
35
34
|
export const sched: FeatureModule = {
|
|
36
35
|
name: "sched",
|
|
36
|
+
tools: ["schedule_create", "schedule_list", "schedule_delete"],
|
|
37
|
+
options: { skipIfBusy: "boolean" },
|
|
37
38
|
defaultEnabled: true,
|
|
38
39
|
requires: ["session.promptAsync", "session.messages", "session.create"],
|
|
39
|
-
async init(ctx, options) {
|
|
40
|
+
async init(ctx, options, shared) {
|
|
40
41
|
const dir = await ensureStateDir(ctx.directory)
|
|
41
42
|
const storePath = `${dir}/schedules.json`
|
|
42
43
|
const schedules = new Map<string, Schedule>()
|
|
43
44
|
const timers = new Map<string, Cron | ReturnType<typeof setInterval>>()
|
|
44
45
|
const skipIfBusy = options.skipIfBusy !== false
|
|
45
|
-
const busy = createBusyTracker()
|
|
46
46
|
|
|
47
47
|
const persist = () => writeJson(storePath, [...schedules.values()])
|
|
48
48
|
|
|
@@ -50,7 +50,7 @@ export const sched: FeatureModule = {
|
|
|
50
50
|
try {
|
|
51
51
|
if (s.target === "current") {
|
|
52
52
|
// target still chewing on previous turn -> skip this fire, no pileup
|
|
53
|
-
if (skipIfBusy && busy.isBusy(s.sessionID)) {
|
|
53
|
+
if (skipIfBusy && shared.busy.isBusy(s.sessionID)) {
|
|
54
54
|
await toast(ctx.client, `schedule ${s.id} skipped (session busy)`, "info")
|
|
55
55
|
return
|
|
56
56
|
}
|
|
@@ -103,7 +103,6 @@ export const sched: FeatureModule = {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
return {
|
|
106
|
-
event: async ({ event }) => busy.onEvent(event),
|
|
107
106
|
dispose: async () => {
|
|
108
107
|
for (const id of [...timers.keys()]) disarm(id)
|
|
109
108
|
},
|
package/src/features/tasks.ts
CHANGED
|
@@ -236,6 +236,13 @@ const fmt = (t: TaskRecord) =>
|
|
|
236
236
|
*/
|
|
237
237
|
export const tasks: FeatureModule = {
|
|
238
238
|
name: "tasks",
|
|
239
|
+
tools: ["task_run", "task_status", "task_output", "task_kill"],
|
|
240
|
+
options: {
|
|
241
|
+
killOnExit: "boolean",
|
|
242
|
+
stallDetection: "boolean",
|
|
243
|
+
stallThresholdMs: "number",
|
|
244
|
+
stallCheckIntervalMs: "number",
|
|
245
|
+
},
|
|
239
246
|
defaultEnabled: true,
|
|
240
247
|
requires: ["session.promptAsync", "session.messages"],
|
|
241
248
|
async init(ctx, options) {
|
package/src/features/usage.ts
CHANGED
|
@@ -236,6 +236,7 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
|
|
|
236
236
|
*/
|
|
237
237
|
export const usage: FeatureModule = {
|
|
238
238
|
name: "usage",
|
|
239
|
+
tools: ["usage_report"],
|
|
239
240
|
defaultEnabled: true,
|
|
240
241
|
async init(ctx) {
|
|
241
242
|
const dir = await ensureStateDir(ctx.directory)
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
1
|
+
import type { Hooks, Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { features } from "./features/index.ts"
|
|
3
3
|
import { loadConfig } from "./config.ts"
|
|
4
4
|
import { mergeHooks } from "./merge.ts"
|
|
5
5
|
import { missingSurfaces } from "./lib/probe.ts"
|
|
6
6
|
import { toast } from "./lib/inject.ts"
|
|
7
|
+
import { firstRun } from "./lib/state.ts"
|
|
8
|
+
import { validateConfig, summarise } from "./validate.ts"
|
|
9
|
+
import { createBusyTracker } from "./lib/busy.ts"
|
|
10
|
+
import type { FeatureModule, SharedDeps } from "./types.ts"
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Entry. Load config -> probe surfaces -> init enabled modules -> merge hooks.
|
|
@@ -11,8 +15,27 @@ import { toast } from "./lib/inject.ts"
|
|
|
11
15
|
*/
|
|
12
16
|
export const Overclock: Plugin = async (ctx) => {
|
|
13
17
|
const config = await loadConfig(ctx.directory)
|
|
14
|
-
|
|
18
|
+
|
|
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.
|
|
22
|
+
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
|
+
|
|
34
|
+
const shared: SharedDeps = { busy: createBusyTracker() }
|
|
35
|
+
// First part, so the tracker is current before any module's own event hook reads it.
|
|
36
|
+
const parts: Partial<Hooks>[] = [{ event: async ({ event }) => shared.busy.onEvent(event) }]
|
|
15
37
|
const skipped: string[] = []
|
|
38
|
+
const enabled: FeatureModule[] = []
|
|
16
39
|
|
|
17
40
|
for (const feature of features) {
|
|
18
41
|
const setting = config.features?.[feature.name] ?? feature.defaultEnabled
|
|
@@ -27,12 +50,21 @@ export const Overclock: Plugin = async (ctx) => {
|
|
|
27
50
|
}
|
|
28
51
|
const options = typeof setting === "object" ? setting : {}
|
|
29
52
|
try {
|
|
30
|
-
parts.push(await feature.init(ctx, options))
|
|
53
|
+
parts.push(await feature.init(ctx, options, shared))
|
|
54
|
+
enabled.push(feature)
|
|
31
55
|
} catch (e) {
|
|
32
56
|
console.warn(`[overclock] feature ${feature.name} failed init: ${e}`)
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
|
|
60
|
+
// Say what was added. This plugin grants the agent background shell execution and
|
|
61
|
+
// recurring scheduling; that should not be discovered by accident. Log every start
|
|
62
|
+
// (stderr, invisible unless you look), toast only on a project's first run.
|
|
63
|
+
console.warn(`[overclock] ${summarise(enabled, skipped)}`)
|
|
64
|
+
if (await firstRun(ctx.directory)) {
|
|
65
|
+
void toast(ctx.client, `overclock active: ${summarise(enabled, skipped)}`, "info")
|
|
66
|
+
}
|
|
67
|
+
|
|
36
68
|
if (skipped.length)
|
|
37
69
|
void toast(ctx.client, `overclock: ${skipped.join(", ")} disabled (SDK drift)`, "warning")
|
|
38
70
|
return mergeHooks(parts)
|
package/src/lib/state.ts
CHANGED
|
@@ -7,6 +7,18 @@ export async function ensureStateDir(directory: string, sub?: string): Promise<s
|
|
|
7
7
|
return dir
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* True once per project, then never again. Marker lives beside the other state, so
|
|
12
|
+
* deleting .opencode/overclock/ re-arms the first-run notice.
|
|
13
|
+
*/
|
|
14
|
+
export async function firstRun(directory: string): Promise<boolean> {
|
|
15
|
+
const dir = await ensureStateDir(directory)
|
|
16
|
+
const marker = Bun.file(`${dir}/.installed`)
|
|
17
|
+
if (await marker.exists()) return false
|
|
18
|
+
await Bun.write(marker, new Date().toISOString())
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export async function readJson<T>(path: string, fallback: T): Promise<T> {
|
|
11
23
|
const file = Bun.file(path)
|
|
12
24
|
if (!(await file.exists())) return fallback
|
package/src/tui.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { registerBuddy } from "./buddy/tui.ts"
|
|
2
3
|
|
|
3
4
|
const STATE_SUBDIR = ".opencode/overclock"
|
|
4
5
|
|
|
@@ -7,6 +8,23 @@ export interface TuiOptions {
|
|
|
7
8
|
notifyPermission?: boolean
|
|
8
9
|
notifyQuestion?: boolean
|
|
9
10
|
notifyError?: boolean
|
|
11
|
+
buddy?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The buddy toggles from the same overclock.json as the server features, so one
|
|
16
|
+
* config file governs both surfaces. `features.buddy: false` disables; missing
|
|
17
|
+
* file or unreadable config = default on.
|
|
18
|
+
*/
|
|
19
|
+
export async function buddyEnabledInConfig(directory: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const file = Bun.file(`${directory}/.opencode/overclock.json`)
|
|
22
|
+
if (!(await file.exists())) return true
|
|
23
|
+
const config = (await file.json()) as { features?: Record<string, unknown> }
|
|
24
|
+
return config.features?.["buddy"] !== false
|
|
25
|
+
} catch {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
10
28
|
}
|
|
11
29
|
|
|
12
30
|
export interface TaskMirrorEntry {
|
|
@@ -222,6 +240,16 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
222
240
|
} catch (e) {
|
|
223
241
|
console.warn(`[overclock-tui] /oc-schedules command registration failed: ${e}`)
|
|
224
242
|
}
|
|
243
|
+
|
|
244
|
+
// ASCII pet beside the prompt. Anything here failing (missing @opentui/solid,
|
|
245
|
+
// slot API drift) must degrade to "no buddy", never take the notifications down.
|
|
246
|
+
try {
|
|
247
|
+
if (opts.buddy !== false && (await buddyEnabledInConfig(api.state.path.directory))) {
|
|
248
|
+
await registerBuddy(api)
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
console.warn(`[overclock-tui] buddy disabled: ${e}`)
|
|
252
|
+
}
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
export default { id: "overclock-tui", tui } satisfies TuiPluginModule
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|
2
|
+
import type { BusyTracker } from "./lib/busy.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Singletons built once by the entry and handed to every module.
|
|
6
|
+
* Derived from the event bus, so they must have exactly one subscription -- a per-module
|
|
7
|
+
* copy would be N subscriptions maintaining N identical copies of the same state.
|
|
8
|
+
*/
|
|
9
|
+
export interface SharedDeps {
|
|
10
|
+
/** live per-session busy/idle state; the entry owns the subscription that feeds it */
|
|
11
|
+
busy: BusyTracker
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
/** Per-feature config from .opencode/overclock.json. `false` = off, object = options. */
|
|
4
15
|
export type FeatureConfig = boolean | Record<string, unknown>
|
|
5
16
|
|
|
17
|
+
/** Option value kinds a module declares, so a typo in overclock.json can be caught. */
|
|
18
|
+
export type OptionType = "boolean" | "number" | "string" | "array" | "object"
|
|
19
|
+
|
|
6
20
|
export interface OverclockConfig {
|
|
7
21
|
features?: Record<string, FeatureConfig>
|
|
8
22
|
}
|
|
@@ -17,5 +31,9 @@ export interface FeatureModule {
|
|
|
17
31
|
defaultEnabled: boolean
|
|
18
32
|
/** SDK client surfaces (dot-paths) the module needs. Missing -> module skipped + warn. */
|
|
19
33
|
requires?: string[]
|
|
20
|
-
|
|
34
|
+
/** Tool names registered. Declared, not derived -- feeds the first-run summary. */
|
|
35
|
+
tools?: string[]
|
|
36
|
+
/** Accepted option keys -> expected type. Anything else in config is a typo. */
|
|
37
|
+
options?: Record<string, OptionType>
|
|
38
|
+
init(ctx: PluginInput, options: Record<string, unknown>, shared: SharedDeps): Promise<Partial<Hooks>>
|
|
21
39
|
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { FeatureModule, OptionType } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
export interface ConfigIssue {
|
|
4
|
+
/** dotted location in overclock.json, e.g. "features.tasks.killOnExit" */
|
|
5
|
+
path: string
|
|
6
|
+
message: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Levenshtein, capped -- only used to turn a typo into a "did you mean". */
|
|
10
|
+
function distance(a: string, b: string): number {
|
|
11
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
12
|
+
const cur = new Array<number>(b.length + 1)
|
|
13
|
+
for (let i = 1; i <= a.length; i++) {
|
|
14
|
+
cur[0] = i
|
|
15
|
+
for (let j = 1; j <= b.length; j++) {
|
|
16
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
17
|
+
}
|
|
18
|
+
prev.splice(0, prev.length, ...cur)
|
|
19
|
+
}
|
|
20
|
+
return prev[b.length]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Closest known name within edit distance 2, else undefined. */
|
|
24
|
+
function nearest(input: string, known: readonly string[]): string | undefined {
|
|
25
|
+
let best: string | undefined
|
|
26
|
+
let bestD = 3
|
|
27
|
+
for (const k of known) {
|
|
28
|
+
const d = distance(input.toLowerCase(), k.toLowerCase())
|
|
29
|
+
if (d < bestD) {
|
|
30
|
+
bestD = d
|
|
31
|
+
best = k
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return best
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unknownKey(input: string, known: readonly string[], what: string): string {
|
|
38
|
+
const guess = nearest(input, known)
|
|
39
|
+
if (guess) return `unknown ${what} "${input}" -- did you mean "${guess}"?`
|
|
40
|
+
return `unknown ${what} "${input}". Known: ${known.join(", ")}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function typeOf(v: unknown): OptionType | "null" {
|
|
44
|
+
if (v === null) return "null"
|
|
45
|
+
if (Array.isArray(v)) return "array"
|
|
46
|
+
const t = typeof v
|
|
47
|
+
if (t === "boolean" || t === "number" || t === "string" || t === "object") return t
|
|
48
|
+
return "object"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
52
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check overclock.json against the feature registry.
|
|
57
|
+
*
|
|
58
|
+
* Exists because an unrecognised key is otherwise a silent no-op: `killOnExist: true`
|
|
59
|
+
* reads as "option not set", the feature runs with defaults, and nothing complains.
|
|
60
|
+
* Returns every issue found -- callers warn, never throw. A bad config degrades to
|
|
61
|
+
* defaults rather than taking the plugin down.
|
|
62
|
+
*/
|
|
63
|
+
export function validateConfig(config: unknown, features: readonly FeatureModule[]): ConfigIssue[] {
|
|
64
|
+
const issues: ConfigIssue[] = []
|
|
65
|
+
if (!isPlainObject(config)) {
|
|
66
|
+
return [{ path: "", message: `config must be an object, got ${typeOf(config)}` }]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TOP = ["features"]
|
|
70
|
+
for (const key of Object.keys(config)) {
|
|
71
|
+
if (!TOP.includes(key)) issues.push({ path: key, message: unknownKey(key, TOP, "top-level key") })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { features: featuresCfg } = config
|
|
75
|
+
if (featuresCfg === undefined) return issues
|
|
76
|
+
if (!isPlainObject(featuresCfg)) {
|
|
77
|
+
issues.push({
|
|
78
|
+
path: "features",
|
|
79
|
+
message: `"features" must be an object, got ${typeOf(featuresCfg)}`,
|
|
80
|
+
})
|
|
81
|
+
return issues
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const names = features.map((f) => f.name)
|
|
85
|
+
for (const [name, setting] of Object.entries(featuresCfg)) {
|
|
86
|
+
const feature = features.find((f) => f.name === name)
|
|
87
|
+
if (!feature) {
|
|
88
|
+
issues.push({ path: `features.${name}`, message: unknownKey(name, names, "feature") })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (typeof setting === "boolean") continue
|
|
92
|
+
if (!isPlainObject(setting)) {
|
|
93
|
+
issues.push({
|
|
94
|
+
path: `features.${name}`,
|
|
95
|
+
message: `must be true, false, or an options object -- got ${typeOf(setting)}`,
|
|
96
|
+
})
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const schema = feature.options ?? {}
|
|
101
|
+
const optionNames = Object.keys(schema)
|
|
102
|
+
for (const [key, value] of Object.entries(setting)) {
|
|
103
|
+
const expected = schema[key]
|
|
104
|
+
if (!expected) {
|
|
105
|
+
issues.push({
|
|
106
|
+
path: `features.${name}.${key}`,
|
|
107
|
+
message: optionNames.length
|
|
108
|
+
? unknownKey(key, optionNames, "option")
|
|
109
|
+
: `"${name}" takes no options, got "${key}"`,
|
|
110
|
+
})
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const actual = typeOf(value)
|
|
114
|
+
if (actual !== expected) {
|
|
115
|
+
issues.push({
|
|
116
|
+
path: `features.${name}.${key}`,
|
|
117
|
+
message: `expected ${expected}, got ${actual}`,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return issues
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One-line inventory of what this plugin just added to the session.
|
|
128
|
+
*
|
|
129
|
+
* Not about context cost -- the full tool surface is only ~800 tokens. It is about
|
|
130
|
+
* capability: installing overclock hands the agent background shell execution and
|
|
131
|
+
* recurring scheduling, and that should not be something a user discovers by accident.
|
|
132
|
+
*/
|
|
133
|
+
export function summarise(enabled: readonly FeatureModule[], skipped: readonly string[]): string {
|
|
134
|
+
const toolCount = enabled.reduce((n, f) => n + (f.tools?.length ?? 0), 0)
|
|
135
|
+
const parts = enabled.map((f) => {
|
|
136
|
+
const tools = f.tools?.length ? ` (${f.tools.join(", ")})` : ""
|
|
137
|
+
return `${f.name}${tools}`
|
|
138
|
+
})
|
|
139
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
140
|
+
let line = `${plural(enabled.length, "module")}, ${plural(toolCount, "tool")}: ${parts.join(" · ")}`
|
|
141
|
+
if (skipped.length) line += ` | skipped: ${skipped.join(", ")}`
|
|
142
|
+
return line
|
|
143
|
+
}
|