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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +49 -0
- package/src/config.ts +19 -0
- package/src/features/checkpoints.ts +127 -0
- package/src/features/guard.ts +227 -0
- package/src/features/index.ts +10 -0
- package/src/features/sandbox.ts +102 -0
- package/src/features/sched.ts +162 -0
- package/src/features/tasks.ts +345 -0
- package/src/features/usage.ts +270 -0
- package/src/index.ts +39 -0
- package/src/lib/busy.ts +25 -0
- package/src/lib/inject.ts +56 -0
- package/src/lib/probe.ts +15 -0
- package/src/lib/state.ts +27 -0
- package/src/merge.ts +35 -0
- package/src/tui.ts +227 -0
- package/src/types.ts +21 -0
|
@@ -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
|
+
}
|
package/src/lib/busy.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/probe.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/state.ts
ADDED
|
@@ -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
|