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 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.1.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
+ }
@@ -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) {
@@ -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
- if (sessionID)
147
- await deps.onInject(sessionID, failurePayload(hook.name, result.code, result.combined))
148
- } else if (hook.onSuccess === "notify") {
149
- await deps.onNotify(hook.name)
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 {
@@ -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]
@@ -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 = {
@@ -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
  },
@@ -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) {
@@ -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
- const parts = []
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
- init(ctx: PluginInput, options: Record<string, unknown>): Promise<Partial<Hooks>>
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
  }
@@ -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
+ }