@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +36 -11
  3. package/agents/opencode-loop-local.md +10 -0
  4. package/commands/loop-ask.md +1 -0
  5. package/commands/loop-clear.md +1 -0
  6. package/commands/loop-cmd.md +1 -0
  7. package/commands/loop-command.md +1 -0
  8. package/commands/loop-compact.md +1 -0
  9. package/commands/loop-dev.md +1 -0
  10. package/commands/loop-doctor.md +1 -0
  11. package/commands/loop-export.md +1 -0
  12. package/commands/loop-goal-blocked.md +1 -0
  13. package/commands/loop-goal-clear.md +1 -0
  14. package/commands/loop-goal-done.md +1 -0
  15. package/commands/loop-goal-pause.md +1 -0
  16. package/commands/loop-goal-resume.md +1 -0
  17. package/commands/loop-goal-status.md +1 -0
  18. package/commands/loop-goal.md +1 -0
  19. package/commands/loop-help.md +1 -0
  20. package/commands/loop-init.md +1 -0
  21. package/commands/loop-logs.md +1 -0
  22. package/commands/loop-now.md +1 -0
  23. package/commands/loop-pause.md +1 -0
  24. package/commands/loop-progress.md +1 -0
  25. package/commands/loop-prompt.md +1 -0
  26. package/commands/loop-remove.md +1 -0
  27. package/commands/loop-resume.md +1 -0
  28. package/commands/loop-safe-dev.md +1 -0
  29. package/commands/loop-shell.md +1 -0
  30. package/commands/loop-status.md +1 -0
  31. package/commands/loop-stop.md +1 -0
  32. package/commands/loop-testfix.md +1 -0
  33. package/commands/loop.md +1 -0
  34. package/package.json +6 -5
  35. package/scripts/comprehensive-test.mjs +486 -0
  36. package/scripts/install-node.mjs +146 -4
  37. package/scripts/install-test.mjs +104 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/loopd-test.mjs +208 -0
  41. package/scripts/loopd.mjs +125 -41
  42. package/scripts/smoke-test.mjs +1 -1
  43. 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 FAILED_RUN_RETRY_MS = 5_000
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
- return i >= 0 ? args[i + 1] ?? fallback : fallback
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 || "0s").trim().toLowerCase()
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 readPrompt(project) {
92
- const promptFile = arg("--prompt-file")
93
- const promptArg = arg("--prompt")
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
- const every = arg("--every", "0s")
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 = Number(arg("--max-runs", "0")) || 0
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 code = await run(OPENCODE_BIN, ["run", "--continue", prompt], project)
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
- break
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
- const commandParts = [
173
- quoteWindowsArg(node),
174
- quoteWindowsArg(script),
175
- "daemon",
176
- "--project",
177
- quoteWindowsArg(project),
178
- "--every",
179
- "0s",
180
- "--max-runs",
181
- "1",
182
- ]
183
-
184
- if (promptFile) commandParts.push("--prompt-file", quoteWindowsArg(promptFile))
185
- if (promptArg) commandParts.push("--prompt", quoteWindowsArg(promptArg))
186
-
187
- const taskCommand = commandParts.join(" ")
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(["schtasks", ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
191
- const result = spawnSync("schtasks", taskArgs, { shell: false, stdio: "inherit" })
192
- process.exit(result.status ?? 0)
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 = spawnSync("schtasks", ["/Delete", "/F", "/TN", name], { shell: false, stdio: "inherit" })
202
- process.exit(result.status ?? 0)
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)
@@ -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.match(output.parts[0].text, /handled by the local plugin/i)
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))