@bybrawe/opencode-loop 0.5.11 → 0.5.12
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 +13 -0
- package/README.md +9 -8
- package/package.json +3 -3
- package/scripts/install-node.mjs +3 -2
- package/scripts/install.ps1 +4 -3
- package/scripts/install.sh +3 -2
- package/scripts/loopd.mjs +7 -2
- package/src/index.js +27 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.12
|
|
4
|
+
|
|
5
|
+
- Fixed local plugin installs to write `opencode-loop.ts`, matching OpenCode local plugin discovery.
|
|
6
|
+
- Removed stale `opencode-loop.js` local plugin copies during install to avoid duplicate or outdated local plugin loads.
|
|
7
|
+
- Made the PowerShell source installer honor `OPENCODE_CONFIG_DIR`, matching the npm and shell installers.
|
|
8
|
+
- Fixed `opencode-loopd` to pass the prompt to `opencode run` as the positional message instead of the non-existent `--prompt` flag.
|
|
9
|
+
- Added a short retry backoff for failing `opencode-loopd --every 0s` runs.
|
|
10
|
+
- Normalized command arguments from array/object forms so `opencode run --command loop ...` can schedule jobs like TUI slash commands.
|
|
11
|
+
- Stripped whole-argument quote wrappers before parsing loop durations, fixing quoted `opencode run --command loop "1h ..."` invocations.
|
|
12
|
+
- Fixed a goal finalization reassignment so the generated local TypeScript plugin parses under Bun.
|
|
13
|
+
- Fixed the Windows Task Scheduler helper script path used by `opencode-loopd install-task`.
|
|
14
|
+
- Tried current OpenCode SDK `{ path, body }` and `{ query }` argument shapes before older fallback shapes.
|
|
15
|
+
|
|
3
16
|
## 0.5.11
|
|
4
17
|
|
|
5
18
|
- Added a watchdog loop in addition to one-shot due timers, so recurring jobs keep checking due work even if OpenCode misses an idle event or a due timeout gets effectively stuck behind a stale busy state.
|
package/README.md
CHANGED
|
@@ -178,18 +178,19 @@ npx -y @bybrawe/opencode-loop
|
|
|
178
178
|
```
|
|
179
179
|
|
|
180
180
|
The installer copies the plugin and slash command files into your OpenCode config directory.
|
|
181
|
+
It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
|
|
181
182
|
|
|
182
183
|
Windows target paths:
|
|
183
184
|
|
|
184
185
|
```text
|
|
185
|
-
%USERPROFILE%\.config\opencode\plugins\opencode-loop.
|
|
186
|
+
%USERPROFILE%\.config\opencode\plugins\opencode-loop.ts
|
|
186
187
|
%USERPROFILE%\.config\opencode\commands\loop*.md
|
|
187
188
|
```
|
|
188
189
|
|
|
189
190
|
macOS / Linux target paths:
|
|
190
191
|
|
|
191
192
|
```text
|
|
192
|
-
~/.config/opencode/plugins/opencode-loop.
|
|
193
|
+
~/.config/opencode/plugins/opencode-loop.ts
|
|
193
194
|
~/.config/opencode/commands/loop*.md
|
|
194
195
|
```
|
|
195
196
|
|
|
@@ -273,7 +274,7 @@ Windows PowerShell:
|
|
|
273
274
|
```powershell
|
|
274
275
|
mkdir "$env:USERPROFILE\.config\opencode\plugins" -Force
|
|
275
276
|
mkdir "$env:USERPROFILE\.config\opencode\commands" -Force
|
|
276
|
-
copy .\src\index.js "$env:USERPROFILE\.config\opencode\plugins\opencode-loop.
|
|
277
|
+
copy .\src\index.js "$env:USERPROFILE\.config\opencode\plugins\opencode-loop.ts"
|
|
277
278
|
copy .\commands\*.md "$env:USERPROFILE\.config\opencode\commands\"
|
|
278
279
|
```
|
|
279
280
|
|
|
@@ -281,7 +282,7 @@ macOS / Linux:
|
|
|
281
282
|
|
|
282
283
|
```bash
|
|
283
284
|
mkdir -p ~/.config/opencode/plugins ~/.config/opencode/commands
|
|
284
|
-
cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.
|
|
285
|
+
cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.ts
|
|
285
286
|
cp ./commands/*.md ~/.config/opencode/commands/
|
|
286
287
|
```
|
|
287
288
|
|
|
@@ -291,7 +292,7 @@ Use this when you want the plugin to be available only inside one repository.
|
|
|
291
292
|
|
|
292
293
|
```bash
|
|
293
294
|
mkdir -p .opencode/plugins .opencode/commands
|
|
294
|
-
cp ./src/index.js .opencode/plugins/opencode-loop.
|
|
295
|
+
cp ./src/index.js .opencode/plugins/opencode-loop.ts
|
|
295
296
|
cp ./commands/*.md .opencode/commands/
|
|
296
297
|
```
|
|
297
298
|
|
|
@@ -300,7 +301,7 @@ On Windows PowerShell:
|
|
|
300
301
|
```powershell
|
|
301
302
|
mkdir .opencode\plugins -Force
|
|
302
303
|
mkdir .opencode\commands -Force
|
|
303
|
-
copy .\src\index.js .opencode\plugins\opencode-loop.
|
|
304
|
+
copy .\src\index.js .opencode\plugins\opencode-loop.ts
|
|
304
305
|
copy .\commands\*.md .opencode\commands\
|
|
305
306
|
```
|
|
306
307
|
|
|
@@ -316,7 +317,7 @@ After restarting OpenCode, run:
|
|
|
316
317
|
If the commands do not appear:
|
|
317
318
|
|
|
318
319
|
1. Make sure OpenCode was fully restarted.
|
|
319
|
-
2. Check that `opencode-loop.
|
|
320
|
+
2. Check that `opencode-loop.ts` exists in the OpenCode plugin directory.
|
|
320
321
|
3. Check that `loop.md`, `loop-help.md`, and the other command files exist in the OpenCode commands directory.
|
|
321
322
|
4. Run `npx -y @bybrawe/opencode-loop` again to reinstall the command files.
|
|
322
323
|
|
|
@@ -1177,4 +1178,4 @@ Improve the application in small safe steps.
|
|
|
1177
1178
|
|
|
1178
1179
|
## License
|
|
1179
1180
|
|
|
1180
|
-
MIT
|
|
1181
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
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",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"@opencode-ai/plugin": ">=1.4.0"
|
|
56
56
|
},
|
|
57
57
|
"bin": {
|
|
58
|
-
"opencode-loop": "
|
|
59
|
-
"opencode-loopd": "
|
|
58
|
+
"opencode-loop": "scripts/install-node.mjs",
|
|
59
|
+
"opencode-loopd": "scripts/loopd.mjs"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
package/scripts/install-node.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { copyFile, mkdir, readdir } from "node:fs/promises"
|
|
2
|
+
import { copyFile, mkdir, readdir, rm } from "node:fs/promises"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { fileURLToPath } from "node:url"
|
|
@@ -11,7 +11,8 @@ const commandDir = join(config, "commands")
|
|
|
11
11
|
|
|
12
12
|
await mkdir(pluginDir, { recursive: true })
|
|
13
13
|
await mkdir(commandDir, { recursive: true })
|
|
14
|
-
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.
|
|
14
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
15
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
15
16
|
|
|
16
17
|
for (const name of await readdir(join(root, "commands"))) {
|
|
17
18
|
if (name.endsWith(".md")) {
|
package/scripts/install.ps1
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
$ErrorActionPreference = "Stop"
|
|
2
2
|
|
|
3
|
-
$ConfigDir = Join-Path $env:USERPROFILE ".config\opencode"
|
|
3
|
+
$ConfigDir = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".config\opencode" }
|
|
4
4
|
$PluginDir = Join-Path $ConfigDir "plugins"
|
|
5
5
|
$CommandDir = Join-Path $ConfigDir "commands"
|
|
6
6
|
$Root = Split-Path -Parent $PSScriptRoot
|
|
@@ -8,10 +8,11 @@ $Root = Split-Path -Parent $PSScriptRoot
|
|
|
8
8
|
New-Item -ItemType Directory -Force -Path $PluginDir | Out-Null
|
|
9
9
|
New-Item -ItemType Directory -Force -Path $CommandDir | Out-Null
|
|
10
10
|
|
|
11
|
-
Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.
|
|
11
|
+
Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
|
|
12
|
+
Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
|
|
12
13
|
Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
|
|
13
14
|
|
|
14
15
|
Write-Host "Installed OpenCode Loop plugin." -ForegroundColor Green
|
|
15
|
-
Write-Host "Plugin: $PluginDir\opencode-loop.
|
|
16
|
+
Write-Host "Plugin: $PluginDir\opencode-loop.ts"
|
|
16
17
|
Write-Host "Commands: $CommandDir\loop*.md"
|
|
17
18
|
Write-Host "Restart OpenCode, then run: /loop-help"
|
package/scripts/install.sh
CHANGED
|
@@ -7,10 +7,11 @@ COMMAND_DIR="$CONFIG_DIR/commands"
|
|
|
7
7
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
|
8
8
|
|
|
9
9
|
mkdir -p "$PLUGIN_DIR" "$COMMAND_DIR"
|
|
10
|
-
cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.
|
|
10
|
+
cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
|
|
11
|
+
rm -f "$PLUGIN_DIR/opencode-loop.js"
|
|
11
12
|
cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
|
|
12
13
|
|
|
13
14
|
echo "Installed OpenCode Loop plugin."
|
|
14
|
-
echo "Plugin: $PLUGIN_DIR/opencode-loop.
|
|
15
|
+
echo "Plugin: $PLUGIN_DIR/opencode-loop.ts"
|
|
15
16
|
echo "Commands: $COMMAND_DIR/loop*.md"
|
|
16
17
|
echo "Restart OpenCode, then run: /loop-help"
|
package/scripts/loopd.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
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 { fileURLToPath } from "node:url"
|
|
5
6
|
|
|
6
7
|
const args = process.argv.slice(2)
|
|
8
|
+
const FAILED_RUN_RETRY_MS = 5_000
|
|
7
9
|
|
|
8
10
|
function arg(name, fallback = null) {
|
|
9
11
|
const i = args.indexOf(name)
|
|
@@ -97,11 +99,14 @@ async function daemon() {
|
|
|
97
99
|
console.log("")
|
|
98
100
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
99
101
|
|
|
100
|
-
const command = `opencode run --continue
|
|
102
|
+
const command = `opencode run --continue ${JSON.stringify(prompt)}`
|
|
101
103
|
const code = await run(command, project)
|
|
102
104
|
|
|
103
105
|
if (code !== 0) {
|
|
104
106
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
107
|
+
if (delay === 0) {
|
|
108
|
+
await sleep(FAILED_RUN_RETRY_MS)
|
|
109
|
+
}
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
if (maxRuns > 0 && count >= maxRuns) {
|
|
@@ -127,7 +132,7 @@ function installTask() {
|
|
|
127
132
|
const promptFile = arg("--prompt-file")
|
|
128
133
|
const promptArg = arg("--prompt")
|
|
129
134
|
const node = process.execPath
|
|
130
|
-
const script =
|
|
135
|
+
const script = fileURLToPath(import.meta.url)
|
|
131
136
|
|
|
132
137
|
const commandParts = [
|
|
133
138
|
`"${node}"`,
|
package/src/index.js
CHANGED
|
@@ -148,7 +148,7 @@ function parseCompactEvery(value) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
function parseLoopArgs(raw, defaults = {}) {
|
|
151
|
-
let input = String(raw || "").trim()
|
|
151
|
+
let input = stripOuterQuotes(String(raw || "").trim())
|
|
152
152
|
let first = ""
|
|
153
153
|
let rest = input
|
|
154
154
|
let intervalMs = defaults.intervalMs ?? null
|
|
@@ -390,8 +390,8 @@ async function executeTuiCommand(client, command) {
|
|
|
390
390
|
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
391
391
|
return await sdkCall(
|
|
392
392
|
client.tui.executeCommand.bind(client.tui),
|
|
393
|
-
{ command },
|
|
394
393
|
{ body: { command } },
|
|
394
|
+
{ command },
|
|
395
395
|
)
|
|
396
396
|
}
|
|
397
397
|
|
|
@@ -415,7 +415,7 @@ async function compactSession(client, sessionID) {
|
|
|
415
415
|
}
|
|
416
416
|
}
|
|
417
417
|
try {
|
|
418
|
-
await sdkCall(client.session.summarize.bind(client.session), {
|
|
418
|
+
await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID })
|
|
419
419
|
return true
|
|
420
420
|
} catch (error) {
|
|
421
421
|
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
@@ -428,22 +428,22 @@ async function log(client, level, message, extra) {
|
|
|
428
428
|
try {
|
|
429
429
|
await sdkCall(
|
|
430
430
|
client.app.log.bind(client.app),
|
|
431
|
-
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
432
431
|
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
432
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
433
433
|
)
|
|
434
434
|
} catch {}
|
|
435
435
|
}
|
|
436
436
|
|
|
437
437
|
async function toast(client, message, variant = "info") {
|
|
438
|
-
try { await sdkCall(client.tui.showToast.bind(client.tui), { message, variant }, {
|
|
438
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
async function say(client, sessionID, text) {
|
|
442
442
|
try {
|
|
443
443
|
await sdkCall(
|
|
444
444
|
client.session.prompt.bind(client.session),
|
|
445
|
-
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
446
445
|
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
446
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
447
447
|
)
|
|
448
448
|
} catch {}
|
|
449
449
|
}
|
|
@@ -468,6 +468,18 @@ function normalizeArgsForKey(args) {
|
|
|
468
468
|
try { return JSON.stringify(args) } catch { return String(args) }
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
+
function commandArgsText(args) {
|
|
472
|
+
if (args === undefined || args === null) return ""
|
|
473
|
+
if (typeof args === "string") return args
|
|
474
|
+
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
475
|
+
if (typeof args === "object") {
|
|
476
|
+
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
477
|
+
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return String(args)
|
|
481
|
+
}
|
|
482
|
+
|
|
471
483
|
function rememberSession(directory, client, sessionID) {
|
|
472
484
|
if (!sessionID) return
|
|
473
485
|
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
@@ -809,7 +821,7 @@ function staleActiveRun(sessionID) {
|
|
|
809
821
|
|
|
810
822
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
811
823
|
const argsList = []
|
|
812
|
-
if (directory) argsList.push({ workspace: directory }, { directory })
|
|
824
|
+
if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
|
|
813
825
|
argsList.push({})
|
|
814
826
|
for (const args of argsList) {
|
|
815
827
|
try {
|
|
@@ -1140,7 +1152,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
1140
1152
|
if (!active) return
|
|
1141
1153
|
clearActiveRun(sessionID)
|
|
1142
1154
|
const state = await readState(directory, sessionID)
|
|
1143
|
-
|
|
1155
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1144
1156
|
if (!job) return
|
|
1145
1157
|
job.lastFinishedAt = now()
|
|
1146
1158
|
|
|
@@ -1209,7 +1221,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1209
1221
|
await compactSession(client, sessionID)
|
|
1210
1222
|
return { startsAssistantTurn: true }
|
|
1211
1223
|
}
|
|
1212
|
-
await sdkCall(client.session.command.bind(client.session), {
|
|
1224
|
+
await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: { command, arguments: argumentsText } }, { sessionID, command, arguments: argumentsText })
|
|
1213
1225
|
return { startsAssistantTurn: true }
|
|
1214
1226
|
}
|
|
1215
1227
|
if (kind === "shell") {
|
|
@@ -1219,18 +1231,18 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1219
1231
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1220
1232
|
return { startsAssistantTurn: false }
|
|
1221
1233
|
}
|
|
1222
|
-
fireSdk(client, "session.shell", client.session.shell.bind(client.session), {
|
|
1234
|
+
fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: { command } }, { sessionID, command })
|
|
1223
1235
|
return { startsAssistantTurn: true }
|
|
1224
1236
|
}
|
|
1225
1237
|
const prompt = await buildPrompt(directory, job)
|
|
1226
1238
|
const prefix = kind === "goal"
|
|
1227
1239
|
? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
|
|
1228
1240
|
: "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."
|
|
1229
|
-
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1241
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
|
|
1230
1242
|
|
|
1231
|
-
${prompt}` }] }, {
|
|
1243
|
+
${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1232
1244
|
|
|
1233
|
-
${prompt}` }] }
|
|
1245
|
+
${prompt}` }] })
|
|
1234
1246
|
return { startsAssistantTurn: true }
|
|
1235
1247
|
}
|
|
1236
1248
|
|
|
@@ -1346,7 +1358,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1346
1358
|
return
|
|
1347
1359
|
}
|
|
1348
1360
|
let timer
|
|
1349
|
-
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), {
|
|
1361
|
+
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)
|
|
1350
1362
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1351
1363
|
await reschedule(BUSY_RETRY_MS)
|
|
1352
1364
|
} catch (error) {
|
|
@@ -1588,7 +1600,7 @@ async function exportLoop(directory, client, sessionID) {
|
|
|
1588
1600
|
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
|
|
1589
1601
|
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
1590
1602
|
const sessionID = input?.sessionID
|
|
1591
|
-
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
1603
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
1592
1604
|
if (!sessionID || !name) return false
|
|
1593
1605
|
rememberSession(directory, client, sessionID)
|
|
1594
1606
|
if (wasHandled(sessionID, name, args)) return true
|