@bybrawe/opencode-loop 0.5.13 → 0.5.15

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.15
4
+
5
+ - Hardened experimental Goal Mode completion: agent tool completion now requires concrete evidence instead of accepting empty or generic claims.
6
+ - Required configured goal `--check` commands to pass before agent-tool completion, unless the user explicitly opts out or manually marks the goal done.
7
+ - Added a no-progress guard for goals. Goals pause after 3 turns without recorded meaningful progress by default, configurable with `--max-no-progress`.
8
+ - Paused active goals when a real user message arrives, so new user intent wins before the next automatic continuation.
9
+ - Preferred the OpenCode plugin SDK's `{ body }`, `{ path: { id }, body }`, and `{ query }` argument shapes while keeping flat and older path-key fallbacks.
10
+ - Added the resolved working directory and workspace-relative path rules to Goal Mode prompts so agents do not accidentally target filesystem-root paths.
11
+ - Increased Goal Mode's default active-turn recovery window to 3 minutes so slow model/tool turns are not finalized as stale after 20 seconds.
12
+ - Added plugin disposal cleanup for heartbeat, idle, due, watchdog, and active-run timers.
13
+ - Added an automated smoke test for SDK call order, Goal Mode prompts, evidence rejection, and successful completion state.
14
+ - Updated local installers to create or update config `package.json` with `@opencode-ai/plugin`, needed by local `.ts` plugins that import `tool()`.
15
+ - Updated Goal Mode status/report output and README guidance for the new guards.
16
+
17
+ ## 0.5.14
18
+
19
+ - Guarded active-run finalization so heartbeat and retry checks do not treat a still-running assistant turn as finished.
20
+ - Made loop state writes atomic and backed up corrupt state files before falling back to an empty state.
21
+ - Changed `opencode-loopd` to pass prompts as process arguments instead of shell strings, preserving quotes in inline prompts.
22
+ - Stripped UTF-8 BOMs from daemon prompt files before sending them to OpenCode.
23
+ - Removed the package plugin entry from the example config to avoid duplicate loading after the recommended local installer path.
24
+
3
25
  ## 0.5.13
4
26
 
5
27
  - Wrapped experimental goal tools with `tool()` and Zod schemas for current OpenCode tool validation.
package/README.md CHANGED
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
16
16
 
17
17
  ## Current status
18
18
 
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.
19
+ **v0.5.15 is a Goal Mode and SDK compatibility hardening release.** It keeps the timer/watchdog reliability fixes from v0.5.11-v0.5.14, then tightens experimental goals with evidence-gated completion, passing-check requirements, no-progress pausing, user-message interruption, explicit workspace paths, and safer active-turn recovery. It also prefers the OpenCode plugin SDK argument shape while keeping fallbacks for older builds.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
@@ -26,13 +26,13 @@ The known update-related symptoms from older builds are fixed:
26
26
  - prompt, shell, or toast calls silently fail after OpenCode SDK changes
27
27
  - `/compact` accidentally being treated as a normal agent prompt
28
28
  - intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
29
- - experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
29
+ - experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, cleared, or paused by no-progress/user-interrupt guards
30
30
  - `--no-now` interval jobs not waking until another OpenCode event happens
31
31
  - `/loop-ask`, `/loop-command`, and `/loop-shell` being configured but not reliably firing on their own
32
32
 
33
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`.
34
34
 
35
- ## v0.5.11 quick behavior guide
35
+ ## v0.5.15 quick behavior guide
36
36
 
37
37
  OpenCode Loop has two triggers now:
38
38
 
@@ -151,33 +151,13 @@ OpenCode Loop is designed for developers searching for:
151
151
 
152
152
  ### Recommended: install from npm
153
153
 
154
- For a clean reinstall or upgrade, use the latest published package:
154
+ Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or Linux.
155
155
 
156
156
  ```bash
157
157
  npx -y @bybrawe/opencode-loop@latest
158
158
  ```
159
159
 
160
- Then fully restart OpenCode.
161
-
162
- Install from npm with `npx`:
163
-
164
- ```bash
165
- npx -y @bybrawe/opencode-loop
166
- ```
167
-
168
- On Windows CMD:
169
-
170
- ```bat
171
- npx -y @bybrawe/opencode-loop
172
- ```
173
-
174
- On Windows PowerShell:
175
-
176
- ```powershell
177
- npx -y @bybrawe/opencode-loop
178
- ```
179
-
180
- The installer copies the plugin and slash command files into your OpenCode config directory.
160
+ The installer copies the plugin and slash command files into your OpenCode config directory. It also ensures that the config directory has a `package.json` dependency on `@opencode-ai/plugin`, which local `.ts` plugins need for the `tool()` helper.
181
161
  It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
182
162
 
183
163
  Windows target paths:
@@ -217,6 +197,7 @@ The `npx` installer installs both parts:
217
197
  - the `/loop-*` command markdown files
218
198
 
219
199
  Use the OpenCode config-only method only if you already installed the command files separately or you are only testing plugin loading.
200
+ Do not use the config package entry and the local `npx`-installed plugin at the same time unless you intentionally want to test duplicate plugin loading.
220
201
 
221
202
  ### Optional: OpenCode config package entry
222
203
 
@@ -239,6 +220,8 @@ If `/loop` does not appear after using only the config method, run the installer
239
220
  npx -y @bybrawe/opencode-loop
240
221
  ```
241
222
 
223
+ If you later switch back to the recommended `npx` local install, remove `@bybrawe/opencode-loop` from the `plugin` array to avoid loading the package plugin and local plugin together.
224
+
242
225
  ### Install from GitHub
243
226
 
244
227
  Use this if you want to install from source instead of npm.
@@ -407,7 +390,7 @@ That can look like a chat prompt in older versions or unclear setups. Prefer `/l
407
390
 
408
391
  ### Experimental Goal Mode
409
392
 
410
- Goal Mode is experimental. Use it when you do **not** want a timer such as “every 5 minutes”. Use it when you want OpenCode to keep pursuing a result until it is complete or blocked.
393
+ Goal Mode is experimental. Use it when you do **not** want a timer such as "every 5 minutes". Use it when you want OpenCode to keep pursuing a result until it is complete or blocked.
411
394
 
412
395
  Basic goal:
413
396
 
@@ -425,6 +408,16 @@ What happens:
425
408
 
426
409
  Goal Mode is idle-safe. If OpenCode is busy, it waits. It does not intentionally start another turn on top of a running turn.
427
410
 
411
+ Completion guards:
412
+
413
+ - The agent tool cannot mark a goal complete with empty or generic evidence. Evidence should mention commands, files, checks, results, or code inspection details.
414
+ - If a goal has `--check` commands, agent-tool completion requires the latest configured checks to pass by default.
415
+ - `/loop-goal-done` is still a manual user override when you want to mark the goal complete yourself.
416
+ - Goal Mode pauses when a real user message arrives, so a new instruction can take control before the next automatic continuation.
417
+ - Goal Mode pauses after 3 turns without recorded meaningful progress by default. Change this with `--max-no-progress <n>`, or disable it with `--max-no-progress 0`.
418
+ - Each Goal Mode prompt includes the resolved project directory and requires workspace-relative file paths.
419
+ - A running goal turn gets 3 minutes by default before stale-run recovery can finalize it. This avoids overlapping or prematurely closing slower model/tool turns.
420
+
428
421
  Simple status and control commands:
429
422
 
430
423
  ```text
