opencode-overclock 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +206 -111
  2. package/package.json +2 -2
  3. package/src/bridge.ts +1 -0
  4. package/src/buddy/companion.ts +104 -5
  5. package/src/buddy/sprites.ts +4 -4
  6. package/src/buddy/tui.ts +175 -65
  7. package/src/core/bridge.ts +34 -0
  8. package/src/core/lifecycle.ts +53 -0
  9. package/src/core/policy.ts +128 -0
  10. package/src/core/summary.ts +33 -0
  11. package/src/core/types.ts +164 -0
  12. package/src/features/buddy.ts +1 -2
  13. package/src/features/guard.ts +168 -30
  14. package/src/features/index.ts +6 -4
  15. package/src/features/recovery.ts +143 -0
  16. package/src/features/sched.ts +147 -89
  17. package/src/features/tasks.ts +52 -18
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +96 -67
  21. package/src/lib/busy.ts +1 -25
  22. package/src/lib/exec.ts +7 -0
  23. package/src/lib/inject.ts +10 -56
  24. package/src/lib/mirror.ts +13 -0
  25. package/src/lib/probe.ts +1 -15
  26. package/src/lib/state.ts +10 -39
  27. package/src/lib/tmux.ts +1 -0
  28. package/src/lib/ui.ts +208 -0
  29. package/src/merge.ts +2 -66
  30. package/src/platform/probe.ts +25 -0
  31. package/src/platform/process/exec.ts +76 -0
  32. package/src/platform/process/tmux.ts +60 -0
  33. package/src/platform/session/busy.ts +33 -0
  34. package/src/platform/session/inject.ts +82 -0
  35. package/src/platform/session/notify.ts +20 -0
  36. package/src/platform/storage/state.ts +77 -0
  37. package/src/platform/storage/store.ts +61 -0
  38. package/src/summary.ts +1 -0
  39. package/src/tools.ts +8 -244
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -73
  42. package/src/v2/context.ts +470 -0
  43. package/src/v2/host.ts +117 -0
  44. package/src/v2/loader.ts +150 -0
  45. package/src/buddy/reactions.ts +0 -41
  46. package/src/buddy/types.ts +0 -30
  47. package/src/config.ts +0 -19
  48. package/src/features/checkpoints.ts +0 -128
  49. package/src/features/sandbox.ts +0 -104
  50. package/src/validate.ts +0 -197
@@ -1,27 +1,9 @@
1
- import { tool } from "@opencode-ai/plugin"
2
1
  import type { FeatureModule } from "../types.ts"
3
2
  import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
3
+ import { usageStore, type DayBucket, type UsageState, type UsageTokens } from "../lib/mirror.ts"
4
4
 
5
- const z = tool.schema
6
-
7
- export interface UsageTokens {
8
- input: number
9
- output: number
10
- reasoning: number
11
- cacheRead: number
12
- cacheWrite: number
13
- }
14
-
15
- export interface DayBucket {
16
- cost: number
17
- tokens: UsageTokens
18
- messages: number
19
- seen: string[]
20
- }
21
-
22
- export interface UsageState {
23
- days: Record<string, DayBucket>
24
- }
5
+ // Declared in platform/storage/store.ts so the TUI can inspect usage data without importing this feature module.
6
+ export type { UsageTokens, DayBucket, UsageState } from "../lib/mirror.ts"
25
7
 
26
8
  export interface SessionUsage {
27
9
  sessionID: string
@@ -99,27 +81,23 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
99
81
  pruneOldDays(state, Date.now())
100
82
  }
101
83
 
