@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 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.js
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.js
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.js"
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.js
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.js
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.js
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.js` exists in the OpenCode plugin directory.
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.11",
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": "./scripts/install-node.mjs",
59
- "opencode-loopd": "./scripts/loopd.mjs"
58
+ "opencode-loop": "scripts/install-node.mjs",
59
+ "opencode-loopd": "scripts/loopd.mjs"
60
60
  },
61
61
  "publishConfig": {
62
62
  "access": "public"
@@ -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.js"))
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")) {
@@ -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.js")
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.js"
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"
@@ -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.js"
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.js"
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 --prompt ${JSON.stringify(prompt)}`
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 = path.resolve(new URL(import.meta.url).pathname)
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), { sessionID }, { path: { id: sessionID }, body: {} })
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 }, { body: { message, variant } }) } catch {}
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
- const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
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), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
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), { sessionID, command }, { path: { id: sessionID }, body: { command } })
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}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
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), { sessionID }, { path: { id: sessionID }, body: {} }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
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