@bybrawe/opencode-loop 0.4.2 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Clear all loop jobs for this session.
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop clear command. The plugin handles this command.
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Preset compact loop. Usage: /loop-compact 200m
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop compact preset. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Preset dev loop. Usage: /loop-dev 0s, /loop-dev 5m optional extra instruction
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop dev preset. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Diagnose the OpenCode Loop plugin installation and current session state.
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- Plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Export the current session loop state as JSON.
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- Plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Show OpenCode Loop help and examples.
2
+ description: Show OpenCode Loop help.
3
3
  ---
4
4
 
5
- OpenCode Loop help command. The plugin handles this command.
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Create a starter progress.md file for autonomous OpenCode loop workflows.
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- Plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Show recent OpenCode Loop log entries.
2
+ description: Show OpenCode Loop logs.
3
3
  ---
4
4
 
5
- OpenCode Loop logs command. The plugin handles this command.
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Run loop jobs immediately. Usage: /loop-now, /loop-now <id|name|number>
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop run-now command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Pause loop jobs. Usage: /loop-pause, /loop-pause <id|name|number|all>
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop pause command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Preset progress.md loop. Usage: /loop-progress 0s or /loop-progress 5m
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop progress preset. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Remove loop jobs. Alias of /loop-stop. Usage: /loop-remove <id|name|number|all>
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop remove command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Resume loop jobs. Usage: /loop-resume, /loop-resume <id|name|number|all>
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop resume command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Preset safe dev loop with ask-never, safe mode, batch 5 and checkpoint-only. Usage: /loop-safe-dev 0s
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop safe-dev preset. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Show active OpenCode loop jobs for this session.
2
+ description: Show OpenCode Loop status.
3
3
  ---
4
4
 
5
- OpenCode Loop status command. The plugin handles this command.
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Stop loop jobs. Usage: /loop-stop, /loop-stop all, /loop-stop <id|name|number>
2
+ description: Stop OpenCode Loop jobs.
3
3
  ---
4
4
 
5
- OpenCode Loop stop command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Preset test-fix loop. Usage: /loop-testfix 0s "npm test"
2
+ description: OpenCode Loop plugin command.
3
3
  ---
4
4
 
5
- OpenCode Loop test-fix preset. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
package/commands/loop.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Start an OpenCode idle/interval loop. Usage: /loop 0s prompt, /loop 5m --safe --ask-never prompt, /loop 200m /compact, /loop --watch progress.md prompt
2
+ description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
3
3
  ---
4
4
 
5
- OpenCode Loop command. The plugin handles this command. Arguments: $ARGUMENTS
5
+ Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -0,0 +1,10 @@
1
+ Continue from progress.md and implement the next unfinished TODO.
2
+
3
+ Rules:
4
+ - Do not ask questions.
5
+ - Make reasonable assumptions.
6
+ - Mark completed TODO items with [x].
7
+ - Add useful follow-up TODOs when needed.
8
+ - Run tests/lint/build when available.
9
+ - Do not run destructive commands such as git reset, git clean, rm -rf, force push, deploy, or production migrations.
10
+ - Keep going while work remains.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.4.2",
4
- "description": "Claude Code style auto-continue /loop command for OpenCode: autonomous loops, compact scheduling, progress.md TODO automation, verification, checkpoints, and safe long-running coding sessions.",
3
+ "version": "0.5.0",
4
+ "description": "Claude Code style /loop command and background daemon for OpenCode: auto-continue, progress.md TODO automation, compact scheduling, verification, checkpoints, and safe autonomous coding loops.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "CHANGELOG.md"
18
18
  ],
19
19
  "scripts": {
20
- "check": "node --check src/index.js",
20
+ "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs",
21
21
  "install:global": "node scripts/install-node.mjs",
22
22
  "pack:zip": "node scripts/make-zip.mjs"
23
23
  },
@@ -36,7 +36,10 @@
36
36
  "scheduler",
37
37
  "progress-md",
38
38
  "todo-automation",
39
- "ralph-loop"
39
+ "ralph-loop",
40
+ "daemon",
41
+ "background-runner",
42
+ "windows-task-scheduler"
40
43
  ],
41
44
  "author": "ByBrawe",
42
45
  "license": "MIT",
@@ -52,6 +55,7 @@
52
55
  "@opencode-ai/plugin": ">=1.4.0"
53
56
  },
54
57
  "bin": {
55
- "opencode-loop": "./scripts/install-node.mjs"
58
+ "opencode-loop": "./scripts/install-node.mjs",
59
+ "opencode-loopd": "./scripts/loopd.mjs"
56
60
  }
