@bybrawe/opencode-loop 0.5.10 → 0.5.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/README.md +17 -11
- package/package.json +4 -4
- package/scripts/install-node.mjs +3 -2
- package/scripts/install.ps1 +4 -3
- package/scripts/install.sh +3 -2
- package/scripts/loopd.mjs +7 -2
- package/src/index.js +177 -105
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
3
|
+
## 0.5.12
|
|
4
|
+
|
|
5
|
+
- Fixed local plugin installs to write `opencode-loop.ts`, matching OpenCode local plugin discovery.
|
|
6
|
+
- Removed stale `opencode-loop.js` local plugin copies during install to avoid duplicate or outdated local plugin loads.
|
|
7
|
+
- Made the PowerShell source installer honor `OPENCODE_CONFIG_DIR`, matching the npm and shell installers.
|
|
8
|
+
- Fixed `opencode-loopd` to pass the prompt to `opencode run` as the positional message instead of the non-existent `--prompt` flag.
|
|
9
|
+
- Added a short retry backoff for failing `opencode-loopd --every 0s` runs.
|
|
10
|
+
- Normalized command arguments from array/object forms so `opencode run --command loop ...` can schedule jobs like TUI slash commands.
|
|
11
|
+
- Stripped whole-argument quote wrappers before parsing loop durations, fixing quoted `opencode run --command loop "1h ..."` invocations.
|
|
12
|
+
- Fixed a goal finalization reassignment so the generated local TypeScript plugin parses under Bun.
|
|
13
|
+
- Fixed the Windows Task Scheduler helper script path used by `opencode-loopd install-task`.
|
|
14
|
+
- Tried current OpenCode SDK `{ path, body }` and `{ query }` argument shapes before older fallback shapes.
|
|
15
|
+
|
|
16
|
+
## 0.5.11
|
|
4
17
|
|
|
5
18
|
- Added a watchdog loop in addition to one-shot due timers, so recurring jobs keep checking due work even if OpenCode misses an idle event or a due timeout gets effectively stuck behind a stale busy state.
|
|
6
19
|
- Added stronger stale-active-run recovery. If a plugin-injected run is still marked active after the recovery window, the scheduler treats it as finished, finalizes it, and continues without waiting for another manual command such as `/loop-status`.
|
package/README.md
CHANGED
|
@@ -9,9 +9,14 @@ It is useful for long coding sessions, `progress.md` workflows, TODO automation,
|
|
|
9
9
|
Repository: **ByBrawe/opencode-loop**
|
|
10
10
|
NPM package name: **@bybrawe/opencode-loop**
|
|
11
11
|
|
|
12
|
+
### Scheduler heartbeat note
|
|
13
|
+
|
|
14
|
+
v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode TUI because local command hooks are event-driven: a normal `/loop-status` command can wake the plugin, but a timer-only loop should not depend on a user typing another command. The heartbeat periodically checks active loop jobs, clears stale completed runs, and starts due work only when the session appears idle.
|
|
15
|
+
|
|
16
|
+
|
|
12
17
|
## Current status
|
|
13
18
|
|
|
14
|
-
**v0.5.
|
|
19
|
+
**v0.5.11 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
|
|
15
20
|
|
|
16
21
|
The known update-related symptoms from older builds are fixed:
|
|
17
22
|
|
|
@@ -27,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
27
32
|
|
|
28
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`.
|
|
29
34
|
|
|
30
|
-
## v0.5.
|
|
35
|
+
## v0.5.11 quick behavior guide
|
|
31
36
|
|
|
32
37
|
OpenCode Loop has two triggers now:
|
|
33
38
|
|
|
@@ -74,7 +79,7 @@ Run a shell command every 10 minutes when idle.
|
|
|
74
79
|
|
|
75
80
|
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
81
|
|
|
77
|
-
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.
|
|
82
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.11 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
|
|
78
83
|
|
|
79
84
|
## Why this exists
|
|
80
85
|
|
|
@@ -173,18 +178,19 @@ npx -y @bybrawe/opencode-loop
|
|
|
173
178
|
```
|
|
174
179
|
|
|
175
180
|
The installer copies the plugin and slash command files into your OpenCode config directory.
|
|
181
|
+
It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
|
|
176
182
|
|
|
177
183
|
Windows target paths:
|
|
178
184
|
|
|
179
185
|
```text
|
|
180
|
-
%USERPROFILE%\.config\opencode\plugins\opencode-loop.
|
|
186
|
+
%USERPROFILE%\.config\opencode\plugins\opencode-loop.ts
|
|
181
187
|
%USERPROFILE%\.config\opencode\commands\loop*.md
|
|
182
188
|
```
|
|
183
189
|
|
|
184
190
|
macOS / Linux target paths:
|
|
185
191
|
|
|
186
192
|
```text
|
|
187
|
-
~/.config/opencode/plugins/opencode-loop.
|
|
193
|
+
~/.config/opencode/plugins/opencode-loop.ts
|
|
188
194
|
~/.config/opencode/commands/loop*.md
|
|
189
195
|
```
|
|
190
196
|
|
|
@@ -268,7 +274,7 @@ Windows PowerShell:
|
|
|
268
274
|
```powershell
|
|
269
275
|
mkdir "$env:USERPROFILE\.config\opencode\plugins" -Force
|
|
270
276
|
mkdir "$env:USERPROFILE\.config\opencode\commands" -Force
|
|
271
|
-
copy .\src\index.js "$env:USERPROFILE\.config\opencode\plugins\opencode-loop.
|
|
277
|
+
copy .\src\index.js "$env:USERPROFILE\.config\opencode\plugins\opencode-loop.ts"
|
|
272
278
|
copy .\commands\*.md "$env:USERPROFILE\.config\opencode\commands\"
|
|
273
279
|
```
|
|
274
280
|
|
|
@@ -276,7 +282,7 @@ macOS / Linux:
|
|
|
276
282
|
|
|
277
283
|
```bash
|
|
278
284
|
mkdir -p ~/.config/opencode/plugins ~/.config/opencode/commands
|
|
279
|
-
cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.
|
|
285
|
+
cp ./src/index.js ~/.config/opencode/plugins/opencode-loop.ts
|
|
280
286
|
cp ./commands/*.md ~/.config/opencode/commands/
|
|
281
287
|
```
|
|
282
288
|
|
|
@@ -286,7 +292,7 @@ Use this when you want the plugin to be available only inside one repository.
|
|
|
286
292
|
|
|
287
293
|
```bash
|
|
288
294
|
mkdir -p .opencode/plugins .opencode/commands
|
|
289
|
-
cp ./src/index.js .opencode/plugins/opencode-loop.
|
|
295
|
+
cp ./src/index.js .opencode/plugins/opencode-loop.ts
|
|
290
296
|
cp ./commands/*.md .opencode/commands/
|
|
291
297
|
```
|
|
292
298
|
|
|
@@ -295,7 +301,7 @@ On Windows PowerShell:
|
|
|
295
301
|
```powershell
|
|
296
302
|
mkdir .opencode\plugins -Force
|
|
297
303
|
mkdir .opencode\commands -Force
|
|
298
|
-
copy .\src\index.js .opencode\plugins\opencode-loop.
|
|
304
|
+
copy .\src\index.js .opencode\plugins\opencode-loop.ts
|
|
299
305
|
copy .\commands\*.md .opencode\commands\
|
|
300
306
|
```
|
|
301
307
|
|
|
@@ -311,7 +317,7 @@ After restarting OpenCode, run:
|
|
|
311
317
|
If the commands do not appear:
|
|
312
318
|
|
|
313
319
|
1. Make sure OpenCode was fully restarted.
|
|
314
|
-
2. Check that `opencode-loop.
|
|
320
|
+
2. Check that `opencode-loop.ts` exists in the OpenCode plugin directory.
|
|
315
321
|
3. Check that `loop.md`, `loop-help.md`, and the other command files exist in the OpenCode commands directory.
|
|
316
322
|
4. Run `npx -y @bybrawe/opencode-loop` again to reinstall the command files.
|
|
317
323
|
|
|
@@ -1172,4 +1178,4 @@ Improve the application in small safe steps.
|
|
|
1172
1178
|
|
|
1173
1179
|
## License
|
|
1174
1180
|
|
|
1175
|
-
MIT
|
|
1181
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode:
|
|
3
|
+
"version": "0.5.12",
|
|
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",
|
|
7
7
|
"exports": {
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"@opencode-ai/plugin": ">=1.4.0"
|
|
56
56
|
},
|
|
57
57
|
"bin": {
|
|
58
|
-
"opencode-loop": "
|
|
59
|
-
"opencode-loopd": "
|
|
58
|
+
"opencode-loop": "scripts/install-node.mjs",
|
|
59
|
+
"opencode-loopd": "scripts/loopd.mjs"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
package/scripts/install-node.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { copyFile, mkdir, readdir } from "node:fs/promises"
|
|
2
|
+
import { copyFile, mkdir, readdir, rm } 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"
|
|
@@ -11,7 +11,8 @@ const commandDir = join(config, "commands")
|
|
|
11
11
|
|
|
12
12
|
await mkdir(pluginDir, { recursive: true })
|
|
13
13
|
await mkdir(commandDir, { recursive: true })
|
|
14
|
-
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.
|
|
14
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
15
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
15
16
|
|
|
16
17
|
for (const name of await readdir(join(root, "commands"))) {
|
|
17
18
|
if (name.endsWith(".md")) {
|
package/scripts/install.ps1
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
$ErrorActionPreference = "Stop"
|
|
2
2
|
|
|
3
|
-
$ConfigDir = Join-Path $env:USERPROFILE ".config\opencode"
|
|
3
|
+
$ConfigDir = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".config\opencode" }
|
|
4
4
|
$PluginDir = Join-Path $ConfigDir "plugins"
|
|
5
5
|
$CommandDir = Join-Path $ConfigDir "commands"
|
|
6
6
|
$Root = Split-Path -Parent $PSScriptRoot
|
|
@@ -8,10 +8,11 @@ $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
|
-
Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.
|
|
11
|
+
Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
|
|
12
|
+
Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
|
|
12
13
|
Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
|
|
13
14
|
|
|
14
15
|
Write-Host "Installed OpenCode Loop plugin." -ForegroundColor Green
|
|
15
|
-
Write-Host "Plugin: $PluginDir\opencode-loop.
|
|
16
|
+
Write-Host "Plugin: $PluginDir\opencode-loop.ts"
|
|
16
17
|
Write-Host "Commands: $CommandDir\loop*.md"
|
|
17
18
|
Write-Host "Restart OpenCode, then run: /loop-help"
|
package/scripts/install.sh
CHANGED
|
@@ -7,10 +7,11 @@ 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
|
-
cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.
|
|
10
|
+
cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
|
|
11
|
+
rm -f "$PLUGIN_DIR/opencode-loop.js"
|
|
11
12
|
cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
|
|
12
13
|
|
|
13
14
|
echo "Installed OpenCode Loop plugin."
|
|
14
|
-
echo "Plugin: $PLUGIN_DIR/opencode-loop.
|
|
15
|
+
echo "Plugin: $PLUGIN_DIR/opencode-loop.ts"
|
|
15
16
|
echo "Commands: $COMMAND_DIR/loop*.md"
|
|
16
17
|
echo "Restart OpenCode, then run: /loop-help"
|
package/scripts/loopd.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
5
6
|
|
|
6
7
|
const args = process.argv.slice(2)
|
|
8
|
+
const FAILED_RUN_RETRY_MS = 5_000
|
|
7
9
|
|
|
8
10
|
function arg(name, fallback = null) {
|
|
9
11
|
const i = args.indexOf(name)
|
|
@@ -97,11 +99,14 @@ async function daemon() {
|
|
|
97
99
|
console.log("")
|
|
98
100
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
99
101
|
|
|
100
|
-
const command = `opencode run --continue
|
|
102
|
+
const command = `opencode run --continue ${JSON.stringify(prompt)}`
|
|
101
103
|
const code = await run(command, project)
|
|
102
104
|
|
|
103
105
|
if (code !== 0) {
|
|
104
106
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
107
|
+
if (delay === 0) {
|
|
108
|
+
await sleep(FAILED_RUN_RETRY_MS)
|
|
109
|
+
}
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
if (maxRuns > 0 && count >= maxRuns) {
|
|
@@ -127,7 +132,7 @@ function installTask() {
|
|
|
127
132
|
const promptFile = arg("--prompt-file")
|
|
128
133
|
const promptArg = arg("--prompt")
|
|
129
134
|
const node = process.execPath
|
|
130
|
-
const script =
|
|
135
|
+
const script = fileURLToPath(import.meta.url)
|
|
131
136
|
|
|
132
137
|
const commandParts = [
|
|
133
138
|
`"${node}"`,
|
package/src/index.js
CHANGED
|
@@ -4,13 +4,14 @@ import { spawn } from "node:child_process"
|
|
|
4
4
|
|
|
5
5
|
const SERVICE = "opencode-loop"
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
|
-
const DEFAULT_ACTIVE_GUARD_MS =
|
|
8
|
-
const STALE_ACTIVE_RECOVERY_MS =
|
|
7
|
+
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
8
|
+
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
9
9
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
10
10
|
const BUSY_RETRY_MS = 5_000
|
|
11
11
|
const SESSION_STATUS_CACHE_MS = 1_500
|
|
12
12
|
const MIN_DUE_TIMER_MS = 250
|
|
13
13
|
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
14
|
+
const HEARTBEAT_MS = 2_500
|
|
14
15
|
const MAX_SCAN_FILES = 200
|
|
15
16
|
const MAX_SCAN_BYTES = 2_000_000
|
|
16
17
|
const GOAL_REPORT_DIR = "goals"
|
|
@@ -21,6 +22,9 @@ const handledCommands = new Map()
|
|
|
21
22
|
const idleTimers = new Map()
|
|
22
23
|
const dueTimers = new Map()
|
|
23
24
|
const watchdogTimers = new Map()
|
|
25
|
+
const runLocks = new Map()
|
|
26
|
+
const knownSessions = new Map()
|
|
27
|
+
let heartbeatTimer
|
|
24
28
|
const sessionStatuses = new Map()
|
|
25
29
|
const sessionStatusSeenAt = new Map()
|
|
26
30
|
|
|
@@ -144,7 +148,7 @@ function parseCompactEvery(value) {
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
function parseLoopArgs(raw, defaults = {}) {
|
|
147
|
-
let input = String(raw || "").trim()
|
|
151
|
+
let input = stripOuterQuotes(String(raw || "").trim())
|
|
148
152
|
let first = ""
|
|
149
153
|
let rest = input
|
|
150
154
|
let intervalMs = defaults.intervalMs ?? null
|
|
@@ -386,8 +390,8 @@ async function executeTuiCommand(client, command) {
|
|
|
386
390
|
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
387
391
|
return await sdkCall(
|
|
388
392
|
client.tui.executeCommand.bind(client.tui),
|
|
389
|
-
{ command },
|
|
390
393
|
{ body: { command } },
|
|
394
|
+
{ command },
|
|
391
395
|
)
|
|
392
396
|
}
|
|
393
397
|
|
|
@@ -411,7 +415,7 @@ async function compactSession(client, sessionID) {
|
|
|
411
415
|
}
|
|
412
416
|
}
|
|
413
417
|
try {
|
|
414
|
-
await sdkCall(client.session.summarize.bind(client.session), {
|
|
418
|
+
await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID })
|
|
415
419
|
return true
|
|
416
420
|
} catch (error) {
|
|
417
421
|
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
@@ -424,27 +428,27 @@ async function log(client, level, message, extra) {
|
|
|
424
428
|
try {
|
|
425
429
|
await sdkCall(
|
|
426
430
|
client.app.log.bind(client.app),
|
|
427
|
-
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
428
431
|
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
432
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
429
433
|
)
|
|
430
434
|
} catch {}
|
|
431
435
|
}
|
|
432
436
|
|
|
433
437
|
async function toast(client, message, variant = "info") {
|
|
434
|
-
try { await sdkCall(client.tui.showToast.bind(client.tui), { message, variant }, {
|
|
438
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
435
439
|
}
|
|
436
440
|
|
|
437
441
|
async function say(client, sessionID, text) {
|
|
438
442
|
try {
|
|
439
443
|
await sdkCall(
|
|
440
444
|
client.session.prompt.bind(client.session),
|
|
441
|
-
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
442
445
|
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
446
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
443
447
|
)
|
|
444
448
|
} catch {}
|
|
445
449
|
}
|
|
446
450
|
|
|
447
|
-
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args
|
|
451
|
+
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
448
452
|
function markHandled(sessionID, name, args) {
|
|
449
453
|
handledCommands.set(commandKey(sessionID, name, args), now())
|
|
450
454
|
for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
|
|
@@ -456,6 +460,53 @@ function wasHandled(sessionID, name, args) {
|
|
|
456
460
|
|
|
457
461
|
function commandName(name) { return String(name || "") }
|
|
458
462
|
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) }
|
|
463
|
+
|
|
464
|
+
function normalizeArgsForKey(args) {
|
|
465
|
+
if (args === undefined || args === null) return ""
|
|
466
|
+
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
467
|
+
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
468
|
+
try { return JSON.stringify(args) } catch { return String(args) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function commandArgsText(args) {
|
|
472
|
+
if (args === undefined || args === null) return ""
|
|
473
|
+
if (typeof args === "string") return args
|
|
474
|
+
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
475
|
+
if (typeof args === "object") {
|
|
476
|
+
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
477
|
+
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return String(args)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function rememberSession(directory, client, sessionID) {
|
|
484
|
+
if (!sessionID) return
|
|
485
|
+
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
486
|
+
startHeartbeat()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function startHeartbeat() {
|
|
490
|
+
if (heartbeatTimer) return
|
|
491
|
+
heartbeatTimer = setInterval(() => {
|
|
492
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
493
|
+
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
494
|
+
knownSessions.delete(sessionID)
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
Promise.resolve()
|
|
498
|
+
.then(async () => {
|
|
499
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
|
|
500
|
+
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
501
|
+
})
|
|
502
|
+
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
503
|
+
}
|
|
504
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
505
|
+
clearInterval(heartbeatTimer)
|
|
506
|
+
heartbeatTimer = undefined
|
|
507
|
+
}
|
|
508
|
+
}, HEARTBEAT_MS)
|
|
509
|
+
}
|
|
459
510
|
function presetDefaults(name, args) {
|
|
460
511
|
const [maybeDuration, rest] = splitFirst(args)
|
|
461
512
|
const parsed = parseDuration(maybeDuration)
|
|
@@ -770,7 +821,7 @@ function staleActiveRun(sessionID) {
|
|
|
770
821
|
|
|
771
822
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
772
823
|
const argsList = []
|
|
773
|
-
if (directory) argsList.push({ workspace: directory }, { directory })
|
|
824
|
+
if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
|
|
774
825
|
argsList.push({})
|
|
775
826
|
for (const args of argsList) {
|
|
776
827
|
try {
|
|
@@ -889,7 +940,9 @@ async function startWatchdog(directory, client, sessionID) {
|
|
|
889
940
|
})
|
|
890
941
|
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
891
942
|
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
892
|
-
|
|
943
|
+
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
944
|
+
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
945
|
+
// when no manual /loop-status command is typed.
|
|
893
946
|
watchdogTimers.set(sessionID, timer)
|
|
894
947
|
}
|
|
895
948
|
|
|
@@ -1099,7 +1152,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
1099
1152
|
if (!active) return
|
|
1100
1153
|
clearActiveRun(sessionID)
|
|
1101
1154
|
const state = await readState(directory, sessionID)
|
|
1102
|
-
|
|
1155
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1103
1156
|
if (!job) return
|
|
1104
1157
|
job.lastFinishedAt = now()
|
|
1105
1158
|
|
|
@@ -1168,7 +1221,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1168
1221
|
await compactSession(client, sessionID)
|
|
1169
1222
|
return { startsAssistantTurn: true }
|
|
1170
1223
|
}
|
|
1171
|
-
await sdkCall(client.session.command.bind(client.session), {
|
|
1224
|
+
await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: { command, arguments: argumentsText } }, { sessionID, command, arguments: argumentsText })
|
|
1172
1225
|
return { startsAssistantTurn: true }
|
|
1173
1226
|
}
|
|
1174
1227
|
if (kind === "shell") {
|
|
@@ -1178,128 +1231,144 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1178
1231
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1179
1232
|
return { startsAssistantTurn: false }
|
|
1180
1233
|
}
|
|
1181
|
-
fireSdk(client, "session.shell", client.session.shell.bind(client.session), {
|
|
1234
|
+
fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: { command } }, { sessionID, command })
|
|
1182
1235
|
return { startsAssistantTurn: true }
|
|
1183
1236
|
}
|
|
1184
1237
|
const prompt = await buildPrompt(directory, job)
|
|
1185
1238
|
const prefix = kind === "goal"
|
|
1186
1239
|
? "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."
|
|
1187
1240
|
: "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."
|
|
1188
|
-
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1241
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
|
|
1189
1242
|
|
|
1190
|
-
${prompt}` }] }, {
|
|
1243
|
+
${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1191
1244
|
|
|
1192
|
-
${prompt}` }] }
|
|
1245
|
+
${prompt}` }] })
|
|
1193
1246
|
return { startsAssistantTurn: true }
|
|
1194
1247
|
}
|
|
1195
1248
|
|
|
1196
1249
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1250
|
+
rememberSession(directory, client, sessionID)
|
|
1197
1251
|
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1198
1252
|
|
|
1199
|
-
if (
|
|
1200
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1201
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1202
|
-
return
|
|
1203
|
-
}
|
|
1204
|
-
const active = activeRuns.get(sessionID)
|
|
1205
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1253
|
+
if (runLocks.has(sessionID)) {
|
|
1206
1254
|
await reschedule(BUSY_RETRY_MS)
|
|
1207
1255
|
return
|
|
1208
1256
|
}
|
|
1257
|
+
runLocks.set(sessionID, now())
|
|
1258
|
+
let job
|
|
1259
|
+
try {
|
|
1260
|
+
await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
|
|
1261
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1262
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1263
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1209
1266
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
return
|
|
1219
|
-
}
|
|
1220
|
-
let job = due[0]
|
|
1267
|
+
const active = activeRuns.get(sessionID)
|
|
1268
|
+
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1269
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1270
|
+
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1271
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1274
|
+
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1221
1275
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
await writeState(directory, sessionID, state)
|
|
1234
|
-
await notifyJob(directory, job, "stop_file")
|
|
1235
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1236
|
-
await reschedule()
|
|
1237
|
-
return
|
|
1238
|
-
}
|
|
1239
|
-
if (await untilReached(directory, job)) {
|
|
1240
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1241
|
-
await writeState(directory, sessionID, state)
|
|
1242
|
-
await notifyJob(directory, job, "until_reached")
|
|
1243
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1244
|
-
await reschedule()
|
|
1245
|
-
return
|
|
1246
|
-
}
|
|
1276
|
+
const state = await readState(directory, sessionID)
|
|
1277
|
+
for (const candidate of state.jobs || []) {
|
|
1278
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
|
|
1279
|
+
}
|
|
1280
|
+
const due = dueJobs(state, options.force)
|
|
1281
|
+
if (!due.length) {
|
|
1282
|
+
await writeState(directory, sessionID, state)
|
|
1283
|
+
await reschedule()
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
job = due[0]
|
|
1247
1287
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
job.paused = true
|
|
1288
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1289
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1251
1290
|
await writeState(directory, sessionID, state)
|
|
1252
|
-
await notifyJob(directory, job, "
|
|
1253
|
-
await toast(client,
|
|
1291
|
+
await notifyJob(directory, job, "max_runtime_reached")
|
|
1292
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1293
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1254
1294
|
await reschedule()
|
|
1255
1295
|
return
|
|
1256
1296
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (preflight.code !== 0) {
|
|
1260
|
-
job.paused = true
|
|
1261
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1262
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1263
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1297
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1298
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1264
1299
|
await writeState(directory, sessionID, state)
|
|
1265
|
-
await notifyJob(directory, job, "
|
|
1266
|
-
await toast(client, "
|
|
1300
|
+
await notifyJob(directory, job, "stop_file")
|
|
1301
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1267
1302
|
await reschedule()
|
|
1268
1303
|
return
|
|
1269
1304
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
job.runCount = (job.runCount || 0) + 1
|
|
1276
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1277
|
-
job.enabled = false
|
|
1278
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
1279
|
-
}
|
|
1280
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1281
|
-
await writeState(directory, sessionID, state)
|
|
1282
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1283
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1284
|
-
|
|
1285
|
-
try {
|
|
1286
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
1287
|
-
if (!result.startsAssistantTurn) {
|
|
1288
|
-
const fresh = await readState(directory, sessionID)
|
|
1289
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1290
|
-
await writeState(directory, sessionID, fresh)
|
|
1305
|
+
if (await untilReached(directory, job)) {
|
|
1306
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1307
|
+
await writeState(directory, sessionID, state)
|
|
1308
|
+
await notifyJob(directory, job, "until_reached")
|
|
1309
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1291
1310
|
await reschedule()
|
|
1292
1311
|
return
|
|
1293
1312
|
}
|
|
1294
|
-
|
|
1295
|
-
if (job.
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1313
|
+
|
|
1314
|
+
if (job.preflightCommand) {
|
|
1315
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1316
|
+
job.paused = true
|
|
1317
|
+
await writeState(directory, sessionID, state)
|
|
1318
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
1319
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1320
|
+
await reschedule()
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1324
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1325
|
+
if (preflight.code !== 0) {
|
|
1326
|
+
job.paused = true
|
|
1327
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1328
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1329
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1330
|
+
await writeState(directory, sessionID, state)
|
|
1331
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
1332
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1333
|
+
await reschedule()
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
job = await ensureBranch(directory, job, client, sessionID)
|
|
1339
|
+
job = await maybeCompact(client, sessionID, job)
|
|
1340
|
+
job.lastRunAt = now()
|
|
1341
|
+
job.runCount = (job.runCount || 0) + 1
|
|
1342
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1343
|
+
job.enabled = false
|
|
1344
|
+
await notifyJob(directory, job, "max_runs_reached")
|
|
1345
|
+
}
|
|
1346
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1347
|
+
await writeState(directory, sessionID, state)
|
|
1348
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1349
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
const result = await fireAction(directory, client, sessionID, job)
|
|
1353
|
+
if (!result.startsAssistantTurn) {
|
|
1354
|
+
const fresh = await readState(directory, sessionID)
|
|
1355
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1356
|
+
await writeState(directory, sessionID, fresh)
|
|
1357
|
+
await reschedule()
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
let timer
|
|
1361
|
+
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)
|
|
1362
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1363
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
clearActiveRun(sessionID)
|
|
1366
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1367
|
+
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1368
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1369
|
+
}
|
|
1370
|
+
} finally {
|
|
1371
|
+
runLocks.delete(sessionID)
|
|
1303
1372
|
}
|
|
1304
1373
|
}
|
|
1305
1374
|
|
|
@@ -1320,6 +1389,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1320
1389
|
const parsed = parseLoopArgs(args, defaults)
|
|
1321
1390
|
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1322
1391
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1392
|
+
if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1323
1393
|
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 }
|
|
1324
1394
|
const state = await readState(directory, sessionID)
|
|
1325
1395
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -1530,8 +1600,9 @@ async function exportLoop(directory, client, sessionID) {
|
|
|
1530
1600
|
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
|
|
1531
1601
|
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
1532
1602
|
const sessionID = input?.sessionID
|
|
1533
|
-
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
1603
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
1534
1604
|
if (!sessionID || !name) return false
|
|
1605
|
+
rememberSession(directory, client, sessionID)
|
|
1535
1606
|
if (wasHandled(sessionID, name, args)) return true
|
|
1536
1607
|
markHandled(sessionID, name, args)
|
|
1537
1608
|
|
|
@@ -1616,6 +1687,7 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
1616
1687
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
1617
1688
|
}
|
|
1618
1689
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1690
|
+
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1619
1691
|
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
1620
1692
|
},
|
|
1621
1693
|
}
|