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,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
|
+
}
|