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,162 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { Cron } from "croner"
3
+ import type { FeatureModule } from "../types.ts"
4
+ import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
5
+ import { inject, toast } from "../lib/inject.ts"
6
+ import { createBusyTracker } from "../lib/busy.ts"
7
+
8
+ const z = tool.schema
9
+
10
+ export type Spec = { kind: "interval"; ms: number } | { kind: "cron"; expr: string }
11
+
12
+ const UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
13
+
14
+ /** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage. */
15
+ export function parseSpec(spec: string): Spec {
16
+ const m = spec.trim().match(/^(\d+)([smhd])$/)
17
+ if (m) return { kind: "interval", ms: Number(m[1]) * UNITS[m[2]!]! }
18
+ new Cron(spec) // throws if invalid
19
+ return { kind: "cron", expr: spec }
20
+ }
21
+
22
+ interface Schedule {
23
+ id: string
24
+ spec: string
25
+ prompt: string
26
+ target: "current" | "new-session"
27
+ sessionID: string // creator; inject target when target=current
28
+ createdAt: string
29
+ }
30
+
31
+ /**
32
+ * Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
33
+ * Persisted, rearmed on startup.
34
+ */
35
+ export const sched: FeatureModule = {
36
+ name: "sched",
37
+ defaultEnabled: true,
38
+ requires: ["session.promptAsync", "session.messages", "session.create"],
39
+ async init(ctx, options) {
40
+ const dir = await ensureStateDir(ctx.directory)
41
+ const storePath = `${dir}/schedules.json`
42
+ const schedules = new Map<string, Schedule>()
43
+ const timers = new Map<string, Cron | ReturnType<typeof setInterval>>()
44
+ const skipIfBusy = options.skipIfBusy !== false
45
+ const busy = createBusyTracker()
46
+
47
+ const persist = () => writeJson(storePath, [...schedules.values()])
48
+
49
+ async function fire(s: Schedule) {
50
+ try {
51
+ if (s.target === "current") {
52
+ // target still chewing on previous turn -> skip this fire, no pileup
53
+ if (skipIfBusy && busy.isBusy(s.sessionID)) {
54
+ await toast(ctx.client, `schedule ${s.id} skipped (session busy)`, "info")
55
+ return
56
+ }
57
+ const ok = await inject(ctx.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
58
+ if (!ok) await toast(ctx.client, `schedule ${s.id}: target session gone`, "warning")
59
+ } else {
60
+ const res = await ctx.client.session.create({ body: { title: `sched:${s.id}` } })
61
+ const id = res.data?.id
62
+ if (!id) throw new Error("session.create returned no id")
63
+ await inject(ctx.client, id, s.prompt)
64
+ await toast(ctx.client, `schedule ${s.id} fired -> new session`, "info")
65
+ }
66
+ } catch (e) {
67
+ console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
68
+ await toast(ctx.client, `schedule ${s.id} failed: ${e}`, "error")
69
+ }
70
+ }
71
+
72
+ function arm(s: Schedule) {
73
+ const spec = parseSpec(s.spec)
74
+ timers.set(
75
+ s.id,
76
+ spec.kind === "interval"
77
+ ? setInterval(() => fire(s), spec.ms)
78
+ : new Cron(spec.expr, () => fire(s)),
79
+ )
80
+ }
81
+
82
+ function disarm(id: string) {
83
+ const t = timers.get(id)
84
+ if (!t) return
85
+ t instanceof Cron ? t.stop() : clearInterval(t)
86
+ timers.delete(id)
87
+ }
88
+
89
+ const nextRun = (s: Schedule): string => {
90
+ const spec = parseSpec(s.spec)
91
+ if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
92
+ return `every ${s.spec}`
93
+ }
94
+
95
+ // rearm persisted schedules (restart-safe)
96
+ for (const s of await readJson<Schedule[]>(storePath, [])) {
97
+ schedules.set(s.id, s)
98
+ try {
99
+ arm(s)
100
+ } catch (e) {
101
+ console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
102
+ }
103
+ }
104
+
105
+ return {
106
+ event: async ({ event }) => busy.onEvent(event),
107
+ dispose: async () => {
108
+ for (const id of [...timers.keys()]) disarm(id)
109
+ },
110
+ tool: {
111
+ schedule_create: tool({
112
+ description:
113
+ 'Schedule a recurring prompt. spec = cron expr ("0 9 * * *") or interval ("30s"/"5m"/"2h"/"1d"). target "current" posts into this session (interval + current = a loop); "new-session" spawns a fresh session per fire.',
114
+ args: {
115
+ spec: z.string(),
116
+ prompt: z.string(),
117
+ target: z.enum(["current", "new-session"]).default("current"),
118
+ },
119
+ async execute(args, tctx) {
120
+ parseSpec(args.spec) // validate before storing
121
+ const s: Schedule = {
122
+ id: `s-${crypto.randomUUID().slice(0, 6)}`,
123
+ spec: args.spec,
124
+ prompt: args.prompt,
125
+ target: args.target,
126
+ sessionID: tctx.sessionID,
127
+ createdAt: new Date().toISOString(),
128
+ }
129
+ schedules.set(s.id, s)
130
+ arm(s)
131
+ await persist()
132
+ return `created ${s.id}: ${args.spec} -> ${args.target} (next: ${nextRun(s)})`
133
+ },
134
+ }),
135
+ schedule_list: tool({
136
+ description: "List schedules.",
137
+ args: {},
138
+ async execute() {
139
+ const all = [...schedules.values()]
140
+ if (!all.length) return "no schedules"
141
+ return all
142
+ .map(
143
+ (s) =>
144
+ `${s.id} [${s.spec}] -> ${s.target} (next: ${nextRun(s)}) :: ${s.prompt.slice(0, 60)}`,
145
+ )
146
+ .join("\n")
147
+ },
148
+ }),
149
+ schedule_delete: tool({
150
+ description: "Delete a schedule by id.",
151
+ args: { id: z.string() },
152
+ async execute(args) {
153
+ if (!schedules.delete(args.id)) return `no schedule ${args.id}`
154
+ disarm(args.id)
155
+ await persist()
156
+ return `deleted ${args.id}`
157
+ },
158
+ }),
159
+ },
160
+ }
161
+ },
162
+ }
@@ -0,0 +1,345 @@
1
+ import { stat } from "node:fs/promises"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import type { FeatureModule } from "../types.ts"
4
+ import { ensureStateDir, shellQuote, writeJson } from "../lib/state.ts"
5
+ import { inject, toast } from "../lib/inject.ts"
6
+
7
+ const z = tool.schema
8
+
9
+ const PROMPT_PATTERNS = [
10
+ /\(y\/n\)/i, // (Y/n), (y/N)
11
+ /\[y\/n\]/i, // [Y/n], [y/N]
12
+ /\(yes\/no\)/i,
13
+ /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\?\s*$/i,
14
+ /Press (any key|Enter)/i,
15
+ /Continue\?/i,
16
+ /Overwrite\?/i,
17
+ ]
18
+
19
+ /** Last non-empty line of `tail` looks like an interactive y/n or press-key prompt. */
20
+ export function looksLikePrompt(tail: string): boolean {
21
+ const lastLine = tail.trimEnd().split("\n").pop() ?? ""
22
+ return PROMPT_PATTERNS.some((p) => p.test(lastLine))
23
+ }
24
+
25
+ export interface TaskRecord {
26
+ id: string
27
+ description: string
28
+ command: string
29
+ cwd: string
30
+ sessionID: string
31
+ status: "running" | "exited" | "killed"
32
+ exitCode: number | null
33
+ logPath: string
34
+ }
35
+
36
+ interface TaskEntry extends TaskRecord {
37
+ proc: Bun.Subprocess
38
+ timeoutTimer?: ReturnType<typeof setTimeout>
39
+ stallTimer?: ReturnType<typeof setInterval>
40
+ stallNotified: boolean
41
+ startedAt: number
42
+ }
43
+
44
+ interface TaskMirrorEntry {
45
+ id: string
46
+ description: string
47
+ status: TaskRecord["status"]
48
+ exitCode: number | null
49
+ startedAt: number
50
+ }
51
+
52
+ export interface TaskManager {
53
+ run(input: {
54
+ command: string
55
+ description: string
56
+ cwd: string
57
+ sessionID: string
58
+ timeoutMs?: number
59
+ }): TaskRecord
60
+ get(id: string): TaskRecord | undefined
61
+ list(): TaskRecord[]
62
+ output(id: string, tailLines?: number): Promise<string>
63
+ kill(id: string): boolean
64
+ killAll(): void
65
+ }
66
+
67
+ const strip = ({
68
+ proc: _p,
69
+ timeoutTimer: _t,
70
+ stallTimer: _s,
71
+ stallNotified: _n,
72
+ ...rec
73
+ }: TaskEntry): TaskRecord => ({
74
+ ...rec,
75
+ })
76
+
77
+ /**
78
+ * Stall watchdog: poll log size; no growth past threshold AND tail looks like an
79
+ * interactive prompt -> fire onStall once, stop polling.
80
+ */
81
+ function startStallWatchdog(
82
+ entry: TaskEntry,
83
+ checkIntervalMs: number,
84
+ thresholdMs: number,
85
+ tailBytes: number,
86
+ onStall: (task: TaskRecord, tail: string) => void,
87
+ ): void {
88
+ let lastSize = 0
89
+ let lastGrowth = Date.now()
90
+ entry.stallTimer = setInterval(() => {
91
+ void stat(entry.logPath)
92
+ .then(async (s) => {
93
+ if (s.size > lastSize) {
94
+ lastSize = s.size
95
+ lastGrowth = Date.now()
96
+ return
97
+ }
98
+ if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified) return
99
+ const file = Bun.file(entry.logPath)
100
+ const start = Math.max(0, s.size - tailBytes)
101
+ const tail = await file.slice(start).text()
102
+ if (!looksLikePrompt(tail)) {
103
+ lastGrowth = Date.now() // not a prompt — recheck a full interval out, not every tick
104
+ return
105
+ }
106
+ entry.stallNotified = true
107
+ if (entry.stallTimer) clearInterval(entry.stallTimer)
108
+ entry.stallTimer = undefined
109
+ onStall(strip(entry), tail)
110
+ })
111
+ .catch(() => {
112
+ // log file not created yet (race with spawn) — ignore, retry next tick
113
+ })
114
+ }, checkIntervalMs)
115
+ }
116
+
117
+ /** Exported for tests. onExit fires after status/exitCode settled. */
118
+ export function createTaskManager(opts: {
119
+ logDir: string
120
+ /** mirror JSON path, written on every state change (spawn/exit/kill); omit to disable */
121
+ mirrorPath?: string
122
+ onExit?: (task: TaskRecord) => void
123
+ /** enables the stall watchdog; absent -> no polling at all */
124
+ onStall?: (task: TaskRecord, tail: string) => void
125
+ stallCheckIntervalMs?: number
126
+ stallThresholdMs?: number
127
+ stallTailBytes?: number
128
+ }): TaskManager {
129
+ const tasks = new Map<string, TaskEntry>()
130
+ let counter = 0
131
+
132
+ function persistMirror(): void {
133
+ if (!opts.mirrorPath) return
134
+ const mirror: TaskMirrorEntry[] = [...tasks.values()].map((t) => ({
135
+ id: t.id,
136
+ description: t.description,
137
+ status: t.status,
138
+ exitCode: t.exitCode,
139
+ startedAt: t.startedAt,
140
+ }))
141
+ writeJson(opts.mirrorPath, mirror).catch(console.warn)
142
+ }
143
+
144
+ function run(input: {
145
+ command: string
146
+ description: string
147
+ cwd: string
148
+ sessionID: string
149
+ timeoutMs?: number
150
+ }): TaskRecord {
151
+ const id = `t${(++counter).toString(36)}-${crypto.randomUUID().slice(0, 6)}`
152
+ const logPath = `${opts.logDir}/${id}.log`
153
+ // shell-level redirection: no piping code, survives plugin restart losing streams
154
+ const proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
155
+ cwd: input.cwd,
156
+ })
157
+ const entry: TaskEntry = {
158
+ id,
159
+ description: input.description,
160
+ command: input.command,
161
+ cwd: input.cwd,
162
+ sessionID: input.sessionID,
163
+ status: "running",
164
+ exitCode: null,
165
+ logPath,
166
+ proc,
167
+ stallNotified: false,
168
+ startedAt: Date.now(),
169
+ }
170
+ tasks.set(id, entry)
171
+ persistMirror()
172
+ if (input.timeoutMs) {
173
+ entry.timeoutTimer = setTimeout(() => kill(id), input.timeoutMs)
174
+ }
175
+ if (opts.onStall) {
176
+ startStallWatchdog(
177
+ entry,
178
+ opts.stallCheckIntervalMs ?? 5000,
179
+ opts.stallThresholdMs ?? 45_000,
180
+ opts.stallTailBytes ?? 1024,
181
+ opts.onStall,
182
+ )
183
+ }
184
+ proc.exited.then((code) => {
185
+ if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
186
+ if (entry.stallTimer) clearInterval(entry.stallTimer)
187
+ if (entry.status === "running") entry.status = "exited"
188
+ entry.exitCode = code
189
+ persistMirror()
190
+ opts.onExit?.(strip(entry))
191
+ })
192
+ return strip(entry)
193
+ }
194
+
195
+ function kill(id: string): boolean {
196
+ const entry = tasks.get(id)
197
+ if (!entry || entry.status !== "running") return false
198
+ entry.status = "killed"
199
+ persistMirror()
200
+ if (entry.stallTimer) clearInterval(entry.stallTimer)
201
+ entry.stallTimer = undefined
202
+ entry.proc.kill("SIGTERM")
203
+ const hard = setTimeout(() => entry.proc.kill("SIGKILL"), 3000)
204
+ entry.proc.exited.then(() => clearTimeout(hard))
205
+ return true
206
+ }
207
+
208
+ return {
209
+ run,
210
+ kill,
211
+ get: (id) => {
212
+ const e = tasks.get(id)
213
+ return e && strip(e)
214
+ },
215
+ list: () => [...tasks.values()].map(strip),
216
+ output: async (id, tailLines = 50) => {
217
+ const e = tasks.get(id)
218
+ if (!e) return `no task ${id}`
219
+ const file = Bun.file(e.logPath)
220
+ if (!(await file.exists())) return "(no output)"
221
+ const lines = (await file.text()).split("\n")
222
+ return lines.slice(-tailLines - 1).join("\n")
223
+ },
224
+ killAll: () => {
225
+ for (const id of tasks.keys()) kill(id)
226
+ },
227
+ }
228
+ }
229
+
230
+ const fmt = (t: TaskRecord) =>
231
+ `${t.id} [${t.status}${t.exitCode !== null ? ` ${t.exitCode}` : ""}] ${t.description}`
232
+
233
+ /**
234
+ * Background tasks: spawn shell cmds that outlive the turn.
235
+ * Exit -> result injected back into spawning session.
236
+ */
237
+ export const tasks: FeatureModule = {
238
+ name: "tasks",
239
+ defaultEnabled: true,
240
+ requires: ["session.promptAsync", "session.messages"],
241
+ async init(ctx, options) {
242
+ const logDir = await ensureStateDir(ctx.directory, "tasks")
243
+ const stateDir = await ensureStateDir(ctx.directory)
244
+ const killOnExit = options.killOnExit !== false
245
+ const stallDetection = options.stallDetection !== false
246
+ const stallThresholdMs =
247
+ typeof options.stallThresholdMs === "number" ? options.stallThresholdMs : 45_000
248
+ const stallCheckIntervalMs =
249
+ typeof options.stallCheckIntervalMs === "number" ? options.stallCheckIntervalMs : 5_000
250
+
251
+ const manager = createTaskManager({
252
+ logDir,
253
+ mirrorPath: `${stateDir}/tasks.json`,
254
+ onExit: async (task) => {
255
+ if (task.status === "killed") return
256
+ const tail = await manager.output(task.id, 20)
257
+ const ok = task.exitCode === 0
258
+ await toast(
259
+ ctx.client,
260
+ `task ${task.id} done (exit ${task.exitCode})`,
261
+ ok ? "success" : "warning",
262
+ )
263
+ await inject(
264
+ ctx.client,
265
+ task.sessionID,
266
+ `[background task ${task.id} "${task.description}" exited ${task.exitCode}]\nlog tail:\n${tail}`,
267
+ )
268
+ },
269
+ ...(stallDetection
270
+ ? {
271
+ stallThresholdMs,
272
+ stallCheckIntervalMs,
273
+ onStall: async (task: TaskRecord, tail: string) => {
274
+ await toast(ctx.client, `task ${task.id} looks stalled (waiting for input?)`, "warning")
275
+ await inject(
276
+ ctx.client,
277
+ task.sessionID,
278
+ `[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
279
+ `last output:\n${tail.trimEnd()}\n\n` +
280
+ `The command is likely blocked on a prompt. Kill it with task_kill and re-run non-interactively ` +
281
+ `(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
282
+ )
283
+ },
284
+ }
285
+ : {}),
286
+ })
287
+
288
+ return {
289
+ dispose: async () => {
290
+ if (killOnExit) manager.killAll()
291
+ },
292
+ tool: {
293
+ task_run: tool({
294
+ description:
295
+ "Run a shell command in the background. Returns a task id immediately; when the task exits, its result is posted back into this session. Use for long builds, servers, watchers.",
296
+ args: {
297
+ command: z.string().describe("shell command"),
298
+ description: z.string().describe("short human label"),
299
+ cwd: z.string().optional().describe("working dir, default project dir"),
300
+ timeout: z.number().optional().describe("seconds until auto-kill"),
301
+ },
302
+ async execute(args, tctx) {
303
+ const task = manager.run({
304
+ command: args.command,
305
+ description: args.description,
306
+ cwd: args.cwd ?? tctx.directory,
307
+ sessionID: tctx.sessionID,
308
+ timeoutMs: args.timeout ? args.timeout * 1000 : undefined,
309
+ })
310
+ return `started ${fmt(task)} (log: ${task.logPath})`
311
+ },
312
+ }),
313
+ task_status: tool({
314
+ description: "Status of one background task (id) or all (no id).",
315
+ args: { id: z.string().optional() },
316
+ async execute(args) {
317
+ if (args.id) {
318
+ const t = manager.get(args.id)
319
+ return t ? fmt(t) : `no task ${args.id}`
320
+ }
321
+ const all = manager.list()
322
+ return all.length ? all.map(fmt).join("\n") : "no tasks"
323
+ },
324
+ }),
325
+ task_output: tool({
326
+ description: "Tail a background task's log.",
327
+ args: {
328
+ id: z.string(),
329
+ tail: z.number().optional().describe("lines, default 50"),
330
+ },
331
+ async execute(args) {
332
+ return manager.output(args.id, args.tail ?? 50)
333
+ },
334
+ }),
335
+ task_kill: tool({
336
+ description: "Kill a running background task (SIGTERM, SIGKILL after 3s).",
337
+ args: { id: z.string() },
338
+ async execute(args) {
339
+ return manager.kill(args.id) ? `killed ${args.id}` : `${args.id} not running`
340
+ },
341
+ }),
342
+ },
343
+ }
344
+ },
345
+ }