opencode-overclock 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +285 -81
  2. package/package.json +3 -3
  3. package/src/bridge.ts +1 -0
  4. package/src/buddy/companion.ts +104 -5
  5. package/src/buddy/sprites.ts +4 -4
  6. package/src/buddy/tui.ts +175 -65
  7. package/src/core/bridge.ts +34 -0
  8. package/src/core/lifecycle.ts +53 -0
  9. package/src/core/policy.ts +128 -0
  10. package/src/core/summary.ts +33 -0
  11. package/src/core/types.ts +164 -0
  12. package/src/features/buddy.ts +1 -2
  13. package/src/features/guard.ts +168 -30
  14. package/src/features/index.ts +6 -4
  15. package/src/features/recovery.ts +143 -0
  16. package/src/features/sched.ts +147 -89
  17. package/src/features/tasks.ts +54 -20
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +98 -55
  21. package/src/lib/busy.ts +1 -25
  22. package/src/lib/exec.ts +7 -0
  23. package/src/lib/inject.ts +10 -56
  24. package/src/lib/mirror.ts +13 -0
  25. package/src/lib/probe.ts +1 -15
  26. package/src/lib/state.ts +10 -39
  27. package/src/lib/tmux.ts +1 -0
  28. package/src/lib/ui.ts +208 -0
  29. package/src/merge.ts +2 -35
  30. package/src/platform/probe.ts +25 -0
  31. package/src/platform/process/exec.ts +76 -0
  32. package/src/platform/process/tmux.ts +60 -0
  33. package/src/platform/session/busy.ts +33 -0
  34. package/src/platform/session/inject.ts +82 -0
  35. package/src/platform/session/notify.ts +20 -0
  36. package/src/platform/storage/state.ts +77 -0
  37. package/src/platform/storage/store.ts +61 -0
  38. package/src/summary.ts +1 -0
  39. package/src/tools.ts +8 -0
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -39
  42. package/src/v2/context.ts +470 -0
  43. package/src/v2/host.ts +117 -0
  44. package/src/v2/loader.ts +150 -0
  45. package/src/buddy/reactions.ts +0 -41
  46. package/src/buddy/types.ts +0 -30
  47. package/src/config.ts +0 -19
  48. package/src/features/checkpoints.ts +0 -128
  49. package/src/features/sandbox.ts +0 -104
  50. package/src/validate.ts +0 -143
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @fileoverview OpenCode V2 Plugin Resolution and Loading
3
+ *
4
+ * Provides resolution, dynamic importation, and execution of V2 plugins
5
+ * on top of a synthetic V2 `PluginContext`.
6
+ *
7
+ * Supported plugin formats:
8
+ * - Direct instances: `{ id, setup(context) }` or `{ id, effect(context) }`
9
+ * - File paths: `./plugins/custom.ts`, `/absolute/path/plugin.js`, `file:///...`
10
+ * - Npm packages: bare module specifiers resolved via node/bun module resolution
11
+ * - Tuples: `[specifier, pluginOptions]` for supplying per-plugin options
12
+ */
13
+
14
+ import { resolve, isAbsolute } from "path"
15
+ import { pathToFileURL, fileURLToPath } from "url"
16
+ import type { Plugin as V2Plugin, PluginOptions } from "@opencode-ai/plugin/v2/promise"
17
+ import type { Disposer, V2ContextHandle } from "./context.ts"
18
+
19
+ /**
20
+ * Union of accepted V2 plugin declaration formats:
21
+ * - String specifier: file path or package name
22
+ * - Tuple: `[specifier, options]`
23
+ * - Plugin instance conforming to V2 interface
24
+ * - Wrapper object: `{ plugin, options }`
25
+ */
26
+ export type V2PluginSpec =
27
+ string | [string, PluginOptions] | V2Plugin | { plugin: V2Plugin; options?: PluginOptions }
28
+
29
+ /**
30
+ * Validates whether an unknown value conforms to the OpenCode V2 plugin contract:
31
+ * requires non-empty string `id`, and either an async `setup` method or an `effect` function.
32
+ */
33
+ export function isV2Plugin(value: unknown): value is V2Plugin {
34
+ if (!value || typeof value !== "object") return false
35
+ const p = value as Record<string, unknown>
36
+ if (typeof p.id !== "string" || !p.id.trim()) return false
37
+ return typeof p.setup === "function" || typeof (p as any).effect === "function"
38
+ }
39
+
40
+ /**
41
+ * Resolves a file path or URL specifier against the workspace directory.
42
+ * Preserves bare package names for standard Node/Bun module resolution.
43
+ */
44
+ export function resolvePluginPath(spec: string, baseDir: string): string {
45
+ if (spec.startsWith("file://")) return fileURLToPath(spec)
46
+ if (isAbsolute(spec)) return spec
47
+ if (spec.startsWith(".")) return resolve(baseDir, spec)
48
+ return spec
49
+ }
50
+
51
+ /**
52
+ * Resolves a V2 plugin specifier into a concrete V2Plugin object and its associated options.
53
+ * Dynamically imports file paths or npm packages if necessary.
54
+ */
55
+ export async function resolveV2Plugin(
56
+ spec: V2PluginSpec,
57
+ baseDir: string,
58
+ ): Promise<{ plugin: V2Plugin; options: PluginOptions } | null> {
59
+ // Case 1: Already an instantiated V2Plugin object
60
+ if (isV2Plugin(spec)) {
61
+ return { plugin: spec, options: {} }
62
+ }
63
+
64
+ // Case 2: Wrapped { plugin, options } object
65
+ if (
66
+ typeof spec === "object" &&
67
+ spec !== null &&
68
+ "plugin" in spec &&
69
+ isV2Plugin((spec as any).plugin)
70
+ ) {
71
+ return {
72
+ plugin: (spec as any).plugin,
73
+ options: (spec as any).options ?? {},
74
+ }
75
+ }
76
+
77
+ // Case 3: Specifier string or [string, options] tuple
78
+ let moduleSpec: string
79
+ let options: PluginOptions = {}
80
+
81
+ if (Array.isArray(spec)) {
82
+ moduleSpec = spec[0]
83
+ options = spec[1] ?? {}
84
+ } else if (typeof spec === "string") {
85
+ moduleSpec = spec
86
+ } else {
87
+ return null
88
+ }
89
+
90
+ const resolved = resolvePluginPath(moduleSpec, baseDir)
91
+ const importTarget = resolved.startsWith("/") ? pathToFileURL(resolved).href : resolved
92
+
93
+ try {
94
+ const mod = await import(importTarget)
95
+ const candidate = mod?.default ?? mod
96
+ if (isV2Plugin(candidate)) {
97
+ return { plugin: candidate, options }
98
+ }
99
+ // Check named exports for a V2 plugin definition
100
+ for (const val of Object.values(mod)) {
101
+ if (isV2Plugin(val)) {
102
+ return { plugin: val, options }
103
+ }
104
+ }
105
+ console.warn(
106
+ `[overclock] v2: module '${moduleSpec}' does not export a valid V2 plugin ({ id, setup/effect })`,
107
+ )
108
+ return null
109
+ } catch (e) {
110
+ console.warn(`[overclock] v2: failed to import plugin '${moduleSpec}': ${e}`)
111
+ return null
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Loads and initializes a V2 plugin against the synthetic context.
117
+ * Creates a scoped context, handles Effect vs Promise lifecycle, and tracks disposers.
118
+ * Returns the plugin ID if loaded successfully, or null on error.
119
+ */
120
+ export async function loadV2Plugin(
121
+ spec: V2PluginSpec,
122
+ baseDir: string,
123
+ handle: V2ContextHandle,
124
+ ): Promise<string | null> {
125
+ const resolved = await resolveV2Plugin(spec, baseDir)
126
+ if (!resolved) return null
127
+
128
+ const { plugin, options } = resolved
129
+ const pluginDisposers = new Set<Disposer>()
130
+ const scopedCtx = handle.scopedContext(options, pluginDisposers)
131
+
132
+ handle.state.activePlugins.set(plugin.id, {
133
+ plugin,
134
+ disposers: pluginDisposers,
135
+ })
136
+
137
+ try {
138
+ if (typeof (plugin as any).effect === "function") {
139
+ const { runPromise } = await import("effect/Effect")
140
+ await runPromise((plugin as any).effect(scopedCtx))
141
+ } else if (typeof plugin.setup === "function") {
142
+ await plugin.setup(scopedCtx)
143
+ }
144
+ return plugin.id
145
+ } catch (e) {
146
+ console.warn(`[overclock] v2: plugin '${plugin.id}' failed during setup: ${e}`)
147
+ await handle.context.plugin.remove(plugin.id)
148
+ return null
149
+ }
150
+ }
@@ -1,41 +0,0 @@
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
- }
@@ -1,30 +0,0 @@
1
- export const SPECIES = [
2
- "cat",
3
- "dog",
4
- "bunny",
5
- "owl",
6
- "bat",
7
- "penguin",
8
- "duck",
9
- "ghost",
10
- "slime",
11
- ] as const
12
- export type Species = (typeof SPECIES)[number]
13
-
14
- export type Rarity = "common" | "uncommon" | "rare" | "legendary"
15
-
16
- export interface CompanionStats {
17
- patience: number
18
- chaos: number
19
- wisdom: number
20
- snark: number
21
- }
22
-
23
- /** Persisted in TUI kv. Rolled once at hatch, then stable for the life of the install. */
24
- export interface Companion {
25
- species: Species
26
- rarity: Rarity
27
- name: string
28
- stats: CompanionStats
29
- hatchedAt: number
30
- }
package/src/config.ts DELETED
@@ -1,19 +0,0 @@
1
- import type { OverclockConfig } from "./types.ts"
2
-
3
- const CONFIG_PATHS = [".opencode/overclock.json", "overclock.json"]
4
-
5
- /** Load plugin config from project dir. Missing file -> {} (all defaults). */
6
- export async function loadConfig(directory: string): Promise<OverclockConfig> {
7
- for (const rel of CONFIG_PATHS) {
8
- const file = Bun.file(`${directory}/${rel}`)
9
- if (await file.exists()) {
10
- try {
11
- return (await file.json()) as OverclockConfig
12
- } catch (e) {
13
- console.warn(`[overclock] bad config ${rel}: ${e}`)
14
- return {}
15
- }
16
- }
17
- }
18
- return {}
19
- }
@@ -1,128 +0,0 @@
1
- import { tool, type PluginInput } from "@opencode-ai/plugin"
2
- import type { FeatureModule } from "../types.ts"
3
-
4
- const z = tool.schema
5
-
6
- type Client = PluginInput["client"]
7
-
8
- interface MessagePart {
9
- type: string
10
- text?: string
11
- }
12
-
13
- interface MessageEntry {
14
- info: {
15
- id: string
16
- role: string
17
- time?: { created?: number }
18
- }
19
- parts: MessagePart[]
20
- }
21
-
22
- const collapse = (s: string) => s.replace(/\s+/g, " ").trim()
23
-
24
- /** Exported for tests: revert/unrevert/list core, decoupled from plugin ctx. */
25
- export interface Checkpoints {
26
- list(sessionID: string): Promise<string>
27
- revert(sessionID: string, messageID: string): Promise<string>
28
- restore(sessionID: string): Promise<string>
29
- }
30
-
31
- export function createCheckpoints(client: Client): Checkpoints {
32
- async function list(sessionID: string): Promise<string> {
33
- try {
34
- const res = await client.session.messages({ path: { id: sessionID } })
35
- const msgs = ((res.data ?? []) as MessageEntry[]).filter((m) => m.info.role === "user")
36
- if (!msgs.length) return "no checkpoints"
37
- return msgs
38
- .map((m) => {
39
- const time = m.info.time?.created
40
- ? new Date(m.info.time.created).toISOString()
41
- : "unknown time"
42
- const text = m.parts.find((p) => p.type === "text" && typeof p.text === "string")?.text ?? ""
43
- const preview = collapse(text).slice(0, 60)
44
- return `${m.info.id} ${time} ${preview}`
45
- })
46
- .join("\n")
47
- } catch (e) {
48
- console.warn(`[overclock] checkpoints: list failed (session ${sessionID}): ${e}`)
49
- return `error listing checkpoints: ${e}`
50
- }
51
- }
52
-
53
- async function revert(sessionID: string, messageID: string): Promise<string> {
54
- try {
55
- await client.session.revert({ path: { id: sessionID }, body: { messageID } })
56
- return `reverted session ${sessionID} to before message ${messageID}`
57
- } catch (e) {
58
- console.warn(
59
- `[overclock] checkpoints: revert failed (session ${sessionID}, message ${messageID}): ${e}`,
60
- )
61
- return `error reverting checkpoint: ${e}`
62
- }
63
- }
64
-
65
- async function restore(sessionID: string): Promise<string> {
66
- try {
67
- await client.session.unrevert({ path: { id: sessionID } })
68
- return `restored session ${sessionID} to latest (undo revert)`
69
- } catch (e) {
70
- console.warn(`[overclock] checkpoints: restore failed (session ${sessionID}): ${e}`)
71
- return `error restoring checkpoint: ${e}`
72
- }
73
- }
74
-
75
- return { list, revert, restore }
76
- }
77
-
78
- /**
79
- * Shadow-git revert tools (map doc: "Checkpoints — none gap"). Wraps native
80
- * session.revert/unrevert around each user message as a revert point.
81
- */
82
- export const checkpoints: FeatureModule = {
83
- name: "checkpoints",
84
- tools: ["checkpoint_list", "checkpoint_revert", "checkpoint_restore"],
85
- defaultEnabled: true,
86
- requires: ["session.revert", "session.unrevert", "session.messages"],
87
- async init(ctx) {
88
- const core = createCheckpoints(ctx.client)
89
-
90
- return {
91
- tool: {
92
- checkpoint_list: tool({
93
- description:
94
- "List user messages of a session as revert points: messageID, time, first 60 chars. Most recent last.",
95
- args: { sessionID: z.string().optional().describe("default: current session") },
96
- async execute(args, tctx) {
97
- return core.list(args.sessionID ?? tctx.sessionID)
98
- },
99
- }),
100
- checkpoint_revert: tool({
101
- description:
102
- "Revert session files + conversation back to before the given message (shadow-git, reversible via checkpoint_restore). Requires user permission.",
103
- args: {
104
- messageID: z.string(),
105
- sessionID: z.string().optional().describe("default: current session"),
106
- },
107
- async execute(args, tctx) {
108
- const sessionID = args.sessionID ?? tctx.sessionID
109
- await tctx.ask({
110
- permission: "checkpoint_revert",
111
- patterns: [args.messageID],
112
- always: [],
113
- metadata: { sessionID, messageID: args.messageID },
114
- })
115
- return core.revert(sessionID, args.messageID)
116
- },
117
- }),
118
- checkpoint_restore: tool({
119
- description: "Undo the most recent checkpoint_revert for a session.",
120
- args: { sessionID: z.string().optional().describe("default: current session") },
121
- async execute(args, tctx) {
122
- return core.restore(args.sessionID ?? tctx.sessionID)
123
- },
124
- }),
125
- },
126
- }
127
- },
128
- }
@@ -1,104 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin"
2
- import type { FeatureModule } from "../types.ts"
3
- import { shellQuote } from "../lib/state.ts"
4
-
5
- const z = tool.schema
6
-
7
- export interface SandboxPolicy {
8
- project: string
9
- net: boolean
10
- }
11
-
12
- /** Pure: wrap shell cmd in bwrap. / ro, project + /tmp rw, net per policy. */
13
- export function wrapCommand(cmd: string, policy: SandboxPolicy): string {
14
- const args = [
15
- "bwrap",
16
- "--ro-bind / /",
17
- "--dev /dev",
18
- "--proc /proc",
19
- `--bind ${shellQuote(policy.project)} ${shellQuote(policy.project)}`,
20
- "--bind /tmp /tmp",
21
- "--die-with-parent",
22
- ]
23
- if (!policy.net) args.push("--unshare-net")
24
- args.push("bash -c", shellQuote(cmd))
25
- return args.join(" ")
26
- }
27
-
28
- /** Functional probe: bwrap present AND userns allowed (WSL2/distros vary). */
29
- export function probeBwrap(): boolean {
30
- try {
31
- const res = Bun.spawnSync([
32
- "bwrap",
33
- "--ro-bind",
34
- "/",
35
- "/",
36
- "--dev",
37
- "/dev",
38
- "--proc",
39
- "/proc",
40
- "true",
41
- ])
42
- return res.exitCode === 0
43
- } catch {
44
- return false
45
- }
46
- }
47
-
48
- /**
49
- * Sandboxed bash via bubblewrap. Rewrites every bash tool call.
50
- * Off by default. No bwrap -> warn once, passthrough.
51
- */
52
- export const sandbox: FeatureModule = {
53
- name: "sandbox",
54
- tools: ["bash_unsandboxed"],
55
- options: { net: "boolean" },
56
- defaultEnabled: false,
57
- async init(ctx, options) {
58
- const policy: SandboxPolicy = {
59
- project: ctx.directory,
60
- net: options.net !== false,
61
- }
62
- const available = probeBwrap()
63
- if (!available) console.warn("[overclock] sandbox: bwrap unavailable/blocked -> passthrough")
64
-
65
- return {
66
- "tool.execute.before": async (input, output) => {
67
- if (!available || input.tool !== "bash") return
68
- const cmd = (output.args as { command?: string }).command
69
- if (typeof cmd !== "string") return
70
- output.args.command = wrapCommand(cmd, policy)
71
- },
72
- tool: {
73
- bash_unsandboxed: tool({
74
- description:
75
- "Run a shell command OUTSIDE the sandbox (full FS write access). Requires user permission. Use only when the sandbox blocks a legitimate operation.",
76
- args: {
77
- command: z.string(),
78
- cwd: z.string().optional(),
79
- },
80
- async execute(args, tctx) {
81
- await tctx.ask({
82
- permission: "bash_unsandboxed",
83
- patterns: [args.command],
84
- always: [],
85
- metadata: { command: args.command },
86
- })
87
- const proc = Bun.spawn(["bash", "-c", args.command], {
88
- cwd: args.cwd ?? tctx.directory,
89
- stdout: "pipe",
90
- stderr: "pipe",
91
- })
92
- const [out, err, code] = await Promise.all([
93
- new Response(proc.stdout).text(),
94
- new Response(proc.stderr).text(),
95
- proc.exited,
96
- ])
97
- const text = (out + (err ? `\nstderr:\n${err}` : "")).slice(0, 30_000)
98
- return `exit ${code}\n${text}`
99
- },
100
- }),
101
- },
102
- }
103
- },
104
- }
package/src/validate.ts DELETED
@@ -1,143 +0,0 @@
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
- }