@bybrawe/opencode-loop 0.5.11 → 0.5.13

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.13
4
+
5
+ - Wrapped experimental goal tools with `tool()` and Zod schemas for current OpenCode tool validation.
6
+ - Added `@opencode-ai/plugin` as a dev dependency so fresh clones can resolve the tool helper during local development.
7
+
8
+ ## 0.5.12
9
+
10
+ - Fixed local plugin installs to write `opencode-loop.ts`, matching OpenCode local plugin discovery.
11
+ - Removed stale `opencode-loop.js` local plugin copies during install to avoid duplicate or outdated local plugin loads.
12
+ - Made the PowerShell source installer honor `OPENCODE_CONFIG_DIR`, matching the npm and shell installers.
13
+ - Fixed `opencode-loopd` to pass the prompt to `opencode run` as the positional message instead of the non-existent `--prompt` flag.
14
+ - Added a short retry backoff for failing `opencode-loopd --every 0s` runs.
15
+ - Normalized command arguments from array/object forms so `opencode run --command loop ...` can schedule jobs like TUI slash commands.
16
+ - Stripped whole-argument quote wrappers before parsing loop durations, fixing quoted `opencode run --command loop "1h ..."` invocations.
17
+ - Fixed a goal finalization reassignment so the generated local TypeScript plugin parses under Bun.
18
+ - Fixed the Windows Task Scheduler helper script path used by `opencode-loopd install-task`.
19
+ - Tried current OpenCode SDK `{ path, body }` and `{ query }` argument shapes before older fallback shapes.
20
+
3
21
  ## 0.5.11
4
22
 
5
23
  - 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.13",
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",
@@ -54,9 +54,12 @@
54
54
  "peerDependencies": {
55
55
  "@opencode-ai/plugin": ">=1.4.0"
56
56
  },
57
+ "devDependencies": {
58
+ "@opencode-ai/plugin": "^1.4.0"
59
+ },
57
60
  "bin": {
58
- "opencode-loop": "./scripts/install-node.mjs",
59
- "opencode-loopd": "./scripts/loopd.mjs"
61
+ "opencode-loop": "scripts/install-node.mjs",
62
+ "opencode-loopd": "scripts/loopd.mjs"
60
63
  },
61
64
  "publishConfig": {
62
65
  "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
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs"
2
2
  import path from "node:path"
3
3
  import { spawn } from "node:child_process"
4
+ import { tool } from "@opencode-ai/plugin/tool"
4
5
 
5
6
  const SERVICE = "opencode-loop"
6
7
  const STATE_DIR = ".opencode/opencode-loop"
@@ -148,7 +149,7 @@ function parseCompactEvery(value) {
148
149
  }
149
150
 
150
151
  function parseLoopArgs(raw, defaults = {}) {
151
- let input = String(raw || "").trim()
152
+ let input = stripOuterQuotes(String(raw || "").trim())
152
153
  let first = ""
153
154
  let rest = input
154
155
  let intervalMs = defaults.intervalMs ?? null
@@ -390,8 +391,8 @@ async function executeTuiCommand(client, command) {
390
391
  if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
391
392
  return await sdkCall(
392
393
  client.tui.executeCommand.bind(client.tui),
393
- { command },
394
394
  { body: { command } },
395
+ { command },
395
396
  )
396
397
  }
397
398
 
@@ -415,7 +416,7 @@ async function compactSession(client, sessionID) {
415
416
  }
416
417
  }
417
418
  try {
418
- await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
419
+ await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID })
419
420
  return true