84
+ function extractTokens(t: any): UsageTokens {
85
+ return {
86
+ input: t?.input ?? 0,
87
+ output: t?.output ?? 0,
88
+ reasoning: t?.reasoning ?? 0,
89
+ cacheRead: t?.cache?.read ?? 0,
90
+ cacheWrite: t?.cache?.write ?? 0,
91
+ }
92
+ }
93
+
102
94
  function onEvent(event: { type: string; properties?: unknown }): void {
103
95
  try {
104
96
  if (event.type !== "message.updated") return
105
- const info = (event.properties as { info?: unknown } | undefined)?.info as
106
- | {
107
- id?: string
108
- role?: string
109
- sessionID?: string
110
- time?: { created?: number; completed?: number }
111
- cost?: number
112
- tokens?: {
113
- input?: number
114
- output?: number
115
- reasoning?: number
116
- cache?: { read?: number; write?: number }
117
- }
118
- }
119
- | undefined
120
- if (!info || info.role !== "assistant") return
121
- if (!info.time?.completed) return
97
+ const info = (event.properties as any)?.info
98
+ if (!info || info.role !== "assistant" || !info.time?.completed) return
122
99
  if (typeof info.id !== "string" || typeof info.sessionID !== "string") return
100
+
123
101
  const created = info.time.created ?? info.time.completed
124
102
  const day = dayKey(created)
125
103
  const bucket = state.days[day] ?? emptyBucket()
@@ -128,20 +106,15 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
128
106
  bucket.seen.push(info.id)
129
107
 
130
108
  const cost = info.cost ?? 0
131
- const t = info.tokens ?? {}
132
- const input = t.input ?? 0
133
- const output = t.output ?? 0
134
- const reasoning = t.reasoning ?? 0
135
- const cacheRead = t.cache?.read ?? 0
136
- const cacheWrite = t.cache?.write ?? 0
109
+ const tok = extractTokens(info.tokens)
137
110
 
138
111
  bucket.cost += cost
139
112
  bucket.messages += 1
140
- bucket.tokens.input += input
141
- bucket.tokens.output += output
142
- bucket.tokens.reasoning += reasoning
143
- bucket.tokens.cacheRead += cacheRead
144
- bucket.tokens.cacheWrite += cacheWrite
113
+ bucket.tokens.input += tok.input
114
+ bucket.tokens.output += tok.output
115
+ bucket.tokens.reasoning += tok.reasoning
116
+ bucket.tokens.cacheRead += tok.cacheRead
117
+ bucket.tokens.cacheWrite += tok.cacheWrite
145
118
 
146
119
  const sess = sessions.get(info.sessionID) ?? {
147
120
  sessionID: info.sessionID,
@@ -149,8 +122,8 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
149
122
  tokens: { input: 0, output: 0 },
150
123
  }
151
124
  sess.cost += cost
152
- sess.tokens.input += input
153
- sess.tokens.output += output
125
+ sess.tokens.input += tok.input
126
+ sess.tokens.output += tok.output
154
127
  sessions.set(info.sessionID, sess)
155
128
 
156
129
  scheduleFlush()
@@ -236,11 +209,11 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
236
209
  */
237
210
  export const usage: FeatureModule = {
238
211
  name: "usage",
239
- tools: ["usage_report"],
212
+ tools: [],
240
213
  defaultEnabled: true,
241
214
  async init(ctx) {
242
215
  const dir = await ensureStateDir(ctx.directory)
243
- const tracker = createUsageTracker({ statePath: `${dir}/usage.json` })
216
+ const tracker = createUsageTracker({ statePath: usageStore.path(ctx.directory) })
244
217
  await tracker.load()
245
218
 
246
219
  return {
@@ -254,18 +227,6 @@ export const usage: FeatureModule = {
254
227
  console.warn(`[overclock] usage: event handling failed: ${e}`)
255
228
  }
256
229
  },
257
- tool: {
258
- usage_report: tool({
259
- description:
260
- "Report cost/token usage for the last N days (default 7) plus today's per-session breakdown.",
261
- args: {
262
- days: z.number().optional().describe("number of days to report, default 7"),
263
- },
264
- async execute(args) {
265
- return tracker.report(args.days ?? 7)
266
- },
267
- }),
268
- },
269
230
  }
270
231
  },
271
232
  }
package/src/index.ts CHANGED
@@ -1,85 +1,114 @@
1
- import type { Hooks, Plugin } from "@opencode-ai/plugin"
1
+ import type { Hooks } from "@opencode-ai/plugin"
2
2
  import { features } from "./features/index.ts"
3
- import { loadConfig } from "./config.ts"
4
- import { mergeHooks } from "./merge.ts"
3
+ import { mergeHooks } from "./core/lifecycle.ts"
4
+ import { resolveToolPolicy } from "./core/policy.ts"
5
+ import { summarise } from "./core/summary.ts"
6
+ import { createHybridPlugin, type HybridPlugin } from "./core/bridge.ts"
7
+ import type { FeatureModule, OverclockOptions, SharedDeps } from "./core/types.ts"
5
8
  import { missingSurfaces } from "./lib/probe.ts"
6
9
  import { toast } from "./lib/inject.ts"
7
10
  import { firstRun } from "./lib/state.ts"
8
- import { validateConfig, summarise } from "./validate.ts"
9
11
  import { createBusyTracker } from "./lib/busy.ts"
10
- import { EMPTY_POLICY, resolveToolPolicy, type ToolPolicy } from "./tools.ts"
11
- import type { FeatureModule, SharedDeps } from "./types.ts"
12
+ import { createV2Host } from "./v2/host.ts"
13
+
14
+ function featureOptions(
15
+ options: OverclockOptions,
16
+ feature: FeatureModule,
17
+ ): Record<string, unknown> | null {
18
+ const setting = options[feature.name] ?? options.features?.[feature.name] ?? feature.defaultEnabled
19
+ if (setting === false) return null
20
+ return typeof setting === "object" && setting !== null ? (setting as Record<string, unknown>) : {}
21
+ }
12
22
 
13
23
  /**
14
- * Entry. Load config -> probe surfaces -> init enabled modules -> merge hooks.
15
- * Module crash or missing SDK surface (upstream drift) -> skip module, plugin survive.
24
+ * Entry. Read options -> probe surfaces -> init enabled modules -> merge hooks.
25
+ * Employs createHybridPlugin so the plugin is runnable on both V1 and V2 OpenCode harnesses.
16
26
  */
17
- export const Overclock: Plugin = async (ctx) => {
18
- const config = await loadConfig(ctx.directory)
27
+ export const Overclock: HybridPlugin<OverclockOptions> = createHybridPlugin<OverclockOptions>({
28
+ id: "overclock",
29
+
30
+ /** V1 lifecycle: tools, execution interception, event bus hooks */
31
+ server: async (ctx, pluginOptions) => {
32
+ const options = (pluginOptions ?? {}) as OverclockOptions
33
+ const { policy, issues } = resolveToolPolicy(options, features)
34
+
35
+ const shared: SharedDeps = {
36
+ busy: createBusyTracker(),
37
+ toolName: (declared) => policy.rename[declared] ?? declared,
38
+ }
39
+ const parts: Partial<Hooks>[] = [{ event: async ({ event }) => shared.busy.onEvent(event) }]
40
+ const skipped: string[] = []
41
+ const enabled: FeatureModule[] = []
42
+
43
+ for (const feature of features) {
44
+ const opts = featureOptions(options, feature)
45
+ if (opts === null) continue
19
46
 
20
- // Collected now, reported once the tool policy is known so a single pass covers both.
21
- const issues = validateConfig(config, features)
47
+ const missing = missingSurfaces(ctx.client, feature.requires ?? [])
48
+ if (missing.length) {
49
+ console.warn(
50
+ `[overclock] ${feature.name} disabled: client lacks ${missing.join(", ")} (upstream drift?)`,
51
+ )
52
+ skipped.push(feature.name)
53
+ continue
54
+ }
22
55
 
23
- // Resolved after the init loop, against the modules that actually loaded -- warning about a
24
- // tool belonging to a disabled feature would be noise. Modules only call `toolName` at
25
- // runtime (a hook or timer, long after init), so reading it through this binding is safe.
26
- let policy: ToolPolicy = EMPTY_POLICY
27
- const shared: SharedDeps = {
28
- busy: createBusyTracker(),
29
- toolName: (declared) => policy.rename[declared] ?? declared,
30
- }
31
- // First part, so the tracker is current before any module's own event hook reads it.
32
- const parts: Partial<Hooks>[] = [{ event: async ({ event }) => shared.busy.onEvent(event) }]
33
- const skipped: string[] = []
34
- const enabled: FeatureModule[] = []
56
+ try {
57
+ parts.push(await feature.init(ctx, opts, shared))
58
+ enabled.push(feature)
59
+ } catch (e) {
60
+ console.warn(`[overclock] feature ${feature.name} failed init: ${e}`)
61
+ }
62
+ }
35
63
 
36
- for (const feature of features) {
37
- const setting = config.features?.[feature.name] ?? feature.defaultEnabled
38
- if (setting === false) continue
39
- const missing = missingSurfaces(ctx.client, feature.requires ?? [])
40
- if (missing.length) {
41
- console.warn(
42
- `[overclock] ${feature.name} disabled: client lacks ${missing.join(", ")} (upstream drift?)`,
64
+ if (Array.isArray(options.plugins) && options.plugins.length > 0) {
65
+ const v2Host = createV2Host(ctx, options)
66
+ const loadedV2 = await v2Host.loadPlugins(options.plugins)
67
+ if (loadedV2.length > 0) {
68
+ console.warn(`[overclock] loaded ${loadedV2.length} v2 plugin(s): ${loadedV2.join(", ")}`)
69
+ }
70
+ parts.push(v2Host.createHooks())
71
+ }
72
+
73
+ for (const issue of issues) {
74
+ console.warn(`[overclock] config: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`)
75
+ }
76
+ if (issues.length) {
77
+ void toast(
78
+ ctx.client,
79
+ `overclock: ${issues.length} config issue${issues.length > 1 ? "s" : ""} (see logs)`,
80
+ "warning",
43
81
  )
44
- skipped.push(feature.name)
45
- continue
46
82
  }
47
- const options = typeof setting === "object" ? setting : {}
48
- try {
49
- parts.push(await feature.init(ctx, options, shared))
50
- enabled.push(feature)
51
- } catch (e) {
52
- console.warn(`[overclock] feature ${feature.name} failed init: ${e}`)
83
+
84
+ const summary = summarise(enabled, skipped, policy)
85
+ console.warn(`[overclock] ${summary}`)
86
+ if (await firstRun(ctx.directory)) {
87
+ void toast(ctx.client, `overclock active: ${summary}`, "info")
53
88
  }
54
- }
55
89
 
56
- const resolved = resolveToolPolicy(config, enabled)
57
- policy = resolved.policy
58
- issues.push(...resolved.issues)
90
+ if (skipped.length) {
91
+ void toast(ctx.client, `overclock: ${skipped.join(", ")} disabled (SDK drift)`, "warning")
92
+ }
93
+ return mergeHooks(parts, policy)
94
+ },
59
95
 
60
- // A mistyped key is otherwise a silent no-op -- the feature runs with defaults and the
61
- // user believes their setting took effect. Warn, never throw: bad config degrades to
62
- // defaults rather than taking the plugin down.
63
- for (const issue of issues) {
64
- console.warn(`[overclock] config: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`)
65
- }
66
- if (issues.length) {
67
- void toast(
68
- ctx.client,
69
- `overclock: ${issues.length} config issue${issues.length > 1 ? "s" : ""} (see logs)`,
70
- "warning",
71
- )
72
- }
96
+ /** V2 lifecycle: domain transforms (agents, commands, catalog, aisdk) */
97
+ setup: async (v2Context, pluginOptions) => {
98
+ const options = (pluginOptions ?? {}) as OverclockOptions
73
99
 
74
- // Say what was added. This plugin grants the agent background shell execution and
75
- // recurring scheduling; that should not be discovered by accident. Log every start
76
- // (stderr, invisible unless you look), toast only on a project's first run.
77
- console.warn(`[overclock] ${summarise(enabled, skipped, policy)}`)
78
- if (await firstRun(ctx.directory)) {
79
- void toast(ctx.client, `overclock active: ${summarise(enabled, skipped, policy)}`, "info")
80
- }
100
+ for (const feature of features) {
101
+ if (!feature.setup) continue
102
+ const opts = featureOptions(options, feature)
103
+ if (opts === null) continue
81
104
 
82
- if (skipped.length)
83
- void toast(ctx.client, `overclock: ${skipped.join(", ")} disabled (SDK drift)`, "warning")
84
- return mergeHooks(parts, policy)
85
- }
105
+ try {
106
+ await feature.setup(v2Context, opts)
107
+ } catch (e) {
108
+ console.warn(`[overclock] feature ${feature.name} failed v2 setup: ${e}`)
109
+ }
110
+ }
111
+ },
112
+ })
113
+
114
+ export default Overclock
package/src/lib/busy.ts CHANGED
@@ -1,25 +1 @@
1
- /**
2
- * Session busy tracking via `session.status` (doc-preferred; `session.idle` deprecated).
3
- * Unknown/never-firing statuses degrade gracefully: empty set = nothing reported busy.
4
- */
5
- export interface BusyTracker {
6
- /** feed bus events */
7
- onEvent(event: { type: string; properties?: unknown }): void
8
- isBusy(sessionID: string): boolean
9
- }
10
-
11
- export function createBusyTracker(): BusyTracker {
12
- const busy = new Set<string>()
13
- return {
14
- onEvent(event) {
15
- const p = (event.properties ?? {}) as { sessionID?: string; status?: { type?: string } }
16
- if (!p.sessionID) return
17
- if (event.type === "session.status") {
18
- p.status?.type === "idle" ? busy.delete(p.sessionID) : busy.add(p.sessionID)
19
- } else if (event.type === "session.idle" || event.type === "session.deleted") {
20
- busy.delete(p.sessionID)
21
- }
22
- },
23
- isBusy: (id) => busy.has(id),
24
- }
25
- }
1
+ export { createBusyTracker, type BusyTracker } from "../platform/session/busy.ts"
@@ -0,0 +1,7 @@
1
+ export {
2
+ NON_INTERACTIVE_ENV,
3
+ execBash,
4
+ shellQuote,
5
+ type ExecBashOptions,
6
+ type ExecBashResult,
7
+ } from "../platform/process/exec.ts"
package/src/lib/inject.ts CHANGED
@@ -1,56 +1,10 @@
1
- import type { PluginInput } from "@opencode-ai/plugin"
2
-
3
- type Client = PluginInput["client"]
4
- type ModelRef = { providerID: string; modelID: string }
5
-
6
- /**
7
- * Session's active model = model of last assistant message.
8
- * Without this, promptAsync falls back to config default model -> injected turns
9
- * run on the wrong model (and pile up QUEUED behind a hung default).
10
- */
11
- export async function sessionModel(client: Client, sessionID: string): Promise<ModelRef | undefined> {
12
- try {
13
- const res = await client.session.messages({ path: { id: sessionID } })
14
- const msgs = res.data ?? []
15
- for (let i = msgs.length - 1; i >= 0; i--) {
16
- const info = msgs[i]?.info
17
- if (info?.role === "assistant" && info.modelID) {
18
- return { providerID: info.providerID, modelID: info.modelID }
19
- }
20
- }
21
- } catch (e) {
22
- console.warn(`[overclock] sessionModel lookup failed (${sessionID}): ${e}`)
23
- }
24
- return undefined
25
- }
26
-
27
- /**
28
- * Re-entry: push text into session as user prompt, on the session's own model.
29
- * promptAsync = fire-and-forget, server queues if busy. Failure -> warn, never throw.
30
- */
31
- export async function inject(client: Client, sessionID: string, text: string): Promise<boolean> {
32
- try {
33
- const model = await sessionModel(client, sessionID)
34
- await client.session.promptAsync({
35
- path: { id: sessionID },
36
- body: { parts: [{ type: "text", text }], ...(model ? { model } : {}) },
37
- })
38
- return true
39
- } catch (e) {
40
- console.warn(`[overclock] inject failed (session ${sessionID}): ${e}`)
41
- return false
42
- }
43
- }
44
-
45
- /** TUI toast, best-effort (headless server -> no TUI, swallow). */
46
- export async function toast(
47
- client: Client,
48
- message: string,
49
- variant: "info" | "success" | "warning" | "error" = "info",
50
- ): Promise<void> {
51
- try {
52
- await client.tui.showToast({ body: { message, variant } })
53
- } catch {
54
- // no TUI attached
55
- }
56
- }
1
+ export {
2
+ inject,
3
+ sessionContext,
4
+ sessionModel,
5
+ toast,
6
+ type InjectOptions,
7
+ type ModelRef,
8
+ type SessionContext,
9
+ type ToastVariant,
10
+ } from "../platform/session/inject.ts"
@@ -0,0 +1,13 @@
1
+ export {
2
+ scheduleStore,
3
+ taskStore,
4
+ usageStore,
5
+ type DayBucket,
6
+ type DayBucketView,
7
+ type ScheduleEntry,
8
+ type ScheduleEntryView,
9
+ type TaskMirrorEntry,
10
+ type UsageState,
11
+ type UsageStateView,
12
+ type UsageTokens,
13
+ } from "../platform/storage/store.ts"
package/src/lib/probe.ts CHANGED
@@ -1,15 +1 @@
1
- /**
2
- * Runtime surface probe (doc: "probe for the surfaces we depend on ... instead of
3
- * failing silently when upstream moves"). Dot-paths resolved against the SDK client;
4
- * leaf must be a function.
5
- */
6
- export function missingSurfaces(client: unknown, paths: string[]): string[] {
7
- return paths.filter((path) => {
8
- let node: unknown = client
9
- for (const key of path.split(".")) {
10
- if (node == null || typeof node !== "object") return true
11
- node = (node as Record<string, unknown>)[key]
12
- }
13
- return typeof node !== "function"
14
- })
15
- }
1
+ export { missingSurfaces } from "../platform/probe.ts"
package/src/lib/state.ts CHANGED
@@ -1,39 +1,10 @@
1
- import { mkdir } from "node:fs/promises"
2
-
3
- /** State root: <project>/.opencode/overclock/[sub]. Creates if missing. */
4
- export async function ensureStateDir(directory: string, sub?: string): Promise<string> {
5
- const dir = `${directory}/.opencode/overclock${sub ? `/${sub}` : ""}`
6
- await mkdir(dir, { recursive: true })
7
- return dir
8
- }
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
-
22
- export async function readJson<T>(path: string, fallback: T): Promise<T> {
23
- const file = Bun.file(path)
24
- if (!(await file.exists())) return fallback
25
- try {
26
- return (await file.json()) as T
27
- } catch {
28
- return fallback
29
- }
30
- }
31
-
32
- export async function writeJson(path: string, value: unknown): Promise<void> {
33
- await Bun.write(path, JSON.stringify(value, null, 2))
34
- }
35
-
36
- /** POSIX single-quote escape. */
37
- export function shellQuote(s: string): string {
38
- return `'${s.replace(/'/g, `'\\''`)}'`
39
- }
1
+ export {
2
+ defineStore,
3
+ ensureStateDir,
4
+ firstRun,
5
+ readJson,
6
+ shellQuote,
7
+ stateDir,
8
+ writeJson,
9
+ type Store,
10
+ } from "../platform/storage/state.ts"
@@ -0,0 +1 @@
1
+ export { isInsideTmux, spawnTaskPane, type TmuxPane } from "../platform/process/tmux.ts"