@@ -468,6 +461,12 @@ Goal that automatically stops when all configured checks pass:
468
461
  /loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
469
462
  ```
470
463
 
464
+ Goal that gives the agent more attempts before the no-progress guard pauses it:
465
+
466
+ ```text
467
+ /loop-goal --max-no-progress 6 --check "npm test" fix the failing tests and keep evidence in the goal report
468
+ ```
469
+
471
470
  Goal with a safety/runtime limit:
472
471
 
473
472
  ```text
@@ -1113,6 +1112,23 @@ Improve the application in small safe steps.
1113
1112
 
1114
1113
  ## Changelog highlights
1115
1114
 
1115
+ ### v0.5.15
1116
+
1117
+ - Hardened experimental Goal Mode completion with concrete evidence, passing-check requirements, no-progress pausing, and user-message interruption.
1118
+ - Preferred OpenCode plugin SDK `{ body }`, `{ path: { id }, body }`, and `{ query }` shapes before compatibility fallbacks.
1119
+ - Added explicit workspace path guidance, safer 3-minute active-turn recovery, timer disposal cleanup, and an automated Goal Mode smoke test.
1120
+ - Made local installers add the `@opencode-ai/plugin` dependency needed by local `.ts` plugins.
1121
+
1122
+ ### v0.5.13
1123
+
1124
+ - Wrapped experimental goal tools with OpenCode's `tool()` helper and Zod schemas for current OpenCode tool validation.
1125
+ - Added `@opencode-ai/plugin` as a development dependency so fresh clones can resolve the tool helper.
1126
+
1127
+ ### v0.5.12
1128
+
1129
+ - Changed local installs to write `opencode-loop.ts` and remove stale `opencode-loop.js` copies.
1130
+ - Fixed OpenCode CLI command argument parsing, current SDK call shapes, and `opencode-loopd` prompt passing.
1131
+
1116
1132
  ### v0.5.7
1117
1133
 
1118
1134
  - Added experimental Goal Mode with `/loop-goal <objective>`.
@@ -1127,11 +1143,11 @@ Improve the application in small safe steps.
1127
1143
  - Stabilized `/loop-command 200m /compact` by routing compact/summarize through OpenCode TUI compact handling instead of treating it as a custom prompt command.
1128
1144
  - Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
1129
1145
 
1130
- ## v0.5.5
1146
+ ### v0.5.5
1131
1147
 
1132
1148
  - Added explicit loop action types: prompt, scheduled question, OpenCode slash command, and shell command.
1133
1149
  - Added `/loop-command` / `/loop-cmd` for commands such as `/compact`; these wait for the first interval by default.
1134
- - Added `/loop-ask` for recurring checks such as “did you run tests, tsc --noEmit, and build?”; it waits for the first interval by default.
1150
+ - Added `/loop-ask` for recurring checks such as "did you run tests, tsc --noEmit, and build?"; it waits for the first interval by default.
1135
1151
  - Added `/loop-shell` for recurring shell commands.
1136
1152
  - Added `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact` type flags for `/loop`.
1137
1153
  - Clarified idle behavior: if a job becomes due while OpenCode is busy, it waits and runs on the next idle event.
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "plugin": [
4
- "@bybrawe/opencode-loop",
5
4
  "@tarquinen/opencode-dcp@latest"
6
5
  ],
7
6
  "permission": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
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",
@@ -17,7 +17,8 @@
17
17
  "CHANGELOG.md"
18
18
  ],
19
19
  "scripts": {
20
- "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs",
20
+ "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/smoke-test.mjs",
21
+ "test": "node scripts/smoke-test.mjs",
21
22
  "install:global": "node scripts/install-node.mjs",
22
23
  "pack:zip": "node scripts/make-zip.mjs"
23
24
  },
@@ -58,8 +59,8 @@
58
59
  "@opencode-ai/plugin": "^1.4.0"
59
60
  },
60
61
  "bin": {
61
- "opencode-loop": "scripts/install-node.mjs",
62
- "opencode-loopd": "scripts/loopd.mjs"
62
+ "opencode-loop": "./scripts/install-node.mjs",
63
+ "opencode-loopd": "./scripts/loopd.mjs"
63
64
  },
64
65
  "publishConfig": {
65
66
  "access": "public"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { copyFile, mkdir, readdir, rm } from "node:fs/promises"
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } 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"
@@ -8,9 +8,30 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)))
8
8
  const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
9
9
  const pluginDir = join(config, "plugins")
10
10
  const commandDir = join(config, "commands")
11
+ const packagePath = join(config, "package.json")
12
+
13
+ async function ensureDependency() {
14
+ let pkg = {}
15
+ try {
16
+ pkg = JSON.parse(await readFile(packagePath, "utf8"))
17
+ } catch (error) {
18
+ if (error?.code !== "ENOENT") {
19
+ console.warn(`Could not update ${packagePath}: ${error.message}`)
20
+ console.warn('Add "@opencode-ai/plugin": ">=1.4.0" to that package.json if OpenCode cannot load the local plugin.')
21
+ return
22
+ }
23
+ }
24
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
25
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === "object" && !Array.isArray(pkg.dependencies) ? pkg.dependencies : {}
26
+ if (!pkg.dependencies["@opencode-ai/plugin"]) {
27
+ pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
28
+ await writeFile(packagePath, JSON.stringify(pkg, null, 2) + "\n", "utf8")
29
+ }
30
+ }
11
31
 
12
32
  await mkdir(pluginDir, { recursive: true })
13
33
  await mkdir(commandDir, { recursive: true })
34
+ await ensureDependency()
14
35
  await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
15
36
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
16
37
 