420
421
  } catch (error) {
421
422
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
@@ -428,22 +429,22 @@ async function log(client, level, message, extra) {
428
429
  try {
429
430
  await sdkCall(
430
431
  client.app.log.bind(client.app),
431
- extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
432
432
  { body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
433
+ extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
433
434
  )
434
435
  } catch {}
435
436
  }
436
437
 
437
438
  async function toast(client, message, variant = "info") {
438
- try { await sdkCall(client.tui.showToast.bind(client.tui), { message, variant }, { body: { message, variant } }) } catch {}
439
+ try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
439
440
  }
440
441
 
441
442
  async function say(client, sessionID, text) {
442
443
  try {
443
444
  await sdkCall(
444
445
  client.session.prompt.bind(client.session),
445
- { sessionID, noReply: true, parts: [{ type: "text", text }] },
446
446
  { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
447
+ { sessionID, noReply: true, parts: [{ type: "text", text }] },
447
448
  )
448
449
  } catch {}
449
450
  }
@@ -468,6 +469,18 @@ function normalizeArgsForKey(args) {
468
469
  try { return JSON.stringify(args) } catch { return String(args) }
469
470
  }
470
471
 
472
+ function commandArgsText(args) {
473
+ if (args === undefined || args === null) return ""
474
+ if (typeof args === "string") return args
475
+ if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
476
+ if (typeof args === "object") {
477
+ for (const key of ["arguments", "args", "message", "text", "value"]) {
478
+ if (args[key] !== undefined) return commandArgsText(args[key])
479
+ }
480
+ }
481
+ return String(args)
482
+ }
483
+
471
484
  function rememberSession(directory, client, sessionID) {
472
485
  if (!sessionID) return
473
486
  knownSessions.set(sessionID, { directory, client, seenAt: now() })
@@ -809,7 +822,7 @@ function staleActiveRun(sessionID) {
809
822
 
810
823
  async function readLiveSessionStatus(client, sessionID, directory) {
811
824
  const argsList = []
812
- if (directory) argsList.push({ workspace: directory }, { directory })
825
+ if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
813
826
  argsList.push({})
814
827
  for (const args of argsList) {
815
828
  try {
@@ -1140,7 +1153,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
1140
1153
  if (!active) return
1141
1154
  clearActiveRun(sessionID)
1142
1155
  const state = await readState(directory, sessionID)
1143
- const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
1156
+ let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
1144
1157
  if (!job) return
1145
1158
  job.lastFinishedAt = now()
1146
1159
 
@@ -1209,7 +1222,7 @@ async function fireAction(directory, client, sessionID, job) {
1209
1222
  await compactSession(client, sessionID)
1210
1223
  return { startsAssistantTurn: true }
1211
1224
  }
1212
- await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
1225
+ await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: { command, arguments: argumentsText } }, { sessionID, command, arguments: argumentsText })
1213
1226
  return { startsAssistantTurn: true }
1214
1227
  }
1215
1228
  if (kind === "shell") {
@@ -1219,18 +1232,18 @@ async function fireAction(directory, client, sessionID, job) {
1219
1232
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1220
1233
  return { startsAssistantTurn: false }
1221
1234
  }
1222
- fireSdk(client, "session.shell", client.session.shell.bind(client.session), { sessionID, command }, { path: { id: sessionID }, body: { command } })
1235
+ fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: { command } }, { sessionID, command })
1223
1236
  return { startsAssistantTurn: true }
1224
1237
  }
1225
1238
  const prompt = await buildPrompt(directory, job)
1226
1239
  const prefix = kind === "goal"
1227
1240
  ? "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
1241
  : "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}
1242
+ fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1230
1243
 
1231
- ${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1244
+ ${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
1232
1245
 
1233
- ${prompt}` }] } })
1246
+ ${prompt}` }] })
1234
1247
  return { startsAssistantTurn: true }
1235
1248
  }
1236
1249
 
@@ -1346,7 +1359,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1346
1359
  return
1347
1360
  }
1348
1361
  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)
1362
+ 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
1363
  activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1351
1364
  await reschedule(BUSY_RETRY_MS)
1352
1365
  } catch (error) {
@@ -1588,7 +1601,7 @@ async function exportLoop(directory, client, sessionID) {
1588
1601
  async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
1589
1602
  const name = commandName(input?.command ?? input?.name ?? fallbackName)
1590
1603
  const sessionID = input?.sessionID
1591
- const args = input?.arguments ?? fallbackArgs ?? ""
1604
+ const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
1592
1605
  if (!sessionID || !name) return false
1593
1606
  rememberSession(directory, client, sessionID)
1594
1607
  if (wasHandled(sessionID, name, args)) return true
@@ -1628,39 +1641,39 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1628
1641
 
1629
1642
  function goalTools(defaultDirectory) {
1630
1643
  return {
1631
- opencode_loop_goal_complete: {
1644
+ opencode_loop_goal_complete: tool({
1632
1645
  description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
1633
1646
  args: {
1634
- summary: { type: "string", description: "Short human-readable summary of what was completed." },
1635
- evidence: { type: "string", description: "Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results." },
1647
+ summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
1648
+ evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
1636
1649
  },
1637
1650
  execute: async (args, context) => {
1638
1651
  const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
1639
1652
  return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
1640
1653
  },
1641
- },
1642
- opencode_loop_goal_blocked: {
1654
+ }),
1655
+ opencode_loop_goal_blocked: tool({
1643
1656
  description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
1644
1657
  args: {
1645
- reason: { type: "string", description: "Why the goal is blocked." },
1646
- needed: { type: "string", description: "What user input, credential, decision, or manual action is needed to continue." },
1658
+ reason: tool.schema.string().describe("Why the goal is blocked."),
1659
+ needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
1647
1660
  },
1648
1661
  execute: async (args, context) => {
1649
1662
  const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
1650
1663
  return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
1651
1664
  },
1652
- },
1653
- opencode_loop_goal_progress: {
1665
+ }),
1666
+ opencode_loop_goal_progress: tool({
1654
1667
  description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
1655
1668
  args: {
1656
- summary: { type: "string", description: "What useful progress was made." },
1657
- next: { type: "string", description: "The next step toward completing the goal." },
1669
+ summary: tool.schema.string().describe("What useful progress was made."),
1670
+ next: tool.schema.string().describe("The next step toward completing the goal."),
1658
1671
  },
1659
1672
  execute: async (args, context) => {
1660
1673
  const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
1661
1674
  return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
1662
1675
  },
1663
- },
1676
+ }),
1664
1677
  }
1665
1678
  }
1666
1679