opencode-overclock 0.1.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.
@@ -0,0 +1,270 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import type { FeatureModule } from "../types.ts"
3
+ import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
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
+ }
25
+
26
+ export interface SessionUsage {
27
+ sessionID: string
28
+ cost: number
29
+ tokens: { input: number; output: number }
30
+ }
31
+
32
+ const RETENTION_DAYS = 60
33
+
34
+ function zeroTokens(): UsageTokens {
35
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
36
+ }
37
+
38
+ function emptyBucket(): DayBucket {
39
+ return { cost: 0, tokens: zeroTokens(), messages: 0, seen: [] }
40
+ }
41
+
42
+ /** Local YYYY-MM-DD from an epoch-ms timestamp. */
43
+ export function dayKey(ms: number): string {
44
+ const d = new Date(ms)
45
+ const y = d.getFullYear()
46
+ const m = String(d.getMonth() + 1).padStart(2, "0")
47
+ const day = String(d.getDate()).padStart(2, "0")
48
+ return `${y}-${m}-${day}`
49
+ }
50
+
51
+ function pruneOldDays(state: UsageState, now: number, retentionDays = RETENTION_DAYS): void {
52
+ const cutoff = new Date(now)
53
+ cutoff.setDate(cutoff.getDate() - retentionDays)
54
+ const cutoffKey = dayKey(cutoff.getTime())
55
+ for (const day of Object.keys(state.days)) {
56
+ if (day < cutoffKey) delete state.days[day]
57
+ }
58
+ }
59
+
60
+ const fmtCost = (n: number) => `$${n.toFixed(4)}`
61
+ const col = (s: string, n: number) => s.padEnd(n)
62
+
63
+ export interface UsageTrackerOpts {
64
+ statePath: string
65
+ debounceMs?: number
66
+ }
67
+
68
+ /** Exported for tests: event-consuming core, decoupled from plugin ctx. */
69
+ export interface UsageTracker {
70
+ load(): Promise<void>
71
+ onEvent(event: { type: string; properties?: unknown }): void
72
+ getState(): UsageState
73
+ getSessionTotals(): SessionUsage[]
74
+ report(days?: number): string
75
+ flush(): Promise<void>
76
+ dispose(): Promise<void>
77
+ }
78
+
79
+ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
80
+ const debounceMs = opts.debounceMs ?? 2000
81
+ let state: UsageState = { days: {} }
82
+ const sessions = new Map<string, SessionUsage>()
83
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
84
+
85
+ function scheduleFlush(): void {
86
+ if (flushTimer) clearTimeout(flushTimer)
87
+ flushTimer = setTimeout(() => {
88
+ flushTimer = undefined
89
+ void flush()
90
+ }, debounceMs)
91
+ }
92
+
93
+ async function flush(): Promise<void> {
94
+ await writeJson(opts.statePath, state)
95
+ }
96
+
97
+ async function load(): Promise<void> {
98
+ state = await readJson<UsageState>(opts.statePath, { days: {} })
99
+ pruneOldDays(state, Date.now())
100
+ }
101
+
102
+ function onEvent(event: { type: string; properties?: unknown }): void {
103
+ try {
104
+ 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
122
+ if (typeof info.id !== "string" || typeof info.sessionID !== "string") return
123
+ const created = info.time.created ?? info.time.completed
124
+ const day = dayKey(created)
125
+ const bucket = state.days[day] ?? emptyBucket()
126
+ state.days[day] = bucket
127
+ if (bucket.seen.includes(info.id)) return
128
+ bucket.seen.push(info.id)
129
+
130
+ 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
137
+
138
+ bucket.cost += cost
139
+ 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
145
+
146
+ const sess = sessions.get(info.sessionID) ?? {
147
+ sessionID: info.sessionID,
148
+ cost: 0,
149
+ tokens: { input: 0, output: 0 },
150
+ }
151
+ sess.cost += cost
152
+ sess.tokens.input += input
153
+ sess.tokens.output += output
154
+ sessions.set(info.sessionID, sess)
155
+
156
+ scheduleFlush()
157
+ } catch (e) {
158
+ console.warn(`[overclock] usage: event handling failed: ${e}`)
159
+ }
160
+ }
161
+
162
+ function report(daysCount = 7): string {
163
+ const lines: string[] = []
164
+ const base = new Date()
165
+ base.setHours(0, 0, 0, 0)
166
+ const dayKeys: string[] = []
167
+ for (let i = daysCount - 1; i >= 0; i--) {
168
+ const d = new Date(base)
169
+ d.setDate(d.getDate() - i)
170
+ dayKeys.push(dayKey(d.getTime()))
171
+ }
172
+
173
+ lines.push(
174
+ col("Date", 12) +
175
+ col("Cost", 10) +
176
+ col("Input", 9) +
177
+ col("Output", 9) +
178
+ col("Reason", 9) +
179
+ col("CacheR", 9) +
180
+ col("CacheW", 9) +
181
+ "Msgs",
182
+ )
183
+ for (const day of dayKeys) {
184
+ const b = state.days[day]
185
+ const t = b?.tokens ?? zeroTokens()
186
+ lines.push(
187
+ col(day, 12) +
188
+ col(fmtCost(b?.cost ?? 0), 10) +
189
+ col(String(t.input), 9) +
190
+ col(String(t.output), 9) +
191
+ col(String(t.reasoning), 9) +
192
+ col(String(t.cacheRead), 9) +
193
+ col(String(t.cacheWrite), 9) +
194
+ String(b?.messages ?? 0),
195
+ )
196
+ }
197
+
198
+ lines.push("")
199
+ lines.push("Today per-session (since plugin start):")
200
+ if (sessions.size === 0) {
201
+ lines.push("(none)")
202
+ } else {
203
+ lines.push(col("Session", 16) + col("Cost", 10) + col("Input", 9) + "Output")
204
+ for (const s of sessions.values()) {
205
+ lines.push(
206
+ col(s.sessionID, 16) +
207
+ col(fmtCost(s.cost), 10) +
208
+ col(String(s.tokens.input), 9) +
209
+ String(s.tokens.output),
210
+ )
211
+ }
212
+ }
213
+ return lines.join("\n")
214
+ }
215
+
216
+ return {
217
+ load,
218
+ onEvent,
219
+ getState: () => state,
220
+ getSessionTotals: () => [...sessions.values()],
221
+ report,
222
+ flush,
223
+ dispose: async () => {
224
+ if (flushTimer) {
225
+ clearTimeout(flushTimer)
226
+ flushTimer = undefined
227
+ }
228
+ await flush()
229
+ },
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Cost/token telemetry from `message.updated` events. Persisted daily buckets +
235
+ * an in-memory per-session aggregate since plugin start.
236
+ */
237
+ export const usage: FeatureModule = {
238
+ name: "usage",
239
+ defaultEnabled: true,
240
+ async init(ctx) {
241
+ const dir = await ensureStateDir(ctx.directory)
242
+ const tracker = createUsageTracker({ statePath: `${dir}/usage.json` })
243
+ await tracker.load()
244
+
245
+ return {
246
+ dispose: async () => {
247
+ await tracker.dispose()
248
+ },
249
+ event: async ({ event }) => {
250
+ try {
251
+ tracker.onEvent(event)
252
+ } catch (e) {
253
+ console.warn(`[overclock] usage: event handling failed: ${e}`)
254
+ }
255
+ },
256
+ tool: {
257
+ usage_report: tool({
258
+ description:
259
+ "Report cost/token usage for the last N days (default 7) plus today's per-session breakdown.",
260
+ args: {
261
+ days: z.number().optional().describe("number of days to report, default 7"),
262
+ },
263
+ async execute(args) {
264
+ return tracker.report(args.days ?? 7)
265
+ },
266
+ }),
267
+ },
268
+ }
269
+ },
270
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { features } from "./features/index.ts"
3
+ import { loadConfig } from "./config.ts"
4
+ import { mergeHooks } from "./merge.ts"
5
+ import { missingSurfaces } from "./lib/probe.ts"
6
+ import { toast } from "./lib/inject.ts"
7
+
8
+ /**
9
+ * Entry. Load config -> probe surfaces -> init enabled modules -> merge hooks.
10
+ * Module crash or missing SDK surface (upstream drift) -> skip module, plugin survive.
11
+ */
12
+ export const Overclock: Plugin = async (ctx) => {
13
+ const config = await loadConfig(ctx.directory)
14
+ const parts = []
15
+ const skipped: string[] = []
16
+
17
+ for (const feature of features) {
18
+ const setting = config.features?.[feature.name] ?? feature.defaultEnabled
19
+ if (setting === false) continue
20
+ const missing = missingSurfaces(ctx.client, feature.requires ?? [])
21
+ if (missing.length) {
22
+ console.warn(
23
+ `[overclock] ${feature.name} disabled: client lacks ${missing.join(", ")} (upstream drift?)`,
24
+ )
25
+ skipped.push(feature.name)
26
+ continue
27
+ }
28
+ const options = typeof setting === "object" ? setting : {}
29
+ try {
30
+ parts.push(await feature.init(ctx, options))
31
+ } catch (e) {
32
+ console.warn(`[overclock] feature ${feature.name} failed init: ${e}`)
33
+ }
34
+ }
35
+
36
+ if (skipped.length)
37
+ void toast(ctx.client, `overclock: ${skipped.join(", ")} disabled (SDK drift)`, "warning")
38
+ return mergeHooks(parts)
39
+ }
@@ -0,0 +1,25 @@
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
+ }
@@ -0,0 +1,56 @@
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
+ }
@@ -0,0 +1,15 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ export async function readJson<T>(path: string, fallback: T): Promise<T> {
11
+ const file = Bun.file(path)
12
+ if (!(await file.exists())) return fallback
13
+ try {
14
+ return (await file.json()) as T
15
+ } catch {
16
+ return fallback
17
+ }
18
+ }
19
+
20
+ export async function writeJson(path: string, value: unknown): Promise<void> {
21
+ await Bun.write(path, JSON.stringify(value, null, 2))
22
+ }
23
+
24
+ /** POSIX single-quote escape. */
25
+ export function shellQuote(s: string): string {
26
+ return `'${s.replace(/'/g, `'\\''`)}'`
27
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Hooks } from "@opencode-ai/plugin"
2
+
3
+ /**
4
+ * Compose many Partial<Hooks> into one Hooks.
5
+ * - fn hooks: call sequentially, module order. Each sees prior mutations of `output`.
6
+ * - `tool` map: shallow merge. Name collision -> later module wins, warn.
7
+ */
8
+ export function mergeHooks(parts: Partial<Hooks>[]): Hooks {
9
+ const merged: Record<string, unknown> = {}
10
+ const tools: Record<string, unknown> = {}
11
+
12
+ for (const part of parts) {
13
+ for (const [key, value] of Object.entries(part)) {
14
+ if (value === undefined) continue
15
+ if (key === "tool") {
16
+ for (const [name, def] of Object.entries(value as Record<string, unknown>)) {
17
+ if (tools[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
18
+ tools[name] = def
19
+ }
20
+ continue
21
+ }
22
+ const prev = merged[key] as ((...a: unknown[]) => Promise<void>) | undefined
23
+ const next = value as (...a: unknown[]) => Promise<void>
24
+ merged[key] = prev
25
+ ? async (...args: unknown[]) => {
26
+ await prev(...args)
27
+ await next(...args)
28
+ }
29
+ : next
30
+ }
31
+ }
32
+
33
+ if (Object.keys(tools).length) merged.tool = tools
34
+ return merged as Hooks
35
+ }
package/src/tui.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
2
+
3
+ const STATE_SUBDIR = ".opencode/overclock"
4
+
5
+ export interface TuiOptions {
6
+ notifyIdle?: boolean
7
+ notifyPermission?: boolean
8
+ notifyQuestion?: boolean
9
+ notifyError?: boolean
10
+ }
11
+
12
+ export interface TaskMirrorEntry {
13
+ id: string
14
+ description: string
15
+ status: "running" | "exited" | "killed"
16
+ exitCode: number | null
17
+ startedAt: number
18
+ }
19
+
20
+ export interface UsageDayBucket {
21
+ cost: number
22
+ tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number }
23
+ messages: number
24
+ }
25
+
26
+ export interface UsageStateShape {
27
+ days: Record<string, UsageDayBucket>
28
+ }
29
+
30
+ export interface ScheduleMirrorEntry {
31
+ id: string
32
+ spec: string
33
+ target: string
34
+ }
35
+
36
+ /** Local YYYY-MM-DD from an epoch-ms timestamp (mirrors usage.ts, kept local to stay decoupled). */
37
+ export function dayKey(ms: number): string {
38
+ const d = new Date(ms)
39
+ const y = d.getFullYear()
40
+ const m = String(d.getMonth() + 1).padStart(2, "0")
41
+ const day = String(d.getDate()).padStart(2, "0")
42
+ return `${y}-${m}-${day}`
43
+ }
44
+
45
+ /** Pure: format the /oc-tasks toast summary from a parsed tasks.json mirror. */
46
+ export function formatTasksSummary(entries: TaskMirrorEntry[] | undefined | null): string {
47
+ if (!entries) return "no data yet"
48
+ if (!entries.length) return "no tasks"
49
+ const counts: Record<string, number> = {}
50
+ for (const e of entries) counts[e.status] = (counts[e.status] ?? 0) + 1
51
+ const order: TaskMirrorEntry["status"][] = ["running", "exited", "killed"]
52
+ return order
53
+ .filter((s) => counts[s])
54
+ .map((s) => `${counts[s]} ${s}`)
55
+ .join(", ")
56
+ }
57
+
58
+ /** Pure: format the /oc-usage toast summary from a parsed usage.json, for a given "now". */
59
+ export function formatUsageSummary(
60
+ state: UsageStateShape | undefined | null,
61
+ now: number = Date.now(),
62
+ ): string {
63
+ if (!state) return "no data yet"
64
+ const bucket = state.days?.[dayKey(now)]
65
+ if (!bucket) return "no usage today"
66
+ const tokens = bucket.tokens.input + bucket.tokens.output
67
+ return `today: $${bucket.cost.toFixed(4)}, ${tokens} tokens, ${bucket.messages} msgs`
68
+ }
69
+
70
+ /** Pure: format the /oc-schedules toast summary from a parsed schedules.json. */
71
+ export function formatSchedulesSummary(schedules: ScheduleMirrorEntry[] | undefined | null): string {
72
+ if (!schedules) return "no data yet"
73
+ if (!schedules.length) return "no schedules"
74
+ const list = schedules.map((s) => `${s.id} (${s.spec})`).join(", ")
75
+ return `${schedules.length} schedule${schedules.length === 1 ? "" : "s"}: ${list}`
76
+ }
77
+
78
+ async function readState<T>(directory: string, file: string): Promise<T | undefined> {
79
+ try {
80
+ const f = Bun.file(`${directory}/${STATE_SUBDIR}/${file}`)
81
+ if (!(await f.exists())) return undefined
82
+ return (await f.json()) as T
83
+ } catch {
84
+ return undefined
85
+ }
86
+ }
87
+
88
+ const tui: TuiPlugin = async (api, options) => {
89
+ const opts = (options ?? {}) as TuiOptions
90
+ const notifyIdle = opts.notifyIdle !== false
91
+ const notifyPermission = opts.notifyPermission !== false
92
+ const notifyQuestion = opts.notifyQuestion !== false
93
+ const notifyError = opts.notifyError !== false
94
+
95
+ const unsubs: Array<() => void> = []
96
+
97
+ try {
98
+ if (notifyIdle) {
99
+ unsubs.push(
100
+ api.event.on("session.status", (event) => {
101
+ if (event.properties.status.type !== "idle") return
102
+ void api.attention.notify({
103
+ title: "opencode",
104
+ message: "turn complete",
105
+ sound: { name: "done", when: "blurred" },
106
+ notification: { when: "blurred" },
107
+ })
108
+ }),
109
+ )
110
+ }
111
+ } catch (e) {
112
+ console.warn(`[overclock-tui] session.status subscription failed: ${e}`)
113
+ }
114
+
115
+ try {
116
+ if (notifyPermission) {
117
+ unsubs.push(
118
+ api.event.on("permission.asked", () => {
119
+ void api.attention.notify({
120
+ title: "opencode",
121
+ message: "needs permission",
122
+ sound: { name: "permission", when: "blurred" },
123
+ notification: { when: "blurred" },
124
+ })
125
+ }),
126
+ )
127
+ }
128
+ } catch (e) {
129
+ console.warn(`[overclock-tui] permission.asked subscription failed: ${e}`)
130
+ }
131
+
132
+ try {
133
+ if (notifyQuestion) {
134
+ unsubs.push(
135
+ api.event.on("question.asked", () => {
136
+ void api.attention.notify({
137
+ title: "opencode",
138
+ message: "asking a question",
139
+ sound: { name: "question", when: "blurred" },
140
+ notification: { when: "blurred" },
141
+ })
142
+ }),
143
+ )
144
+ }
145
+ } catch (e) {
146
+ console.warn(`[overclock-tui] question.asked subscription failed: ${e}`)
147
+ }
148
+
149
+ try {
150
+ if (notifyError) {
151
+ unsubs.push(
152
+ api.event.on("session.error", () => {
153
+ void api.attention.notify({
154
+ title: "opencode",
155
+ message: "session error",
156
+ sound: { name: "error", when: "blurred" },
157
+ notification: { when: "blurred" },
158
+ })
159
+ }),
160
+ )
161
+ }
162
+ } catch (e) {
163
+ console.warn(`[overclock-tui] session.error subscription failed: ${e}`)
164
+ }
165
+
166
+ try {
167
+ for (const unsub of unsubs) api.lifecycle.onDispose(async () => unsub())
168
+ } catch (e) {
169
+ console.warn(`[overclock-tui] lifecycle registration failed: ${e}`)
170
+ }
171
+
172
+ try {
173
+ const unregister = api.command?.register(() => [
174
+ {
175
+ title: "Overclock: Tasks",
176
+ value: "overclock.tasks",
177
+ slash: { name: "oc-tasks" },
178
+ onSelect: async () => {
179
+ const entries = await readState<TaskMirrorEntry[]>(api.state.path.directory, "tasks.json")
180
+ api.ui.toast({ message: formatTasksSummary(entries) })
181
+ },
182
+ },
183
+ ])
184
+ if (unregister) api.lifecycle.onDispose(async () => unregister())
185
+ } catch (e) {
186
+ console.warn(`[overclock-tui] /oc-tasks command registration failed: ${e}`)
187
+ }
188
+
189
+ try {
190
+ const unregister = api.command?.register(() => [
191
+ {
192
+ title: "Overclock: Usage",
193
+ value: "overclock.usage",
194
+ slash: { name: "oc-usage" },
195
+ onSelect: async () => {
196
+ const state = await readState<UsageStateShape>(api.state.path.directory, "usage.json")
197
+ api.ui.toast({ message: formatUsageSummary(state) })
198
+ },
199
+ },
200
+ ])
201
+ if (unregister) api.lifecycle.onDispose(async () => unregister())
202
+ } catch (e) {
203
+ console.warn(`[overclock-tui] /oc-usage command registration failed: ${e}`)
204
+ }
205
+
206
+ try {
207
+ const unregister = api.command?.register(() => [
208
+ {
209
+ title: "Overclock: Schedules",
210
+ value: "overclock.schedules",
211
+ slash: { name: "oc-schedules" },
212
+ onSelect: async () => {
213
+ const schedules = await readState<ScheduleMirrorEntry[]>(
214
+ api.state.path.directory,
215
+ "schedules.json",
216
+ )
217
+ api.ui.toast({ message: formatSchedulesSummary(schedules) })
218
+ },
219
+ },
220
+ ])
221
+ if (unregister) api.lifecycle.onDispose(async () => unregister())
222
+ } catch (e) {
223
+ console.warn(`[overclock-tui] /oc-schedules command registration failed: ${e}`)
224
+ }
225
+ }
226
+
227
+ export default { id: "overclock-tui", tui } satisfies TuiPluginModule