@bybrawe/opencode-loop 0.5.14 → 0.5.16
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 +20 -0
- package/README.md +36 -7
- 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 +158 -0
- package/src/index.js +402 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.16
|
|
4
|
+
|
|
5
|
+
- Treat active OpenCode tools, shell calls, and foreground or background subtasks as busy even if the parent session receives an idle/status event while they are still running.
|
|
6
|
+
- Prevent stale active-run recovery from finalizing a loop turn while any tracked tool call remains active.
|
|
7
|
+
- Added regression coverage proving that a due loop waits for long-running tools and background child sessions, then resumes only after that work finishes and the parent session becomes idle.
|
|
8
|
+
|
|
9
|
+
## 0.5.15
|
|
10
|
+
|
|
11
|
+
- Hardened experimental Goal Mode completion: agent tool completion now requires concrete evidence instead of accepting empty or generic claims.
|
|
12
|
+
- Required configured goal `--check` commands to pass before agent-tool completion, unless the user explicitly opts out or manually marks the goal done.
|
|
13
|
+
- Added a no-progress guard for goals. Goals pause after 3 turns without recorded meaningful progress by default, configurable with `--max-no-progress`.
|
|
14
|
+
- Paused active goals when a real user message arrives, so new user intent wins before the next automatic continuation.
|
|
15
|
+
- Preferred the OpenCode plugin SDK's `{ body }`, `{ path: { id }, body }`, and `{ query }` argument shapes while keeping flat and older path-key fallbacks.
|
|
16
|
+
- Added the resolved working directory and workspace-relative path rules to Goal Mode prompts so agents do not accidentally target filesystem-root paths.
|
|
17
|
+
- 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.
|
|
18
|
+
- Added plugin disposal cleanup for heartbeat, idle, due, watchdog, and active-run timers.
|
|
19
|
+
- Added an automated smoke test for SDK call order, Goal Mode prompts, evidence rejection, and successful completion state.
|
|
20
|
+
- Updated local installers to create or update config `package.json` with `@opencode-ai/plugin`, needed by local `.ts` plugins that import `tool()`.
|
|
21
|
+
- Updated Goal Mode status/report output and README guidance for the new guards.
|
|
22
|
+
|
|
3
23
|
## 0.5.14
|
|
4
24
|
|
|
5
25
|
- 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.16 fixes idle detection during long-running tools and background subtasks.** The scheduler now treats OpenCode tool lifecycle activity and running child sessions as stronger busy signals than a premature parent idle/status event, so due prompts wait instead of filling the queue. It keeps the Goal Mode and SDK compatibility hardening from v0.5.15.
|
|
20
20
|
|
|
21
21
|
The known update-related symptoms from older builds are fixed:
|
|
22
22
|
|
|
@@ -26,20 +26,20 @@ 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.16 quick behavior guide
|
|
36
36
|
|
|
37
37
|
OpenCode Loop has two triggers now:
|
|
38
38
|
|
|
39
39
|
1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
|
|
40
40
|
2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
|
|
41
41
|
|
|
42
|
-
All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, the job does not interrupt the active turn. It waits and retries until the session becomes idle.
|
|
42
|
+
All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, or while a tool/background child session is still executing, the job does not interrupt the active turn. It waits and retries until that work finishes and the parent session becomes idle.
|
|
43
43
|
|
|
44
44
|
Simple examples:
|
|
45
45
|
|
|
@@ -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:
|
|
@@ -352,7 +352,7 @@ Experimental goal mode: keep working until the objective is actually done, block
|
|
|
352
352
|
|
|
353
353
|
### What “when idle” means
|
|
354
354
|
|
|
355
|
-
OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
|
|
355
|
+
OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy. Active tool calls and running child sessions also count as busy, even if OpenCode emits a premature idle/status signal for the parent during long-running background work.
|
|
356
356
|
|
|
357
357
|
Example:
|
|
358
358
|
|
|
@@ -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,19 @@ Improve the application in small safe steps.
|
|
|
1096
1112
|
|
|
1097
1113
|
## Changelog highlights
|
|
1098
1114
|
|
|
1115
|
+
### v0.5.16
|
|
1116
|
+
|
|
1117
|
+
- Treat active tools, shell calls, and running foreground/background child sessions as busy even when the parent session appears idle.
|
|
1118
|
+
- Prevent stale-run recovery from enqueueing another continuation while tracked background work is still active.
|
|
1119
|
+
- Added regression tests for long-running tool calls and background child sessions.
|
|
1120
|
+
|
|
1121
|
+
### v0.5.15
|
|
1122
|
+
|
|
1123
|
+
- Hardened experimental Goal Mode completion with concrete evidence, passing-check requirements, no-progress pausing, and user-message interruption.
|
|
1124
|
+
- Preferred OpenCode plugin SDK `{ body }`, `{ path: { id }, body }`, and `{ query }` shapes before compatibility fallbacks.
|
|
1125
|
+
- Added explicit workspace path guidance, safer 3-minute active-turn recovery, timer disposal cleanup, and an automated Goal Mode smoke test.
|
|
1126
|
+
- Made local installers add the `@opencode-ai/plugin` dependency needed by local `.ts` plugins.
|
|
1127
|
+
|
|
1099
1128
|
### v0.5.13
|
|
1100
1129
|
|
|
1101
1130
|
- 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.16",
|
|
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,158 @@
|
|
|
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
|
+
const liveStatuses = new Map([[sessionID, "idle"]])
|
|
13
|
+
let hooks
|
|
14
|
+
|
|
15
|
+
function legacyBody(name) {
|
|
16
|
+
return async (args) => {
|
|
17
|
+
attempts[name]++
|
|
18
|
+
assert.ok(args?.body, `${name} must use the plugin SDK body shape first`)
|
|
19
|
+
return { data: true }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = {
|
|
24
|
+
app: {
|
|
25
|
+
log: legacyBody("log"),
|
|
26
|
+
},
|
|
27
|
+
tui: {
|
|
28
|
+
executeCommand: async (args) => {
|
|
29
|
+
assert.ok(args?.body?.command)
|
|
30
|
+
return { data: true }
|
|
31
|
+
},
|
|
32
|
+
showToast: legacyBody("toast"),
|
|
33
|
+
},
|
|
34
|
+
session: {
|
|
35
|
+
list: async (args) => {
|
|
36
|
+
assert.equal(args?.query?.directory, directory)
|
|
37
|
+
return { data: [{ id: sessionID, directory, projectID: "project", title: "smoke", version: "test", time: { created: Date.now(), updated: Date.now() } }] }
|
|
38
|
+
},
|
|
39
|
+
abort: async (args) => {
|
|
40
|
+
assert.equal(args?.path?.id, sessionID)
|
|
41
|
+
return { data: true }
|
|
42
|
+
},
|
|
43
|
+
command: async (args) => {
|
|
44
|
+
assert.equal(args?.path?.id, sessionID)
|
|
45
|
+
assert.ok(args?.body?.command)
|
|
46
|
+
return { data: true }
|
|
47
|
+
},
|
|
48
|
+
prompt: async (args) => {
|
|
49
|
+
attempts.prompt++
|
|
50
|
+
assert.equal(args?.path?.id, sessionID, "session.prompt must use path.id first")
|
|
51
|
+
assert.ok(Array.isArray(args?.body?.parts))
|
|
52
|
+
prompts.push(args.body.parts.map((part) => part.text || "").join("\n"))
|
|
53
|
+
return { data: true }
|
|
54
|
+
},
|
|
55
|
+
shell: async (args) => {
|
|
56
|
+
assert.equal(args?.path?.id, sessionID)
|
|
57
|
+
assert.ok(args?.body?.command)
|
|
58
|
+
return { data: true }
|
|
59
|
+
},
|
|
60
|
+
status: async (args) => {
|
|
61
|
+
attempts.status++
|
|
62
|
+
assert.equal(args?.query?.directory, directory, "session.status must use query.directory first")
|
|
63
|
+
return { data: Object.fromEntries([...liveStatuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }])) }
|
|
64
|
+
},
|
|
65
|
+
summarize: async (args) => {
|
|
66
|
+
assert.equal(args?.path?.id, sessionID)
|
|
67
|
+
return { data: true }
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
hooks = await OpenCodeLoopPlugin({ client, directory })
|
|
74
|
+
assert.deepEqual(Object.keys(hooks.tool).sort(), [
|
|
75
|
+
"opencode_loop_goal_blocked",
|
|
76
|
+
"opencode_loop_goal_complete",
|
|
77
|
+
"opencode_loop_goal_progress",
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const output = { parts: [{ type: "text", text: "original command body" }] }
|
|
81
|
+
await hooks["command.execute.before"]({
|
|
82
|
+
command: "loop-goal",
|
|
83
|
+
sessionID,
|
|
84
|
+
arguments: "Create proof.txt and verify it --max-turns 3",
|
|
85
|
+
}, output)
|
|
86
|
+
assert.match(output.parts[0].text, /handled by the local plugin/i)
|
|
87
|
+
|
|
88
|
+
await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
90
|
+
assert.ok(prompts.some((prompt) => prompt.includes(path.resolve(directory))), "goal prompt must include the working directory")
|
|
91
|
+
assert.ok(prompts.some((prompt) => prompt.includes("never turn a relative path into a root path")))
|
|
92
|
+
|
|
93
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
94
|
+
const activeState = JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
95
|
+
assert.equal(activeState.jobs[0].activeRecoveryMs, 180_000)
|
|
96
|
+
|
|
97
|
+
const rejected = await hooks.tool.opencode_loop_goal_complete.execute({ summary: "Done", evidence: "done" }, { directory, sessionID })
|
|
98
|
+
assert.equal(rejected.title, "Goal completion rejected")
|
|
99
|
+
|
|
100
|
+
const completed = await hooks.tool.opencode_loop_goal_complete.execute({
|
|
101
|
+
summary: "Created and verified proof.txt",
|
|
102
|
+
evidence: "Created proof.txt and read the file back; its exact content matched the requested value.",
|
|
103
|
+
}, { directory, sessionID })
|
|
104
|
+
assert.equal(completed.title, "Goal completed")
|
|
105
|
+
|
|
106
|
+
const completedState = JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
107
|
+
assert.equal(completedState.jobs[0].goalStatus, "completed")
|
|
108
|
+
assert.equal(completedState.jobs[0].paused, true)
|
|
109
|
+
assert.equal(completedState.jobs[0].enabled, false)
|
|
110
|
+
|
|
111
|
+
await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
|
|
112
|
+
|
|
113
|
+
const promptCountBeforeBackgroundTool = prompts.length
|
|
114
|
+
await hooks["tool.execute.before"]({ tool: "bash", sessionID, callID: "call_background" }, { args: {} })
|
|
115
|
+
await hooks["command.execute.before"]({
|
|
116
|
+
command: "loop",
|
|
117
|
+
sessionID,
|
|
118
|
+
arguments: "0s --max-runs 1 continue after the background task",
|
|
119
|
+
}, { parts: [] })
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 1_400))
|
|
121
|
+
assert.equal(prompts.length, promptCountBeforeBackgroundTool, "an active tool call must keep the loop busy even when session.status says idle")
|
|
122
|
+
|
|
123
|
+
await hooks["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_background", args: {} }, { title: "done", output: "", metadata: {} })
|
|
124
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 1_400))
|
|
126
|
+
assert.equal(prompts.length, promptCountBeforeBackgroundTool + 1, "the due loop must resume after the active tool finishes and the session becomes idle")
|
|
127
|
+
|
|
128
|
+
await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
|
|
129
|
+
|
|
130
|
+
const childSessionID = "ses_background_child"
|
|
131
|
+
liveStatuses.set(childSessionID, "busy")
|
|
132
|
+
await hooks.event({ event: { type: "session.created", properties: { info: { id: childSessionID, parentID: sessionID } } } })
|
|
133
|
+
await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "busy" } } } })
|
|
134
|
+
const promptCountBeforeBackgroundChild = prompts.length
|
|
135
|
+
await hooks["command.execute.before"]({
|
|
136
|
+
command: "loop",
|
|
137
|
+
sessionID,
|
|
138
|
+
arguments: "0s --max-runs 1 continue after the background subtask",
|
|
139
|
+
}, { parts: [] })
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 2_700))
|
|
141
|
+
assert.equal(prompts.length, promptCountBeforeBackgroundChild, "a running background child session must keep its parent loop busy")
|
|
142
|
+
|
|
143
|
+
liveStatuses.set(childSessionID, "idle")
|
|
144
|
+
await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "idle" } } } })
|
|
145
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 1_400))
|
|
147
|
+
assert.equal(prompts.length, promptCountBeforeBackgroundChild + 1, "the parent loop must resume after its background child becomes idle")
|
|
148
|
+
|
|
149
|
+
await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
|
|
150
|
+
assert.equal(attempts.log, 1)
|
|
151
|
+
assert.ok(attempts.prompt >= 1)
|
|
152
|
+
assert.ok(attempts.status >= 1)
|
|
153
|
+
assert.ok(attempts.toast >= 1)
|
|
154
|
+
console.log("OpenCode Loop smoke test passed")
|
|
155
|
+
} finally {
|
|
156
|
+
await hooks?.dispose?.()
|
|
157
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
158
|
+
}
|
package/src/index.js
CHANGED
|
@@ -17,15 +17,21 @@ 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()
|
|
28
32
|
const stateWriteLocks = new Map()
|
|
33
|
+
const activeToolCalls = new Map()
|
|
34
|
+
const sessionParents = new Map()
|
|
29
35
|
let heartbeatTimer
|
|
30
36
|
const sessionStatuses = new Map()
|
|
31
37
|
const sessionStatusSeenAt = new Map()
|
|
@@ -142,6 +148,11 @@ function parsePositiveInt(value, fallback = 0) {
|
|
|
142
148
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
function parseNonNegativeInt(value, fallback = 0) {
|
|
152
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
153
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
function parseCompactEvery(value) {
|
|
146
157
|
const duration = parseDuration(value)
|
|
147
158
|
if (duration !== null) return { compactEveryMs: duration }
|
|
@@ -203,11 +214,16 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
203
214
|
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
204
215
|
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
205
216
|
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
217
|
+
goalRequireEvidence: defaults.goalRequireEvidence,
|
|
218
|
+
goalRequireChecksPass: defaults.goalRequireChecksPass,
|
|
206
219
|
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
207
220
|
goalSummary: defaults.goalSummary || "",
|
|
208
221
|
goalEvidence: defaults.goalEvidence || "",
|
|
209
222
|
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
210
223
|
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
224
|
+
maxNoProgress: defaults.maxNoProgress,
|
|
225
|
+
noProgressCount: defaults.noProgressCount ?? 0,
|
|
226
|
+
lastProgressAt: defaults.lastProgressAt ?? 0,
|
|
211
227
|
noOverlap: defaults.noOverlap ?? true,
|
|
212
228
|
safe: defaults.safe ?? false,
|
|
213
229
|
quiet: defaults.quiet ?? false,
|
|
@@ -255,10 +271,16 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
255
271
|
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
256
272
|
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
257
273
|
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
274
|
+
;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
|
|
275
|
+
;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
|
|
276
|
+
;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
|
|
277
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
|
|
278
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
|
|
258
279
|
|
|
259
280
|
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
260
281
|
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
261
282
|
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
283
|
+
;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
262
284
|
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
263
285
|
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
264
286
|
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
@@ -309,6 +331,9 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
309
331
|
job.safe = job.safe !== false
|
|
310
332
|
job.askNever = job.askNever !== false
|
|
311
333
|
job.noOverlap = job.noOverlap !== false
|
|
334
|
+
job.goalRequireEvidence = job.goalRequireEvidence !== false
|
|
335
|
+
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
|
|
336
|
+
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
|
|
312
337
|
}
|
|
313
338
|
job.lastRunAt = job.immediate ? 0 : now()
|
|
314
339
|
|
|
@@ -399,32 +424,25 @@ function sdkErrorMessage(error) {
|
|
|
399
424
|
return String(error)
|
|
400
425
|
}
|
|
401
426
|
|
|
402
|
-
async function sdkCall(method,
|
|
427
|
+
async function sdkCall(method, ...argsList) {
|
|
403
428
|
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) {
|
|
429
|
+
for (const args of argsList) {
|
|
430
|
+
if (args === undefined) continue
|
|
413
431
|
try {
|
|
414
|
-
const result = await method(
|
|
432
|
+
const result = await method(args)
|
|
415
433
|
const error = sdkError(result)
|
|
416
434
|
if (!error) return sdkData(result)
|
|
417
|
-
|
|
435
|
+
firstError = firstError || new Error(sdkErrorMessage(error))
|
|
418
436
|
} catch (error) {
|
|
419
|
-
|
|
437
|
+
firstError = firstError || error
|
|
420
438
|
}
|
|
421
439
|
}
|
|
422
|
-
throw firstError
|
|
440
|
+
throw firstError || new Error("SDK call failed without arguments")
|
|
423
441
|
}
|
|
424
442
|
|
|
425
|
-
function fireSdk(client, label, method,
|
|
443
|
+
function fireSdk(client, label, method, ...argsList) {
|
|
426
444
|
Promise.resolve()
|
|
427
|
-
.then(() => sdkCall(method,
|
|
445
|
+
.then(() => sdkCall(method, ...argsList))
|
|
428
446
|
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
429
447
|
}
|
|
430
448
|
|
|
@@ -457,7 +475,12 @@ async function compactSession(client, sessionID) {
|
|
|
457
475
|
}
|
|
458
476
|
}
|
|
459
477
|
try {
|
|
460
|
-
await sdkCall(
|
|
478
|
+
await sdkCall(
|
|
479
|
+
client.session.summarize.bind(client.session),
|
|
480
|
+
{ path: { id: sessionID }, body: {} },
|
|
481
|
+
{ path: { sessionID }, body: {} },
|
|
482
|
+
{ sessionID },
|
|
483
|
+
)
|
|
461
484
|
return true
|
|
462
485
|
} catch (error) {
|
|
463
486
|
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
@@ -480,11 +503,29 @@ async function toast(client, message, variant = "info") {
|
|
|
480
503
|
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
481
504
|
}
|
|
482
505
|
|
|
506
|
+
function guardLoopOwnedUserMessage(sessionID) {
|
|
507
|
+
if (!sessionID) return
|
|
508
|
+
loopOwnedUserMessageGuards.set(sessionID, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
509
|
+
for (const [key, time] of loopOwnedUserMessageGuards.entries()) if (time < now()) loopOwnedUserMessageGuards.delete(key)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function loopOwnedUserMessageGuardActive(sessionID) {
|
|
513
|
+
const until = loopOwnedUserMessageGuards.get(sessionID)
|
|
514
|
+
if (typeof until !== "number") return false
|
|
515
|
+
if (until < now()) {
|
|
516
|
+
loopOwnedUserMessageGuards.delete(sessionID)
|
|
517
|
+
return false
|
|
518
|
+
}
|
|
519
|
+
return true
|
|
520
|
+
}
|
|
521
|
+
|
|
483
522
|
async function say(client, sessionID, text) {
|
|
523
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
484
524
|
try {
|
|
485
525
|
await sdkCall(
|
|
486
526
|
client.session.prompt.bind(client.session),
|
|
487
527
|
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
528
|
+
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
488
529
|
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
489
530
|
)
|
|
490
531
|
} catch {}
|
|
@@ -502,6 +543,9 @@ function wasHandled(sessionID, name, args) {
|
|
|
502
543
|
|
|
503
544
|
function commandName(name) { return String(name || "") }
|
|
504
545
|
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) }
|
|
546
|
+
function isLoopCommandName(name) {
|
|
547
|
+
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)
|
|
548
|
+
}
|
|
505
549
|
|
|
506
550
|
function normalizeArgsForKey(args) {
|
|
507
551
|
if (args === undefined || args === null) return ""
|
|
@@ -528,6 +572,100 @@ function rememberSession(directory, client, sessionID) {
|
|
|
528
572
|
startHeartbeat()
|
|
529
573
|
}
|
|
530
574
|
|
|
575
|
+
function hasActiveToolCalls(sessionID) {
|
|
576
|
+
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function markToolCallActive(input) {
|
|
580
|
+
const sessionID = input?.sessionID
|
|
581
|
+
const callID = input?.callID
|
|
582
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
583
|
+
const calls = activeToolCalls.get(sessionID) || new Set()
|
|
584
|
+
calls.add(callID)
|
|
585
|
+
activeToolCalls.set(sessionID, calls)
|
|
586
|
+
sessionStatuses.set(sessionID, "busy")
|
|
587
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function markToolCallFinished(input) {
|
|
591
|
+
const sessionID = input?.sessionID
|
|
592
|
+
const callID = input?.callID
|
|
593
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
594
|
+
const calls = activeToolCalls.get(sessionID)
|
|
595
|
+
if (!calls) return
|
|
596
|
+
calls.delete(callID)
|
|
597
|
+
if (!calls.size) activeToolCalls.delete(sessionID)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function updateSessionRelationship(info, removed = false) {
|
|
601
|
+
const sessionID = info?.id
|
|
602
|
+
if (typeof sessionID !== "string") return
|
|
603
|
+
if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
|
|
604
|
+
else sessionParents.set(sessionID, info.parentID)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function updateSessionRelationshipFromEvent(event) {
|
|
608
|
+
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
|
|
609
|
+
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function isDescendantSession(sessionID, ancestorID) {
|
|
613
|
+
const visited = new Set()
|
|
614
|
+
let current = sessionID
|
|
615
|
+
while (sessionParents.has(current) && !visited.has(current)) {
|
|
616
|
+
visited.add(current)
|
|
617
|
+
current = sessionParents.get(current)
|
|
618
|
+
if (current === ancestorID) return true
|
|
619
|
+
}
|
|
620
|
+
return false
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function hasBusyDescendant(sessionID) {
|
|
624
|
+
for (const childID of sessionParents.keys()) {
|
|
625
|
+
if (!isDescendantSession(childID, sessionID)) continue
|
|
626
|
+
const status = sessionStatuses.get(childID)
|
|
627
|
+
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
|
|
628
|
+
}
|
|
629
|
+
return false
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function refreshSessionRelationships(client, directory) {
|
|
633
|
+
if (!client?.session?.list) return
|
|
634
|
+
try {
|
|
635
|
+
const sessions = await sdkCall(
|
|
636
|
+
client.session.list.bind(client.session),
|
|
637
|
+
{ query: { directory } },
|
|
638
|
+
{ directory },
|
|
639
|
+
{},
|
|
640
|
+
)
|
|
641
|
+
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function updateToolActivityFromEvent(event) {
|
|
646
|
+
const props = event?.properties || {}
|
|
647
|
+
if (event?.type === "message.part.updated") {
|
|
648
|
+
const part = props.part
|
|
649
|
+
if (part?.type !== "tool") return
|
|
650
|
+
const sessionID = part.sessionID || props.sessionID
|
|
651
|
+
if (["pending", "running"].includes(part.state?.status)) {
|
|
652
|
+
markToolCallActive({ sessionID, callID: part.callID })
|
|
653
|
+
}
|
|
654
|
+
if (["completed", "error"].includes(part.state?.status)) {
|
|
655
|
+
// Task/subagent hooks have used the part id as their hook call id in some
|
|
656
|
+
// OpenCode versions, while normal tools use part.callID. Clear either.
|
|
657
|
+
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
|
|
658
|
+
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
659
|
+
}
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
|
|
664
|
+
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
|
|
665
|
+
if (started) markToolCallActive(props)
|
|
666
|
+
if (finished) markToolCallFinished(props)
|
|
667
|
+
}
|
|
668
|
+
|
|
531
669
|
function startHeartbeat() {
|
|
532
670
|
if (heartbeatTimer) return
|
|
533
671
|
heartbeatTimer = setInterval(() => {
|
|
@@ -549,6 +687,34 @@ function startHeartbeat() {
|
|
|
549
687
|
}
|
|
550
688
|
}, HEARTBEAT_MS)
|
|
551
689
|
}
|
|
690
|
+
|
|
691
|
+
function disposeRuntime(directory, client) {
|
|
692
|
+
const sessions = [...knownSessions.entries()]
|
|
693
|
+
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
694
|
+
.map(([sessionID]) => sessionID)
|
|
695
|
+
for (const sessionID of sessions) {
|
|
696
|
+
clearActiveRun(sessionID)
|
|
697
|
+
const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
|
|
698
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
|
|
699
|
+
const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
|
|
700
|
+
idleTimers.delete(sessionID)
|
|
701
|
+
dueTimers.delete(sessionID)
|
|
702
|
+
watchdogTimers.delete(sessionID)
|
|
703
|
+
runLocks.delete(sessionID)
|
|
704
|
+
knownSessions.delete(sessionID)
|
|
705
|
+
loopOwnedUserMessageGuards.delete(sessionID)
|
|
706
|
+
activeToolCalls.delete(sessionID)
|
|
707
|
+
sessionParents.delete(sessionID)
|
|
708
|
+
sessionStatuses.delete(sessionID)
|
|
709
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
710
|
+
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
711
|
+
}
|
|
712
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
713
|
+
clearInterval(heartbeatTimer)
|
|
714
|
+
heartbeatTimer = undefined
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
552
718
|
function presetDefaults(name, args) {
|
|
553
719
|
const [maybeDuration, rest] = splitFirst(args)
|
|
554
720
|
const parsed = parseDuration(maybeDuration)
|
|
@@ -575,10 +741,11 @@ function jobLabel(job) {
|
|
|
575
741
|
const verify = job.verifyCommand ? ", verify" : ""
|
|
576
742
|
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
577
743
|
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
744
|
+
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
578
745
|
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
579
746
|
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
580
747
|
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}`
|
|
748
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
|
|
582
749
|
}
|
|
583
750
|
|
|
584
751
|
function matchJob(job, target, index) {
|
|
@@ -682,6 +849,7 @@ function goalStatusText(job) {
|
|
|
682
849
|
|
|
683
850
|
async function buildGoalPrompt(directory, job) {
|
|
684
851
|
const sections = []
|
|
852
|
+
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
853
|
const objective = String(job.action || "").trim()
|
|
686
854
|
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
687
855
|
if (job.goalFile) {
|
|
@@ -698,6 +866,8 @@ async function buildGoalPrompt(directory, job) {
|
|
|
698
866
|
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
699
867
|
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
700
868
|
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
869
|
+
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
870
|
+
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
871
|
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
702
872
|
for (const file of job.includeFiles || []) {
|
|
703
873
|
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
@@ -712,9 +882,12 @@ Rules:
|
|
|
712
882
|
- Work on the next smallest useful step toward the goal.
|
|
713
883
|
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
714
884
|
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
885
|
+
- 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.
|
|
886
|
+
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
715
887
|
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
716
888
|
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
717
889
|
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
890
|
+
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
718
891
|
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
719
892
|
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
720
893
|
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
@@ -852,6 +1025,39 @@ function updateSessionStatusFromEvent(event) {
|
|
|
852
1025
|
return undefined
|
|
853
1026
|
}
|
|
854
1027
|
|
|
1028
|
+
function userInterruptSessionFromEvent(event) {
|
|
1029
|
+
if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
|
|
1030
|
+
const props = event?.properties || {}
|
|
1031
|
+
const info = props.info || props.message || props
|
|
1032
|
+
const role = info?.role
|
|
1033
|
+
const sessionID = info?.sessionID || props.sessionID
|
|
1034
|
+
if (role !== "user" || typeof sessionID !== "string") return undefined
|
|
1035
|
+
if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
|
|
1036
|
+
return sessionID
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
1040
|
+
const state = await readState(directory, sessionID)
|
|
1041
|
+
const interruptedAt = now()
|
|
1042
|
+
let count = 0
|
|
1043
|
+
state.jobs = (state.jobs || []).map((job) => {
|
|
1044
|
+
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
|
|
1045
|
+
count++
|
|
1046
|
+
return {
|
|
1047
|
+
...job,
|
|
1048
|
+
paused: true,
|
|
1049
|
+
lastUserInterruptAt: interruptedAt,
|
|
1050
|
+
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
|
|
1051
|
+
}
|
|
1052
|
+
})
|
|
1053
|
+
if (!count) return false
|
|
1054
|
+
await writeState(directory, sessionID, state)
|
|
1055
|
+
await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
|
|
1056
|
+
await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
|
|
1057
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1058
|
+
return true
|
|
1059
|
+
}
|
|
1060
|
+
|
|
855
1061
|
function staleActiveRun(sessionID) {
|
|
856
1062
|
const active = activeRuns.get(sessionID)
|
|
857
1063
|
if (!active) return false
|
|
@@ -862,6 +1068,7 @@ function staleActiveRun(sessionID) {
|
|
|
862
1068
|
}
|
|
863
1069
|
|
|
864
1070
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1071
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
865
1072
|
if (!options.requireIdle && !options.forceStale) return true
|
|
866
1073
|
if (options.forceStale && staleActiveRun(sessionID)) return true
|
|
867
1074
|
if (!options.requireIdle) return false
|
|
@@ -876,7 +1083,7 @@ async function canFinalizeActiveRun(directory, client, sessionID, active, option
|
|
|
876
1083
|
|
|
877
1084
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
878
1085
|
const argsList = []
|
|
879
|
-
if (directory) argsList.push({ query: { directory } }, {
|
|
1086
|
+
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
880
1087
|
argsList.push({})
|
|
881
1088
|
for (const args of argsList) {
|
|
882
1089
|
try {
|
|
@@ -884,15 +1091,41 @@ async function readLiveSessionStatus(client, sessionID, directory) {
|
|
|
884
1091
|
const error = sdkError(result)
|
|
885
1092
|
if (error) continue
|
|
886
1093
|
const data = sdkData(result)
|
|
1094
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
|
1095
|
+
const observedAt = now()
|
|
1096
|
+
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
1097
|
+
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
1098
|
+
if (typeof observedType !== "string") continue
|
|
1099
|
+
sessionStatuses.set(observedSessionID, observedType)
|
|
1100
|
+
sessionStatusSeenAt.set(observedSessionID, observedAt)
|
|
1101
|
+
}
|
|
1102
|
+
// OpenCode's status list contains active sessions; idle sessions are
|
|
1103
|
+
// normally omitted. Clear a completed descendant that was previously busy.
|
|
1104
|
+
for (const childID of sessionParents.keys()) {
|
|
1105
|
+
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
1106
|
+
sessionStatuses.set(childID, "idle")
|
|
1107
|
+
sessionStatusSeenAt.set(childID, observedAt)
|
|
1108
|
+
}
|
|
1109
|
+
if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
|
|
887
1110
|
const status = data?.[sessionID]
|
|
888
1111
|
const type = status && typeof status === "object" ? status.type : undefined
|
|
889
1112
|
if (typeof type === "string") return { type, source: "sdk" }
|
|
1113
|
+
return { type: "idle", source: "sdk" }
|
|
890
1114
|
} catch {}
|
|
891
1115
|
}
|
|
892
1116
|
return undefined
|
|
893
1117
|
}
|
|
894
1118
|
|
|
895
1119
|
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
1120
|
+
// OpenCode can briefly report an idle session while a long-running tool or
|
|
1121
|
+
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
1122
|
+
// signal here, so never enqueue another turn until every active call ends.
|
|
1123
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
1124
|
+
sessionStatuses.set(sessionID, "busy")
|
|
1125
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1126
|
+
return "busy"
|
|
1127
|
+
}
|
|
1128
|
+
|
|
896
1129
|
const cached = sessionStatuses.get(sessionID)
|
|
897
1130
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
898
1131
|
|
|
@@ -1075,11 +1308,17 @@ function goalReportText(job) {
|
|
|
1075
1308
|
lines.push(`Created: ${job.createdAt || ""}`)
|
|
1076
1309
|
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
1077
1310
|
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
1311
|
+
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
1312
|
+
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
1078
1313
|
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
1314
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
1079
1315
|
lines.push("")
|
|
1080
1316
|
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
1081
1317
|
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
1082
1318
|
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
1319
|
+
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
1320
|
+
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
1321
|
+
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
1083
1322
|
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
1084
1323
|
if (job.lastGoalChecks?.length) {
|
|
1085
1324
|
lines.push("## Latest checks", "")
|
|
@@ -1115,17 +1354,101 @@ function parseGoalToolText(args, fields) {
|
|
|
1115
1354
|
return result
|
|
1116
1355
|
}
|
|
1117
1356
|
|
|
1357
|
+
function hasConcreteGoalEvidence(value) {
|
|
1358
|
+
const text = String(value || "").trim()
|
|
1359
|
+
if (text.length < 24) return false
|
|
1360
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
1361
|
+
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
1362
|
+
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
1363
|
+
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
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function goalChecksPassed(job) {
|
|
1367
|
+
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function goalRequiresPassingChecks(job) {
|
|
1371
|
+
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1375
|
+
job.goalCompletionRejectedAt = now()
|
|
1376
|
+
job.goalCompletionRejectedReason = reason
|
|
1377
|
+
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
1378
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1379
|
+
await writeState(directory, sessionID, state)
|
|
1380
|
+
await writeGoalReport(directory, sessionID, job)
|
|
1381
|
+
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
1382
|
+
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function goalProgressSnapshot(job) {
|
|
1386
|
+
return {
|
|
1387
|
+
status: job?.goalStatus || "",
|
|
1388
|
+
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
1389
|
+
evidence: String(job?.goalEvidence || ""),
|
|
1390
|
+
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
1391
|
+
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
1392
|
+
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
1393
|
+
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
1398
|
+
const before = goalProgressSnapshot(beforeJob || {})
|
|
1399
|
+
const after = goalProgressSnapshot(afterJob || {})
|
|
1400
|
+
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
1401
|
+
if (after.progressCount > before.progressCount) return true
|
|
1402
|
+
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
1403
|
+
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
1404
|
+
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
1405
|
+
return false
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
1409
|
+
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
|
|
1410
|
+
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
1411
|
+
if (!Number.isFinite(limit) || limit <= 0) return job
|
|
1412
|
+
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
1413
|
+
job.noProgressCount = 0
|
|
1414
|
+
job.lastProgressAt = now()
|
|
1415
|
+
return job
|
|
1416
|
+
}
|
|
1417
|
+
job.noProgressCount = (job.noProgressCount || 0) + 1
|
|
1418
|
+
job.lastNoProgressAt = now()
|
|
1419
|
+
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1420
|
+
if (job.noProgressCount >= limit) {
|
|
1421
|
+
job.paused = true
|
|
1422
|
+
job.goalNoProgressPausedAt = now()
|
|
1423
|
+
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
|
|
1424
|
+
await toast(client, job.goalNoProgressReason, "warning")
|
|
1425
|
+
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1426
|
+
}
|
|
1427
|
+
return job
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1118
1430
|
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
1119
1431
|
const state = await readState(directory, sessionID)
|
|
1120
1432
|
const job = pickGoalJob(state, args.target)
|
|
1121
1433
|
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1122
1434
|
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
1435
|
+
const manualOverride = args.manual === true || args.manualOverride === true
|
|
1436
|
+
const completionEvidence = parsed.evidence || job.goalEvidence || ""
|
|
1437
|
+
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
|
|
1438
|
+
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
|
|
1439
|
+
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
1440
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
1441
|
+
}
|
|
1442
|
+
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
1443
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
1444
|
+
}
|
|
1123
1445
|
job.goalStatus = "completed"
|
|
1124
1446
|
job.enabled = false
|
|
1125
1447
|
job.paused = true
|
|
1126
1448
|
job.goalCompletedAt = now()
|
|
1127
1449
|
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
1128
|
-
job.goalEvidence =
|
|
1450
|
+
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
1451
|
+
job.noProgressCount = 0
|
|
1129
1452
|
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1130
1453
|
await writeState(directory, sessionID, state)
|
|
1131
1454
|
await writeGoalReport(directory, sessionID, job)
|
|
@@ -1159,6 +1482,8 @@ async function setGoalProgress(directory, sessionID, args = {}) {
|
|
|
1159
1482
|
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
1160
1483
|
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
1161
1484
|
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1485
|
+
job.noProgressCount = 0
|
|
1486
|
+
job.lastProgressAt = now()
|
|
1162
1487
|
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1163
1488
|
await writeState(directory, sessionID, state)
|
|
1164
1489
|
await writeGoalReport(directory, sessionID, job)
|
|
@@ -1252,7 +1577,10 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
|
1252
1577
|
}
|
|
1253
1578
|
}
|
|
1254
1579
|
|
|
1255
|
-
if (isGoalJob(job))
|
|
1580
|
+
if (isGoalJob(job)) {
|
|
1581
|
+
job = await runGoalChecks(directory, sessionID, job, client)
|
|
1582
|
+
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
1583
|
+
}
|
|
1256
1584
|
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1257
1585
|
await writeState(directory, sessionID, state)
|
|
1258
1586
|
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
@@ -1277,10 +1605,17 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1277
1605
|
}
|
|
1278
1606
|
const tuiCommand = compactTuiCommandName(command)
|
|
1279
1607
|
if (tuiCommand) {
|
|
1608
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1280
1609
|
await compactSession(client, sessionID)
|
|
1281
1610
|
return { startsAssistantTurn: true }
|
|
1282
1611
|
}
|
|
1283
|
-
|
|
1612
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1613
|
+
await sdkCall(
|
|
1614
|
+
client.session.command.bind(client.session),
|
|
1615
|
+
{ path: { id: sessionID }, body: { command, arguments: argumentsText } },
|
|
1616
|
+
{ path: { sessionID }, body: { command, arguments: argumentsText } },
|
|
1617
|
+
{ sessionID, command, arguments: argumentsText },
|
|
1618
|
+
)
|
|
1284
1619
|
return { startsAssistantTurn: true }
|
|
1285
1620
|
}
|
|
1286
1621
|
if (kind === "shell") {
|
|
@@ -1290,18 +1625,33 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1290
1625
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1291
1626
|
return { startsAssistantTurn: false }
|
|
1292
1627
|
}
|
|
1293
|
-
|
|
1628
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1629
|
+
fireSdk(
|
|
1630
|
+
client,
|
|
1631
|
+
"session.shell",
|
|
1632
|
+
client.session.shell.bind(client.session),
|
|
1633
|
+
{ path: { id: sessionID }, body: { command } },
|
|
1634
|
+
{ path: { sessionID }, body: { command } },
|
|
1635
|
+
{ sessionID, command },
|
|
1636
|
+
)
|
|
1294
1637
|
return { startsAssistantTurn: true }
|
|
1295
1638
|
}
|
|
1296
1639
|
const prompt = await buildPrompt(directory, job)
|
|
1297
1640
|
const prefix = kind === "goal"
|
|
1298
1641
|
? "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
1642
|
: "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
|
-
|
|
1643
|
+
const promptText = `${prefix}
|
|
1644
|
+
|
|
1645
|
+
${prompt}`
|
|
1646
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1647
|
+
fireSdk(
|
|
1648
|
+
client,
|
|
1649
|
+
"session.prompt",
|
|
1650
|
+
client.session.prompt.bind(client.session),
|
|
1651
|
+
{ path: { id: sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
|
|
1652
|
+
{ path: { sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
|
|
1653
|
+
{ sessionID, parts: [{ type: "text", text: promptText }] },
|
|
1654
|
+
)
|
|
1305
1655
|
return { startsAssistantTurn: true }
|
|
1306
1656
|
}
|
|
1307
1657
|
|
|
@@ -1417,7 +1767,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1417
1767
|
return
|
|
1418
1768
|
}
|
|
1419
1769
|
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)
|
|
1770
|
+
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
1771
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1422
1772
|
sessionStatuses.set(sessionID, "busy")
|
|
1423
1773
|
sessionStatusSeenAt.set(sessionID, now())
|
|
@@ -1450,7 +1800,11 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1450
1800
|
const parsed = parseLoopArgs(args, defaults)
|
|
1451
1801
|
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1452
1802
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1453
|
-
if (!parsed.job.activeRecoveryMs)
|
|
1803
|
+
if (!parsed.job.activeRecoveryMs) {
|
|
1804
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
1805
|
+
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
1806
|
+
: Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1807
|
+
}
|
|
1454
1808
|
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
1809
|
const state = await readState(directory, sessionID)
|
|
1456
1810
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -1516,7 +1870,9 @@ async function statusGoal(directory, client, sessionID) {
|
|
|
1516
1870
|
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
1517
1871
|
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
1518
1872
|
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
1519
|
-
|
|
1873
|
+
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
1874
|
+
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
1875
|
+
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
1876
|
}) : ["No experimental goal jobs."]
|
|
1521
1877
|
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
1522
1878
|
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
@@ -1539,7 +1895,7 @@ async function resumeGoal(directory, client, sessionID, args) {
|
|
|
1539
1895
|
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1540
1896
|
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
1541
1897
|
count++
|
|
1542
|
-
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
|
|
1898
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
|
|
1543
1899
|
})
|
|
1544
1900
|
await writeState(directory, sessionID, state)
|
|
1545
1901
|
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
@@ -1560,7 +1916,7 @@ async function clearGoal(directory, client, sessionID, args) {
|
|
|
1560
1916
|
}
|
|
1561
1917
|
|
|
1562
1918
|
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." })
|
|
1919
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
1564
1920
|
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
1565
1921
|
}
|
|
1566
1922
|
|
|
@@ -1666,6 +2022,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1666
2022
|
rememberSession(directory, client, sessionID)
|
|
1667
2023
|
if (wasHandled(sessionID, name, args)) return true
|
|
1668
2024
|
markHandled(sessionID, name, args)
|
|
2025
|
+
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
1669
2026
|
|
|
1670
2027
|
const handled = () => {
|
|
1671
2028
|
if (output && Array.isArray(output.parts)) {
|
|
@@ -1709,7 +2066,7 @@ function goalTools(defaultDirectory) {
|
|
|
1709
2066
|
},
|
|
1710
2067
|
execute: async (args, context) => {
|
|
1711
2068
|
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1712
|
-
return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
|
|
2069
|
+
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
1713
2070
|
},
|
|
1714
2071
|
}),
|
|
1715
2072
|
opencode_loop_goal_blocked: tool({
|
|
@@ -1739,14 +2096,25 @@ function goalTools(defaultDirectory) {
|
|
|
1739
2096
|
|
|
1740
2097
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
1741
2098
|
await log(client, "info", "Plugin initialized", { directory })
|
|
2099
|
+
await refreshSessionRelationships(client, directory)
|
|
1742
2100
|
return {
|
|
2101
|
+
dispose: async () => { disposeRuntime(directory, client) },
|
|
1743
2102
|
tool: goalTools(directory),
|
|
1744
2103
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
2104
|
+
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
2105
|
+
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
1745
2106
|
event: async ({ event }) => {
|
|
2107
|
+
updateSessionRelationshipFromEvent(event)
|
|
2108
|
+
updateToolActivityFromEvent(event)
|
|
1746
2109
|
if (event.type === "command.executed") {
|
|
1747
2110
|
const props = event.properties || {}
|
|
1748
2111
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
1749
2112
|
}
|
|
2113
|
+
const interruptedSessionID = userInterruptSessionFromEvent(event)
|
|
2114
|
+
if (interruptedSessionID) {
|
|
2115
|
+
rememberSession(directory, client, interruptedSessionID)
|
|
2116
|
+
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
|
|
2117
|
+
}
|
|
1750
2118
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1751
2119
|
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1752
2120
|
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|