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