@@ -8,6 +8,24 @@ $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
+ $PackageFile = Join-Path $ConfigDir "package.json"
12
+ if (Test-Path $PackageFile) {
13
+ try {
14
+ $PackageJson = Get-Content -Raw -Path $PackageFile | ConvertFrom-Json
15
+ if (-not $PackageJson.dependencies) {
16
+ $PackageJson | Add-Member -MemberType NoteProperty -Name dependencies -Value ([pscustomobject]@{})
17
+ }
18
+ if (-not $PackageJson.dependencies.PSObject.Properties["@opencode-ai/plugin"]) {
19
+ $PackageJson.dependencies | Add-Member -MemberType NoteProperty -Name "@opencode-ai/plugin" -Value ">=1.4.0"
20
+ $PackageJson | ConvertTo-Json -Depth 20 | Set-Content -Path $PackageFile -Encoding UTF8
21
+ }
22
+ } catch {
23
+ Write-Warning "Could not update $PackageFile. Add '@opencode-ai/plugin': '>=1.4.0' if OpenCode cannot load the local plugin."
24
+ }
25
+ } else {
26
+ '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' | Set-Content -Path $PackageFile -Encoding UTF8
27
+ }
28
+
11
29
  Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
12
30
  Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
13
31
  Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
@@ -7,6 +7,31 @@ 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
+ PACKAGE_FILE="$CONFIG_DIR/package.json"
11
+ if command -v node >/dev/null 2>&1; then
12
+ PACKAGE_FILE="$PACKAGE_FILE" node --input-type=module - <<'NODE'
13
+ import fs from "node:fs"
14
+
15
+ const file = process.env.PACKAGE_FILE
16
+ let pkg = {}
17
+ try {
18
+ pkg = JSON.parse(fs.readFileSync(file, "utf8"))
19
+ } catch (error) {
20
+ if (error.code !== "ENOENT") {
21
+ console.warn(`Could not update ${file}: ${error.message}`)
22
+ process.exit(0)
23
+ }
24
+ }
25
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
26
+ if (!pkg.dependencies || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) pkg.dependencies = {}
27
+ if (!pkg.dependencies["@opencode-ai/plugin"]) {
28
+ pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
29
+ fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n")
30
+ }
31
+ NODE
32
+ elif [ ! -f "$PACKAGE_FILE" ]; then
33
+ printf '%s\n' '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' > "$PACKAGE_FILE"
34
+ fi
10
35
  cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
11
36
  rm -f "$PLUGIN_DIR/opencode-loop.js"
12
37
  cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
package/scripts/loopd.mjs CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"
6
6
 
7
7
  const args = process.argv.slice(2)
8
8
  const FAILED_RUN_RETRY_MS = 5_000
9
+ const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
9
10
 
