@bybrawe/opencode-loop 0.5.14 → 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,19 @@
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
+
3
17
  ## 0.5.14
4
18
 
5
19
  - Guarded active-run finalization so heartbeat and retry checks do not treat a still-running assistant turn as finished.
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
 
@@ -157,7 +157,7 @@ Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or
157
157
  npx -y @bybrawe/opencode-loop@latest
158
158
  ```
159
159
 
160
- 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.
161
161
  It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
162
162
 
163
163
  Windows target paths:
@@ -390,7 +390,7 @@ That can look like a chat prompt in older versions or unclear setups. Prefer `/l
390
390
 
391
391
  ### Experimental Goal Mode
392
392
 
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.
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.
394
394
 
395
395
  Basic goal:
396
396
 
@@ -408,6 +408,16 @@ What happens:
408
408
 
409
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.
410
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
+
411
421
  Simple status and control commands:
412
422
 
413
423
  ```text
@@ -451,6 +461,12 @@ Goal that automatically stops when all configured checks pass:
451
461
  /loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
452
462
  ```
453
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
+
454
470
  Goal with a safety/runtime limit:
455
471
 
456
472
  ```text
@@ -1096,6 +1112,13 @@ Improve the application in small safe steps.
1096
1112
 
1097
1113
  ## Changelog highlights
1098
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
+
1099
1122
  ### v0.5.13
1100
1123
 
1101
1124
  - Wrapped experimental goal tools with OpenCode's `tool()` helper and Zod schemas for current OpenCode tool validation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.14",
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/"
@@ -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,9 +17,13 @@ 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()
@@ -142,6 +146,11 @@ function parsePositiveInt(value, fallback = 0) {
142
146
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
143
147
  }
144
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
+
145
154
  function parseCompactEvery(value) {
146
155
  const duration = parseDuration(value)
147
156
  if (duration !== null) return { compactEveryMs: duration }
@@ -203,11 +212,16 @@ function parseLoopArgs(raw, defaults = {}) {
203
212
  goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
204
213
  goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
205
214
  goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
215
+ goalRequireEvidence: defaults.goalRequireEvidence,
216
+ goalRequireChecksPass: defaults.goalRequireChecksPass,
206
217
  goalEvidenceFile: defaults.goalEvidenceFile,
207
218
  goalSummary: defaults.goalSummary || "",
208
219
  goalEvidence: defaults.goalEvidence || "",
209
220
  goalBlockedReason: defaults.goalBlockedReason || "",
210
221
  goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
222
+ maxNoProgress: defaults.maxNoProgress,
223
+ noProgressCount: defaults.noProgressCount ?? 0,
224
+ lastProgressAt: defaults.lastProgressAt ?? 0,
211
225
  noOverlap: defaults.noOverlap ?? true,
212
226
  safe: defaults.safe ?? false,
213
227
  quiet: defaults.quiet ?? false,
@@ -255,10 +269,16 @@ function parseLoopArgs(raw, defaults = {}) {
255
269
  ;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
256
270
  ;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
257
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
258
277
 
259
278
  ;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
260
279
  ;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
261
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)
262
282
  ;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
263
283
  ;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
264
284
  ;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
@@ -309,6 +329,9 @@ function parseLoopArgs(raw, defaults = {}) {
309
329
  job.safe = job.safe !== false
310
330
  job.askNever = job.askNever !== false
311
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
312
335
  }
313
336
  job.lastRunAt = job.immediate ? 0 : now()
314
337
 
@@ -399,32 +422,25 @@ function sdkErrorMessage(error) {
399
422
  return String(error)
400
423
  }
401
424
 
402
- async function sdkCall(method, modernArgs, legacyArgs) {
425
+ async function sdkCall(method, ...argsList) {
403
426
  let firstError
404
- try {
405
- const result = await method(modernArgs)
406
- const error = sdkError(result)
407
- if (!error) return sdkData(result)
408
- firstError = new Error(sdkErrorMessage(error))
409
- } catch (error) {
410
- firstError = error
411
- }
412
- if (legacyArgs !== undefined) {
427
+ for (const args of argsList) {
428
+ if (args === undefined) continue
413
429
  try {
414
- const result = await method(legacyArgs)
430
+ const result = await method(args)
415
431
  const error = sdkError(result)
416
432
  if (!error) return sdkData(result)
417
- throw new Error(sdkErrorMessage(error))
433
+ firstError = firstError || new Error(sdkErrorMessage(error))
418
434
  } catch (error) {
419
- throw error
435
+ firstError = firstError || error
420
436
  }
421
437
  }
422
- throw firstError
438
+ throw firstError || new Error("SDK call failed without arguments")
423
439
  }
424
440
 
425
- function fireSdk(client, label, method, modernArgs, legacyArgs) {
441
+ function fireSdk(client, label, method, ...argsList) {
426
442
  Promise.resolve()
427
- .then(() => sdkCall(method, modernArgs, legacyArgs))
443
+ .then(() => sdkCall(method, ...argsList))
428
444
  .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
429
445
  }
430
446
 
@@ -457,7 +473,12 @@ async function compactSession(client, sessionID) {
457
473
  }
458
474
  }
459
475
  try {
460
- 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
+ )
461
482
  return true
462
483
  } catch (error) {
463
484
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
@@ -480,11 +501,29 @@ async function toast(client, message, variant = "info") {
480
501
  try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
481
502
  }
482
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
+
483
520
  async function say(client, sessionID, text) {
521
+ guardLoopOwnedUserMessage(sessionID)
484
522
  try {
485
523
  await sdkCall(
486
524
  client.session.prompt.bind(client.session),
487
525
  { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
526
+ { path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
488
527
  { sessionID, noReply: true, parts: [{ type: "text", text }] },
489
528
  )
490
529
  } catch {}
@@ -502,6 +541,9 @@ function wasHandled(sessionID, name, args) {
502
541
 
503
542
  function commandName(name) { return String(name || "") }
504
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
+ }
505
547
 
506
548
  function normalizeArgsForKey(args) {
507
549
  if (args === undefined || args === null) return ""
@@ -549,6 +591,32 @@ function startHeartbeat() {
549
591
  }
550
592
  }, HEARTBEAT_MS)
551
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
+
552
620
  function presetDefaults(name, args) {
553
621
  const [maybeDuration, rest] = splitFirst(args)
554
622
  const parsed = parseDuration(maybeDuration)
@@ -575,10 +643,11 @@ function jobLabel(job) {
575
643
  const verify = job.verifyCommand ? ", verify" : ""
576
644
  const preflight = job.preflightCommand ? ", preflight" : ""
577
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}` : ""
578
647
  const stopFile = job.stopFile ? ", stop-file" : ""