57
61
  }
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { spawn, spawnSync } from "node:child_process"
5
+
6
+ const args = process.argv.slice(2)
7
+
8
+ function arg(name, fallback = null) {
9
+ const i = args.indexOf(name)
10
+ return i >= 0 ? args[i + 1] ?? fallback : fallback
11
+ }
12
+
13
+ function has(name) {
14
+ return args.includes(name)
15
+ }
16
+
17
+ function parseMs(value) {
18
+ const v = String(value || "0s").trim().toLowerCase()
19
+ if (v === "0" || v === "0s" || v === "now") return 0
20
+
21
+ const m = v.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
22
+ if (!m) throw new Error(`Invalid duration: ${value}`)
23
+
24
+ const n = Number(m[1])
25
+ const unit = m[2]
26
+
27
+ if (unit === "ms") return n
28
+ if (unit.startsWith("s")) return n * 1000
29
+ if (unit.startsWith("m")) return n * 60_000
30
+ if (unit.startsWith("h")) return n * 3_600_000
31
+ if (unit.startsWith("d")) return n * 86_400_000
32
+
33
+ return n
34
+ }
35
+
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms))
38
+ }
39
+
40
+ function run(command, cwd) {
41
+ return new Promise((resolve) => {
42
+ const child = spawn(command, {
43
+ cwd,
44
+ shell: true,
45
+ stdio: "inherit",
46
+ env: process.env,
47
+ })
48
+
49
+ child.on("exit", (code) => resolve(code ?? 0))
50
+ })
51
+ }
52
+
53
+ function readPrompt(project) {
54
+ const promptFile = arg("--prompt-file")
55
+ const promptArg = arg("--prompt")
56
+
57
+ if (promptFile) {
58
+ return fs.readFileSync(path.resolve(project, promptFile), "utf8")
59
+ }
60
+
61
+ if (promptArg) return promptArg
62
+
63
+ return [
64
+ "Continue from progress.md and implement the next unfinished TODO.",
65
+ "Do not ask questions.",
66
+ "Make reasonable assumptions.",
67
+ "Mark completed TODO items with [x].",
68
+ "Add useful follow-up TODOs when needed.",
69
+ "Run tests/lint/build when available.",
70
+ "Do not run destructive commands such as git reset, git clean, rm -rf, force push, deploy, or production migrations.",
71
+ "Keep going while work remains.",
72
+ ].join(" ")
73
+ }
74
+
75
+ async function daemon() {
76
+ const project = path.resolve(arg("--project", process.cwd()))
77
+ const every = arg("--every", "0s")
78
+ const delay = parseMs(every)
79
+ const maxRuns = Number(arg("--max-runs", "0")) || 0
80
+ const sleepFirst = has("--sleep-first")
81
+ const prompt = readPrompt(project)
82
+
83
+ console.log("OpenCode Loop daemon")
84
+ console.log(`project: ${project}`)
85
+ console.log(`every: ${every}`)
86
+ console.log(`maxRuns: ${maxRuns || "unlimited"}`)
87
+
88
+ let count = 0
89
+
90
+ if (sleepFirst && delay > 0) {
91
+ await sleep(delay)
92
+ }
93
+
94
+ while (true) {
95
+ count += 1
96
+
97
+ console.log("")
98
+ console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
99
+
100
+ const command = `opencode run --continue --prompt ${JSON.stringify(prompt)}`
101
+ const code = await run(command, project)
102
+
103
+ if (code !== 0) {
104
+ console.log(`[opencode-loopd] opencode exited with code ${code}`)
105
+ }
106
+
107
+ if (maxRuns > 0 && count >= maxRuns) {
108
+ console.log("[opencode-loopd] max runs reached")
109
+ break
110
+ }
111
+
112
+ if (delay > 0) {
113
+ await sleep(delay)
114
+ }
115
+ }
116
+ }
117
+
118
+ function installTask() {
119
+ if (process.platform !== "win32") {
120
+ throw new Error("install-task is currently implemented for Windows Task Scheduler only. Use daemon mode on macOS/Linux.")
121
+ }
122
+
123
+ const project = path.resolve(arg("--project", process.cwd()))
124
+ const every = arg("--every", "10m")
125
+ const minutes = Math.max(1, Math.round(parseMs(every) / 60_000))
126
+ const name = arg("--name", "OpenCodeLoop")
127
+ const promptFile = arg("--prompt-file")
128
+ const promptArg = arg("--prompt")
129
+ const node = process.execPath
130
+ const script = path.resolve(new URL(import.meta.url).pathname)
131
+
132
+ const commandParts = [
133
+ `"${node}"`,
134
+ `"${script}"`,
135
+ "daemon",
136
+ "--project",
137
+ `"${project}"`,
138
+ "--every",
139
+ "0s",
140
+ "--max-runs",
141
+ "1",
142
+ ]
143
+
144
+ if (promptFile) commandParts.push("--prompt-file", `"${promptFile}"`)
145
+ if (promptArg) commandParts.push("--prompt", `"${promptArg.replaceAll('"', '\\"')}"`)
146
+
147
+ const taskCommand = commandParts.join(" ")
148
+ const schtasks = `schtasks /Create /F /SC MINUTE /MO ${minutes} /TN "${name}" /TR ${JSON.stringify(taskCommand)}`
149
+
150
+ console.log(schtasks)
151
+ const result = spawnSync(schtasks, { shell: true, stdio: "inherit" })
152
+ process.exit(result.status ?? 0)
153
+ }
154
+
155
+ function uninstallTask() {
156
+ if (process.platform !== "win32") {
157
+ throw new Error("uninstall-task is currently implemented for Windows Task Scheduler only.")
158
+ }
159
+
160
+ const name = arg("--name", "OpenCodeLoop")
161
+ const result = spawnSync(`schtasks /Delete /F /TN "${name}"`, { shell: true, stdio: "inherit" })
162
+ process.exit(result.status ?? 0)
163
+ }
164
+
165
+ function help() {
166
+ console.log(`
167
+ OpenCode Loop daemon
168
+
169
+ Usage:
170
+ opencode-loopd --project . --every 5m --prompt-file loop-prompt.md
171
+ opencode-loopd --project . --every 0s --prompt "continue from progress.md"
172
+ opencode-loopd install-task --project . --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop
173
+ opencode-loopd uninstall-task --name OpenCodeLoop
174
+
175
+ Options:
176
+ --project <path> Project directory
177
+ --every <duration> 0s, 5m, 1h, etc.
178
+ --prompt <text> Prompt text
179
+ --prompt-file <file> Read prompt from file relative to the project
180
+ --max-runs <n> Stop after n runs
181
+ --sleep-first Wait before first run
182
+ `)
183
+ }
184
+
185
+ const command = args[0]
186
+
187
+ try {
188
+ if (command === "daemon" || command === "loopd") {
189
+ args.shift()
190
+ await daemon()
191
+ } else if (command === "install-task") {
192
+ args.shift()
193
+ installTask()
194
+ } else if (command === "uninstall-task") {
195
+ args.shift()
196
+ uninstallTask()
197
+ } else if (has("--help") || has("-h") || command === "help") {
198
+ help()
199
+ } else {
200
+ await daemon()
201
+ }
202
+ } catch (error) {
203
+ console.error(error?.message || error)
204
+ process.exit(1)
205
+ }
package/src/index.js CHANGED
@@ -186,6 +186,7 @@ function parseLoopArgs(raw, defaults = {}) {
186
186
  gitCheckpoint: defaults.gitCheckpoint ?? false,
187
187
  checkpointOnly: defaults.checkpointOnly ?? false,
188
188
  dryRun: defaults.dryRun ?? false,
189
+ multi: defaults.multi ?? false,
189
190
  batch: defaults.batch ?? 0,
190
191
  runCount: 0,
191
192
  failureCount: 0,
@@ -212,6 +213,8 @@ function parseLoopArgs(raw, defaults = {}) {
212
213
  ;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
213
214
  ;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
214
215
  ;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
216
+ ;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
217
+ ;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
215
218
 
216
219
  ;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
217
220
  ;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
@@ -606,7 +609,7 @@ async function fireAction(directory, client, sessionID, job) {
606
609
  return { startsAssistantTurn: true }
607
610
  }
608
611
  const prompt = await buildPrompt(directory, job)
609
- client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: `OpenCode loop continuation. Continue autonomously like Claude Code loop mode.\n\n${prompt}` }] } }).catch(() => {})
612
+ client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: `AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.\n\n${prompt}` }] } }).catch(() => {})
610
613
  return { startsAssistantTurn: true }
