@bybrawe/opencode-loop 0.5.4 → 0.5.7
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 +30 -0
- package/README.md +266 -20
- package/commands/loop-ask.md +5 -0
- package/commands/loop-cmd.md +5 -0
- package/commands/loop-command.md +5 -0
- 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-prompt.md +5 -0
- package/commands/loop-shell.md +5 -0
- package/package.json +7 -4
- package/src/index.js +452 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.7
|
|
4
|
+
|
|
5
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
6
|
+
- Added goal lifecycle commands: `/loop-goal-status`, `/loop-goal-pause`, `/loop-goal-resume`, `/loop-goal-clear`, `/loop-goal-done`, and `/loop-goal-blocked`.
|
|
7
|
+
- Added local goal tools exposed to the agent: `opencode_loop_goal_complete`, `opencode_loop_goal_blocked`, and `opencode_loop_goal_progress`.
|
|
8
|
+
- Added goal acceptance criteria with repeated `--acceptance` / `--success` flags.
|
|
9
|
+
- Added post-turn goal check commands with repeated `--check` flags.
|
|
10
|
+
- Added `--complete-when-checks-pass` for goals that should automatically stop once all configured checks pass.
|
|
11
|
+
- Added `--max-turns` alias for goal run limits.
|
|
12
|
+
- Added goal reports under `.opencode/opencode-loop/goals/` and optional `--evidence-file`.
|
|
13
|
+
- Updated README with simple examples explaining prompt loops, command loops, shell loops, scheduled checks, and experimental persistent goals.
|
|
14
|
+
|
|
15
|
+
## 0.5.6
|
|
16
|
+
|
|
17
|
+
- Added clearer README examples for prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, first-run timing, and idle-safe behavior.
|
|
18
|
+
- Stabilized `/loop-command 200m /compact` by routing `/compact` and `/summarize` through OpenCode TUI compact handling instead of treating them as custom prompt commands.
|
|
19
|
+
- Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
|
|
20
|
+
- Kept fallback compatibility with older SDK-style calls where possible.
|
|
21
|
+
|
|
22
|
+
## 0.5.5
|
|
23
|
+
|
|
24
|
+
- Added explicit loop action types for prompt loops, scheduled question/check loops, OpenCode slash-command loops, and shell-command loops.
|
|
25
|
+
- Added `/loop-command` and `/loop-cmd` for scheduled OpenCode commands such as `/compact`. These wait for the first interval by default and run only when OpenCode is idle.
|
|
26
|
+
- Added `/loop-ask` for recurring check prompts such as “did you run tests, tsc --noEmit, and build?” It waits for the first interval by default.
|
|
27
|
+
- Added `/loop-prompt` to force prompt mode.
|
|
28
|
+
- Added `/loop-shell` for recurring shell commands.
|
|
29
|
+
- Added `/loop` type flags: `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact`.
|
|
30
|
+
- Clarified README examples for scheduled `/compact`, hourly quality checks, and idle-safe timing.
|
|
31
|
+
- Added `publishConfig.access=public` and normalized npm bin paths.
|
|
32
|
+
|
|
3
33
|
## 0.5.4
|
|
4
34
|
|
|
5
35
|
- Fixed compatibility with recent OpenCode SDK/TUI call shapes.
|
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.7 adds experimental Goal Mode.** OpenCode Loop still fixes the recent OpenCode update compatibility issue and keeps the clear action types from v0.5.6. The TUI loop waits for the session to become idle, debounces idle events, and avoids starting a new run while OpenCode is still busy or queued. It separates prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, compact loops, and now experimental persistent goals so commands like `/compact` are not sent as normal chat text.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -19,7 +19,9 @@ The known update-related symptoms from older builds are fixed:
|
|
|
19
19
|
- a loop job is replaced but does not fire after OpenCode becomes idle
|
|
20
20
|
- repeated `/loop` commands create confusing queued turns
|
|
21
21
|
- prompt, shell, or toast calls silently fail after OpenCode SDK changes
|
|
22
|
+
- `/compact` accidentally being treated as a normal agent prompt
|
|
22
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
|
|
23
25
|
|
|
24
26
|
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`.
|
|
25
27
|
|
|
@@ -78,12 +80,13 @@ OpenCode Loop is designed for developers searching for:
|
|
|
78
80
|
- **Failure control** with `--max-failures` and `--pause-on-verify-fail`.
|
|
79
81
|
- **Runtime control** with `--max-runtime 6h`.
|
|
80
82
|
- **Run limit support** with `--max-runs`.
|
|
81
|
-
- **
|
|
83
|
+
- **Wait-before-first-run support** with `--no-now` for TUI loops and `--sleep-first` for daemon mode.
|
|
82
84
|
- **Checkpoints** with `--checkpoint-only` or `--git-checkpoint`.
|
|
83
85
|
- **Safe mode** with `--safe` and prompt-level destructive command warnings.
|
|
84
86
|
- **Branch setup** with `--branch ai-loop`.
|
|
85
87
|
- **Stop controls** with `--stop-file STOP_LOOP`, `--until`, `/loop-stop`, `/loop-pause`, `/loop-resume`, and `/loop-remove`.
|
|
86
88
|
- **Watch mode** with `--watch progress.md`.
|
|
89
|
+
- **Experimental Goal Mode** with `/loop-goal`, goal status, goal tools, acceptance criteria, and verification checks.
|
|
87
90
|
- **Diagnostics** with `/loop-doctor`.
|
|
88
91
|
- **Starter progress file** with `/loop-init`.
|
|
89
92
|
- **State export** with `/loop-export`.
|
|
@@ -263,34 +266,181 @@ If the commands do not appear:
|
|
|
263
266
|
|
|
264
267
|
## Quick start
|
|
265
268
|
|
|
269
|
+
### The 6 examples most people need
|
|
270
|
+
|
|
271
|
+
Auto-continue every time OpenCode finishes a turn:
|
|
272
|
+
|
|
266
273
|
```text
|
|
267
274
|
/loop 0s continue from progress.md and implement the next unfinished TODO
|
|
268
275
|
```
|
|
269
276
|
|
|
270
|
-
|
|
277
|
+
Run now, then continue every 1 minute when OpenCode is idle:
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
/loop 1m continue the project
|
|
281
|
+
```
|
|
271
282
|
|
|
272
|
-
|
|
283
|
+
Wait 1 minute before the first run, then continue every 1 minute when idle:
|
|
273
284
|
|
|
274
285
|
```text
|
|
275
|
-
/loop
|
|
286
|
+
/loop 1m --no-now continue the project
|
|
276
287
|
```
|
|
277
288
|
|
|
278
|
-
|
|
289
|
+
Run OpenCode compact every 200 minutes, but only when OpenCode is idle:
|
|
279
290
|
|
|
280
291
|
```text
|
|
281
|
-
/loop 200m
|
|
292
|
+
/loop-command 200m /compact
|
|
282
293
|
```
|
|
283
294
|
|
|
284
|
-
|
|
295
|
+
Ask a recurring check question every hour:
|
|
285
296
|
|
|
286
297
|
```text
|
|
287
|
-
/loop
|
|
298
|
+
/loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them and fix any error.
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Run a real shell command every 10 minutes when idle:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
/loop-shell 10m npm test
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Experimental goal mode: keep working until the objective is actually done, blocked, paused, or cleared:
|
|
308
|
+
|
|
309
|
+
```text
|
|
310
|
+
/loop-goal finish the feature, run tsc --noEmit and build, fix every error, and stop only when everything passes
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### What “when idle” means
|
|
314
|
+
|
|
315
|
+
OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
|
|
319
|
+
```text
|
|
320
|
+
/loop 5m continue the project
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
If 5 minutes pass while OpenCode is still working, the job becomes due but waits. As soon as OpenCode becomes idle, the loop sends the prompt. This avoids queued turns, aborted tools, and overlapping responses.
|
|
324
|
+
|
|
325
|
+
### Prompt loops vs command loops
|
|
326
|
+
|
|
327
|
+
Use a prompt loop when you want to ask or instruct the agent:
|
|
328
|
+
|
|
329
|
+
```text
|
|
330
|
+
/loop 1h check if tests, tsc --noEmit, and build pass. If not, fix them.
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Use a command loop when the action starts with `/` and should be executed as an OpenCode command:
|
|
334
|
+
|
|
335
|
+
```text
|
|
336
|
+
/loop-command 200m /compact
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Do not use this for compacting:
|
|
340
|
+
|
|
341
|
+
```text
|
|
342
|
+
/loop 200m /compact
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
That can look like a chat prompt in older versions or unclear setups. Prefer `/loop-command 200m /compact` or the shortcut below:
|
|
346
|
+
|
|
347
|
+
```text
|
|
348
|
+
/loop-compact 200m
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Experimental Goal Mode
|
|
352
|
+
|
|
353
|
+
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.
|
|
354
|
+
|
|
355
|
+
Basic goal:
|
|
356
|
+
|
|
357
|
+
```text
|
|
358
|
+
/loop-goal fix the TypeScript build errors. Run tsc --noEmit and npm run build. Stop only when both pass.
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
What happens:
|
|
362
|
+
|
|
363
|
+
1. The goal is saved as the active experimental goal.
|
|
364
|
+
2. OpenCode starts working immediately by default.
|
|
365
|
+
3. When OpenCode becomes idle, the plugin checks whether the goal is still active.
|
|
366
|
+
4. If the goal is not complete or blocked, the plugin injects the next Goal Mode continuation prompt.
|
|
367
|
+
5. The agent is instructed to call a local goal tool when it has real progress, completion evidence, or a blocked reason.
|
|
368
|
+
|
|
369
|
+
Goal Mode is idle-safe. If OpenCode is busy, it waits. It does not intentionally start another turn on top of a running turn.
|
|
370
|
+
|
|
371
|
+
Simple status and control commands:
|
|
372
|
+
|
|
373
|
+
```text
|
|
374
|
+
/loop-goal-status
|
|
375
|
+
/loop-goal-pause
|
|
376
|
+
/loop-goal-resume
|
|
377
|
+
/loop-goal-clear
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
You can also use subcommands through `/loop-goal`:
|
|
381
|
+
|
|
382
|
+
```text
|
|
383
|
+
/loop-goal status
|
|
384
|
+
/loop-goal pause
|
|
385
|
+
/loop-goal resume
|
|
386
|
+
/loop-goal clear
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Manual completion or blocked state:
|
|
390
|
+
|
|
391
|
+
```text
|
|
392
|
+
/loop-goal-done shipped the feature and all checks pass
|
|
393
|
+
/loop-goal-blocked missing API key for the staging provider
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Goal with acceptance criteria:
|
|
397
|
+
|
|
398
|
+
```text
|
|
399
|
+
/loop-goal --acceptance "tsc --noEmit passes" --acceptance "npm run build passes" --acceptance "no new TODO hacks" fix all build errors
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Goal with automatic check commands after each assistant turn:
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" fix every TypeScript and build error
|
|
288
406
|
```
|
|
289
407
|
|
|
290
|
-
|
|
408
|
+
Goal that automatically stops when all configured checks pass:
|
|
291
409
|
|
|
292
410
|
```text
|
|
293
|
-
/loop
|
|
411
|
+
/loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Goal with a safety/runtime limit:
|
|
415
|
+
|
|
416
|
+
```text
|
|
417
|
+
/loop-goal --max-turns 20 --max-runtime 3h --check "npm test" fix the failing tests without destructive commands
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Goal Mode writes a report under:
|
|
421
|
+
|
|
422
|
+
```text
|
|
423
|
+
.opencode/opencode-loop/goals/
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
You can choose a report file:
|
|
427
|
+
|
|
428
|
+
```text
|
|
429
|
+
/loop-goal --evidence-file goal-report.md --check "npm test" make tests pass and document the result
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
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.
|
|
433
|
+
|
|
434
|
+
### Safer development loop
|
|
435
|
+
|
|
436
|
+
```text
|
|
437
|
+
/loop 0s --name dev --ask-never --safe --no-overlap --batch 5 --compact-every 200m --checkpoint-only --progress-file progress.md Treat progress.md as the main project state file. Continue with the next unfinished TODO, implement it, mark completed items with [x], add useful follow-up TODOs when you discover them, run tests/lint/build when available, and keep going while work remains.
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Test-fix loop
|
|
441
|
+
|
|
442
|
+
```text
|
|
443
|
+
/loop 0s --name testfix --ask-never --safe --verify "npm test" Continue from progress.md. If tests fail, analyze the failure, fix it, and run the tests again.
|
|
294
444
|
```
|
|
295
445
|
|
|
296
446
|
## Background daemon
|
|
@@ -388,7 +538,19 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
|
|
|
388
538
|
|
|
389
539
|
| Command | Purpose |
|
|
390
540
|
|---|---|
|
|
391
|
-
| `/loop <interval> <
|
|
541
|
+
| `/loop <interval> <prompt>` | Add an idle/interval prompt loop |
|
|
542
|
+
| `/loop-command <interval> <slash-command>` | Schedule an OpenCode slash command such as `/compact` |
|
|
543
|
+
| `/loop-cmd <interval> <slash-command>` | Alias for `/loop-command` |
|
|
544
|
+
| `/loop-ask <interval> <prompt/question>` | Schedule a recurring question/check prompt; first run waits for the interval by default |
|
|
545
|
+
| `/loop-prompt <interval> <prompt>` | Force prompt mode, even when you want explicit prompt-loop naming |
|
|
546
|
+
| `/loop-shell <interval> <shell-command>` | Schedule a shell command loop |
|
|
547
|
+
| `/loop-goal <objective>` | Start experimental persistent Goal Mode |
|
|
548
|
+
| `/loop-goal-status` | Show experimental goal state |
|
|
549
|
+
| `/loop-goal-pause` | Pause the active experimental goal |
|
|
550
|
+
| `/loop-goal-resume` | Resume the active experimental goal |
|
|
551
|
+
| `/loop-goal-clear` | Clear experimental goals |
|
|
552
|
+
| `/loop-goal-done <summary>` | Manually mark the active goal complete |
|
|
553
|
+
| `/loop-goal-blocked <reason>` | Manually mark the active goal blocked |
|
|
392
554
|
| `/loop-help` | Show usage help inside OpenCode |
|
|
393
555
|
| `/loop-status` | Show active loop jobs |
|
|
394
556
|
| `/loop-logs` | Show recent loop log entries |
|
|
@@ -423,6 +585,28 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
|
|
|
423
585
|
|
|
424
586
|
The plugin is **idle-driven**. It checks jobs when OpenCode becomes idle, so it avoids intentionally starting a second agent turn on top of an active one.
|
|
425
587
|
|
|
588
|
+
If the interval expires while OpenCode is busy, the loop becomes due and waits. It runs on the next idle event.
|
|
589
|
+
|
|
590
|
+
## Loop types
|
|
591
|
+
|
|
592
|
+
OpenCode Loop has separate types for prompts, OpenCode slash commands, and shell commands. This matters because `/compact` should be executed as an OpenCode command, not sent to the agent as text.
|
|
593
|
+
|
|
594
|
+
| Type | Use | Example | First run |
|
|
595
|
+
|---|---|---|---|
|
|
596
|
+
| Prompt loop | Ask or instruct the agent repeatedly | `/loop 1h check whether tests, tsc --noEmit, and build pass` | Now by default |
|
|
597
|
+
| Scheduled question | Ask/check on a timer | `/loop-ask 1h did you run tests and build?` | After the interval by default |
|
|
598
|
+
| Slash-command loop | Run OpenCode commands like `/compact` | `/loop-command 200m /compact` | After the interval by default |
|
|
599
|
+
| Shell loop | Run real terminal commands | `/loop-shell 10m npm test` | After the interval by default |
|
|
600
|
+
| Experimental goal | Keep pursuing an outcome until complete/blocked | `/loop-goal make build pass` | Now by default |
|
|
601
|
+
|
|
602
|
+
You can still force the type on `/loop`:
|
|
603
|
+
|
|
604
|
+
```text
|
|
605
|
+
/loop 200m --command /compact
|
|
606
|
+
/loop 10m --shell npm test
|
|
607
|
+
/loop 1h --prompt did you run tests, tsc --noEmit, and build?
|
|
608
|
+
```
|
|
609
|
+
|
|
426
610
|
## Actions
|
|
427
611
|
|
|
428
612
|
### Prompt action
|
|
@@ -459,18 +643,40 @@ Do not run destructive commands, force pushes, production deploys, or database r
|
|
|
459
643
|
|
|
460
644
|
### Slash command action
|
|
461
645
|
|
|
646
|
+
Use `/loop-command` when the action should be executed as an OpenCode command instead of sent to the agent as text.
|
|
647
|
+
|
|
462
648
|
```text
|
|
463
|
-
/loop 200m /compact
|
|
464
|
-
/loop 15m /review current changes
|
|
649
|
+
/loop-command 200m /compact
|
|
650
|
+
/loop-command 15m /review current changes
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`/compact` and `/summarize` are treated specially and routed to OpenCode's TUI compact action. Custom command templates such as `/review` are routed through OpenCode's session command API. If OpenCode is busy when the interval expires, the command waits and runs when the session becomes idle.
|
|
654
|
+
|
|
655
|
+
Equivalent forced type on `/loop`:
|
|
656
|
+
|
|
657
|
+
```text
|
|
658
|
+
/loop 200m --command /compact
|
|
465
659
|
```
|
|
466
660
|
|
|
467
|
-
|
|
661
|
+
For most users, the clearer form is preferred:
|
|
662
|
+
|
|
663
|
+
```text
|
|
664
|
+
/loop-command 200m /compact
|
|
665
|
+
```
|
|
468
666
|
|
|
469
667
|
### Shell action
|
|
470
668
|
|
|
669
|
+
Use `/loop-shell` for real terminal commands.
|
|
670
|
+
|
|
671
|
+
```text
|
|
672
|
+
/loop-shell 10m npm test
|
|
673
|
+
/loop-shell 30m pnpm lint
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Equivalent forced type on `/loop`:
|
|
677
|
+
|
|
471
678
|
```text
|
|
472
|
-
/loop 10m
|
|
473
|
-
/loop 30m $pnpm lint
|
|
679
|
+
/loop 10m --shell npm test
|
|
474
680
|
```
|
|
475
681
|
|
|
476
682
|
Shell actions starting with `!` or `$` run through the OpenCode shell tool.
|
|
@@ -692,10 +898,17 @@ Parse and preview a loop without saving it.
|
|
|
692
898
|
|
|
693
899
|
### `--now` and `--no-now`
|
|
694
900
|
|
|
695
|
-
By default
|
|
901
|
+
By default `/loop` prompt jobs are due immediately. Use `--no-now` to wait for the first interval.
|
|
696
902
|
|
|
697
903
|
```text
|
|
698
|
-
/loop
|
|
904
|
+
/loop 1h --no-now did you run tests, tsc --noEmit, and build?
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
Command-style shortcuts such as `/loop-command`, `/loop-ask`, and `/loop-shell` wait for the first interval by default. Use `--now` if you want them to run immediately too.
|
|
908
|
+
|
|
909
|
+
```text
|
|
910
|
+
/loop-command 200m /compact
|
|
911
|
+
/loop-command 200m --now /compact
|
|
699
912
|
```
|
|
700
913
|
|
|
701
914
|
## Recommended recipes
|
|
@@ -727,9 +940,19 @@ opencode-loopd --project . --every 5m --prompt-file loop-prompt.md
|
|
|
727
940
|
### Compact loop
|
|
728
941
|
|
|
729
942
|
```text
|
|
730
|
-
/loop 200m
|
|
943
|
+
/loop-command 200m /compact
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
This compacts every 200 minutes when OpenCode is idle. It does not interrupt an active agent turn.
|
|
947
|
+
|
|
948
|
+
### Scheduled quality-check question
|
|
949
|
+
|
|
950
|
+
```text
|
|
951
|
+
/loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them now and fix errors.
|
|
731
952
|
```
|
|
732
953
|
|
|
954
|
+
This asks the agent every hour when idle. If the hour passes while the agent is working, the question waits and is sent after the current turn finishes.
|
|
955
|
+
|
|
733
956
|
### Prompt file loop
|
|
734
957
|
|
|
735
958
|
```text
|
|
@@ -833,6 +1056,29 @@ Improve the application in small safe steps.
|
|
|
833
1056
|
|
|
834
1057
|
## Changelog highlights
|
|
835
1058
|
|
|
1059
|
+
### v0.5.7
|
|
1060
|
+
|
|
1061
|
+
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
1062
|
+
- Added goal lifecycle commands and local goal tools so the agent can report real progress, completion evidence, or blocked state.
|
|
1063
|
+
- Added repeated `--acceptance`, `--success`, and `--check` flags for goal criteria and verification.
|
|
1064
|
+
- Added `--complete-when-checks-pass`, `--max-turns`, and `--evidence-file` for controlled goal workflows.
|
|
1065
|
+
- Goal reports are written under `.opencode/opencode-loop/goals/` by default.
|
|
1066
|
+
|
|
1067
|
+
### v0.5.6
|
|
1068
|
+
|
|
1069
|
+
- Added clearer README examples for prompt loops, scheduled question loops, command loops, shell loops, first-run timing, and idle-safe behavior.
|
|
1070
|
+
- Stabilized `/loop-command 200m /compact` by routing compact/summarize through OpenCode TUI compact handling instead of treating it as a custom prompt command.
|
|
1071
|
+
- Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
|
|
1072
|
+
|
|
1073
|
+
## v0.5.5
|
|
1074
|
+
|
|
1075
|
+
- Added explicit loop action types: prompt, scheduled question, OpenCode slash command, and shell command.
|
|
1076
|
+
- Added `/loop-command` / `/loop-cmd` for commands such as `/compact`; these wait for the first interval by default.
|
|
1077
|
+
- Added `/loop-ask` for recurring checks such as “did you run tests, tsc --noEmit, and build?”; it waits for the first interval by default.
|
|
1078
|
+
- Added `/loop-shell` for recurring shell commands.
|
|
1079
|
+
- Added `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact` type flags for `/loop`.
|
|
1080
|
+
- Clarified idle behavior: if a job becomes due while OpenCode is busy, it waits and runs on the next idle event.
|
|
1081
|
+
|
|
836
1082
|
### v0.5.4
|
|
837
1083
|
|
|
838
1084
|
- Fixed compatibility with recent OpenCode SDK/TUI call shapes.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop ask scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Experimental persistent goal mode. Usage: /loop-goal finish the feature and keep tests green
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Experimental goal command received. The OpenCode Loop plugin handles this goal locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring prompt loop. Usage: /loop-prompt 1h continue the project
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop prompt scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop shell scheduler received. The OpenCode Loop plugin handles this shell schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
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.7",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: auto-continue, command loops, compact scheduling, verification, checkpoints, and safe autonomous coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -55,7 +55,10 @@
|
|
|
55
55
|
"@opencode-ai/plugin": ">=1.4.0"
|
|
56
56
|
},
|
|
57
57
|
"bin": {
|
|
58
|
-
"opencode-loop": "scripts/install-node.mjs",
|
|
59
|
-
"opencode-loopd": "scripts/loopd.mjs"
|
|
58
|
+
"opencode-loop": "./scripts/install-node.mjs",
|
|
59
|
+
"opencode-loopd": "./scripts/loopd.mjs"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@ const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
|
8
8
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
9
|
const MAX_SCAN_FILES = 200
|
|
10
10
|
const MAX_SCAN_BYTES = 2_000_000
|
|
11
|
+
const GOAL_REPORT_DIR = "goals"
|
|
12
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
11
13
|
|
|
12
14
|
const activeRuns = new Map()
|
|
13
15
|
const handledCommands = new Map()
|
|
@@ -149,7 +151,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
149
151
|
} else if (first) {
|
|
150
152
|
const parsedDuration = parseDuration(first)
|
|
151
153
|
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
152
|
-
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop 200m /compact | /loop 10m
|
|
154
|
+
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
155
|
else rest = input
|
|
154
156
|
}
|
|
155
157
|
}
|
|
@@ -160,6 +162,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
160
162
|
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
161
163
|
name: defaults.name,
|
|
162
164
|
action: defaults.action || "",
|
|
165
|
+
kind: defaults.kind || undefined,
|
|
163
166
|
intervalMs,
|
|
164
167
|
immediate: defaults.immediate ?? true,
|
|
165
168
|
maxRuns: defaults.maxRuns ?? 0,
|
|
@@ -181,6 +184,16 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
181
184
|
notifyCommand: defaults.notifyCommand,
|
|
182
185
|
branch: defaults.branch,
|
|
183
186
|
branchDone: false,
|
|
187
|
+
goalStatus: defaults.goalStatus,
|
|
188
|
+
goalFile: defaults.goalFile,
|
|
189
|
+
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
190
|
+
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
191
|
+
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
192
|
+
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
193
|
+
goalSummary: defaults.goalSummary || "",
|
|
194
|
+
goalEvidence: defaults.goalEvidence || "",
|
|
195
|
+
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
196
|
+
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
184
197
|
noOverlap: defaults.noOverlap ?? true,
|
|
185
198
|
safe: defaults.safe ?? false,
|
|
186
199
|
quiet: defaults.quiet ?? false,
|
|
@@ -218,9 +231,20 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
218
231
|
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
219
232
|
;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
|
|
220
233
|
;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
|
|
234
|
+
;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
|
|
235
|
+
;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
|
|
236
|
+
;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
|
|
237
|
+
;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
|
|
238
|
+
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
239
|
+
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
240
|
+
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
241
|
+
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
242
|
+
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
243
|
+
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
221
244
|
|
|
222
245
|
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
223
246
|
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
247
|
+
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
224
248
|
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
225
249
|
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
226
250
|
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
@@ -228,6 +252,8 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
228
252
|
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
229
253
|
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
230
254
|
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
255
|
+
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
256
|
+
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
231
257
|
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
232
258
|
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
233
259
|
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
@@ -246,12 +272,33 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
246
272
|
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
247
273
|
rest = includes[1]
|
|
248
274
|
|
|
275
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
276
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
277
|
+
rest = acceptances[1]
|
|
278
|
+
|
|
279
|
+
const success = takeAllFlagValues(rest, "--success")
|
|
280
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
281
|
+
rest = success[1]
|
|
282
|
+
|
|
283
|
+
const checks = takeAllFlagValues(rest, "--check")
|
|
284
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
285
|
+
rest = checks[1]
|
|
286
|
+
|
|
249
287
|
job.action = stripOuterQuotes(rest || job.action || "")
|
|
250
288
|
job.watchPaths = [...new Set(job.watchPaths)]
|
|
251
289
|
job.includeFiles = [...new Set(job.includeFiles)]
|
|
290
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
291
|
+
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
292
|
+
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
293
|
+
job.name = job.name || "goal"
|
|
294
|
+
job.goalStatus = job.goalStatus || "active"
|
|
295
|
+
job.safe = job.safe !== false
|
|
296
|
+
job.askNever = job.askNever !== false
|
|
297
|
+
job.noOverlap = job.noOverlap !== false
|
|
298
|
+
}
|
|
252
299
|
job.lastRunAt = job.immediate ? 0 : now()
|
|
253
300
|
|
|
254
|
-
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" }
|
|
301
|
+
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" }
|
|
255
302
|
return { ok: true, job }
|
|
256
303
|
}
|
|
257
304
|
|
|
@@ -263,15 +310,15 @@ async function pathExists(filePath) { try { await fs.access(filePath); return tr
|
|
|
263
310
|
async function readState(directory, sessionID) {
|
|
264
311
|
try {
|
|
265
312
|
const parsed = JSON.parse(await fs.readFile(statePath(directory, sessionID), "utf8"))
|
|
266
|
-
return { version:
|
|
313
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
267
314
|
} catch {
|
|
268
|
-
return { version:
|
|
315
|
+
return { version: 4, jobs: [] }
|
|
269
316
|
}
|
|
270
317
|
}
|
|
271
318
|
|
|
272
319
|
async function writeState(directory, sessionID, state) {
|
|
273
320
|
await ensureDir(stateDir(directory))
|
|
274
|
-
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version:
|
|
321
|
+
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
|
|
275
322
|
}
|
|
276
323
|
|
|
277
324
|
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
@@ -327,6 +374,38 @@ function fireSdk(client, label, method, modernArgs, legacyArgs) {
|
|
|
327
374
|
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
328
375
|
}
|
|
329
376
|
|
|
377
|
+
async function executeTuiCommand(client, command) {
|
|
378
|
+
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
379
|
+
return await sdkCall(
|
|
380
|
+
client.tui.executeCommand.bind(client.tui),
|
|
381
|
+
{ command },
|
|
382
|
+
{ body: { command } },
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function compactTuiCommandName(command = "compact") {
|
|
387
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
388
|
+
if (normalized === "compact" || normalized === "summarize") return "session.compact"
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function compactSession(client, sessionID) {
|
|
393
|
+
try {
|
|
394
|
+
await executeTuiCommand(client, "session.compact")
|
|
395
|
+
return true
|
|
396
|
+
} catch (error) {
|
|
397
|
+
await log(client, "warn", "tui session.compact failed", { error: sdkErrorMessage(error) })
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
401
|
+
return true
|
|
402
|
+
} catch (error) {
|
|
403
|
+
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
404
|
+
}
|
|
405
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
|
|
406
|
+
return false
|
|
407
|
+
}
|
|
408
|
+
|
|
330
409
|
async function log(client, level, message, extra) {
|
|
331
410
|
try {
|
|
332
411
|
await sdkCall(
|
|
@@ -362,13 +441,17 @@ function wasHandled(sessionID, name, args) {
|
|
|
362
441
|
}
|
|
363
442
|
|
|
364
443
|
function commandName(name) { return String(name || "") }
|
|
365
|
-
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev"].includes(name) }
|
|
444
|
+
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) }
|
|
366
445
|
function presetDefaults(name, args) {
|
|
367
446
|
const [maybeDuration, rest] = splitFirst(args)
|
|
368
447
|
const parsed = parseDuration(maybeDuration)
|
|
369
448
|
const intervalMs = parsed === null ? 0 : parsed
|
|
370
449
|
const extra = parsed === null ? String(args || "").trim() : rest
|
|
371
|
-
if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", name: "compact", immediate: false }
|
|
450
|
+
if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", kind: "compact", name: "compact", immediate: false }
|
|
451
|
+
if (name === "loop-command" || name === "loop-cmd") return { intervalMs, action: extra, kind: "command", name: "command", immediate: false }
|
|
452
|
+
if (name === "loop-prompt") return { intervalMs, action: extra, kind: "prompt", name: "prompt", immediate: true }
|
|
453
|
+
if (name === "loop-ask") return { intervalMs, action: extra, kind: "prompt", name: "ask", immediate: false }
|
|
454
|
+
if (name === "loop-shell") return { intervalMs, action: extra, kind: "shell", name: "shell", immediate: false }
|
|
372
455
|
if (name === "loop-testfix") return { intervalMs, name: "testfix", safe: true, askNever: true, verifyCommand: extra || "npm test", action: `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${extra || "npm test"}` }
|
|
373
456
|
if (name === "loop-progress") return { intervalMs, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: extra || "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
374
457
|
if (name === "loop-safe-dev") return { intervalMs, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: extra || "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
@@ -377,6 +460,7 @@ function presetDefaults(name, args) {
|
|
|
377
460
|
|
|
378
461
|
function jobLabel(job) {
|
|
379
462
|
const title = job.name ? `${job.name}: ` : ""
|
|
463
|
+
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
380
464
|
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
381
465
|
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
382
466
|
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
@@ -387,7 +471,7 @@ function jobLabel(job) {
|
|
|
387
471
|
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
388
472
|
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
389
473
|
const paused = job.paused ? ", paused" : ""
|
|
390
|
-
return `${title}${durationToText(job.intervalMs)} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
|
|
474
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
|
|
391
475
|
}
|
|
392
476
|
|
|
393
477
|
function matchJob(job, target, index) {
|
|
@@ -448,9 +532,15 @@ function dangerousShell(command) {
|
|
|
448
532
|
return [/\brm\s+-rf\b/, /\bgit\s+reset\b/, /\bgit\s+clean\b/, /\bgit\s+push\b/, /\bdel\s+\/s\b/, /\brmdir\s+\/s\b/, /\bformat\b/, /\bterraform\s+destroy\b/, /\bkubectl\s+delete\b/, /\bdeploy\b.*\bproduction\b/].some((pattern) => pattern.test(text))
|
|
449
533
|
}
|
|
450
534
|
|
|
451
|
-
function actionKind(action) {
|
|
535
|
+
function actionKind(action, job = {}) {
|
|
452
536
|
const text = String(action || "").trim()
|
|
537
|
+
const forced = String(job.kind || "").trim().toLowerCase()
|
|
538
|
+
if (forced === "compact") return "compact"
|
|
539
|
+
if (forced === "goal") return "goal"
|
|
453
540
|
if (text === "/compact" || text === "/summarize") return "compact"
|
|
541
|
+
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
542
|
+
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
543
|
+
if (forced === "shell") return "shell"
|
|
454
544
|
if (text.startsWith("/")) return "command"
|
|
455
545
|
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
456
546
|
return "prompt"
|
|
@@ -470,7 +560,63 @@ function decoratePrompt(job) {
|
|
|
470
560
|
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
471
561
|
}
|
|
472
562
|
|
|
563
|
+
function isGoalJob(job) {
|
|
564
|
+
return String(job?.kind || "").toLowerCase() === "goal"
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function goalStatusText(job) {
|
|
568
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
569
|
+
if (!status) return ""
|
|
570
|
+
if (status === "completed") return "completed"
|
|
571
|
+
if (status === "blocked") return "blocked"
|
|
572
|
+
if (job?.paused) return "paused"
|
|
573
|
+
return status
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function buildGoalPrompt(directory, job) {
|
|
577
|
+
const sections = []
|
|
578
|
+
const objective = String(job.action || "").trim()
|
|
579
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
580
|
+
if (job.goalFile) {
|
|
581
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
582
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
583
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
584
|
+
}
|
|
585
|
+
if (job.promptFile) {
|
|
586
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
587
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
588
|
+
}
|
|
589
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
590
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
591
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
592
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
593
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
594
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
595
|
+
for (const file of job.includeFiles || []) {
|
|
596
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
597
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
601
|
+
|
|
602
|
+
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.
|
|
603
|
+
|
|
604
|
+
Rules:
|
|
605
|
+
- Work on the next smallest useful step toward the goal.
|
|
606
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
607
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
608
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
609
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
610
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
611
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
612
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
613
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
614
|
+
|
|
615
|
+
${sections.join("\n\n---\n\n")}`
|
|
616
|
+
}
|
|
617
|
+
|
|
473
618
|
async function buildPrompt(directory, job) {
|
|
619
|
+
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
474
620
|
const sections = []
|
|
475
621
|
if (job.promptFile) {
|
|
476
622
|
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
@@ -502,11 +648,10 @@ async function maybeCompact(client, sessionID, job) {
|
|
|
502
648
|
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
503
649
|
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
504
650
|
if (!dueRuns && !dueTime) return job
|
|
505
|
-
|
|
506
|
-
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
651
|
+
if (await compactSession(client, sessionID)) {
|
|
507
652
|
job.lastCompactAt = now()
|
|
508
653
|
job.lastCompactRunCount = job.runCount || 0
|
|
509
|
-
}
|
|
654
|
+
}
|
|
510
655
|
return job
|
|
511
656
|
}
|
|
512
657
|
|
|
@@ -639,6 +784,7 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
639
784
|
function dueJobs(state, force = false) {
|
|
640
785
|
const current = now()
|
|
641
786
|
return (state.jobs || []).filter((job) => {
|
|
787
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
642
788
|
if (!job.enabled || job.paused) return false
|
|
643
789
|
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
644
790
|
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
@@ -654,6 +800,146 @@ function clearActiveRun(sessionID) {
|
|
|
654
800
|
activeRuns.delete(sessionID)
|
|
655
801
|
}
|
|
656
802
|
|
|
803
|
+
function goalReportPath(directory, sessionID, job) {
|
|
804
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function goalReportText(job) {
|
|
808
|
+
const lines = []
|
|
809
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
810
|
+
lines.push("")
|
|
811
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
812
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
813
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
814
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
815
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
816
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
817
|
+
lines.push("")
|
|
818
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
819
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
820
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
821
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
822
|
+
if (job.lastGoalChecks?.length) {
|
|
823
|
+
lines.push("## Latest checks", "")
|
|
824
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
825
|
+
lines.push("")
|
|
826
|
+
}
|
|
827
|
+
if (job.goalProgress?.length) {
|
|
828
|
+
lines.push("## Progress", "")
|
|
829
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
830
|
+
lines.push("")
|
|
831
|
+
}
|
|
832
|
+
return lines.join("\n")
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function writeGoalReport(directory, sessionID, job) {
|
|
836
|
+
if (!isGoalJob(job)) return
|
|
837
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
838
|
+
await ensureDir(path.dirname(target))
|
|
839
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function pickGoalJob(state, target = "") {
|
|
843
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
844
|
+
if (!goals.length) return undefined
|
|
845
|
+
const text = String(target || "").trim()
|
|
846
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
847
|
+
return goals.find((job, index) => matchJob(job, text, index))
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function parseGoalToolText(args, fields) {
|
|
851
|
+
const result = {}
|
|
852
|
+
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
853
|
+
return result
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
857
|
+
const state = await readState(directory, sessionID)
|
|
858
|
+
const job = pickGoalJob(state, args.target)
|
|
859
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
860
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
861
|
+
job.goalStatus = "completed"
|
|
862
|
+
job.enabled = false
|
|
863
|
+
job.paused = true
|
|
864
|
+
job.goalCompletedAt = now()
|
|
865
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
866
|
+
job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
|
|
867
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
868
|
+
await writeState(directory, sessionID, state)
|
|
869
|
+
await writeGoalReport(directory, sessionID, job)
|
|
870
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
871
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
875
|
+
const state = await readState(directory, sessionID)
|
|
876
|
+
const job = pickGoalJob(state, args.target)
|
|
877
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
878
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
879
|
+
job.goalStatus = "blocked"
|
|
880
|
+
job.enabled = false
|
|
881
|
+
job.paused = true
|
|
882
|
+
job.goalBlockedAt = now()
|
|
883
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
884
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
885
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
886
|
+
await writeState(directory, sessionID, state)
|
|
887
|
+
await writeGoalReport(directory, sessionID, job)
|
|
888
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
889
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
893
|
+
const state = await readState(directory, sessionID)
|
|
894
|
+
const job = pickGoalJob(state, args.target)
|
|
895
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
896
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
897
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
898
|
+
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
899
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
900
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
901
|
+
await writeState(directory, sessionID, state)
|
|
902
|
+
await writeGoalReport(directory, sessionID, job)
|
|
903
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
904
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function runGoalChecks(directory, sessionID, job, client) {
|
|
908
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
909
|
+
const results = []
|
|
910
|
+
for (const command of job.goalChecks) {
|
|
911
|
+
if (job.safe && dangerousShell(command)) {
|
|
912
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
913
|
+
continue
|
|
914
|
+
}
|
|
915
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
916
|
+
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
917
|
+
}
|
|
918
|
+
job.lastGoalCheckAt = now()
|
|
919
|
+
job.lastGoalChecks = results
|
|
920
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
921
|
+
if (allPassed) {
|
|
922
|
+
job.goalChecksPassedAt = now()
|
|
923
|
+
job.failureCount = 0
|
|
924
|
+
await toast(client, "Goal checks passed.", "success")
|
|
925
|
+
if (job.goalCompleteWhenChecksPass) {
|
|
926
|
+
job.goalStatus = "completed"
|
|
927
|
+
job.enabled = false
|
|
928
|
+
job.paused = true
|
|
929
|
+
job.goalCompletedAt = now()
|
|
930
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
931
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
932
|
+
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
933
|
+
}
|
|
934
|
+
} else {
|
|
935
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
936
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
937
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
938
|
+
}
|
|
939
|
+
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
940
|
+
return job
|
|
941
|
+
}
|
|
942
|
+
|
|
657
943
|
async function finalizeActiveRun(directory, client, sessionID) {
|
|
658
944
|
const active = activeRuns.get(sessionID)
|
|
659
945
|
if (!active) return
|
|
@@ -701,25 +987,37 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
701
987
|
}
|
|
702
988
|
}
|
|
703
989
|
|
|
704
|
-
|
|
990
|
+
if (isGoalJob(job)) job = await runGoalChecks(directory, sessionID, job, client)
|
|
991
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
705
992
|
await writeState(directory, sessionID, state)
|
|
993
|
+
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
706
994
|
await createCheckpoint(directory, sessionID, job, client)
|
|
707
995
|
}
|
|
708
996
|
|
|
709
997
|
async function fireAction(directory, client, sessionID, job) {
|
|
710
998
|
const action = String(job.action || "").trim()
|
|
711
|
-
const kind = actionKind(action)
|
|
999
|
+
const kind = actionKind(action, job)
|
|
712
1000
|
if (kind === "compact") {
|
|
713
|
-
await
|
|
714
|
-
return { startsAssistantTurn:
|
|
1001
|
+
const ok = await compactSession(client, sessionID)
|
|
1002
|
+
return { startsAssistantTurn: ok }
|
|
715
1003
|
}
|
|
716
1004
|
if (kind === "command") {
|
|
717
|
-
const
|
|
718
|
-
|
|
1005
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
1006
|
+
const [command, argumentsText] = splitFirst(normalized)
|
|
1007
|
+
if (!command) {
|
|
1008
|
+
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
1009
|
+
return { startsAssistantTurn: false }
|
|
1010
|
+
}
|
|
1011
|
+
const tuiCommand = compactTuiCommandName(command)
|
|
1012
|
+
if (tuiCommand) {
|
|
1013
|
+
await executeTuiCommand(client, tuiCommand)
|
|
1014
|
+
return { startsAssistantTurn: true }
|
|
1015
|
+
}
|
|
1016
|
+
await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
719
1017
|
return { startsAssistantTurn: true }
|
|
720
1018
|
}
|
|
721
1019
|
if (kind === "shell") {
|
|
722
|
-
const command = action.
|
|
1020
|
+
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
723
1021
|
if (job.safe && dangerousShell(command)) {
|
|
724
1022
|
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
725
1023
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
@@ -729,7 +1027,14 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
729
1027
|
return { startsAssistantTurn: true }
|
|
730
1028
|
}
|
|
731
1029
|
const prompt = await buildPrompt(directory, job)
|
|
732
|
-
|
|
1030
|
+
const prefix = kind === "goal"
|
|
1031
|
+
? "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."
|
|
1032
|
+
: "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."
|
|
1033
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `${prefix}
|
|
1034
|
+
|
|
1035
|
+
${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
|
|
1036
|
+
|
|
1037
|
+
${prompt}` }] } })
|
|
733
1038
|
return { startsAssistantTurn: true }
|
|
734
1039
|
}
|
|
735
1040
|
|
|
@@ -811,7 +1116,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
811
1116
|
const result = await fireAction(directory, client, sessionID, job)
|
|
812
1117
|
if (!result.startsAssistantTurn) {
|
|
813
1118
|
const fresh = await readState(directory, sessionID)
|
|
814
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false)
|
|
1119
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
815
1120
|
await writeState(directory, sessionID, fresh)
|
|
816
1121
|
}
|
|
817
1122
|
if (result.startsAssistantTurn) {
|
|
@@ -835,6 +1140,7 @@ function sameLoopDefinition(a, b) {
|
|
|
835
1140
|
return (a.name || "") === (b.name || "") &&
|
|
836
1141
|
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
837
1142
|
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
1143
|
+
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
838
1144
|
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
839
1145
|
}
|
|
840
1146
|
|
|
@@ -889,12 +1195,81 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
|
|
|
889
1195
|
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
890
1196
|
}
|
|
891
1197
|
|
|
1198
|
+
async function statusGoal(directory, client, sessionID) {
|
|
1199
|
+
const state = await readState(directory, sessionID)
|
|
1200
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1201
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
1202
|
+
const status = goalStatusText(job)
|
|
1203
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
1204
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
1205
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
1206
|
+
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}`
|
|
1207
|
+
}) : ["No experimental goal jobs."]
|
|
1208
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
1209
|
+
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
1213
|
+
const target = String(args || "").trim() || "goal"
|
|
1214
|
+
const state = await readState(directory, sessionID)
|
|
1215
|
+
let count = 0
|
|
1216
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1217
|
+
await writeState(directory, sessionID, state)
|
|
1218
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
1222
|
+
const target = String(args || "").trim() || "goal"
|
|
1223
|
+
const state = await readState(directory, sessionID)
|
|
1224
|
+
let count = 0
|
|
1225
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1226
|
+
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
1227
|
+
count++
|
|
1228
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
|
|
1229
|
+
})
|
|
1230
|
+
await writeState(directory, sessionID, state)
|
|
1231
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1232
|
+
if (count) scheduleIdleWork(directory, client, sessionID)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
1236
|
+
const target = String(args || "").trim()
|
|
1237
|
+
const state = await readState(directory, sessionID)
|
|
1238
|
+
const before = state.jobs.length
|
|
1239
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1240
|
+
await writeState(directory, sessionID, state)
|
|
1241
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1245
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done." })
|
|
1246
|
+
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1250
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
1251
|
+
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
1255
|
+
const text = String(args || "").trim()
|
|
1256
|
+
const [maybeCommand, rest] = splitFirst(text)
|
|
1257
|
+
const sub = maybeCommand.toLowerCase()
|
|
1258
|
+
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
1259
|
+
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
1260
|
+
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
1261
|
+
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
1262
|
+
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
1263
|
+
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
1264
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
1265
|
+
}
|
|
1266
|
+
|
|
892
1267
|
async function statusLoop(directory, client, sessionID) {
|
|
893
1268
|
const state = await readState(directory, sessionID)
|
|
894
1269
|
const jobs = state.jobs || []
|
|
895
1270
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
896
1271
|
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
897
|
-
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(",")
|
|
1272
|
+
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(",")
|
|
898
1273
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
899
1274
|
}) : ["No active loop jobs."]
|
|
900
1275
|
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
@@ -911,9 +1286,14 @@ async function helpLoop(client, sessionID) {
|
|
|
911
1286
|
await say(client, sessionID, [
|
|
912
1287
|
"OpenCode Loop help:",
|
|
913
1288
|
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
914
|
-
"/loop 5m --ask-never --safe <prompt> interval autonomous loop",
|
|
915
|
-
"/loop 200m
|
|
916
|
-
"/loop
|
|
1289
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
1290
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
1291
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
1292
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1293
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1294
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
1295
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
1296
|
+
"/loop 200m --command /compact same as command loop",
|
|
917
1297
|
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
918
1298
|
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
919
1299
|
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
@@ -942,6 +1322,7 @@ async function doctorLoop(directory, client, sessionID) {
|
|
|
942
1322
|
`- node: ${process.version}`,
|
|
943
1323
|
`- platform: ${process.platform}`,
|
|
944
1324
|
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
1325
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
945
1326
|
].join("\n"))
|
|
946
1327
|
}
|
|
947
1328
|
|
|
@@ -975,6 +1356,13 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
975
1356
|
return true
|
|
976
1357
|
}
|
|
977
1358
|
|
|
1359
|
+
if (name === "loop-goal") return await addGoal(directory, client, sessionID, args), handled()
|
|
1360
|
+
if (name === "loop-goal-status") return await statusGoal(directory, client, sessionID), handled()
|
|
1361
|
+
if (name === "loop-goal-pause") return await pauseGoal(directory, client, sessionID, args), handled()
|
|
1362
|
+
if (name === "loop-goal-resume") return await resumeGoal(directory, client, sessionID, args), handled()
|
|
1363
|
+
if (name === "loop-goal-clear") return await clearGoal(directory, client, sessionID, args), handled()
|
|
1364
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete") return await completeGoalCommand(directory, client, sessionID, args), handled()
|
|
1365
|
+
if (name === "loop-goal-blocked") return await blockGoalCommand(directory, client, sessionID, args), handled()
|
|
978
1366
|
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
979
1367
|
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
980
1368
|
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
@@ -992,9 +1380,48 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
992
1380
|
return false
|
|
993
1381
|
}
|
|
994
1382
|
|
|
1383
|
+
function goalTools(defaultDirectory) {
|
|
1384
|
+
return {
|
|
1385
|
+
opencode_loop_goal_complete: {
|
|
1386
|
+
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.",
|
|
1387
|
+
args: {
|
|
1388
|
+
summary: { type: "string", description: "Short human-readable summary of what was completed." },
|
|
1389
|
+
evidence: { type: "string", description: "Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results." },
|
|
1390
|
+
},
|
|
1391
|
+
execute: async (args, context) => {
|
|
1392
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1393
|
+
return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
|
|
1394
|
+
},
|
|
1395
|
+
},
|
|
1396
|
+
opencode_loop_goal_blocked: {
|
|
1397
|
+
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
1398
|
+
args: {
|
|
1399
|
+
reason: { type: "string", description: "Why the goal is blocked." },
|
|
1400
|
+
needed: { type: "string", description: "What user input, credential, decision, or manual action is needed to continue." },
|
|
1401
|
+
},
|
|
1402
|
+
execute: async (args, context) => {
|
|
1403
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1404
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
opencode_loop_goal_progress: {
|
|
1408
|
+
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
1409
|
+
args: {
|
|
1410
|
+
summary: { type: "string", description: "What useful progress was made." },
|
|
1411
|
+
next: { type: "string", description: "The next step toward completing the goal." },
|
|
1412
|
+
},
|
|
1413
|
+
execute: async (args, context) => {
|
|
1414
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1415
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1416
|
+
},
|
|
1417
|
+
},
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
995
1421
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
996
1422
|
await log(client, "info", "Plugin initialized", { directory })
|
|
997
1423
|
return {
|
|
1424
|
+
tool: goalTools(directory),
|
|
998
1425
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
999
1426
|
event: async ({ event }) => {
|
|
1000
1427
|
if (event.type === "command.executed") {
|