@bybrawe/opencode-loop 0.5.16 → 0.5.18
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/CHANGELOG.md +22 -0
- package/README.md +36 -11
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +486 -0
- package/scripts/install-node.mjs +146 -4
- package/scripts/install-test.mjs +104 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/loopd-test.mjs +208 -0
- package/scripts/loopd.mjs +125 -41
- package/scripts/smoke-test.mjs +1 -1
- package/src/index.js +193 -52
package/scripts/loopd.mjs
CHANGED
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process"
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { homedir } from "node:os"
|
|
5
7
|
import { fileURLToPath } from "node:url"
|
|
6
8
|
|
|
7
9
|
const args = process.argv.slice(2)
|
|
8
|
-
const
|
|
10
|
+
const configuredRetryMs = Number(process.env.OPENCODE_LOOPD_FAILED_RUN_RETRY_MS)
|
|
11
|
+
const FAILED_RUN_RETRY_MS = Number.isFinite(configuredRetryMs) && configuredRetryMs >= 0 ? configuredRetryMs : 5_000
|
|
9
12
|
const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
|
|
13
|
+
const SCHTASKS_BIN = process.env.SCHTASKS_BIN || "schtasks"
|
|
14
|
+
const TASK_ROOT = process.env.OPENCODE_LOOPD_TASK_DIR || path.join(process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), "opencode-loop", "tasks")
|
|
10
15
|
|
|
11
16
|
function arg(name, fallback = null) {
|
|
12
17
|
const i = args.indexOf(name)
|
|
13
|
-
|
|
18
|
+
if (i < 0) return fallback
|
|
19
|
+
if (i + 1 >= args.length) throw new Error(`Missing value for ${name}`)
|
|
20
|
+
return args[i + 1]
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
function has(name) {
|
|
@@ -18,7 +25,7 @@ function has(name) {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function parseMs(value) {
|
|
21
|
-
const v = String(value
|
|
28
|
+
const v = String(value ?? "0s").trim().toLowerCase()
|
|
22
29
|
if (v === "0" || v === "0s" || v === "now") return 0
|
|
23
30
|
|
|
24
31
|
const m = v.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
|
|
@@ -36,6 +43,20 @@ function parseMs(value) {
|
|
|
36
43
|
return n
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
function parseMaxRuns(value) {
|
|
47
|
+
const text = String(value ?? "0").trim()
|
|
48
|
+
if (!/^\d+$/.test(text)) throw new Error(`Invalid --max-runs value: ${value}`)
|
|
49
|
+
const parsed = Number(text)
|
|
50
|
+
if (!Number.isSafeInteger(parsed)) throw new Error(`Invalid --max-runs value: ${value}`)
|
|
51
|
+
return parsed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateProject(project) {
|
|
55
|
+
let stat
|
|
56
|
+
try { stat = fs.statSync(project) } catch { throw new Error(`Project directory does not exist: ${project}`) }
|
|
57
|
+
if (!stat.isDirectory()) throw new Error(`Project path is not a directory: ${project}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function sleep(ms) {
|
|
40
61
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
41
62
|
}
|
|
@@ -64,7 +85,7 @@ function spawnOnce(command, commandArgs, cwd) {
|
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
child.on("error", (error) => done({ code: -1, error }))
|
|
67
|
-
child.on("exit", (code) => done({ code: code ?? 0 }))
|
|
88
|
+
child.on("exit", (code, signal) => done({ code: code ?? (signal ? 1 : 0), signal }))
|
|
68
89
|
})
|
|
69
90
|
}
|
|
70
91
|
|
|
@@ -88,9 +109,17 @@ async function run(command, commandArgs, cwd) {
|
|
|
88
109
|
return fallback.code
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
const
|
|
112
|
+
function runSync(command, commandArgs) {
|
|
113
|
+
const options = { shell: false, stdio: "inherit", windowsHide: true }
|
|
114
|
+
const direct = spawnSync(command, commandArgs, options)
|
|
115
|
+
const extension = path.extname(String(command || "")).toLowerCase()
|
|
116
|
+
if (!direct.error || process.platform !== "win32" || direct.error.code !== "EINVAL" || ![".cmd", ".bat"].includes(extension)) return direct
|
|
117
|
+
return spawnSync("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], options)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readPrompt(project, options = {}) {
|
|
121
|
+
const promptFile = options.promptFile ?? arg("--prompt-file")
|
|
122
|
+
const promptArg = options.prompt ?? arg("--prompt")
|
|
94
123
|
|
|
95
124
|
if (promptFile) {
|
|
96
125
|
return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
|
|
@@ -110,13 +139,17 @@ function readPrompt(project) {
|
|
|
110
139
|
].join(" ")
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
async function daemon() {
|
|
114
|
-
const project = path.resolve(arg("--project", process.cwd()))
|
|
115
|
-
|
|
142
|
+
async function daemon(options = {}) {
|
|
143
|
+
const project = path.resolve(options.project ?? arg("--project", process.cwd()))
|
|
144
|
+
validateProject(project)
|
|
145
|
+
const every = options.every ?? arg("--every", "0s")
|
|
116
146
|
const delay = parseMs(every)
|
|
117
|
-
const maxRuns =
|
|
118
|
-
const sleepFirst = has("--sleep-first")
|
|
119
|
-
const prompt = readPrompt(project)
|
|
147
|
+
const maxRuns = parseMaxRuns(options.maxRuns ?? arg("--max-runs", "0"))
|
|
148
|
+
const sleepFirst = options.sleepFirst ?? has("--sleep-first")
|
|
149
|
+
const prompt = readPrompt(project, options)
|
|
150
|
+
const model = options.model ?? arg("--model")
|
|
151
|
+
const agent = options.agent ?? arg("--agent")
|
|
152
|
+
const opencodeBin = options.opencodeBin || OPENCODE_BIN
|
|
120
153
|
|
|
121
154
|
console.log("OpenCode Loop daemon")
|
|
122
155
|
console.log(`project: ${project}`)
|
|
@@ -135,7 +168,11 @@ async function daemon() {
|
|
|
135
168
|
console.log("")
|
|
136
169
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
137
170
|
|
|
138
|
-
const
|
|
171
|
+
const runArgs = ["run", "--continue"]
|
|
172
|
+
if (model) runArgs.push("--model", model)
|
|
173
|
+
if (agent) runArgs.push("--agent", agent)
|
|
174
|
+
runArgs.push(prompt)
|
|
175
|
+
const code = await run(opencodeBin, runArgs, project)
|
|
139
176
|
|
|
140
177
|
if (code !== 0) {
|
|
141
178
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
@@ -146,7 +183,7 @@ async function daemon() {
|
|
|
146
183
|
|
|
147
184
|
if (maxRuns > 0 && count >= maxRuns) {
|
|
148
185
|
console.log("[opencode-loopd] max runs reached")
|
|
149
|
-
|
|
186
|
+
return Number.isInteger(code) && code > 0 ? code : code < 0 ? 1 : 0
|
|
150
187
|
}
|
|
151
188
|
|
|
152
189
|
if (delay > 0) {
|
|
@@ -155,41 +192,75 @@ async function daemon() {
|
|
|
155
192
|
}
|
|
156
193
|
}
|
|
157
194
|
|
|
195
|
+
function taskArtifacts(name) {
|
|
196
|
+
const id = createHash("sha256").update(String(name || "OpenCodeLoop")).digest("hex").slice(0, 16)
|
|
197
|
+
return {
|
|
198
|
+
config: path.join(TASK_ROOT, `${id}.json`),
|
|
199
|
+
launcher: path.join(TASK_ROOT, `${id}.cmd`),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function removeTaskArtifacts(artifacts) {
|
|
204
|
+
for (const target of [artifacts.launcher, artifacts.config]) {
|
|
205
|
+
try { fs.rmSync(target, { force: true }) } catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function taskRun() {
|
|
210
|
+
const configFile = path.resolve(arg("--config"))
|
|
211
|
+
let config
|
|
212
|
+
try { config = JSON.parse(fs.readFileSync(configFile, "utf8")) } catch (error) { throw new Error(`Could not read task config ${configFile}: ${error.message}`) }
|
|
213
|
+
return await daemon(config)
|
|
214
|
+
}
|
|
215
|
+
|
|
158
216
|
function installTask() {
|
|
159
217
|
if (process.platform !== "win32") {
|
|
160
218
|
throw new Error("install-task is currently implemented for Windows Task Scheduler only. Use daemon mode on macOS/Linux.")
|
|
161
219
|
}
|
|
162
220
|
|
|
163
221
|
const project = path.resolve(arg("--project", process.cwd()))
|
|
222
|
+
validateProject(project)
|
|
164
223
|
const every = arg("--every", "10m")
|
|
165
224
|
const minutes = Math.max(1, Math.round(parseMs(every) / 60_000))
|
|
166
225
|
const name = arg("--name", "OpenCodeLoop")
|
|
167
226
|
const promptFile = arg("--prompt-file")
|
|
168
227
|
const promptArg = arg("--prompt")
|
|
228
|
+
const model = arg("--model")
|
|
229
|
+
const agent = arg("--agent")
|
|
169
230
|
const node = process.execPath
|
|
170
231
|
const script = fileURLToPath(import.meta.url)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
232
|
+
const artifacts = taskArtifacts(name)
|
|
233
|
+
fs.mkdirSync(TASK_ROOT, { recursive: true })
|
|
234
|
+
const taskConfig = {
|
|
235
|
+
project,
|
|
236
|
+
every: "0s",
|
|
237
|
+
maxRuns: 1,
|
|
238
|
+
promptFile: promptFile ? path.resolve(project, promptFile) : undefined,
|
|
239
|
+
prompt: promptArg || undefined,
|
|
240
|
+
model: model || undefined,
|
|
241
|
+
agent: agent || undefined,
|
|
242
|
+
opencodeBin: OPENCODE_BIN,
|
|
243
|
+
}
|
|
244
|
+
fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
|
|
245
|
+
const launcherCommand = [quoteWindowsArg(node), quoteWindowsArg(script), "task-run", "--config", quoteWindowsArg(artifacts.config)].join(" ")
|
|
246
|
+
fs.writeFileSync(artifacts.launcher, `@echo off\r\n${launcherCommand}\r\nexit /b %errorlevel%\r\n`, "utf8")
|
|
247
|
+
const taskCommand = `cmd.exe /d /s /c "${quoteWindowsArg(artifacts.launcher)}"`
|
|
248
|
+
if (taskCommand.length > 261) {
|
|
249
|
+
removeTaskArtifacts(artifacts)
|
|
250
|
+
throw new Error(`Task Scheduler command is still too long (${taskCommand.length} characters). Set OPENCODE_LOOPD_TASK_DIR to a shorter directory.`)
|
|
251
|
+
}
|
|
188
252
|
const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
|
|
189
253
|
|
|
190
|
-
console.log([
|
|
191
|
-
const result =
|
|
192
|
-
|
|
254
|
+
console.log([SCHTASKS_BIN, ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
|
|
255
|
+
const result = runSync(SCHTASKS_BIN, taskArgs)
|
|
256
|
+
if (result.error) {
|
|
257
|
+
removeTaskArtifacts(artifacts)
|
|
258
|
+
console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
|
|
259
|
+
return 1
|
|
260
|
+
}
|
|
261
|
+
const status = Number.isInteger(result.status) ? result.status : 1
|
|
262
|
+
if (status !== 0) removeTaskArtifacts(artifacts)
|
|
263
|
+
return status
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
function uninstallTask() {
|
|
@@ -198,8 +269,14 @@ function uninstallTask() {
|
|
|
198
269
|
}
|
|
199
270
|
|
|
200
271
|
const name = arg("--name", "OpenCodeLoop")
|
|
201
|
-
const result =
|
|
202
|
-
|
|
272
|
+
const result = runSync(SCHTASKS_BIN, ["/Delete", "/F", "/TN", name])
|
|
273
|
+
if (result.error) {
|
|
274
|
+
console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
|
|
275
|
+
return 1
|
|
276
|
+
}
|
|
277
|
+
const status = Number.isInteger(result.status) ? result.status : 1
|
|
278
|
+
if (status === 0) removeTaskArtifacts(taskArtifacts(name))
|
|
279
|
+
return status
|
|
203
280
|
}
|
|
204
281
|
|
|
205
282
|
function help() {
|
|
@@ -217,6 +294,8 @@ Options:
|
|
|
217
294
|
--every <duration> 0s, 5m, 1h, etc.
|
|
218
295
|
--prompt <text> Prompt text
|
|
219
296
|
--prompt-file <file> Read prompt from file relative to the project
|
|
297
|
+
--model <provider/id> OpenCode model used for each run
|
|
298
|
+
--agent <name> OpenCode agent used for each run
|
|
220
299
|
--max-runs <n> Stop after n runs
|
|
221
300
|
--sleep-first Wait before first run
|
|
222
301
|
`)
|
|
@@ -225,20 +304,25 @@ Options:
|
|
|
225
304
|
const command = args[0]
|
|
226
305
|
|
|
227
306
|
try {
|
|
307
|
+
let exitCode = 0
|
|
228
308
|
if (command === "daemon" || command === "loopd") {
|
|
229
309
|
args.shift()
|
|
230
|
-
await daemon()
|
|
310
|
+
exitCode = await daemon()
|
|
231
311
|
} else if (command === "install-task") {
|
|
232
312
|
args.shift()
|
|
233
|
-
installTask()
|
|
313
|
+
exitCode = installTask()
|
|
234
314
|
} else if (command === "uninstall-task") {
|
|
235
315
|
args.shift()
|
|
236
|
-
uninstallTask()
|
|
316
|
+
exitCode = uninstallTask()
|
|
317
|
+
} else if (command === "task-run") {
|
|
318
|
+
args.shift()
|
|
319
|
+
exitCode = await taskRun()
|
|
237
320
|
} else if (has("--help") || has("-h") || command === "help") {
|
|
238
321
|
help()
|
|
239
322
|
} else {
|
|
240
|
-
await daemon()
|
|
323
|
+
exitCode = await daemon()
|
|
241
324
|
}
|
|
325
|
+
if (exitCode) process.exitCode = exitCode
|
|
242
326
|
} catch (error) {
|
|
243
327
|
console.error(error?.message || error)
|
|
244
328
|
process.exit(1)
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -83,7 +83,7 @@ try {
|
|
|
83
83
|
sessionID,
|
|
84
84
|
arguments: "Create proof.txt and verify it --max-turns 3",
|
|
85
85
|
}, output)
|
|
86
|
-
assert.
|
|
86
|
+
assert.equal(output.parts.length, 0, "a locally handled slash command must not start a placeholder model turn")
|
|
87
87
|
|
|
88
88
|
await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
|
|
89
89
|
await new Promise((resolve) => setTimeout(resolve, 0))
|