@bybrawe/opencode-loop 0.5.10 → 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,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.10
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
+
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.
6
19
  - Added stronger stale-active-run recovery. If a plugin-injected run is still marked active after the recovery window, the scheduler treats it as finished, finalizes it, and continues without waiting for another manual command such as `/loop-status`.
package/README.md CHANGED
@@ -9,9 +9,14 @@ It is useful for long coding sessions, `progress.md` workflows, TODO automation,
9
9
  Repository: **ByBrawe/opencode-loop**
10
10
  NPM package name: **@bybrawe/opencode-loop**
11
11
 
12
+ ### Scheduler heartbeat note
13
+
14
+ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode TUI because local command hooks are event-driven: a normal `/loop-status` command can wake the plugin, but a timer-only loop should not depend on a user typing another command. The heartbeat periodically checks active loop jobs, clears stale completed runs, and starts due work only when the session appears idle.
15
+
16
+
12
17
  ## Current status
13
18
 
14
- **v0.5.10 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
19
+ **v0.5.11 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
15
20
 
16
21
  The known update-related symptoms from older builds are fixed:
17
22
 
@@ -27,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
27
32
 
28
33
  The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
29
34
 
30
- ## v0.5.10 quick behavior guide
35
+ ## v0.5.11 quick behavior guide
31
36
 
32
37
  OpenCode Loop has two triggers now:
33
38
 
@@ -74,7 +79,7 @@ Run a shell command every 10 minutes when idle.
74
79
 
75
80
  Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
76
81
 
77
- > Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.10 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
82
+ > Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.11 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
78
83
 
79
84
  ## Why this exists
80
85
 
@@ -173,18 +178,19 @@ npx -y @bybrawe/opencode-loop
173
178
  ```
174
179
 
175
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.
176
182
 
177
183
  Windows target paths:
178
184
 
179
185
  ```text
180
- %USERPROFILE%\.config\opencode\plugins\opencode-loop.js
186
+ %USERPROFILE%\.config\opencode\plugins\opencode-loop.ts
181
187
  %USERPROFILE%\.config\opencode\commands\loop*.md
182
188
  ```
183
189
 
184
190
  macOS / Linux target paths:
185
191
 
186
192
  ```text
187
- ~/.config/opencode/plugins/opencode-loop.js
193
+ ~/.config/opencode/plugins/opencode-loop.ts
188
194
  ~/.config/opencode/commands/loop*.md
189
195
  ```
190
196
 
@@ -268,7 +274,7 @@ Windows PowerShell:
268
274
  ```powershell
269
275
  mkdir "$env:USERPROFILE\.config\opencode\plugins" -Force
270
276
  mkdir "$env:USERPROFILE\.config\opencode\commands" -Force
271
- 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"
272
278
  copy .\commands\*.md "$env:USERPROFILE\.config\opencode\commands\"
273
279
  ```
274
280
 
@@ -276,7 +282,7 @@ macOS / Linux:
276
282
 
277
283
  ```bash
278
284
  mkdir -p ~/.config/opencode/plugins ~/.config/opencode/commands
