@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 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
- For a clean reinstall or upgrade, use the latest published package:
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
- ## v0.5.5
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 “did you run tests, tsc --noEmit, and build?”; it waits for the first interval by default.
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.
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "plugin": [
4
- "@bybrawe/opencode-loop",
5
4
  "@tarquinen/opencode-dcp@latest"
6
5
  ],
7
6
  "permission": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.13",
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 run(command, cwd) {
43
+ function spawnOnce(command, commandArgs, cwd) {
43
44
  return new Promise((resolve) => {
44
- const child = spawn(command, {
45
- cwd,
46
- shell: true,
47
- stdio: "inherit",
48
- env: process.env,
49
- })
50
-
51
- child.on("exit", (code) => resolve(code ?? 0))
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 command = `opencode run --continue ${JSON.stringify(prompt)}`
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
- `"${node}"`,
139
- `"${script}"`,
173
+ quoteWindowsArg(node),
174
+ quoteWindowsArg(script),
140
175
  "daemon",
141
176
  "--project",
142
- `"${project}"`,
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", `"${promptFile}"`)
150
- if (promptArg) commandParts.push("--prompt", `"${promptArg.replaceAll('"', '\\"')}"`)
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 schtasks = `schtasks /Create /F /SC MINUTE /MO ${minutes} /TN "${name}" /TR ${JSON.stringify(taskCommand)}`
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: true, stdio: "inherit" })
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(`schtasks /Delete /F /TN "${name}"`, { shell: true, stdio: "inherit" })
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(statePath(directory, sessionID), "utf8"))
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 ensureDir(stateDir(directory))
334
- await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
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) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
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)