611
614
  }
612
615
 
@@ -699,16 +702,47 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
699
702
  }
700
703
  }
701
704
 
705
+ function normalizeActionForCompare(value) {
706
+ return String(value || "").replace(/\s+/g, " ").trim()
707
+ }
708
+
709
+ function sameLoopDefinition(a, b) {
710
+ if (!a || !b) return false
711
+ return (a.name || "") === (b.name || "") &&
712
+ Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
713
+ normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
714
+ normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
715
+ }
716
+
702
717
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
703
718
  const parsed = parseLoopArgs(args, defaults)
704
719
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
705
720
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
706
721
  if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
707
722
  const state = await readState(directory, sessionID)
723
+ const jobs = Array.isArray(state.jobs) ? state.jobs : []
724
+
725
+ // Default behavior is replace/upsert, not append forever. This prevents duplicate
726
+ // loops when OpenCode emits both command.execute.before and command.executed,
727
+ // and it matches the common expectation that /loop configures the current loop.
728
+ let replaced = false
729
+ if (!parsed.job.multi) {
730
+ const targetName = parsed.job.name || "default"
731
+ parsed.job.name = targetName
732
+ state.jobs = jobs.filter((existing) => {
733
+ const existingName = existing.name || "default"
734
+ const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
735
+ if (shouldReplace) replaced = true
736
+ return !shouldReplace
737
+ })
738
+ } else {
739
+ state.jobs = jobs
740
+ }
741
+
708
742
  state.jobs.push(parsed.job)
709
743
  await writeState(directory, sessionID, state)
710
- await toast(client, `Loop added: ${jobLabel(parsed.job)}`, "success")
711
- await appendLoopLog(directory, "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
744
+ await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
745
+ await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
712
746
  }
713
747
 
714
748
  async function stopLoop(directory, client, sessionID, args) {