279
- cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.js
285
+ cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.ts
280
286
  cp ./commands/*.md ~/.config/opencode/commands/
281
287
  ```
282
288
 
@@ -286,7 +292,7 @@ Use this when you want the plugin to be available only inside one repository.
286
292
 
287
293
  ```bash
288
294
  mkdir -p .opencode/plugins .opencode/commands
289
- cp ./src/index.js .opencode/plugins/opencode-loop.js
295
+ cp ./src/index.js .opencode/plugins/opencode-loop.ts
290
296
  cp ./commands/*.md .opencode/commands/
291
297
  ```
292
298
 
@@ -295,7 +301,7 @@ On Windows PowerShell:
295
301
  ```powershell
296
302
  mkdir .opencode\plugins -Force
297
303
  mkdir .opencode\commands -Force
298
- copy .\src\index.js .opencode\plugins\opencode-loop.js
304
+ copy .\src\index.js .opencode\plugins\opencode-loop.ts
299
305
  copy .\commands\*.md .opencode\commands\
300
306
  ```
301
307
 
@@ -311,7 +317,7 @@ After restarting OpenCode, run:
311
317
  If the commands do not appear:
312
318
 
313
319
  1. Make sure OpenCode was fully restarted.
314
- 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.
315
321
  3. Check that `loop.md`, `loop-help.md`, and the other command files exist in the OpenCode commands directory.
316
322
  4. Run `npx -y @bybrawe/opencode-loop` again to reinstall the command files.
317
323
 
@@ -1172,4 +1178,4 @@ Improve the application in small safe steps.
1172
1178
 
1173
1179
  ## License
1174
1180
 
1175
- MIT
1181
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.10",
4
- "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: watchdog timers, stale-busy recovery, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
3
+ "version": "0.5.12",
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",
7
7
  "exports": {
@@ -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
@@ -4,13 +4,14 @@ import { spawn } from "node:child_process"
4
4
 
5
5
  const SERVICE = "opencode-loop"
6
6
  const STATE_DIR = ".opencode/opencode-loop"
7
- const DEFAULT_ACTIVE_GUARD_MS = 60_000
8
- const STALE_ACTIVE_RECOVERY_MS = 60_000
7
+ const DEFAULT_ACTIVE_GUARD_MS = 45_000
8
+ const STALE_ACTIVE_RECOVERY_MS = 45_000
9
9
  const IDLE_DEBOUNCE_MS = 1_200
10
10
  const BUSY_RETRY_MS = 5_000
11
11
  const SESSION_STATUS_CACHE_MS = 1_500
12
12
  const MIN_DUE_TIMER_MS = 250
13
13
  const MAX_DUE_TIMER_MS = 2_147_000_000
14
+ const HEARTBEAT_MS = 2_500
14
15
  const MAX_SCAN_FILES = 200
15
16
  const MAX_SCAN_BYTES = 2_000_000
16
17
  const GOAL_REPORT_DIR = "goals"
@@ -21,6 +22,9 @@ const handledCommands = new Map()
21
22
  const idleTimers = new Map()
22
23
  const dueTimers = new Map()
23
24
  const watchdogTimers = new Map()
25
+ const runLocks = new Map()
26
+ const knownSessions = new Map()
27
+ let heartbeatTimer
24
28
  const sessionStatuses = new Map()
25
29
  const sessionStatusSeenAt = new Map()
26
30
 
@@ -144,7 +148,7 @@ function parseCompactEvery(value) {
144
148
  }
145
149
 
146
150
  function parseLoopArgs(raw, defaults = {}) {
147
- let input = String(raw || "").trim()
151
+ let input = stripOuterQuotes(String(raw || "").trim())
148
152
  let first = ""
149
153
  let rest = input
150
154
  let intervalMs = defaults.intervalMs ?? null
@@ -386,8 +390,8 @@ async function executeTuiCommand(client, command) {
386
390
  if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
387
391
  return await sdkCall(
388
392
  client.tui.executeCommand.bind(client.tui),
389
- { command },
390
393
  { body: { command } },
394
+ { command },
391
395
  )
392
396
  }
393
397
 
@@ -411,7 +415,7 @@ async function compactSession(client, sessionID) {
411
415
  }
412
416
  }
413
417
  try {
414
- 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 })
415
419
  return true
416
420
  } catch (error) {
417
421
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
@@ -424,27 +428,27 @@ async function log(client, level, message, extra) {
424
428
  try {
425
429
  await sdkCall(
426
430
  client.app.log.bind(client.app),
427
- extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
428
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 },
429
433
  )
430
434
  } catch {}
431
435
  }
432
436
 
433
437
  async function toast(client, message, variant = "info") {
434
- 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 {}
435
439
  }
436
440
 
437
441
  async function say(client, sessionID, text) {
438
442
  try {
439
443
  await sdkCall(
440
444
  client.session.prompt.bind(client.session),
441
- { sessionID, noReply: true, parts: [{ type: "text", text }] },
442
445
  { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
446
+ { sessionID, noReply: true, parts: [{ type: "text", text }] },
443
447
  )
444
448
  } catch {}
445
449
  }
446
450
 
447
- function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args || ""}` }
451
+ function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
448
452
  function markHandled(sessionID, name, args) {
449
453
  handledCommands.set(commandKey(sessionID, name, args), now())
450
454
  for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
@@ -456,6 +460,53 @@ function wasHandled(sessionID, name, args) {
456
460
 
457
461
  function commandName(name) { return String(name || "") }
458
462
  function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
463
+
464
+ function normalizeArgsForKey(args) {
465
+ if (args === undefined || args === null) return ""
466
+ if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
467
+ if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
468
+ try { return JSON.stringify(args) } catch { return String(args) }
469
+ }
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
+
483
+ function rememberSession(directory, client, sessionID) {
484
+ if (!sessionID) return
485
+ knownSessions.set(sessionID, { directory, client, seenAt: now() })
486
+ startHeartbeat()
487
+ }
488
+
489
+ function startHeartbeat() {
490
+ if (heartbeatTimer) return
491
+ heartbeatTimer = setInterval(() => {
492
+ for (const [sessionID, info] of [...knownSessions.entries()]) {
493
+ if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
494
+ knownSessions.delete(sessionID)
495
+ continue
496
+ }
497
+ Promise.resolve()
498
+ .then(async () => {
499
+ await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
500
+ await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
501
+ })
502
+ .catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
503
+ }
504
+ if (!knownSessions.size && heartbeatTimer) {
505
+ clearInterval(heartbeatTimer)
506
+ heartbeatTimer = undefined
507
+ }
508
+ }, HEARTBEAT_MS)
509
+ }
459
510
  function presetDefaults(name, args) {
460
511
  const [maybeDuration, rest] = splitFirst(args)
461
512
  const parsed = parseDuration(maybeDuration)
@@ -770,7 +821,7 @@ function staleActiveRun(sessionID) {
770
821
 
771
822
  async function readLiveSessionStatus(client, sessionID, directory) {
772
823
  const argsList = []
773
- if (directory) argsList.push({ workspace: directory }, { directory })
824
+ if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
774
825
  argsList.push({})
775
826
  for (const args of argsList) {
776
827
  try {
@@ -889,7 +940,9 @@ async function startWatchdog(directory, client, sessionID) {
889
940
  })
890
941
  .catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
891
942
  }, Math.max(1_000, BUSY_RETRY_MS))
892
- if (typeof timer.unref === "function") timer.unref()
943
+ // Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
944
+ // are event-driven; a referenced watchdog helps scheduled loops wake up even
945
+ // when no manual /loop-status command is typed.
893
946
  watchdogTimers.set(sessionID, timer)
894
947
  }
895
948
 
@@ -1099,7 +1152,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
1099
1152
  if (!active) return
1100
1153
  clearActiveRun(sessionID)
1101
1154
  const state = await readState(directory, sessionID)
1102
- const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
1155
+ let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
1103
1156
  if (!job) return
1104
1157
  job.lastFinishedAt = now()
1105
1158
 
@@ -1168,7 +1221,7 @@ async function fireAction(directory, client, sessionID, job) {
1168
1221
  await compactSession(client, sessionID)
1169
1222
  return { startsAssistantTurn: true }
1170
1223
  }
1171
- 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 })
1172
1225
  return { startsAssistantTurn: true }
1173
1226
  }
1174
1227
  if (kind === "shell") {
@@ -1178,128 +1231,144 @@ async function fireAction(directory, client, sessionID, job) {
1178
1231
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1179
1232
  return { startsAssistantTurn: false }
1180
1233
  }
1181
- 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 })
1182
1235
  return { startsAssistantTurn: true }
1183
1236
  }
1184
1237
  const prompt = await buildPrompt(directory, job)
1185
1238
  const prefix = kind === "goal"
1186
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."
1187
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."
1188
- 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}
1189
1242
 
1190
- ${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1243
+ ${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
1191
1244
 
1192
- ${prompt}` }] } })
1245
+ ${prompt}` }] })
1193
1246
  return { startsAssistantTurn: true }
1194
1247
  }
1195
1248
 
1196
1249
  async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1250
+ rememberSession(directory, client, sessionID)
1197
1251
  const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
1198
1252
 
1199
- if (!await sessionIsIdle(client, sessionID, directory)) {
1200
- if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
1201
- await reschedule(BUSY_RETRY_MS)
1202
- return
1203
- }
1204
- const active = activeRuns.get(sessionID)
1205
- if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
1253
+ if (runLocks.has(sessionID)) {
1206
1254
  await reschedule(BUSY_RETRY_MS)
1207
1255
  return
1208
1256
  }
1257
+ runLocks.set(sessionID, now())
1258
+ let job
1259
+ try {
1260
+ await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
1261
+ if (!await sessionIsIdle(client, sessionID, directory)) {
1262
+ if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
1263
+ await reschedule(BUSY_RETRY_MS)
1264
+ return
1265
+ }
1209
1266
 
1210
- const state = await readState(directory, sessionID)
1211
- for (const job of state.jobs || []) {
1212
- if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
1213
- }
1214
- let due = dueJobs(state, options.force)
1215
- if (!due.length) {
1216
- await writeState(directory, sessionID, state)
1217
- await reschedule()
1218
- return
1219
- }
1220
- let job = due[0]
1267
+ const active = activeRuns.get(sessionID)
1268
+ const activeAge = active ? now() - (active.startedAt || 0) : 0
1269
+ const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
1270
+ if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
1271
+ await reschedule(BUSY_RETRY_MS)
1272
+ return
1273
+ }
1274
+ if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
1221
1275
 
1222
- if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
1223
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1224
- await writeState(directory, sessionID, state)
1225
- await notifyJob(directory, job, "max_runtime_reached")
1226
- await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1227
- await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1228
- await reschedule()
1229
- return
1230
- }
1231
- if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
1232
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1233
- await writeState(directory, sessionID, state)
1234
- await notifyJob(directory, job, "stop_file")
1235
- await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
1236
- await reschedule()
1237
- return
1238
- }
1239
- if (await untilReached(directory, job)) {
1240
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1241
- await writeState(directory, sessionID, state)
1242
- await notifyJob(directory, job, "until_reached")
1243
- await toast(client, `Loop stopped by --until: ${job.until}`, "success")
1244
- await reschedule()
1245
- return
1246
- }
1276
+ const state = await readState(directory, sessionID)
1277
+ for (const candidate of state.jobs || []) {
1278
+ if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
1279
+ }
1280
+ const due = dueJobs(state, options.force)
1281
+ if (!due.length) {
1282
+ await writeState(directory, sessionID, state)
1283
+ await reschedule()
1284
+ return
1285
+ }
1286
+ job = due[0]
1247
1287
 
1248
- if (job.preflightCommand) {
1249
- if (job.safe && dangerousShell(job.preflightCommand)) {
1250
- job.paused = true
1288
+ if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
1289
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1251
1290
  await writeState(directory, sessionID, state)
1252
- await notifyJob(directory, job, "preflight_blocked")
1253
- await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
1291
+ await notifyJob(directory, job, "max_runtime_reached")
1292
+ await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1293
+ await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1254
1294
  await reschedule()
1255
1295
  return
1256
1296
  }
1257
- const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
1258
- await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
1259
- if (preflight.code !== 0) {
1260
- job.paused = true
1261
- job.failureCount = (job.failureCount || 0) + 1
1262
- job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
1263
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1297
+ if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
1298
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1264
1299
  await writeState(directory, sessionID, state)
1265
- await notifyJob(directory, job, "preflight_failed")
1266
- await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
1300
+ await notifyJob(directory, job, "stop_file")
1301
+ await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
1267
1302
  await reschedule()
1268
1303
  return
1269
1304
  }
1270
- }
1271
-
1272
- job = await ensureBranch(directory, job, client, sessionID)
1273
- job = await maybeCompact(client, sessionID, job)
1274
- job.lastRunAt = now()
1275
- job.runCount = (job.runCount || 0) + 1
1276
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
1277
- job.enabled = false
1278
- await notifyJob(directory, job, "max_runs_reached")
1279
- }
1280
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1281
- await writeState(directory, sessionID, state)
1282
- await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
1283
- await toast(client, `Loop running: ${job.name || job.id}`, "info")
1284
-
1285
- try {
1286
- const result = await fireAction(directory, client, sessionID, job)
1287
- if (!result.startsAssistantTurn) {
1288
- const fresh = await readState(directory, sessionID)
1289
- fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1290
- await writeState(directory, sessionID, fresh)
1305
+ if (await untilReached(directory, job)) {
1306
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1307
+ await writeState(directory, sessionID, state)
1308
+ await notifyJob(directory, job, "until_reached")
1309
+ await toast(client, `Loop stopped by --until: ${job.until}`, "success")
1291
1310
  await reschedule()
1292
1311
  return
1293
1312
  }
1294
- let timer
1295
- 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)
1296
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1297
- await reschedule(BUSY_RETRY_MS)
1298
- } catch (error) {
1299
- clearActiveRun(sessionID)
1300
- await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
1301
- await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
1302
- await reschedule(BUSY_RETRY_MS)
1313
+
1314
+ if (job.preflightCommand) {
1315
+ if (job.safe && dangerousShell(job.preflightCommand)) {
1316
+ job.paused = true
1317
+ await writeState(directory, sessionID, state)
1318
+ await notifyJob(directory, job, "preflight_blocked")
1319
+ await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
1320
+ await reschedule()
1321
+ return
1322
+ }
1323
+ const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
1324
+ await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
1325
+ if (preflight.code !== 0) {
1326
+ job.paused = true
1327
+ job.failureCount = (job.failureCount || 0) + 1
1328
+ job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
1329
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1330
+ await writeState(directory, sessionID, state)
1331
+ await notifyJob(directory, job, "preflight_failed")
1332
+ await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
1333
+ await reschedule()
1334
+ return
1335
+ }
1336
+ }
1337
+
1338
+ job = await ensureBranch(directory, job, client, sessionID)
1339
+ job = await maybeCompact(client, sessionID, job)
1340
+ job.lastRunAt = now()
1341
+ job.runCount = (job.runCount || 0) + 1
1342
+ if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
1343
+ job.enabled = false
1344
+ await notifyJob(directory, job, "max_runs_reached")
1345
+ }
1346
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1347
+ await writeState(directory, sessionID, state)
1348
+ await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
1349
+ await toast(client, `Loop running: ${job.name || job.id}`, "info")
1350
+
1351
+ try {
1352
+ const result = await fireAction(directory, client, sessionID, job)
1353
+ if (!result.startsAssistantTurn) {
1354
+ const fresh = await readState(directory, sessionID)
1355
+ fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1356
+ await writeState(directory, sessionID, fresh)
1357
+ await reschedule()
1358
+ return
1359
+ }
1360
+ let timer
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)
1362
+ activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1363
+ await reschedule(BUSY_RETRY_MS)
1364
+ } catch (error) {
1365
+ clearActiveRun(sessionID)
1366
+ await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
1367
+ await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
1368
+ await reschedule(BUSY_RETRY_MS)
1369
+ }
1370
+ } finally {
1371
+ runLocks.delete(sessionID)
1303
1372
  }
1304
1373
  }
1305
1374
 
@@ -1320,6 +1389,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1320
1389
  const parsed = parseLoopArgs(args, defaults)
1321
1390
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1322
1391
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1392
+ if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1323
1393
  if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
1324
1394
  const state = await readState(directory, sessionID)
1325
1395
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -1530,8 +1600,9 @@ async function exportLoop(directory, client, sessionID) {
1530
1600
  async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
1531
1601
  const name = commandName(input?.command ?? input?.name ?? fallbackName)
1532
1602
  const sessionID = input?.sessionID
1533
- const args = input?.arguments ?? fallbackArgs ?? ""
1603
+ const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
1534
1604
  if (!sessionID || !name) return false
1605
+ rememberSession(directory, client, sessionID)
1535
1606
  if (wasHandled(sessionID, name, args)) return true
1536
1607
  markHandled(sessionID, name, args)
1537
1608
 
@@ -1616,6 +1687,7 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1616
1687
  await handleCommand(directory, client, props, props.name, props.arguments)
1617
1688
  }
1618
1689
  const statusUpdate = updateSessionStatusFromEvent(event)
1690
+ if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
1619
1691
  if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
1620
1692
  },
1621
1693
  }