10
11
  function arg(name, fallback = null) {
11
12
  const i = args.indexOf(name)
@@ -39,25 +40,60 @@ function sleep(ms) {
39
40
  return new Promise((resolve) => setTimeout(resolve, ms))
40
41
  }
41
42
 
42
- function run(command, cwd) {
43
+ function spawnOnce(command, commandArgs, cwd) {
43
44
  return new Promise((resolve) => {
44
- const child = spawn(command, {
45
- cwd,
46
- shell: true,
47
- stdio: "inherit",
48
- env: process.env,
49
- })
50
-
51
- child.on("exit", (code) => resolve(code ?? 0))
45
+ let settled = false
46
+ const done = (result) => {
47
+ if (settled) return
48
+ settled = true
49
+ resolve(result)
50
+ }
51
+
52
+ let child
53
+ try {
54
+ child = spawn(command, commandArgs, {
55
+ cwd,
56
+ shell: false,
57
+ stdio: "inherit",
58
+ env: process.env,
59
+ windowsHide: true,
60
+ })
61
+ } catch (error) {
62
+ done({ code: -1, error })
63
+ return
64
+ }
65
+
66
+ child.on("error", (error) => done({ code: -1, error }))
67
+ child.on("exit", (code) => done({ code: code ?? 0 }))
52
68
  })
53
69
  }
54
70
 
71
+ function quoteWindowsArg(value) {
72
+ const text = String(value ?? "")
73
+ return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`
74
+ }
75
+
76
+ function stripBom(text) {
77
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
78
+ }
79
+
80
+ async function run(command, commandArgs, cwd) {
81
+ const direct = await spawnOnce(command, commandArgs, cwd)
82
+ if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct.code
83
+
84
+ const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd)
85
+ if (fallback.code === -1) {
86
+ console.error(`[opencode-loopd] failed to start ${command}: ${fallback.error?.message || direct.error?.message || "unknown error"}`)
87
+ }
88
+ return fallback.code
89
+ }
90
+
55
91
  function readPrompt(project) {
56
92
  const promptFile = arg("--prompt-file")
57
93
  const promptArg = arg("--prompt")
58
94
 
59
95
  if (promptFile) {
60
- return fs.readFileSync(path.resolve(project, promptFile), "utf8")
96
+ return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
61
97
  }
62
98
 
63
99
  if (promptArg) return promptArg
@@ -99,8 +135,7 @@ async function daemon() {
99
135
  console.log("")
100
136
  console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
101
137
 
102
- const command = `opencode run --continue ${JSON.stringify(prompt)}`
103
- const code = await run(command, project)
138
+ const code = await run(OPENCODE_BIN, ["run", "--continue", prompt], project)
104
139
 
105
140
  if (code !== 0) {
106
141
  console.log(`[opencode-loopd] opencode exited with code ${code}`)
@@ -135,25 +170,25 @@ function installTask() {
135
170
  const script = fileURLToPath(import.meta.url)
136
171
 
137
172
  const commandParts = [
138
- `"${node}"`,
139
- `"${script}"`,
173
+ quoteWindowsArg(node),
174
+ quoteWindowsArg(script),
140
175
  "daemon",
141
176
  "--project",
142
- `"${project}"`,
177
+ quoteWindowsArg(project),
143
178
  "--every",
144
179
  "0s",
145
180
  "--max-runs",
146
181
  "1",
147
182
  ]
148
183
 
149
- if (promptFile) commandParts.push("--prompt-file", `"${promptFile}"`)
150
- if (promptArg) commandParts.push("--prompt", `"${promptArg.replaceAll('"', '\\"')}"`)
184
+ if (promptFile) commandParts.push("--prompt-file", quoteWindowsArg(promptFile))
185
+ if (promptArg) commandParts.push("--prompt", quoteWindowsArg(promptArg))
151
186
 
152
187
  const taskCommand = commandParts.join(" ")
153
- const schtasks = `schtasks /Create /F /SC MINUTE /MO ${minutes} /TN "${name}" /TR ${JSON.stringify(taskCommand)}`
188
+ const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
154
189
 
155
- console.log(schtasks)
156
- const result = spawnSync(schtasks, { shell: true, stdio: "inherit" })
190
+ console.log(["schtasks", ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
191
+ const result = spawnSync("schtasks", taskArgs, { shell: false, stdio: "inherit" })
157
192
  process.exit(result.status ?? 0)
158
193
  }
159
194
 
@@ -163,7 +198,7 @@ function uninstallTask() {
163
198
  }
164
199
 
165
200
  const name = arg("--name", "OpenCodeLoop")
166
- const result = spawnSync(`schtasks /Delete /F /TN "${name}"`, { shell: true, stdio: "inherit" })
201
+ const result = spawnSync("schtasks", ["/Delete", "/F", "/TN", name], { shell: false, stdio: "inherit" })
167
202
  process.exit(result.status ?? 0)
168
203
  }
169
204
 
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict"
2
+ import { promises as fs } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ import OpenCodeLoopPlugin from "../src/index.js"
7
+
8
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-smoke-"))
9
+ const sessionID = "ses_smoke_goal"
10
+ const attempts = { log: 0, prompt: 0, status: 0, toast: 0 }
11
+ const prompts = []
12
+ let hooks
13
+
14
+ function legacyBody(name) {
15
+ return async (args) => {
16
+ attempts[name]++
17
+ assert.ok(args?.body, `${name} must use the plugin SDK body shape first`)
18
+ return { data: true }
19
+ }
20
+ }
21
+
22
+ const client = {
23
+ app: {
24
+ log: legacyBody("log"),
25
+ },
26
+ tui: {
27
+ executeCommand: async (args) => {
28
+ assert.ok(args?.body?.command)
29
+ return { data: true }
30
+ },
31
+ showToast: legacyBody("toast"),
32
+ },
33
+ session: {
34
+ abort: async (args) => {
35
+ assert.equal(args?.path?.id, sessionID)
36
+ return { data: true }
37
+ },
38
+ command: async (args) => {
39
+ assert.equal(args?.path?.id, sessionID)
40
+ assert.ok(args?.body?.command)
41
+ return { data: true }
42
+ },
43
+ prompt: async (args) => {
44
+ attempts.prompt++
45
+ assert.equal(args?.path?.id, sessionID, "session.prompt must use path.id first")
46
+ assert.ok(Array.isArray(args?.body?.parts))
47
+ prompts.push(args.body.parts.map((part) => part.text || "").join("\n"))
48
+ return { data: true }
49
+ },
50
+ shell: async (args) => {
51
+ assert.equal(args?.path?.id, sessionID)
52
+ assert.ok(args?.body?.command)
53
+ return { data: true }
54
+ },
55
+ status: async (args) => {
56
+ attempts.status++
57
+ assert.equal(args?.query?.directory, directory, "session.status must use query.directory first")
58
+ return { data: { [sessionID]: { type: "idle" } } }
59
+ },
60
+ summarize: async (args) => {
61
+ assert.equal(args?.path?.id, sessionID)
62
+ return { data: true }
63
+ },
64
+ },
65
+ }
66
+
67
+ try {
68
+ hooks = await OpenCodeLoopPlugin({ client, directory })
69
+ assert.deepEqual(Object.keys(hooks.tool).sort(), [
70
+ "opencode_loop_goal_blocked",
71
+ "opencode_loop_goal_complete",
72
+ "opencode_loop_goal_progress",
73
+ ])
74
+
75
+ const output = { parts: [{ type: "text", text: "original command body" }] }
76
+ await hooks["command.execute.before"]({
77
+ command: "loop-goal",
78
+ sessionID,
79
+ arguments: "Create proof.txt and verify it --max-turns 3",
80
+ }, output)
81
+ assert.match(output.parts[0].text, /handled by the local plugin/i)
82
+
83
+ await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
84
+ await new Promise((resolve) => setTimeout(resolve, 0))
85
+ assert.ok(prompts.some((prompt) => prompt.includes(path.resolve(directory))), "goal prompt must include the working directory")
86
+ assert.ok(prompts.some((prompt) => prompt.includes("never turn a relative path into a root path")))
87
+
88
+ const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
89
+ const activeState = JSON.parse(await fs.readFile(stateFile, "utf8"))
90
+ assert.equal(activeState.jobs[0].activeRecoveryMs, 180_000)
91
+
92
+ const rejected = await hooks.tool.opencode_loop_goal_complete.execute({ summary: "Done", evidence: "done" }, { directory, sessionID })
93
+ assert.equal(rejected.title, "Goal completion rejected")
94
+
95
+ const completed = await hooks.tool.opencode_loop_goal_complete.execute({
96
+ summary: "Created and verified proof.txt",
97
+ evidence: "Created proof.txt and read the file back; its exact content matched the requested value.",
98
+ }, { directory, sessionID })
99
+ assert.equal(completed.title, "Goal completed")
100
+
101
+ const completedState = JSON.parse(await fs.readFile(stateFile, "utf8"))
102
+ assert.equal(completedState.jobs[0].goalStatus, "completed")
103
+ assert.equal(completedState.jobs[0].paused, true)
104
+ assert.equal(completedState.jobs[0].enabled, false)
105
+
106
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
107
+ assert.equal(attempts.log, 1)
108
+ assert.ok(attempts.prompt >= 1)
109
+ assert.ok(attempts.status >= 1)
110
+ assert.ok(attempts.toast >= 1)
111
+ console.log("OpenCode Loop smoke test passed")
112
+ } finally {
113
+ await hooks?.dispose?.()
114
+ await fs.rm(directory, { recursive: true, force: true })
115
+ }
package/src/index.js CHANGED
@@ -17,14 +17,19 @@ const MAX_SCAN_FILES = 200
17
17
  const MAX_SCAN_BYTES = 2_000_000
18
18
  const GOAL_REPORT_DIR = "goals"
19
19
  const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
20
+ const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
21
+ const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
22
+ const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
20
23
 
21
24
  const activeRuns = new Map()
22
25
  const handledCommands = new Map()
26
+ const loopOwnedUserMessageGuards = new Map()
23
27
  const idleTimers = new Map()
24
28
  const dueTimers = new Map()
25
29
  const watchdogTimers = new Map()
26
30
  const runLocks = new Map()
27
31
  const knownSessions = new Map()
32
+ const stateWriteLocks = new Map()
28
33
  let heartbeatTimer
29
34
  const sessionStatuses = new Map()
30
35
  const sessionStatusSeenAt = new Map()
@@ -141,6 +146,11 @@ function parsePositiveInt(value, fallback = 0) {
141
146
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
142
147
  }
143
148
 
149
+ function parseNonNegativeInt(value, fallback = 0) {
150
+ const parsed = Number.parseInt(String(value || ""), 10)
151
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
152
+ }
153
+
144
154
  function parseCompactEvery(value) {
145
155
  const duration = parseDuration(value)
146
156
  if (duration !== null) return { compactEveryMs: duration }
@@ -202,11 +212,16 @@ function parseLoopArgs(raw, defaults = {}) {
202
212
  goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
203
213
  goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
204
214
  goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
215
+ goalRequireEvidence: defaults.goalRequireEvidence,
216
+ goalRequireChecksPass: defaults.goalRequireChecksPass,
205
217
  goalEvidenceFile: defaults.goalEvidenceFile,
206
218
  goalSummary: defaults.goalSummary || "",
207
219
  goalEvidence: defaults.goalEvidence || "",
208
220
  goalBlockedReason: defaults.goalBlockedReason || "",
209
221
  goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
222
+ maxNoProgress: defaults.maxNoProgress,
223
+ noProgressCount: defaults.noProgressCount ?? 0,
224
+ lastProgressAt: defaults.lastProgressAt ?? 0,
210
225
  noOverlap: defaults.noOverlap ?? true,
211
226
  safe: defaults.safe ?? false,
212
227
  quiet: defaults.quiet ?? false,
@@ -254,10 +269,16 @@ function parseLoopArgs(raw, defaults = {}) {
254
269
  ;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
255
270
  ;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
256
271
  ;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
272
+ ;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
273
+ ;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
274
+ ;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
275
+ ;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
276
+ ;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
257
277
 
258
278
  ;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
259
279
  ;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
260
280
  ;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
281
+ ;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
261
282
  ;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
262
283
  ;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
263
284
  ;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
@@ -308,6 +329,9 @@ function parseLoopArgs(raw, defaults = {}) {
308
329
  job.safe = job.safe !== false
309
330
  job.askNever = job.askNever !== false
310
331
  job.noOverlap = job.noOverlap !== false
332
+ job.goalRequireEvidence = job.goalRequireEvidence !== false
333
+ job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
334
+ job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
311
335
  }
312
336
  job.lastRunAt = job.immediate ? 0 : now()
313
337
 
@@ -320,21 +344,61 @@ function statePath(directory, sessionID) { return path.join(stateDir(directory),
320
344
  async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
321
345
  async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
322
346
 
347
+ function stateLockKey(directory, sessionID) {
348
+ return `${path.resolve(directory)}:${safeID(sessionID)}`
349
+ }
350
+
351
+ async function withStateWriteLock(directory, sessionID, fn) {
352
+ const key = stateLockKey(directory, sessionID)
353
+ const previous = stateWriteLocks.get(key) || Promise.resolve()
354
+ let release
355
+ const current = new Promise((resolve) => { release = resolve })
356
+ const next = previous.catch(() => {}).then(() => current)
357
+ stateWriteLocks.set(key, next)
358
+ await previous.catch(() => {})
359
+ try {
360
+ return await fn()
361
+ } finally {
362
+ release()
363
+ if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
364
+ }
365
+ }
366
+
323
367
  async function readState(directory, sessionID) {
368
+ const target = statePath(directory, sessionID)
324
369
  try {
325
- const parsed = JSON.parse(await fs.readFile(statePath(directory, sessionID), "utf8"))
370
+ const parsed = JSON.parse(await fs.readFile(target, "utf8"))
326
371
  return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
327
- } catch {
372
+ } catch (error) {
373
+ if (error?.code !== "ENOENT") {
374
+ try {
375
+ await ensureDir(stateDir(directory))
376
+ await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
377
+ } catch {}
378
+ }
328
379
  return { version: 4, jobs: [] }
329
380
  }
330
381
  }
331
382
 
332
383
  async function writeState(directory, sessionID, state) {
333
- await ensureDir(stateDir(directory))
334
- await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
384
+ await withStateWriteLock(directory, sessionID, async () => {
385
+ await ensureDir(stateDir(directory))
386
+ const target = statePath(directory, sessionID)
387
+ const temp = `${target}.${process.pid}.${Date.now()}.tmp`
388
+ try {
389
+ await fs.writeFile(temp, JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
390
+ await fs.rename(temp, target)
391
+ } finally {
392
+ try { await fs.rm(temp, { force: true }) } catch {}
393
+ }
394
+ })
335
395
  }
336
396
 
337
- async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
397
+ async function removeState(directory, sessionID) {
398
+ await withStateWriteLock(directory, sessionID, async () => {
399
+ try { await fs.unlink(statePath(directory, sessionID)) } catch {}
400
+ })
401
+ }
338
402
 
339
403
  function sdkError(result) {
340
404
  if (!result || typeof result !== "object") return undefined
@@ -358,32 +422,25 @@ function sdkErrorMessage(error) {
358
422
  return String(error)
359
423
  }
360
424
 
361
- async function sdkCall(method, modernArgs, legacyArgs) {
425
+ async function sdkCall(method, ...argsList) {
362
426
  let firstError
363
- try {
364
- const result = await method(modernArgs)
365
- const error = sdkError(result)
366
- if (!error) return sdkData(result)
367
- firstError = new Error(sdkErrorMessage(error))
368
- } catch (error) {
369
- firstError = error
370
- }
371
- if (legacyArgs !== undefined) {
427
+ for (const args of argsList) {
428
+ if (args === undefined) continue
372
429
  try {
373
- const result = await method(legacyArgs)
430
+ const result = await method(args)
374
431
  const error = sdkError(result)
375
432
  if (!error) return sdkData(result)
376
- throw new Error(sdkErrorMessage(error))
433
+ firstError = firstError || new Error(sdkErrorMessage(error))
377
434
  } catch (error) {
378
- throw error
435
+ firstError = firstError || error
379
436
  }
380
437
  }
381
- throw firstError
438
+ throw firstError || new Error("SDK call failed without arguments")
382
439
  }
383
440
 
384
- function fireSdk(client, label, method, modernArgs, legacyArgs) {
441
+ function fireSdk(client, label, method, ...argsList) {
385
442
  Promise.resolve()
386
- .then(() => sdkCall(method, modernArgs, legacyArgs))
443
+ .then(() => sdkCall(method, ...argsList))
387
444
  .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
388
445
  }
389
446
 
@@ -416,7 +473,12 @@ async function compactSession(client, sessionID) {
416
473
  }
417
474
  }
418
475
  try {
419
- await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID })
476
+ await sdkCall(
477
+ client.session.summarize.bind(client.session),
478
+ { path: { id: sessionID }, body: {} },
479
+ { path: { sessionID }, body: {} },
480
+ { sessionID },
481
+ )
420
482
  return true
421
483
  } catch (error) {
422
484
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
@@ -439,11 +501,29 @@ async function toast(client, message, variant = "info") {
439
501
  try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
440
502
  }
441
503
 
504
+ function guardLoopOwnedUserMessage(sessionID) {
505
+ if (!sessionID) return
506
+ loopOwnedUserMessageGuards.set(sessionID, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
507
+ for (const [key, time] of loopOwnedUserMessageGuards.entries()) if (time < now()) loopOwnedUserMessageGuards.delete(key)
508
+ }
509
+
510
+ function loopOwnedUserMessageGuardActive(sessionID) {
511
+ const until = loopOwnedUserMessageGuards.get(sessionID)
512
+ if (typeof until !== "number") return false
513
+ if (until < now()) {
514
+ loopOwnedUserMessageGuards.delete(sessionID)
515
+ return false
516
+ }
517
+ return true
518
+ }
519
+
442
520
  async function say(client, sessionID, text) {
521
+ guardLoopOwnedUserMessage(sessionID)
443
522
  try {
444
523
  await sdkCall(
445
524
  client.session.prompt.bind(client.session),
446
525
  { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
526
+ { path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
447
527
  { sessionID, noReply: true, parts: [{ type: "text", text }] },
448
528
  )
449
529
  } catch {}
@@ -461,6 +541,9 @@ function wasHandled(sessionID, name, args) {
461
541
 
462
542
  function commandName(name) { return String(name || "") }
463
543
  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) }
544
+ function isLoopCommandName(name) {
545
+ return name === "loop" || name === "loop-stop" || name === "loop-remove" || name === "loop-clear" || name === "loop-status" || name === "loop-logs" || name === "loop-help" || name === "loop-now" || name === "loop-pause" || name === "loop-resume" || name === "loop-doctor" || name === "loop-init" || name === "loop-export" || name === "loop-goal" || name === "loop-goal-status" || name === "loop-goal-pause" || name === "loop-goal-resume" || name === "loop-goal-clear" || name === "loop-goal-done" || name === "loop-goal-complete" || name === "loop-goal-blocked" || isPreset(name)
546
+ }
464
547
 
465
548
  function normalizeArgsForKey(args) {
466
549
  if (args === undefined || args === null) return ""
@@ -497,7 +580,7 @@ function startHeartbeat() {
497
580
  }
498
581
  Promise.resolve()
499
582
  .then(async () => {
500
- await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
583
+ await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
501
584
  await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
502
585
  })
503
586
  .catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
@@ -508,6 +591,32 @@ function startHeartbeat() {
508
591
  }
509
592
  }, HEARTBEAT_MS)
510
593
  }
594
+
595
+ function disposeRuntime(directory, client) {
596
+ const sessions = [...knownSessions.entries()]
597
+ .filter(([, info]) => info?.directory === directory && info?.client === client)
598
+ .map(([sessionID]) => sessionID)
599
+ for (const sessionID of sessions) {
600
+ clearActiveRun(sessionID)
601
+ const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
602
+ const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
603
+ const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
604
+ idleTimers.delete(sessionID)
605
+ dueTimers.delete(sessionID)
606
+ watchdogTimers.delete(sessionID)
607
+ runLocks.delete(sessionID)
608
+ knownSessions.delete(sessionID)
609
+ loopOwnedUserMessageGuards.delete(sessionID)
610
+ sessionStatuses.delete(sessionID)
611
+ sessionStatusSeenAt.delete(sessionID)
612
+ for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
613
+ }
614
+ if (!knownSessions.size && heartbeatTimer) {
615
+ clearInterval(heartbeatTimer)
616
+ heartbeatTimer = undefined
617
+ }
618
+ }
619
+
511
620
  function presetDefaults(name, args) {
512
621
  const [maybeDuration, rest] = splitFirst(args)
513
622
  const parsed = parseDuration(maybeDuration)
@@ -534,10 +643,11 @@ function jobLabel(job) {
534
643
  const verify = job.verifyCommand ? ", verify" : ""
535
644
  const preflight = job.preflightCommand ? ", preflight" : ""
536
645
  const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
646
+ const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
537
647
  const stopFile = job.stopFile ? ", stop-file" : ""
538
648
  const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
539
649
  const paused = job.paused ? ", paused" : ""
540
- return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
650
+ return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
541
651
  }
542
652
 
543
653
  function matchJob(job, target, index) {
@@ -641,6 +751,7 @@ function goalStatusText(job) {
641
751
 
642
752
  async function buildGoalPrompt(directory, job) {
643
753
  const sections = []
754
+ sections.push(`Working directory:\n${path.resolve(directory)}\nKeep every file operation inside this directory. Prefer workspace-relative paths such as \"src/index.js\"; never turn a relative path into a root path such as \"/src/index.js\".`)
644
755
  const objective = String(job.action || "").trim()
645
756
  if (objective) sections.push(`Goal objective:\n${objective}`)
646
757
  if (job.goalFile) {
@@ -657,6 +768,8 @@ async function buildGoalPrompt(directory, job) {
657
768
  if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
658
769
  if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
659
770
  if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
771
+ if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
772
+ if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) sections.push(`No-progress guard:\n${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`)
660
773
  if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
661
774
  for (const file of job.includeFiles || []) {
662
775
  const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
@@ -671,9 +784,12 @@ Rules:
671
784
  - Work on the next smallest useful step toward the goal.
672
785
  - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
673
786
  - Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
787
+ - If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
788
+ - Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
674
789
  - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
675
790
  - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
676
791
  - If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
792
+ - If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
677
793
  - Do not call completion tools just to be polite; only call them when the state is real.
678
794
  - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
679
795
  - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
@@ -811,6 +927,39 @@ function updateSessionStatusFromEvent(event) {
811
927
  return undefined
812
928
  }
813
929
 
930
+ function userInterruptSessionFromEvent(event) {
931
+ if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
932
+ const props = event?.properties || {}
933
+ const info = props.info || props.message || props
934
+ const role = info?.role
935
+ const sessionID = info?.sessionID || props.sessionID
936
+ if (role !== "user" || typeof sessionID !== "string") return undefined
937
+ if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
938
+ return sessionID
939
+ }
940
+
941
+ async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
942
+ const state = await readState(directory, sessionID)
943
+ const interruptedAt = now()
944
+ let count = 0
945
+ state.jobs = (state.jobs || []).map((job) => {
946
+ if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
947
+ count++
948
+ return {
949
+ ...job,
950
+ paused: true,
951
+ lastUserInterruptAt: interruptedAt,
952
+ goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
953
+ }
954
+ })
955
+ if (!count) return false
956
+ await writeState(directory, sessionID, state)
957
+ await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
958
+ await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
959
+ await scheduleDueWork(directory, client, sessionID)
960
+ return true
961
+ }
962
+
814
963
  function staleActiveRun(sessionID) {
815
964
  const active = activeRuns.get(sessionID)
816
965
  if (!active) return false
@@ -820,9 +969,22 @@ function staleActiveRun(sessionID) {
820
969
  return age >= threshold
821
970
  }
822
971
 
972
+ async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
973
+ if (!options.requireIdle && !options.forceStale) return true
974
+ if (options.forceStale && staleActiveRun(sessionID)) return true
975
+ if (!options.requireIdle) return false
976
+
977
+ const live = await readLiveSessionStatus(client, sessionID, directory)
978
+ if (live?.type) return live.type === "idle"
979
+
980
+ const cached = sessionStatuses.get(sessionID)
981
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
982
+ return cached === "idle" && seenAt > (active.startedAt || 0)
983
+ }
984
+
823
985
  async function readLiveSessionStatus(client, sessionID, directory) {
824
986
  const argsList = []
825
- if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
987
+ if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
826
988
  argsList.push({})
827
989
  for (const args of argsList) {
828
990
  try {
@@ -1021,11 +1183,17 @@ function goalReportText(job) {
1021
1183
  lines.push(`Created: ${job.createdAt || ""}`)
1022
1184
  if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
1023
1185
  if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
1186
+ if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
1187
+ if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
1024
1188
  if (job.runCount) lines.push(`Turns: ${job.runCount}`)
1189
+ if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
1025
1190
  lines.push("")
1026
1191
  if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
1027
1192
  if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
1028
1193
  if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
1194
+ if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
1195
+ if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
1196
+ if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
1029
1197
  if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
1030
1198
  if (job.lastGoalChecks?.length) {
1031
1199
  lines.push("## Latest checks", "")
@@ -1061,17 +1229,101 @@ function parseGoalToolText(args, fields) {
1061
1229
  return result
1062
1230
  }
1063
1231
 
1232
+ function hasConcreteGoalEvidence(value) {
1233
+ const text = String(value || "").trim()
1234
+ if (text.length < 24) return false
1235
+ const normalized = text.toLowerCase().replace(/\s+/g, " ")
1236
+ const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
1237
+ if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
1238
+ return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80
1239
+ }
1240
+
1241
+ function goalChecksPassed(job) {
1242
+ return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
1243
+ }
1244
+
1245
+ function goalRequiresPassingChecks(job) {
1246
+ return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
1247
+ }
1248
+
1249
+ async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
1250
+ job.goalCompletionRejectedAt = now()
1251
+ job.goalCompletionRejectedReason = reason
1252
+ job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
1253
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1254
+ await writeState(directory, sessionID, state)
1255
+ await writeGoalReport(directory, sessionID, job)
1256
+ await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
1257
+ return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
1258
+ }
1259
+
1260
+ function goalProgressSnapshot(job) {
1261
+ return {
1262
+ status: job?.goalStatus || "",
1263
+ progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
1264
+ evidence: String(job?.goalEvidence || ""),
1265
+ checksPassedAt: Number(job?.goalChecksPassedAt || 0),
1266
+ lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
1267
+ lastVerifyAt: Number(job?.lastVerifyAt || 0),
1268
+ lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
1269
+ }
1270
+ }
1271
+
1272
+ function goalMadeMeaningfulProgress(beforeJob, afterJob) {
1273
+ const before = goalProgressSnapshot(beforeJob || {})
1274
+ const after = goalProgressSnapshot(afterJob || {})
1275
+ if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
1276
+ if (after.progressCount > before.progressCount) return true
1277
+ if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
1278
+ if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
1279
+ if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
1280
+ return false
1281
+ }
1282
+
1283
+ async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
1284
+ if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
1285
+ const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
1286
+ if (!Number.isFinite(limit) || limit <= 0) return job
1287
+ if (goalMadeMeaningfulProgress(beforeJob, job)) {
1288
+ job.noProgressCount = 0
1289
+ job.lastProgressAt = now()
1290
+ return job
1291
+ }
1292
+ job.noProgressCount = (job.noProgressCount || 0) + 1
1293
+ job.lastNoProgressAt = now()
1294
+ await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
1295
+ if (job.noProgressCount >= limit) {
1296
+ job.paused = true
1297
+ job.goalNoProgressPausedAt = now()
1298
+ job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
1299
+ await toast(client, job.goalNoProgressReason, "warning")
1300
+ await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
1301
+ }
1302
+ return job
1303
+ }
1304
+
1064
1305
  async function setGoalComplete(directory, sessionID, args = {}) {
1065
1306
  const state = await readState(directory, sessionID)
1066
1307
  const job = pickGoalJob(state, args.target)
1067
1308
  if (!job) return { ok: false, message: "No active experimental goal was found." }
1068
1309
  const parsed = parseGoalToolText(args, ["summary", "evidence"])
1310
+ const manualOverride = args.manual === true || args.manualOverride === true
1311
+ const completionEvidence = parsed.evidence || job.goalEvidence || ""
1312
+ const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
1313
+ const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
1314
+ if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
1315
+ return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
1316
+ }
1317
+ if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
1318
+ return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
1319
+ }
1069
1320
  job.goalStatus = "completed"
1070
1321
  job.enabled = false
1071
1322
  job.paused = true
1072
1323
  job.goalCompletedAt = now()
1073
1324
  job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
1074
- job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
1325
+ job.goalEvidence = completionEvidence || "No evidence provided."
1326
+ job.noProgressCount = 0
1075
1327
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1076
1328
  await writeState(directory, sessionID, state)
1077
1329
  await writeGoalReport(directory, sessionID, job)
@@ -1105,6 +1357,8 @@ async function setGoalProgress(directory, sessionID, args = {}) {
1105
1357
  const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
1106
1358
  job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
1107
1359
  if (parsed.evidence) job.goalEvidence = parsed.evidence
1360
+ job.noProgressCount = 0
1361
+ job.lastProgressAt = now()
1108
1362
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1109
1363
  await writeState(directory, sessionID, state)
1110
1364
  await writeGoalReport(directory, sessionID, job)
@@ -1148,14 +1402,17 @@ async function runGoalChecks(directory, sessionID, job, client) {
1148
1402
  return job
1149
1403
  }
1150
1404
 
1151
- async function finalizeActiveRun(directory, client, sessionID) {
1405
+ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1152
1406
  const active = activeRuns.get(sessionID)
1153
1407
  if (!active) return
1408
+ if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
1409
+ const recoveredStale = staleActiveRun(sessionID)
1154
1410
  clearActiveRun(sessionID)
1155
1411
  const state = await readState(directory, sessionID)
1156
1412
  let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
1157
1413
  if (!job) return
1158
1414
  job.lastFinishedAt = now()
1415
+ if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
1159
1416
 
1160
1417
  if (job.verifyCommand) {
1161
1418
  const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
@@ -1195,12 +1452,16 @@ async function finalizeActiveRun(directory, client, sessionID) {
1195
1452
  }
1196
1453
  }
1197
1454
 
1198
- if (isGoalJob(job)) job = await runGoalChecks(directory, sessionID, job, client)
1455
+ if (isGoalJob(job)) {
1456
+ job = await runGoalChecks(directory, sessionID, job, client)
1457
+ job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
1458
+ }
1199
1459
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1200
1460
  await writeState(directory, sessionID, state)
1201
1461
  if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
1202
1462
  await createCheckpoint(directory, sessionID, job, client)
1203
1463
  await scheduleDueWork(directory, client, sessionID)
1464
+ return true
1204
1465
  }
1205
1466
 
1206
1467
  async function fireAction(directory, client, sessionID, job) {
@@ -1219,10 +1480,17 @@ async function fireAction(directory, client, sessionID, job) {
1219
1480
  }
1220
1481
  const tuiCommand = compactTuiCommandName(command)
1221
1482
  if (tuiCommand) {
1483
+ guardLoopOwnedUserMessage(sessionID)
1222
1484
  await compactSession(client, sessionID)
1223
1485
  return { startsAssistantTurn: true }
1224
1486
  }
1225
- await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: { command, arguments: argumentsText } }, { sessionID, command, arguments: argumentsText })
1487
+ guardLoopOwnedUserMessage(sessionID)
1488
+ await sdkCall(
1489
+ client.session.command.bind(client.session),
1490
+ { path: { id: sessionID }, body: { command, arguments: argumentsText } },
1491
+ { path: { sessionID }, body: { command, arguments: argumentsText } },
1492
+ { sessionID, command, arguments: argumentsText },
1493
+ )
1226
1494
  return { startsAssistantTurn: true }
1227
1495
  }
1228
1496
  if (kind === "shell") {
@@ -1232,18 +1500,33 @@ async function fireAction(directory, client, sessionID, job) {
1232
1500
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1233
1501
  return { startsAssistantTurn: false }
1234
1502
  }
1235
- fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: { command } }, { sessionID, command })
1503
+ guardLoopOwnedUserMessage(sessionID)
1504
+ fireSdk(
1505
+ client,
1506
+ "session.shell",
1507
+ client.session.shell.bind(client.session),
1508
+ { path: { id: sessionID }, body: { command } },
1509
+ { path: { sessionID }, body: { command } },
1510
+ { sessionID, command },
1511
+ )
1236
1512
  return { startsAssistantTurn: true }
1237
1513
  }
1238
1514
  const prompt = await buildPrompt(directory, job)
1239
1515
  const prefix = kind === "goal"
1240
1516
  ? "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."
1241
1517
  : "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."
1242
- fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1243
-
1244
- ${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
1245
-
1246
- ${prompt}` }] })
1518
+ const promptText = `${prefix}
1519
+
1520
+ ${prompt}`
1521
+ guardLoopOwnedUserMessage(sessionID)
1522
+ fireSdk(
1523
+ client,
1524
+ "session.prompt",
1525
+ client.session.prompt.bind(client.session),
1526
+ { path: { id: sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1527
+ { path: { sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1528
+ { sessionID, parts: [{ type: "text", text: promptText }] },
1529
+ )
1247
1530
  return { startsAssistantTurn: true }
1248
1531
  }
1249
1532
 
@@ -1258,7 +1541,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1258
1541
  runLocks.set(sessionID, now())
1259
1542
  let job
1260
1543
  try {
1261
- await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
1544
+ await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
1262
1545
  if (!await sessionIsIdle(client, sessionID, directory)) {
1263
1546
  if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
1264
1547
  await reschedule(BUSY_RETRY_MS)
@@ -1359,8 +1642,10 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1359
1642
  return
1360
1643
  }
1361
1644
  let timer
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)
1645
+ if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
1363
1646
  activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1647
+ sessionStatuses.set(sessionID, "busy")
1648
+ sessionStatusSeenAt.set(sessionID, now())
1364
1649
  await reschedule(BUSY_RETRY_MS)
1365
1650
  } catch (error) {
1366
1651
  clearActiveRun(sessionID)
@@ -1390,7 +1675,11 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1390
1675
  const parsed = parseLoopArgs(args, defaults)
1391
1676
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1392
1677
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1393
- if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1678
+ if (!parsed.job.activeRecoveryMs) {
1679
+ parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
1680
+ ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
1681
+ : Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1682
+ }
1394
1683
  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 }
1395
1684
  const state = await readState(directory, sessionID)
1396
1685
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -1456,7 +1745,9 @@ async function statusGoal(directory, client, sessionID) {
1456
1745
  const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
1457
1746
  const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
1458
1747
  const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
1459
- return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}`
1748
+ const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
1749
+ const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
1750
+ return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`
1460
1751
  }) : ["No experimental goal jobs."]
1461
1752
  await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
1462
1753
  await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
@@ -1479,7 +1770,7 @@ async function resumeGoal(directory, client, sessionID, args) {
1479
1770
  state.jobs = (state.jobs || []).map((job, index) => {
1480
1771
  if (!isGoalJob(job) || !matchJob(job, target, index)) return job
1481
1772
  count++
1482
- return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
1773
+ return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
1483
1774
  })
1484
1775
  await writeState(directory, sessionID, state)
1485
1776
  await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
@@ -1500,7 +1791,7 @@ async function clearGoal(directory, client, sessionID, args) {
1500
1791
  }
1501
1792
 
1502
1793
  async function completeGoalCommand(directory, client, sessionID, args) {
1503
- const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done." })
1794
+ const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
1504
1795
  await toast(client, result.message, result.ok ? "success" : "warning")
1505
1796
  }
1506
1797
 
@@ -1606,6 +1897,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1606
1897
  rememberSession(directory, client, sessionID)
1607
1898
  if (wasHandled(sessionID, name, args)) return true
1608
1899
  markHandled(sessionID, name, args)
1900
+ if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
1609
1901
 
1610
1902
  const handled = () => {
1611
1903
  if (output && Array.isArray(output.parts)) {
@@ -1649,7 +1941,7 @@ function goalTools(defaultDirectory) {
1649
1941
  },
1650
1942
  execute: async (args, context) => {
1651
1943
  const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
1652
- return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
1944
+ return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
1653
1945
  },
1654
1946
  }),
1655
1947
  opencode_loop_goal_blocked: tool({
@@ -1680,6 +1972,7 @@ function goalTools(defaultDirectory) {
1680
1972
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1681
1973
  await log(client, "info", "Plugin initialized", { directory })
1682
1974
  return {
1975
+ dispose: async () => { disposeRuntime(directory, client) },
1683
1976
  tool: goalTools(directory),
1684
1977
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
1685
1978
  event: async ({ event }) => {
@@ -1687,6 +1980,11 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1687
1980
  const props = event.properties || {}
1688
1981
  await handleCommand(directory, client, props, props.name, props.arguments)
1689
1982
  }
1983
+ const interruptedSessionID = userInterruptSessionFromEvent(event)
1984
+ if (interruptedSessionID) {
1985
+ rememberSession(directory, client, interruptedSessionID)
1986
+ await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
1987
+ }
1690
1988
  const statusUpdate = updateSessionStatusFromEvent(event)
1691
1989
  if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
1692
1990
  if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)