@bybrawe/opencode-loop 0.5.13 → 0.5.14
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 +8 -0
- package/README.md +16 -23
- package/examples/opencode.json +0 -1
- package/package.json +1 -1
- package/scripts/loopd.mjs +56 -21
- package/src/index.js +68 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.14
|
|
4
|
+
|
|
5
|
+
- Guarded active-run finalization so heartbeat and retry checks do not treat a still-running assistant turn as finished.
|
|
6
|
+
- Made loop state writes atomic and backed up corrupt state files before falling back to an empty state.
|
|
7
|
+
- Changed `opencode-loopd` to pass prompts as process arguments instead of shell strings, preserving quotes in inline prompts.
|
|
8
|
+
- Stripped UTF-8 BOMs from daemon prompt files before sending them to OpenCode.
|
|
9
|
+
- Removed the package plugin entry from the example config to avoid duplicate loading after the recommended local installer path.
|
|
10
|
+
|
|
3
11
|
## 0.5.13
|
|
4
12
|
|
|
5
13
|
- Wrapped experimental goal tools with `tool()` and Zod schemas for current OpenCode tool validation.
|
package/README.md
CHANGED
|
@@ -151,32 +151,12 @@ OpenCode Loop is designed for developers searching for:
|
|
|
151
151
|
|
|
152
152
|
### Recommended: install from npm
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or Linux.
|
|
155
155
|
|
|
156
156
|
```bash
|
|
157
157
|
npx -y @bybrawe/opencode-loop@latest
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
Then fully restart OpenCode.
|
|
161
|
-
|
|
162
|
-
Install from npm with `npx`:
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
npx -y @bybrawe/opencode-loop
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
On Windows CMD:
|
|
169
|
-
|
|
170
|
-
```bat
|
|
171
|
-
npx -y @bybrawe/opencode-loop
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
On Windows PowerShell:
|
|
175
|
-
|
|
176
|
-
```powershell
|
|
177
|
-
npx -y @bybrawe/opencode-loop
|
|
178
|
-
```
|
|
179
|
-
|
|
180
160
|
The installer copies the plugin and slash command files into your OpenCode config directory.
|
|
181
161
|
It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
|
|
182
162
|
|
|
@@ -217,6 +197,7 @@ The `npx` installer installs both parts:
|
|
|
217
197
|
- the `/loop-*` command markdown files
|
|
218
198
|
|
|
219
199
|
Use the OpenCode config-only method only if you already installed the command files separately or you are only testing plugin loading.
|
|
200
|
+
Do not use the config package entry and the local `npx`-installed plugin at the same time unless you intentionally want to test duplicate plugin loading.
|
|
220
201
|
|
|
221
202
|
### Optional: OpenCode config package entry
|
|
222
203
|
|
|
@@ -239,6 +220,8 @@ If `/loop` does not appear after using only the config method, run the installer
|
|
|
239
220
|
npx -y @bybrawe/opencode-loop
|
|
240
221
|
```
|
|
241
222
|
|
|
223
|
+
If you later switch back to the recommended `npx` local install, remove `@bybrawe/opencode-loop` from the `plugin` array to avoid loading the package plugin and local plugin together.
|
|
224
|
+
|
|
242
225
|
### Install from GitHub
|
|
243
226
|
|
|
244
227
|
Use this if you want to install from source instead of npm.
|
|
@@ -1113,6 +1096,16 @@ Improve the application in small safe steps.
|
|
|
1113
1096
|
|
|
1114
1097
|
## Changelog highlights
|
|
1115
1098
|
|
|
1099
|
+
### v0.5.13
|
|
1100
|
+
|
|
1101
|
+
- Wrapped experimental goal tools with OpenCode's `tool()` helper and Zod schemas for current OpenCode tool validation.
|
|
1102
|
+
- Added `@opencode-ai/plugin` as a development dependency so fresh clones can resolve the tool helper.
|
|
1103
|
+
|
|
1104
|
+
### v0.5.12
|
|
1105
|
+
|
|
1106
|
+
- Changed local installs to write `opencode-loop.ts` and remove stale `opencode-loop.js` copies.
|
|
1107
|
+
- Fixed OpenCode CLI command argument parsing, current SDK call shapes, and `opencode-loopd` prompt passing.
|
|
1108
|
+
|
|
1116
1109
|
### v0.5.7
|
|
1117
1110
|
|
|
1118
1111
|
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
@@ -1127,11 +1120,11 @@ Improve the application in small safe steps.
|
|
|
1127
1120
|
- Stabilized `/loop-command 200m /compact` by routing compact/summarize through OpenCode TUI compact handling instead of treating it as a custom prompt command.
|
|
1128
1121
|
- Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
|
|
1129
1122
|
|
|
1130
|
-
|
|
1123
|
+
### v0.5.5
|
|
1131
1124
|
|
|
1132
1125
|
- Added explicit loop action types: prompt, scheduled question, OpenCode slash command, and shell command.
|
|
1133
1126
|
- Added `/loop-command` / `/loop-cmd` for commands such as `/compact`; these wait for the first interval by default.
|
|
1134
|
-
- Added `/loop-ask` for recurring checks such as
|
|
1127
|
+
- Added `/loop-ask` for recurring checks such as "did you run tests, tsc --noEmit, and build?"; it waits for the first interval by default.
|
|
1135
1128
|
- Added `/loop-shell` for recurring shell commands.
|
|
1136
1129
|
- Added `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact` type flags for `/loop`.
|
|
1137
1130
|
- Clarified idle behavior: if a job becomes due while OpenCode is busy, it waits and runs on the next idle event.
|
package/examples/opencode.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/scripts/loopd.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"
|
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2)
|
|
8
8
|
const FAILED_RUN_RETRY_MS = 5_000
|
|
9
|
+
const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
|
|
9
10
|
|
|
10
11
|
function arg(name, fallback = null) {
|
|
11
12
|
const i = args.indexOf(name)
|
|
@@ -39,25 +40,60 @@ function sleep(ms) {
|
|
|
39
40
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function
|
|
43
|
+
function spawnOnce(command, commandArgs, cwd) {
|
|
43
44
|
return new Promise((resolve) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
child
|
|
45
|
+
let settled = false
|
|
46
|
+
const done = (result) => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
resolve(result)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let child
|
|
53
|
+
try {
|
|
54
|
+
child = spawn(command, commandArgs, {
|
|
55
|
+
cwd,
|
|
56
|
+
shell: false,
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
env: process.env,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
done({ code: -1, error })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
child.on("error", (error) => done({ code: -1, error }))
|
|
67
|
+
child.on("exit", (code) => done({ code: code ?? 0 }))
|
|
52
68
|
})
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
function quoteWindowsArg(value) {
|
|
72
|
+
const text = String(value ?? "")
|
|
73
|
+
return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripBom(text) {
|
|
77
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function run(command, commandArgs, cwd) {
|
|
81
|
+
const direct = await spawnOnce(command, commandArgs, cwd)
|
|
82
|
+
if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct.code
|
|
83
|
+
|
|
84
|
+
const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd)
|
|
85
|
+
if (fallback.code === -1) {
|
|
86
|
+
console.error(`[opencode-loopd] failed to start ${command}: ${fallback.error?.message || direct.error?.message || "unknown error"}`)
|
|
87
|
+
}
|
|
88
|
+
return fallback.code
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
function readPrompt(project) {
|
|
56
92
|
const promptFile = arg("--prompt-file")
|
|
57
93
|
const promptArg = arg("--prompt")
|
|
58
94
|
|
|
59
95
|
if (promptFile) {
|
|
60
|
-
return fs.readFileSync(path.resolve(project, promptFile), "utf8")
|
|
96
|
+
return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
|
|
61
97
|
}
|
|
62
98
|
|
|
63
99
|
if (promptArg) return promptArg
|
|
@@ -99,8 +135,7 @@ async function daemon() {
|
|
|
99
135
|
console.log("")
|
|
100
136
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
101
137
|
|
|
102
|
-
const
|
|
103
|
-
const code = await run(command, project)
|
|
138
|
+
const code = await run(OPENCODE_BIN, ["run", "--continue", prompt], project)
|
|
104
139
|
|
|
105
140
|
if (code !== 0) {
|
|
106
141
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
@@ -135,25 +170,25 @@ function installTask() {
|
|
|
135
170
|
const script = fileURLToPath(import.meta.url)
|
|
136
171
|
|
|
137
172
|
const commandParts = [
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
quoteWindowsArg(node),
|
|
174
|
+
quoteWindowsArg(script),
|
|
140
175
|
"daemon",
|
|
141
176
|
"--project",
|
|
142
|
-
|
|
177
|
+
quoteWindowsArg(project),
|
|
143
178
|
"--every",
|
|
144
179
|
"0s",
|
|
145
180
|
"--max-runs",
|
|
146
181
|
"1",
|
|
147
182
|
]
|
|
148
183
|
|
|
149
|
-
if (promptFile) commandParts.push("--prompt-file",
|
|
150
|
-
if (promptArg) commandParts.push("--prompt",
|
|
184
|
+
if (promptFile) commandParts.push("--prompt-file", quoteWindowsArg(promptFile))
|
|
185
|
+
if (promptArg) commandParts.push("--prompt", quoteWindowsArg(promptArg))
|
|
151
186
|
|
|
152
187
|
const taskCommand = commandParts.join(" ")
|
|
153
|
-
const
|
|
188
|
+
const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
|
|
154
189
|
|
|
155
|
-
console.log(schtasks)
|
|
156
|
-
const result = spawnSync(schtasks, { shell:
|
|
190
|
+
console.log(["schtasks", ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
|
|
191
|
+
const result = spawnSync("schtasks", taskArgs, { shell: false, stdio: "inherit" })
|
|
157
192
|
process.exit(result.status ?? 0)
|
|
158
193
|
}
|
|
159
194
|
|
|
@@ -163,7 +198,7 @@ function uninstallTask() {
|
|
|
163
198
|
}
|
|
164
199
|
|
|
165
200
|
const name = arg("--name", "OpenCodeLoop")
|
|
166
|
-
const result = spawnSync(
|
|
201
|
+
const result = spawnSync("schtasks", ["/Delete", "/F", "/TN", name], { shell: false, stdio: "inherit" })
|
|
167
202
|
process.exit(result.status ?? 0)
|
|
168
203
|
}
|
|
169
204
|
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ const dueTimers = new Map()
|
|
|
25
25
|
const watchdogTimers = new Map()
|
|
26
26
|
const runLocks = new Map()
|
|
27
27
|
const knownSessions = new Map()
|
|
28
|
+
const stateWriteLocks = new Map()
|
|
28
29
|
let heartbeatTimer
|
|
29
30
|
const sessionStatuses = new Map()
|
|
30
31
|
const sessionStatusSeenAt = new Map()
|
|
@@ -320,21 +321,61 @@ function statePath(directory, sessionID) { return path.join(stateDir(directory),
|
|
|
320
321
|
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
321
322
|
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
322
323
|
|
|
324
|
+
function stateLockKey(directory, sessionID) {
|
|
325
|
+
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function withStateWriteLock(directory, sessionID, fn) {
|
|
329
|
+
const key = stateLockKey(directory, sessionID)
|
|
330
|
+
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
331
|
+
let release
|
|
332
|
+
const current = new Promise((resolve) => { release = resolve })
|
|
333
|
+
const next = previous.catch(() => {}).then(() => current)
|
|
334
|
+
stateWriteLocks.set(key, next)
|
|
335
|
+
await previous.catch(() => {})
|
|
336
|
+
try {
|
|
337
|
+
return await fn()
|
|
338
|
+
} finally {
|
|
339
|
+
release()
|
|
340
|
+
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
323
344
|
async function readState(directory, sessionID) {
|
|
345
|
+
const target = statePath(directory, sessionID)
|
|
324
346
|
try {
|
|
325
|
-
const parsed = JSON.parse(await fs.readFile(
|
|
347
|
+
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
326
348
|
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
327
|
-
} catch {
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error?.code !== "ENOENT") {
|
|
351
|
+
try {
|
|
352
|
+
await ensureDir(stateDir(directory))
|
|
353
|
+
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
328
356
|
return { version: 4, jobs: [] }
|
|
329
357
|
}
|
|
330
358
|
}
|
|
331
359
|
|
|
332
360
|
async function writeState(directory, sessionID, state) {
|
|
333
|
-
await
|
|
334
|
-
|
|
361
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
362
|
+
await ensureDir(stateDir(directory))
|
|
363
|
+
const target = statePath(directory, sessionID)
|
|
364
|
+
const temp = `${target}.${process.pid}.${Date.now()}.tmp`
|
|
365
|
+
try {
|
|
366
|
+
await fs.writeFile(temp, JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
|
|
367
|
+
await fs.rename(temp, target)
|
|
368
|
+
} finally {
|
|
369
|
+
try { await fs.rm(temp, { force: true }) } catch {}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
335
372
|
}
|
|
336
373
|
|
|
337
|
-
async function removeState(directory, sessionID) {
|
|
374
|
+
async function removeState(directory, sessionID) {
|
|
375
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
376
|
+
try { await fs.unlink(statePath(directory, sessionID)) } catch {}
|
|
377
|
+
})
|
|
378
|
+
}
|
|
338
379
|
|
|
339
380
|
function sdkError(result) {
|
|
340
381
|
if (!result || typeof result !== "object") return undefined
|
|
@@ -497,7 +538,7 @@ function startHeartbeat() {
|
|
|
497
538
|
}
|
|
498
539
|
Promise.resolve()
|
|
499
540
|
.then(async () => {
|
|
500
|
-
await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
|
|
541
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
501
542
|
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
502
543
|
})
|
|
503
544
|
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
@@ -820,6 +861,19 @@ function staleActiveRun(sessionID) {
|
|
|
820
861
|
return age >= threshold
|
|
821
862
|
}
|
|
822
863
|
|
|
864
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
865
|
+
if (!options.requireIdle && !options.forceStale) return true
|
|
866
|
+
if (options.forceStale && staleActiveRun(sessionID)) return true
|
|
867
|
+
if (!options.requireIdle) return false
|
|
868
|
+
|
|
869
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
870
|
+
if (live?.type) return live.type === "idle"
|
|
871
|
+
|
|
872
|
+
const cached = sessionStatuses.get(sessionID)
|
|
873
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
874
|
+
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
875
|
+
}
|
|
876
|
+
|
|
823
877
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
824
878
|
const argsList = []
|
|
825
879
|
if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
|
|
@@ -1148,14 +1202,17 @@ async function runGoalChecks(directory, sessionID, job, client) {
|
|
|
1148
1202
|
return job
|
|
1149
1203
|
}
|
|
1150
1204
|
|
|
1151
|
-
async function finalizeActiveRun(directory, client, sessionID) {
|
|
1205
|
+
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1152
1206
|
const active = activeRuns.get(sessionID)
|
|
1153
1207
|
if (!active) return
|
|
1208
|
+
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1209
|
+
const recoveredStale = staleActiveRun(sessionID)
|
|
1154
1210
|
clearActiveRun(sessionID)
|
|
1155
1211
|
const state = await readState(directory, sessionID)
|
|
1156
1212
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1157
1213
|
if (!job) return
|
|
1158
1214
|
job.lastFinishedAt = now()
|
|
1215
|
+
if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
|
|
1159
1216
|
|
|
1160
1217
|
if (job.verifyCommand) {
|
|
1161
1218
|
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
@@ -1201,6 +1258,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
1201
1258
|
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
1202
1259
|
await createCheckpoint(directory, sessionID, job, client)
|
|
1203
1260
|
await scheduleDueWork(directory, client, sessionID)
|
|
1261
|
+
return true
|
|
1204
1262
|
}
|
|
1205
1263
|
|
|
1206
1264
|
async function fireAction(directory, client, sessionID, job) {
|
|
@@ -1258,7 +1316,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1258
1316
|
runLocks.set(sessionID, now())
|
|
1259
1317
|
let job
|
|
1260
1318
|
try {
|
|
1261
|
-
await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
|
|
1319
|
+
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
1262
1320
|
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1263
1321
|
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1264
1322
|
await reschedule(BUSY_RETRY_MS)
|
|
@@ -1361,6 +1419,8 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1361
1419
|
let timer
|
|
1362
1420
|
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1363
1421
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1422
|
+
sessionStatuses.set(sessionID, "busy")
|
|
1423
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1364
1424
|
await reschedule(BUSY_RETRY_MS)
|
|
1365
1425
|
} catch (error) {
|
|
1366
1426
|
clearActiveRun(sessionID)
|