579
648
  const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
580
649
  const paused = job.paused ? ", paused" : ""
581
- 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}`
582
651
  }
583
652
 
584
653
  function matchJob(job, target, index) {
@@ -682,6 +751,7 @@ function goalStatusText(job) {
682
751
 
683
752
  async function buildGoalPrompt(directory, job) {
684
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\".`)
685
755
  const objective = String(job.action || "").trim()
686
756
  if (objective) sections.push(`Goal objective:\n${objective}`)
687
757
  if (job.goalFile) {
@@ -698,6 +768,8 @@ async function buildGoalPrompt(directory, job) {
698
768
  if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
699
769
  if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
700
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.`)
701
773
  if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
702
774
  for (const file of job.includeFiles || []) {
703
775
  const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
@@ -712,9 +784,12 @@ Rules:
712
784
  - Work on the next smallest useful step toward the goal.
713
785
  - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
714
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.
715
789
  - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
716
790
  - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
717
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.
718
793
  - Do not call completion tools just to be polite; only call them when the state is real.
719
794
  - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
720
795
  - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
@@ -852,6 +927,39 @@ function updateSessionStatusFromEvent(event) {
852
927
  return undefined
853
928
  }
854
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
+
855
963
  function staleActiveRun(sessionID) {
856
964
  const active = activeRuns.get(sessionID)
857
965
  if (!active) return false
@@ -876,7 +984,7 @@ async function canFinalizeActiveRun(directory, client, sessionID, active, option
876
984
 
877
985
  async function readLiveSessionStatus(client, sessionID, directory) {
878
986
  const argsList = []
879
- if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
987
+ if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
880
988
  argsList.push({})
881
989
  for (const args of argsList) {
882
990
  try {
@@ -1075,11 +1183,17 @@ function goalReportText(job) {
1075
1183
  lines.push(`Created: ${job.createdAt || ""}`)
1076
1184
  if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
1077
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()}`)
1078
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}`)
1079
1190
  lines.push("")
1080
1191
  if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
1081
1192
  if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
1082
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), "")
1083
1197
  if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
1084
1198
  if (job.lastGoalChecks?.length) {
1085
1199
  lines.push("## Latest checks", "")
@@ -1115,17 +1229,101 @@ function parseGoalToolText(args, fields) {
1115
1229
  return result
1116
1230
  }
1117
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
+
1118
1305
  async function setGoalComplete(directory, sessionID, args = {}) {
1119
1306
  const state = await readState(directory, sessionID)
1120
1307
  const job = pickGoalJob(state, args.target)
1121
1308
  if (!job) return { ok: false, message: "No active experimental goal was found." }
1122
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
+ }
1123
1320
  job.goalStatus = "completed"
1124
1321
  job.enabled = false
1125
1322
  job.paused = true
1126
1323
  job.goalCompletedAt = now()
1127
1324
  job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
1128
- job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
1325
+ job.goalEvidence = completionEvidence || "No evidence provided."
1326
+ job.noProgressCount = 0
1129
1327
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1130
1328
  await writeState(directory, sessionID, state)
1131
1329
  await writeGoalReport(directory, sessionID, job)
@@ -1159,6 +1357,8 @@ async function setGoalProgress(directory, sessionID, args = {}) {
1159
1357
  const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
1160
1358
  job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
1161
1359
  if (parsed.evidence) job.goalEvidence = parsed.evidence
1360
+ job.noProgressCount = 0
1361
+ job.lastProgressAt = now()
1162
1362
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1163
1363
  await writeState(directory, sessionID, state)
1164
1364
  await writeGoalReport(directory, sessionID, job)
@@ -1252,7 +1452,10 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1252
1452
  }
1253
1453
  }
1254
1454
 
1255
- 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
+ }
1256
1459
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1257
1460
  await writeState(directory, sessionID, state)
1258
1461
  if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
@@ -1277,10 +1480,17 @@ async function fireAction(directory, client, sessionID, job) {
1277
1480
  }
1278
1481
  const tuiCommand = compactTuiCommandName(command)
1279
1482
  if (tuiCommand) {
1483
+ guardLoopOwnedUserMessage(sessionID)
1280
1484
  await compactSession(client, sessionID)
1281
1485
  return { startsAssistantTurn: true }
1282
1486
  }
1283
- 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
+ )
1284
1494
  return { startsAssistantTurn: true }
1285
1495
  }
1286
1496
  if (kind === "shell") {
@@ -1290,18 +1500,33 @@ async function fireAction(directory, client, sessionID, job) {
1290
1500
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1291
1501
  return { startsAssistantTurn: false }
1292
1502
  }
1293
- 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
+ )
1294
1512
  return { startsAssistantTurn: true }
1295
1513
  }
1296
1514
  const prompt = await buildPrompt(directory, job)
1297
1515
  const prefix = kind === "goal"
1298
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."
1299
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."
1300
- fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1301
-
1302
- ${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
1303
-
1304
- ${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
+ )
1305
1530
  return { startsAssistantTurn: true }
1306
1531
  }
1307
1532
 
@@ -1417,7 +1642,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1417
1642
  return
1418
1643
  }
1419
1644
  let timer
1420
- 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)
1421
1646
  activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1422
1647
  sessionStatuses.set(sessionID, "busy")
1423
1648
  sessionStatusSeenAt.set(sessionID, now())
@@ -1450,7 +1675,11 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1450
1675
  const parsed = parseLoopArgs(args, defaults)
1451
1676
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1452
1677
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1453
- 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
+ }
1454
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 }
1455
1684
  const state = await readState(directory, sessionID)
1456
1685
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -1516,7 +1745,9 @@ async function statusGoal(directory, client, sessionID) {
1516
1745
  const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
1517
1746
  const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
1518
1747
  const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
1519
- 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}`
1520
1751
  }) : ["No experimental goal jobs."]
