@bybrawe/opencode-loop 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +160 -2
- package/commands/loop-ask.md +1 -1
- package/commands/loop-clear.md +2 -2
- package/commands/loop-cmd.md +1 -1
- package/commands/loop-command.md +1 -1
- package/commands/loop-doctor.md +2 -2
- package/commands/loop-export.md +2 -2
- package/commands/loop-goal-blocked.md +5 -0
- package/commands/loop-goal-clear.md +5 -0
- package/commands/loop-goal-done.md +5 -0
- package/commands/loop-goal-pause.md +5 -0
- package/commands/loop-goal-resume.md +5 -0
- package/commands/loop-goal-status.md +5 -0
- package/commands/loop-goal.md +5 -0
- package/commands/loop-help.md +1 -1
- package/commands/loop-init.md +2 -2
- package/commands/loop-logs.md +1 -1
- package/commands/loop-now.md +2 -2
- package/commands/loop-pause.md +2 -2
- package/commands/loop-resume.md +2 -2
- package/commands/loop-shell.md +1 -1
- package/commands/loop-status.md +1 -1
- package/commands/loop-stop.md +1 -1
- package/commands/loop.md +1 -1
- package/package.json +2 -2
- package/src/index.js +485 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.8
|
|
4
|
+
|
|
5
|
+
- Added a real due timer for TUI loop jobs, so delayed jobs can wake without waiting for another OpenCode event.
|
|
6
|
+
- Fixed `/loop 1m --no-now ...` style jobs so the first delayed run can fire on time when OpenCode is idle.
|
|
7
|
+
- Fixed scheduled action types such as `/loop-ask`, `/loop-command`, and `/loop-shell` so 0s and delayed jobs are scheduled even when their default mode is `--no-now`.
|
|
8
|
+
- Added busy retry behavior: if a due timer expires while OpenCode is busy, the job waits and retries instead of interrupting the active turn.
|
|
9
|
+
- Reschedules due timers after add, stop, pause, resume, run completion, goal pause/resume/clear, and post-run verification.
|
|
10
|
+
- Shortened command markdown templates so unavoidable custom command turns reply `OK` instead of producing misleading status text.
|
|
11
|
+
- Kept Experimental Goal Mode marked experimental.
|
|
12
|
+
|
|
13
|
+
## 0.5.7
|
|
14
|
+
|
|
15
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
16
|
+
- Added goal lifecycle commands: `/loop-goal-status`, `/loop-goal-pause`, `/loop-goal-resume`, `/loop-goal-clear`, `/loop-goal-done`, and `/loop-goal-blocked`.
|
|
17
|
+
- Added local goal tools exposed to the agent: `opencode_loop_goal_complete`, `opencode_loop_goal_blocked`, and `opencode_loop_goal_progress`.
|
|
18
|
+
- Added goal acceptance criteria with repeated `--acceptance` / `--success` flags.
|
|
19
|
+
- Added post-turn goal check commands with repeated `--check` flags.
|
|
20
|
+
- Added `--complete-when-checks-pass` for goals that should automatically stop once all configured checks pass.
|
|
21
|
+
- Added `--max-turns` alias for goal run limits.
|
|
22
|
+
- Added goal reports under `.opencode/opencode-loop/goals/` and optional `--evidence-file`.
|
|
23
|
+
- Updated README with simple examples explaining prompt loops, command loops, shell loops, scheduled checks, and experimental persistent goals.
|
|
24
|
+
|
|
3
25
|
## 0.5.6
|
|
4
26
|
|
|
5
27
|
- Added clearer README examples for prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, first-run timing, and idle-safe behavior.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ NPM package name: **@bybrawe/opencode-loop**
|
|
|
11
11
|
|
|
12
12
|
## Current status
|
|
13
13
|
|
|
14
|
-
**v0.5.
|
|
14
|
+
**v0.5.8 is a reliability hotfix for the new loop types and Experimental Goal Mode.** It adds a real due timer in addition to OpenCode idle/status events, so delayed jobs such as `/loop 1m --no-now ...`, `/loop-command 1m --no-now /compact`, `/loop-ask 1h ...`, and `/loop-shell 10m ...` do not depend on another user action to wake the scheduler. The TUI loop still waits for the session to become idle before starting work, debounces idle events, and avoids starting a new run while OpenCode is busy or queued.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -21,9 +21,61 @@ The known update-related symptoms from older builds are fixed:
|
|
|
21
21
|
- prompt, shell, or toast calls silently fail after OpenCode SDK changes
|
|
22
22
|
- `/compact` accidentally being treated as a normal agent prompt
|
|
23
23
|
- intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
|
|
24
|
+
- experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
|
|
25
|
+
- `--no-now` interval jobs not waking until another OpenCode event happens
|
|
26
|
+
- `/loop-ask`, `/loop-command`, and `/loop-shell` being configured but not reliably firing on their own
|
|
24
27
|
|
|
25
28
|
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`.
|
|
26
29
|
|
|
30
|
+
## v0.5.8 quick behavior guide
|
|
31
|
+
|
|
32
|
+
OpenCode Loop has two triggers now:
|
|
33
|
+
|
|
34
|
+
1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
|
|
35
|
+
2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
Simple examples:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
/loop 0s continue the project
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run every time OpenCode becomes idle.
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
/loop 1m --no-now continue the project
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Wait 1 minute first, then run when OpenCode is idle.
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/loop-command 200m /compact
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run OpenCode compact about every 200 minutes, but only when OpenCode is idle.
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
/loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them and fix errors.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Ask a recurring quality-control question every hour.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
/loop-shell 10m npm test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run a shell command every 10 minutes when idle.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
/loop-goal --max-turns 5 --check "npm run build" --complete-when-checks-pass make the project build cleanly
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
|
+
|
|
77
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.8 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin.
|
|
78
|
+
|
|
27
79
|
## Why this exists
|
|
28
80
|
|
|
29
81
|
Claude Code users often rely on a loop-like workflow where the agent finishes one step, then immediately continues with the next step.
|
|
@@ -85,6 +137,7 @@ OpenCode Loop is designed for developers searching for:
|
|
|
85
137
|
- **Branch setup** with `--branch ai-loop`.
|
|
86
138
|
- **Stop controls** with `--stop-file STOP_LOOP`, `--until`, `/loop-stop`, `/loop-pause`, `/loop-resume`, and `/loop-remove`.
|
|
87
139
|
- **Watch mode** with `--watch progress.md`.
|
|
140
|
+
- **Experimental Goal Mode** with `/loop-goal`, goal status, goal tools, acceptance criteria, and verification checks.
|
|
88
141
|
- **Diagnostics** with `/loop-doctor`.
|
|
89
142
|
- **Starter progress file** with `/loop-init`.
|
|
90
143
|
- **State export** with `/loop-export`.
|
|
@@ -264,7 +317,7 @@ If the commands do not appear:
|
|
|
264
317
|
|
|
265
318
|
## Quick start
|
|
266
319
|
|
|
267
|
-
### The
|
|
320
|
+
### The 6 examples most people need
|
|
268
321
|
|
|
269
322
|
Auto-continue every time OpenCode finishes a turn:
|
|
270
323
|
|
|
@@ -302,6 +355,12 @@ Run a real shell command every 10 minutes when idle:
|
|
|
302
355
|
/loop-shell 10m npm test
|
|
303
356
|
```
|
|
304
357
|
|
|
358
|
+
Experimental goal mode: keep working until the objective is actually done, blocked, paused, or cleared:
|
|
359
|
+
|
|
360
|
+
```text
|
|
361
|
+
/loop-goal finish the feature, run tsc --noEmit and build, fix every error, and stop only when everything passes
|
|
362
|
+
```
|
|
363
|
+
|
|
305
364
|
### What “when idle” means
|
|
306
365
|
|
|
307
366
|
OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
|
|
@@ -340,6 +399,89 @@ That can look like a chat prompt in older versions or unclear setups. Prefer `/l
|
|
|
340
399
|
/loop-compact 200m
|
|
341
400
|
```
|
|
342
401
|
|
|
402
|
+
### Experimental Goal Mode
|
|
403
|
+
|
|
404
|
+
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.
|
|
405
|
+
|
|
406
|
+
Basic goal:
|
|
407
|
+
|
|
408
|
+
```text
|
|
409
|
+
/loop-goal fix the TypeScript build errors. Run tsc --noEmit and npm run build. Stop only when both pass.
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
What happens:
|
|
413
|
+
|
|
414
|
+
1. The goal is saved as the active experimental goal.
|
|
415
|
+
2. OpenCode starts working immediately by default.
|
|
416
|
+
3. When OpenCode becomes idle, the plugin checks whether the goal is still active.
|
|
417
|
+
4. If the goal is not complete or blocked, the plugin injects the next Goal Mode continuation prompt.
|
|
418
|
+
5. The agent is instructed to call a local goal tool when it has real progress, completion evidence, or a blocked reason.
|
|
419
|
+
|
|
420
|
+
Goal Mode is idle-safe. If OpenCode is busy, it waits. It does not intentionally start another turn on top of a running turn.
|
|
421
|
+
|
|
422
|
+
Simple status and control commands:
|
|
423
|
+
|
|
424
|
+
```text
|
|
425
|
+
/loop-goal-status
|
|
426
|
+
/loop-goal-pause
|
|
427
|
+
/loop-goal-resume
|
|
428
|
+
/loop-goal-clear
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
You can also use subcommands through `/loop-goal`:
|
|
432
|
+
|
|
433
|
+
```text
|
|
434
|
+
/loop-goal status
|
|
435
|
+
/loop-goal pause
|
|
436
|
+
/loop-goal resume
|
|
437
|
+
/loop-goal clear
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Manual completion or blocked state:
|
|
441
|
+
|
|
442
|
+
```text
|
|
443
|
+
/loop-goal-done shipped the feature and all checks pass
|
|
444
|
+
/loop-goal-blocked missing API key for the staging provider
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Goal with acceptance criteria:
|
|
448
|
+
|
|
449
|
+
```text
|
|
450
|
+
/loop-goal --acceptance "tsc --noEmit passes" --acceptance "npm run build passes" --acceptance "no new TODO hacks" fix all build errors
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Goal with automatic check commands after each assistant turn:
|
|
454
|
+
|
|
455
|
+
```text
|
|
456
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" fix every TypeScript and build error
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Goal that automatically stops when all configured checks pass:
|
|
460
|
+
|
|
461
|
+
```text
|
|
462
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Goal with a safety/runtime limit:
|
|
466
|
+
|
|
467
|
+
```text
|
|
468
|
+
/loop-goal --max-turns 20 --max-runtime 3h --check "npm test" fix the failing tests without destructive commands
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Goal Mode writes a report under:
|
|
472
|
+
|
|
473
|
+
```text
|
|
474
|
+
.opencode/opencode-loop/goals/
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
You can choose a report file:
|
|
478
|
+
|
|
479
|
+
```text
|
|
480
|
+
/loop-goal --evidence-file goal-report.md --check "npm test" make tests pass and document the result
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Goal Mode is inspired by persistent-objective workflows such as Codex `/goal`, but this package implements it at the OpenCode plugin layer. It is intentionally marked experimental because model tool-calling behavior and OpenCode plugin APIs may change.
|
|
484
|
+
|
|
343
485
|
### Safer development loop
|
|
344
486
|
|
|
345
487
|
```text
|
|
@@ -453,6 +595,13 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
|
|
|
453
595
|
| `/loop-ask <interval> <prompt/question>` | Schedule a recurring question/check prompt; first run waits for the interval by default |
|
|
454
596
|
| `/loop-prompt <interval> <prompt>` | Force prompt mode, even when you want explicit prompt-loop naming |
|
|
455
597
|
| `/loop-shell <interval> <shell-command>` | Schedule a shell command loop |
|
|
598
|
+
| `/loop-goal <objective>` | Start experimental persistent Goal Mode |
|
|
599
|
+
| `/loop-goal-status` | Show experimental goal state |
|
|
600
|
+
| `/loop-goal-pause` | Pause the active experimental goal |
|
|
601
|
+
| `/loop-goal-resume` | Resume the active experimental goal |
|
|
602
|
+
| `/loop-goal-clear` | Clear experimental goals |
|
|
603
|
+
| `/loop-goal-done <summary>` | Manually mark the active goal complete |
|
|
604
|
+
| `/loop-goal-blocked <reason>` | Manually mark the active goal blocked |
|
|
456
605
|
| `/loop-help` | Show usage help inside OpenCode |
|
|
457
606
|
| `/loop-status` | Show active loop jobs |
|
|
458
607
|
| `/loop-logs` | Show recent loop log entries |
|
|
@@ -499,6 +648,7 @@ OpenCode Loop has separate types for prompts, OpenCode slash commands, and shell
|
|
|
499
648
|
| Scheduled question | Ask/check on a timer | `/loop-ask 1h did you run tests and build?` | After the interval by default |
|
|
500
649
|
| Slash-command loop | Run OpenCode commands like `/compact` | `/loop-command 200m /compact` | After the interval by default |
|
|
501
650
|
| Shell loop | Run real terminal commands | `/loop-shell 10m npm test` | After the interval by default |
|
|
651
|
+
| Experimental goal | Keep pursuing an outcome until complete/blocked | `/loop-goal make build pass` | Now by default |
|
|
502
652
|
|
|
503
653
|
You can still force the type on `/loop`:
|
|
504
654
|
|
|
@@ -957,6 +1107,14 @@ Improve the application in small safe steps.
|
|
|
957
1107
|
|
|
958
1108
|
## Changelog highlights
|
|
959
1109
|
|
|
1110
|
+
### v0.5.7
|
|
1111
|
+
|
|
1112
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
1113
|
+
- Added goal lifecycle commands and local goal tools so the agent can report real progress, completion evidence, or blocked state.
|
|
1114
|
+
- Added repeated `--acceptance`, `--success`, and `--check` flags for goal criteria and verification.
|
|
1115
|
+
- Added `--complete-when-checks-pass`, `--max-turns`, and `--evidence-file` for controlled goal workflows.
|
|
1116
|
+
- Goal reports are written under `.opencode/opencode-loop/goals/` by default.
|
|
1117
|
+
|
|
960
1118
|
### v0.5.6
|
|
961
1119
|
|
|
962
1120
|
- Added clearer README examples for prompt loops, scheduled question loops, command loops, shell loops, first-run timing, and idle-safe behavior.
|
package/commands/loop-ask.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop ask scheduler
|
|
5
|
+
OpenCode Loop ask scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-clear.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Clear all OpenCode Loop jobs for this session.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop clear command handled locally. Reply exactly: OK.
|
package/commands/loop-cmd.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop command scheduler
|
|
5
|
+
OpenCode Loop command scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-command.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop command scheduler
|
|
5
|
+
OpenCode Loop command scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-doctor.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Run OpenCode Loop diagnostics.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop doctor command handled locally. Reply exactly: OK.
|
package/commands/loop-export.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Export OpenCode Loop state.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop export command handled locally. Reply exactly: OK.
|
package/commands/loop-help.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop help.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop help command handled locally. Reply exactly: OK.
|
package/commands/loop-init.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Create a starter progress.md file.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop init command handled locally. Reply exactly: OK.
|
package/commands/loop-logs.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop logs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop logs command handled locally. Reply exactly: OK.
|
package/commands/loop-now.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Run due OpenCode Loop jobs now when idle.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop run-now command handled locally. Reply exactly: OK.
|
package/commands/loop-pause.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Pause OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop pause command handled locally. Reply exactly: OK.
|
package/commands/loop-resume.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: OpenCode Loop
|
|
2
|
+
description: Resume OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop resume command handled locally. Reply exactly: OK.
|
package/commands/loop-shell.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Loop shell scheduler
|
|
5
|
+
OpenCode Loop shell scheduler handled locally. Reply exactly: OK.
|
package/commands/loop-status.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Show OpenCode Loop status.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop status command handled locally. Reply exactly: OK.
|
package/commands/loop-stop.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Stop OpenCode Loop jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop stop command handled locally. Reply exactly: OK.
|
package/commands/loop.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop local command handled. Reply exactly: OK.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code style /loop
|
|
3
|
+
"version": "0.5.8",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: reliable timers, 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": {
|
package/src/index.js
CHANGED
|
@@ -6,12 +6,18 @@ const SERVICE = "opencode-loop"
|
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
7
|
const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
8
8
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
|
+
const BUSY_RETRY_MS = 5_000
|
|
10
|
+
const MIN_DUE_TIMER_MS = 250
|
|
11
|
+
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
9
12
|
const MAX_SCAN_FILES = 200
|
|
10
13
|
const MAX_SCAN_BYTES = 2_000_000
|
|
14
|
+
const GOAL_REPORT_DIR = "goals"
|
|
15
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
11
16
|
|
|
12
17
|
const activeRuns = new Map()
|
|
13
18
|
const handledCommands = new Map()
|
|
14
19
|
const idleTimers = new Map()
|
|
20
|
+
const dueTimers = new Map()
|
|
15
21
|
const sessionStatuses = new Map()
|
|
16
22
|
|
|
17
23
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
@@ -149,7 +155,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
149
155
|
} else if (first) {
|
|
150
156
|
const parsedDuration = parseDuration(first)
|
|
151
157
|
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
152
|
-
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
158
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
153
159
|
else rest = input
|
|
154
160
|
}
|
|
155
161
|
}
|
|
@@ -182,6 +188,16 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
182
188
|
notifyCommand: defaults.notifyCommand,
|
|
183
189
|
branch: defaults.branch,
|
|
184
190
|
branchDone: false,
|
|
191
|
+
goalStatus: defaults.goalStatus,
|
|
192
|
+
goalFile: defaults.goalFile,
|
|
193
|
+
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
194
|
+
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
195
|
+
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
196
|
+
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
197
|
+
goalSummary: defaults.goalSummary || "",
|
|
198
|
+
goalEvidence: defaults.goalEvidence || "",
|
|
199
|
+
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
200
|
+
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
185
201
|
noOverlap: defaults.noOverlap ?? true,
|
|
186
202
|
safe: defaults.safe ?? false,
|
|
187
203
|
quiet: defaults.quiet ?? false,
|
|
@@ -226,9 +242,13 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
226
242
|
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
227
243
|
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
228
244
|
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
245
|
+
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
246
|
+
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
247
|
+
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
229
248
|
|
|
230
249
|
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
231
250
|
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
251
|
+
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
232
252
|
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
233
253
|
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
234
254
|
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
@@ -236,6 +256,8 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
236
256
|
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
237
257
|
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
238
258
|
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
259
|
+
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
260
|
+
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
239
261
|
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
240
262
|
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
241
263
|
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
@@ -254,12 +276,33 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
254
276
|
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
255
277
|
rest = includes[1]
|
|
256
278
|
|
|
279
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
280
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
281
|
+
rest = acceptances[1]
|
|
282
|
+
|
|
283
|
+
const success = takeAllFlagValues(rest, "--success")
|
|
284
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
285
|
+
rest = success[1]
|
|
286
|
+
|
|
287
|
+
const checks = takeAllFlagValues(rest, "--check")
|
|
288
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
289
|
+
rest = checks[1]
|
|
290
|
+
|
|
257
291
|
job.action = stripOuterQuotes(rest || job.action || "")
|
|
258
292
|
job.watchPaths = [...new Set(job.watchPaths)]
|
|
259
293
|
job.includeFiles = [...new Set(job.includeFiles)]
|
|
294
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
295
|
+
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
296
|
+
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
297
|
+
job.name = job.name || "goal"
|
|
298
|
+
job.goalStatus = job.goalStatus || "active"
|
|
299
|
+
job.safe = job.safe !== false
|
|
300
|
+
job.askNever = job.askNever !== false
|
|
301
|
+
job.noOverlap = job.noOverlap !== false
|
|
302
|
+
}
|
|
260
303
|
job.lastRunAt = job.immediate ? 0 : now()
|
|
261
304
|
|
|
262
|
-
if (!job.action && !job.promptFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md or /loop 0s --prompt-file loop-prompt.md" }
|
|
305
|
+
if (!job.action && !job.promptFile && !job.goalFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" }
|
|
263
306
|
return { ok: true, job }
|
|
264
307
|
}
|
|
265
308
|
|
|
@@ -271,15 +314,15 @@ async function pathExists(filePath) { try { await fs.access(filePath); return tr
|
|
|
271
314
|
async function readState(directory, sessionID) {
|
|
272
315
|
try {
|
|
273
316
|
const parsed = JSON.parse(await fs.readFile(statePath(directory, sessionID), "utf8"))
|
|
274
|
-
return { version:
|
|
317
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
275
318
|
} catch {
|
|
276
|
-
return { version:
|
|
319
|
+
return { version: 4, jobs: [] }
|
|
277
320
|
}
|
|
278
321
|
}
|
|
279
322
|
|
|
280
323
|
async function writeState(directory, sessionID, state) {
|
|
281
324
|
await ensureDir(stateDir(directory))
|
|
282
|
-
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version:
|
|
325
|
+
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
|
|
283
326
|
}
|
|
284
327
|
|
|
285
328
|
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
@@ -497,6 +540,7 @@ function actionKind(action, job = {}) {
|
|
|
497
540
|
const text = String(action || "").trim()
|
|
498
541
|
const forced = String(job.kind || "").trim().toLowerCase()
|
|
499
542
|
if (forced === "compact") return "compact"
|
|
543
|
+
if (forced === "goal") return "goal"
|
|
500
544
|
if (text === "/compact" || text === "/summarize") return "compact"
|
|
501
545
|
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
502
546
|
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
@@ -520,7 +564,63 @@ function decoratePrompt(job) {
|
|
|
520
564
|
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
521
565
|
}
|
|
522
566
|
|
|
567
|
+
function isGoalJob(job) {
|
|
568
|
+
return String(job?.kind || "").toLowerCase() === "goal"
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function goalStatusText(job) {
|
|
572
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
573
|
+
if (!status) return ""
|
|
574
|
+
if (status === "completed") return "completed"
|
|
575
|
+
if (status === "blocked") return "blocked"
|
|
576
|
+
if (job?.paused) return "paused"
|
|
577
|
+
return status
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function buildGoalPrompt(directory, job) {
|
|
581
|
+
const sections = []
|
|
582
|
+
const objective = String(job.action || "").trim()
|
|
583
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
584
|
+
if (job.goalFile) {
|
|
585
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
586
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
587
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
588
|
+
}
|
|
589
|
+
if (job.promptFile) {
|
|
590
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
591
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
592
|
+
}
|
|
593
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
594
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
595
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
596
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
597
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
598
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
599
|
+
for (const file of job.includeFiles || []) {
|
|
600
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
601
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
605
|
+
|
|
606
|
+
You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
|
|
607
|
+
|
|
608
|
+
Rules:
|
|
609
|
+
- Work on the next smallest useful step toward the goal.
|
|
610
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
611
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
612
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
613
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
614
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
615
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
616
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
617
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
618
|
+
|
|
619
|
+
${sections.join("\n\n---\n\n")}`
|
|
620
|
+
}
|
|
621
|
+
|
|
523
622
|
async function buildPrompt(directory, job) {
|
|
623
|
+
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
524
624
|
const sections = []
|
|
525
625
|
if (job.promptFile) {
|
|
526
626
|
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
@@ -657,8 +757,9 @@ async function sessionStatusType(client, sessionID) {
|
|
|
657
757
|
return type
|
|
658
758
|
}
|
|
659
759
|
} catch {}
|
|
660
|
-
|
|
661
|
-
|
|
760
|
+
const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
|
|
761
|
+
sessionStatuses.set(sessionID, fallback)
|
|
762
|
+
return fallback
|
|
662
763
|
}
|
|
663
764
|
|
|
664
765
|
async function sessionIsIdle(client, sessionID) {
|
|
@@ -672,9 +773,15 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
672
773
|
idleTimers.delete(sessionID)
|
|
673
774
|
Promise.resolve()
|
|
674
775
|
.then(async () => {
|
|
675
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
776
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
777
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
676
780
|
await finalizeActiveRun(directory, client, sessionID)
|
|
677
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
781
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
782
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
783
|
+
return
|
|
784
|
+
}
|
|
678
785
|
await maybeRunDueJobs(directory, client, sessionID)
|
|
679
786
|
})
|
|
680
787
|
.catch((error) => {
|
|
@@ -685,9 +792,65 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
685
792
|
idleTimers.set(sessionID, timer)
|
|
686
793
|
}
|
|
687
794
|
|
|
795
|
+
function jobDueAt(job, current = now()) {
|
|
796
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
797
|
+
if (!job.enabled || job.paused) return Infinity
|
|
798
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
799
|
+
if (job.watchPaths?.length) return Infinity
|
|
800
|
+
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
801
|
+
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
802
|
+
if (job.intervalMs === 0) return current
|
|
803
|
+
if (!job.lastRunAt) return current
|
|
804
|
+
return job.lastRunAt + (job.intervalMs || 0)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function nextDueDelay(state) {
|
|
808
|
+
const current = now()
|
|
809
|
+
let soonest = Infinity
|
|
810
|
+
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
811
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
812
|
+
return Math.max(0, soonest - current)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
816
|
+
const previous = dueTimers.get(sessionID)
|
|
817
|
+
if (previous) clearTimeout(previous)
|
|
818
|
+
|
|
819
|
+
const state = await readState(directory, sessionID)
|
|
820
|
+
const delay = nextDueDelay(state)
|
|
821
|
+
if (!Number.isFinite(delay)) {
|
|
822
|
+
dueTimers.delete(sessionID)
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
827
|
+
const timer = setTimeout(() => {
|
|
828
|
+
dueTimers.delete(sessionID)
|
|
829
|
+
Promise.resolve()
|
|
830
|
+
.then(async () => {
|
|
831
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
832
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
836
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
837
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
841
|
+
})
|
|
842
|
+
.catch((error) => {
|
|
843
|
+
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
844
|
+
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
845
|
+
})
|
|
846
|
+
}, wait)
|
|
847
|
+
dueTimers.set(sessionID, timer)
|
|
848
|
+
}
|
|
849
|
+
|
|
688
850
|
function dueJobs(state, force = false) {
|
|
689
851
|
const current = now()
|
|
690
852
|
return (state.jobs || []).filter((job) => {
|
|
853
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
691
854
|
if (!job.enabled || job.paused) return false
|
|
692
855
|
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
693
856
|
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
@@ -703,6 +866,146 @@ function clearActiveRun(sessionID) {
|
|
|
703
866
|
activeRuns.delete(sessionID)
|
|
704
867
|
}
|
|
705
868
|
|
|
869
|
+
function goalReportPath(directory, sessionID, job) {
|
|
870
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function goalReportText(job) {
|
|
874
|
+
const lines = []
|
|
875
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
876
|
+
lines.push("")
|
|
877
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
878
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
879
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
880
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
881
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
882
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
883
|
+
lines.push("")
|
|
884
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
885
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
886
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
887
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
888
|
+
if (job.lastGoalChecks?.length) {
|
|
889
|
+
lines.push("## Latest checks", "")
|
|
890
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
891
|
+
lines.push("")
|
|
892
|
+
}
|
|
893
|
+
if (job.goalProgress?.length) {
|
|
894
|
+
lines.push("## Progress", "")
|
|
895
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
896
|
+
lines.push("")
|
|
897
|
+
}
|
|
898
|
+
return lines.join("\n")
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function writeGoalReport(directory, sessionID, job) {
|
|
902
|
+
if (!isGoalJob(job)) return
|
|
903
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
904
|
+
await ensureDir(path.dirname(target))
|
|
905
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function pickGoalJob(state, target = "") {
|
|
909
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
910
|
+
if (!goals.length) return undefined
|
|
911
|
+
const text = String(target || "").trim()
|
|
912
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
913
|
+
return goals.find((job, index) => matchJob(job, text, index))
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function parseGoalToolText(args, fields) {
|
|
917
|
+
const result = {}
|
|
918
|
+
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
919
|
+
return result
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
923
|
+
const state = await readState(directory, sessionID)
|
|
924
|
+
const job = pickGoalJob(state, args.target)
|
|
925
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
926
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
927
|
+
job.goalStatus = "completed"
|
|
928
|
+
job.enabled = false
|
|
929
|
+
job.paused = true
|
|
930
|
+
job.goalCompletedAt = now()
|
|
931
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
932
|
+
job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
|
|
933
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
934
|
+
await writeState(directory, sessionID, state)
|
|
935
|
+
await writeGoalReport(directory, sessionID, job)
|
|
936
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
937
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
941
|
+
const state = await readState(directory, sessionID)
|
|
942
|
+
const job = pickGoalJob(state, args.target)
|
|
943
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
944
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
945
|
+
job.goalStatus = "blocked"
|
|
946
|
+
job.enabled = false
|
|
947
|
+
job.paused = true
|
|
948
|
+
job.goalBlockedAt = now()
|
|
949
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
950
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
951
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
952
|
+
await writeState(directory, sessionID, state)
|
|
953
|
+
await writeGoalReport(directory, sessionID, job)
|
|
954
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
955
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
959
|
+
const state = await readState(directory, sessionID)
|
|
960
|
+
const job = pickGoalJob(state, args.target)
|
|
961
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
962
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
963
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
964
|
+
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
965
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
966
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
967
|
+
await writeState(directory, sessionID, state)
|
|
968
|
+
await writeGoalReport(directory, sessionID, job)
|
|
969
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
970
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function runGoalChecks(directory, sessionID, job, client) {
|
|
974
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
975
|
+
const results = []
|
|
976
|
+
for (const command of job.goalChecks) {
|
|
977
|
+
if (job.safe && dangerousShell(command)) {
|
|
978
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
979
|
+
continue
|
|
980
|
+
}
|
|
981
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
982
|
+
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
983
|
+
}
|
|
984
|
+
job.lastGoalCheckAt = now()
|
|
985
|
+
job.lastGoalChecks = results
|
|
986
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
987
|
+
if (allPassed) {
|
|
988
|
+
job.goalChecksPassedAt = now()
|
|
989
|
+
job.failureCount = 0
|
|
990
|
+
await toast(client, "Goal checks passed.", "success")
|
|
991
|
+
if (job.goalCompleteWhenChecksPass) {
|
|
992
|
+
job.goalStatus = "completed"
|
|
993
|
+
job.enabled = false
|
|
994
|
+
job.paused = true
|
|
995
|
+
job.goalCompletedAt = now()
|
|
996
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
997
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
998
|
+
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
999
|
+
}
|
|
1000
|
+
} else {
|
|
1001
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1002
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
1003
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
1004
|
+
}
|
|
1005
|
+
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
1006
|
+
return job
|
|
1007
|
+
}
|
|
1008
|
+
|
|
706
1009
|
async function finalizeActiveRun(directory, client, sessionID) {
|
|
707
1010
|
const active = activeRuns.get(sessionID)
|
|
708
1011
|
if (!active) return
|
|
@@ -750,9 +1053,12 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
750
1053
|
}
|
|
751
1054
|
}
|
|
752
1055
|
|
|
753
|
-
|
|
1056
|
+
if (isGoalJob(job)) job = await runGoalChecks(directory, sessionID, job, client)
|
|
1057
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
754
1058
|
await writeState(directory, sessionID, state)
|
|
1059
|
+
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
755
1060
|
await createCheckpoint(directory, sessionID, job, client)
|
|
1061
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
756
1062
|
}
|
|
757
1063
|
|
|
758
1064
|
async function fireAction(directory, client, sessionID, job) {
|
|
@@ -788,24 +1094,41 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
788
1094
|
return { startsAssistantTurn: true }
|
|
789
1095
|
}
|
|
790
1096
|
const prompt = await buildPrompt(directory, job)
|
|
791
|
-
|
|
1097
|
+
const prefix = kind === "goal"
|
|
1098
|
+
? "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."
|
|
1099
|
+
: "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."
|
|
1100
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1101
|
+
|
|
1102
|
+
${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
|
|
1103
|
+
|
|
1104
|
+
${prompt}` }] } })
|
|
792
1105
|
return { startsAssistantTurn: true }
|
|
793
1106
|
}
|
|
794
1107
|
|
|
795
1108
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1109
|
+
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1110
|
+
|
|
796
1111
|
if (!await sessionIsIdle(client, sessionID)) {
|
|
797
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle
|
|
1112
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1113
|
+
await reschedule(BUSY_RETRY_MS)
|
|
798
1114
|
return
|
|
799
1115
|
}
|
|
800
1116
|
const active = activeRuns.get(sessionID)
|
|
801
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS))
|
|
1117
|
+
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1118
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
802
1121
|
|
|
803
1122
|
const state = await readState(directory, sessionID)
|
|
804
1123
|
for (const job of state.jobs || []) {
|
|
805
1124
|
if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
|
|
806
1125
|
}
|
|
807
1126
|
let due = dueJobs(state, options.force)
|
|
808
|
-
if (!due.length) {
|
|
1127
|
+
if (!due.length) {
|
|
1128
|
+
await writeState(directory, sessionID, state)
|
|
1129
|
+
await reschedule()
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
809
1132
|
let job = due[0]
|
|
810
1133
|
|
|
811
1134
|
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
@@ -814,6 +1137,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
814
1137
|
await notifyJob(directory, job, "max_runtime_reached")
|
|
815
1138
|
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
816
1139
|
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1140
|
+
await reschedule()
|
|
817
1141
|
return
|
|
818
1142
|
}
|
|
819
1143
|
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
@@ -821,6 +1145,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
821
1145
|
await writeState(directory, sessionID, state)
|
|
822
1146
|
await notifyJob(directory, job, "stop_file")
|
|
823
1147
|
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1148
|
+
await reschedule()
|
|
824
1149
|
return
|
|
825
1150
|
}
|
|
826
1151
|
if (await untilReached(directory, job)) {
|
|
@@ -828,6 +1153,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
828
1153
|
await writeState(directory, sessionID, state)
|
|
829
1154
|
await notifyJob(directory, job, "until_reached")
|
|
830
1155
|
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1156
|
+
await reschedule()
|
|
831
1157
|
return
|
|
832
1158
|
}
|
|
833
1159
|
|
|
@@ -837,6 +1163,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
837
1163
|
await writeState(directory, sessionID, state)
|
|
838
1164
|
await notifyJob(directory, job, "preflight_blocked")
|
|
839
1165
|
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1166
|
+
await reschedule()
|
|
840
1167
|
return
|
|
841
1168
|
}
|
|
842
1169
|
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
@@ -849,6 +1176,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
849
1176
|
await writeState(directory, sessionID, state)
|
|
850
1177
|
await notifyJob(directory, job, "preflight_failed")
|
|
851
1178
|
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1179
|
+
await reschedule()
|
|
852
1180
|
return
|
|
853
1181
|
}
|
|
854
1182
|
}
|
|
@@ -870,18 +1198,20 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
870
1198
|
const result = await fireAction(directory, client, sessionID, job)
|
|
871
1199
|
if (!result.startsAssistantTurn) {
|
|
872
1200
|
const fresh = await readState(directory, sessionID)
|
|
873
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false)
|
|
1201
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
874
1202
|
await writeState(directory, sessionID, fresh)
|
|
1203
|
+
await reschedule()
|
|
1204
|
+
return
|
|
875
1205
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
1206
|
+
let timer
|
|
1207
|
+
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1208
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1209
|
+
await reschedule(BUSY_RETRY_MS)
|
|
881
1210
|
} catch (error) {
|
|
882
1211
|
clearActiveRun(sessionID)
|
|
883
1212
|
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
884
1213
|
await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
|
|
1214
|
+
await reschedule(BUSY_RETRY_MS)
|
|
885
1215
|
}
|
|
886
1216
|
}
|
|
887
1217
|
|
|
@@ -925,6 +1255,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
925
1255
|
|
|
926
1256
|
state.jobs.push(parsed.job)
|
|
927
1257
|
await writeState(directory, sessionID, state)
|
|
1258
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
928
1259
|
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
929
1260
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
930
1261
|
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
@@ -932,11 +1263,18 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
932
1263
|
|
|
933
1264
|
async function stopLoop(directory, client, sessionID, args) {
|
|
934
1265
|
const target = String(args || "").trim()
|
|
935
|
-
if (!target || target.toLowerCase() === "all") {
|
|
1266
|
+
if (!target || target.toLowerCase() === "all") {
|
|
1267
|
+
await removeState(directory, sessionID)
|
|
1268
|
+
clearActiveRun(sessionID)
|
|
1269
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1270
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
936
1273
|
const state = await readState(directory, sessionID)
|
|
937
1274
|
const before = state.jobs.length
|
|
938
1275
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
939
1276
|
await writeState(directory, sessionID, state)
|
|
1277
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
940
1278
|
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
941
1279
|
}
|
|
942
1280
|
|
|
@@ -946,15 +1284,90 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
|
|
|
946
1284
|
let count = 0
|
|
947
1285
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
948
1286
|
await writeState(directory, sessionID, state)
|
|
1287
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
949
1288
|
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
950
1289
|
}
|
|
951
1290
|
|
|
1291
|
+
async function statusGoal(directory, client, sessionID) {
|
|
1292
|
+
const state = await readState(directory, sessionID)
|
|
1293
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1294
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
1295
|
+
const status = goalStatusText(job)
|
|
1296
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
1297
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
1298
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
1299
|
+
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}`
|
|
1300
|
+
}) : ["No experimental goal jobs."]
|
|
1301
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
1302
|
+
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
1306
|
+
const target = String(args || "").trim() || "goal"
|
|
1307
|
+
const state = await readState(directory, sessionID)
|
|
1308
|
+
let count = 0
|
|
1309
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1310
|
+
await writeState(directory, sessionID, state)
|
|
1311
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1312
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
1316
|
+
const target = String(args || "").trim() || "goal"
|
|
1317
|
+
const state = await readState(directory, sessionID)
|
|
1318
|
+
let count = 0
|
|
1319
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1320
|
+
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
1321
|
+
count++
|
|
1322
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
|
|
1323
|
+
})
|
|
1324
|
+
await writeState(directory, sessionID, state)
|
|
1325
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1326
|
+
if (count) {
|
|
1327
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1328
|
+
scheduleIdleWork(directory, client, sessionID)
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
1333
|
+
const target = String(args || "").trim()
|
|
1334
|
+
const state = await readState(directory, sessionID)
|
|
1335
|
+
const before = state.jobs.length
|
|
1336
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1337
|
+
await writeState(directory, sessionID, state)
|
|
1338
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1339
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1343
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done." })
|
|
1344
|
+
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1348
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
1349
|
+
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
1353
|
+
const text = String(args || "").trim()
|
|
1354
|
+
const [maybeCommand, rest] = splitFirst(text)
|
|
1355
|
+
const sub = maybeCommand.toLowerCase()
|
|
1356
|
+
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
1357
|
+
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
1358
|
+
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
1359
|
+
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
1360
|
+
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
1361
|
+
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
1362
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
1363
|
+
}
|
|
1364
|
+
|
|
952
1365
|
async function statusLoop(directory, client, sessionID) {
|
|
953
1366
|
const state = await readState(directory, sessionID)
|
|
954
1367
|
const jobs = state.jobs || []
|
|
955
1368
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
956
1369
|
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
957
|
-
const flags = [job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
1370
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
958
1371
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
959
1372
|
}) : ["No active loop jobs."]
|
|
960
1373
|
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
@@ -975,6 +1388,9 @@ async function helpLoop(client, sessionID) {
|
|
|
975
1388
|
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
976
1389
|
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
977
1390
|
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1391
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1392
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
1393
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
978
1394
|
"/loop 200m --command /compact same as command loop",
|
|
979
1395
|
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
980
1396
|
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
@@ -1004,6 +1420,7 @@ async function doctorLoop(directory, client, sessionID) {
|
|
|
1004
1420
|
`- node: ${process.version}`,
|
|
1005
1421
|
`- platform: ${process.platform}`,
|
|
1006
1422
|
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
1423
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
1007
1424
|
].join("\n"))
|
|
1008
1425
|
}
|
|
1009
1426
|
|
|
@@ -1037,6 +1454,13 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1037
1454
|
return true
|
|
1038
1455
|
}
|
|
1039
1456
|
|
|
1457
|
+
if (name === "loop-goal") return await addGoal(directory, client, sessionID, args), handled()
|
|
1458
|
+
if (name === "loop-goal-status") return await statusGoal(directory, client, sessionID), handled()
|
|
1459
|
+
if (name === "loop-goal-pause") return await pauseGoal(directory, client, sessionID, args), handled()
|
|
1460
|
+
if (name === "loop-goal-resume") return await resumeGoal(directory, client, sessionID, args), handled()
|
|
1461
|
+
if (name === "loop-goal-clear") return await clearGoal(directory, client, sessionID, args), handled()
|
|
1462
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete") return await completeGoalCommand(directory, client, sessionID, args), handled()
|
|
1463
|
+
if (name === "loop-goal-blocked") return await blockGoalCommand(directory, client, sessionID, args), handled()
|
|
1040
1464
|
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
1041
1465
|
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
1042
1466
|
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
@@ -1054,9 +1478,48 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1054
1478
|
return false
|
|
1055
1479
|
}
|
|
1056
1480
|
|
|
1481
|
+
function goalTools(defaultDirectory) {
|
|
1482
|
+
return {
|
|
1483
|
+
opencode_loop_goal_complete: {
|
|
1484
|
+
description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
1485
|
+
args: {
|
|
1486
|
+
summary: { type: "string", description: "Short human-readable summary of what was completed." },
|
|
1487
|
+
evidence: { type: "string", description: "Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results." },
|
|
1488
|
+
},
|
|
1489
|
+
execute: async (args, context) => {
|
|
1490
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1491
|
+
return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
opencode_loop_goal_blocked: {
|
|
1495
|
+
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
1496
|
+
args: {
|
|
1497
|
+
reason: { type: "string", description: "Why the goal is blocked." },
|
|
1498
|
+
needed: { type: "string", description: "What user input, credential, decision, or manual action is needed to continue." },
|
|
1499
|
+
},
|
|
1500
|
+
execute: async (args, context) => {
|
|
1501
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1502
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
opencode_loop_goal_progress: {
|
|
1506
|
+
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
1507
|
+
args: {
|
|
1508
|
+
summary: { type: "string", description: "What useful progress was made." },
|
|
1509
|
+
next: { type: "string", description: "The next step toward completing the goal." },
|
|
1510
|
+
},
|
|
1511
|
+
execute: async (args, context) => {
|
|
1512
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1513
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1057
1519
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
1058
1520
|
await log(client, "info", "Plugin initialized", { directory })
|
|
1059
1521
|
return {
|
|
1522
|
+
tool: goalTools(directory),
|
|
1060
1523
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
1061
1524
|
event: async ({ event }) => {
|
|
1062
1525
|
if (event.type === "command.executed") {
|