@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 +14 -0
- package/README.md +28 -5
- package/package.json +5 -4
- package/scripts/install-node.mjs +22 -1
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +25 -0
- package/scripts/smoke-test.mjs +115 -0
- package/src/index.js +272 -34
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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"
|
package/scripts/install-node.mjs
CHANGED
|
@@ -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
|
|
package/scripts/install.ps1
CHANGED
|
@@ -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
|
package/scripts/install.sh
CHANGED
|
@@ -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,
|
|
425
|
+
async function sdkCall(method, ...argsList) {
|
|
403
426
|
let firstError
|
|
404
|
-
|
|
405
|
-
|
|
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(
|
|
430
|
+
const result = await method(args)
|
|
415
431
|
const error = sdkError(result)
|
|
416
432
|
if (!error) return sdkData(result)
|
|
417
|
-
|
|
433
|
+
firstError = firstError || new Error(sdkErrorMessage(error))
|
|
418
434
|
} catch (error) {
|
|
419
|
-
|
|
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,
|
|
441
|
+
function fireSdk(client, label, method, ...argsList) {
|
|
426
442
|
Promise.resolve()
|
|
427
|
-
.then(() => sdkCall(method,
|
|
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(
|
|
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 } }, {
|
|
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 =
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
1302
|
-
${prompt}`
|
|
1303
|
-
|
|
1304
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|