1521
1752
  await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
1522
1753
  await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
@@ -1539,7 +1770,7 @@ async function resumeGoal(directory, client, sessionID, args) {
1539
1770
  state.jobs = (state.jobs || []).map((job, index) => {
1540
1771
  if (!isGoalJob(job) || !matchJob(job, target, index)) return job
1541
1772
  count++
1542
- 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: "" }
1543
1774
  })
1544
1775
  await writeState(directory, sessionID, state)
1545
1776
  await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
@@ -1560,7 +1791,7 @@ async function clearGoal(directory, client, sessionID, args) {
1560
1791
  }
1561
1792
 
1562
1793
  async function completeGoalCommand(directory, client, sessionID, args) {
1563
- 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 })
1564
1795
  await toast(client, result.message, result.ok ? "success" : "warning")
1565
1796
  }
1566
1797
 
@@ -1666,6 +1897,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1666
1897
  rememberSession(directory, client, sessionID)
1667
1898
  if (wasHandled(sessionID, name, args)) return true
1668
1899
  markHandled(sessionID, name, args)
1900
+ if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
1669
1901
 
1670
1902
  const handled = () => {
1671
1903
  if (output && Array.isArray(output.parts)) {
@@ -1709,7 +1941,7 @@ function goalTools(defaultDirectory) {
1709
1941
  },
1710
1942
  execute: async (args, context) => {
1711
1943
  const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
1712
- 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 }
1713
1945
  },
1714
1946
  }),
1715
1947
  opencode_loop_goal_blocked: tool({
@@ -1740,6 +1972,7 @@ function goalTools(defaultDirectory) {
1740
1972
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1741
1973
  await log(client, "info", "Plugin initialized", { directory })
1742
1974
  return {
1975
+ dispose: async () => { disposeRuntime(directory, client) },
1743
1976
  tool: goalTools(directory),
1744
1977
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
1745
1978
  event: async ({ event }) => {
@@ -1747,6 +1980,11 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1747
1980
  const props = event.properties || {}
1748
1981
  await handleCommand(directory, client, props, props.name, props.arguments)
1749
1982
  }
1983
+ const interruptedSessionID = userInterruptSessionFromEvent(event)
1984
+ if (interruptedSessionID) {
1985
+ rememberSession(directory, client, interruptedSessionID)
1986
+ await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
1987
+ }
1750
1988
  const statusUpdate = updateSessionStatusFromEvent(event)
1751
1989
  if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
1752
1990
  if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)