@bybrawe/opencode-loop 0.5.7 → 0.5.9
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 +17 -0
- package/README.md +52 -1
- 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 +2 -2
- package/commands/loop-goal-clear.md +2 -2
- package/commands/loop-goal-done.md +2 -2
- package/commands/loop-goal-pause.md +2 -2
- package/commands/loop-goal-resume.md +2 -2
- package/commands/loop-goal-status.md +2 -2
- package/commands/loop-goal.md +1 -1
- 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 +145 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.9
|
|
4
|
+
|
|
5
|
+
- Fixed stale-busy recovery for TUI sessions. Cached `busy` / `retry` status is now short-lived, so due jobs no longer get stuck at `due in every idle` after `/loop-status` or other short custom-command turns.
|
|
6
|
+
- Improved due-timer behavior when OpenCode misses or delays the final idle event after a command turn.
|
|
7
|
+
- Fixed `/compact` routing for current OpenCode TUI builds by trying `session_compact` first, then `session.compact`, then the session summarize fallback.
|
|
8
|
+
- Kept the command markdown templates short; OpenCode still creates a tiny `OK` turn for custom commands because markdown commands are prompt templates.
|
|
9
|
+
|
|
10
|
+
## 0.5.8
|
|
11
|
+
|
|
12
|
+
- Added a real due timer for TUI loop jobs, so delayed jobs can wake without waiting for another OpenCode event.
|
|
13
|
+
- Fixed `/loop 1m --no-now ...` style jobs so the first delayed run can fire on time when OpenCode is idle.
|
|
14
|
+
- 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`.
|
|
15
|
+
- Added busy retry behavior: if a due timer expires while OpenCode is busy, the job waits and retries instead of interrupting the active turn.
|
|
16
|
+
- Reschedules due timers after add, stop, pause, resume, run completion, goal pause/resume/clear, and post-run verification.
|
|
17
|
+
- Shortened command markdown templates so unavoidable custom command turns reply `OK` instead of producing misleading status text.
|
|
18
|
+
- Kept Experimental Goal Mode marked experimental.
|
|
19
|
+
|
|
3
20
|
## 0.5.7
|
|
4
21
|
|
|
5
22
|
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
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.9 is a reliability hotfix for stale-busy status and `/compact` routing.** It keeps the real due timer from v0.5.8, but no longer trusts a cached `busy` / `retry` session status forever. This fixes the case where `/loop-status` or another short custom-command turn could leave a loop showing `due in every idle` without firing. It also routes scheduled `/compact` through OpenCode's current TUI alias first, with fallback paths for older builds.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -22,9 +22,60 @@ The known update-related symptoms from older builds are fixed:
|
|
|
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
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
|
|
25
27
|
|
|
26
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`.
|
|
27
29
|
|
|
30
|
+
## v0.5.9 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.9 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers by refreshing stale busy status on the next due retry.
|
|
78
|
+
|
|
28
79
|
## Why this exists
|
|
29
80
|
|
|
30
81
|
Claude Code users often rely on a loop-like workflow where the agent finishes one step, then immediately continues with the next step.
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Mark experimental goal blocked.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal blocked command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Clear experimental goal jobs.
|
|
2
|
+
description: Clear experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal clear command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Mark experimental goal done.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal done command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Pause
|
|
2
|
+
description: Pause experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal pause command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Resume
|
|
2
|
+
description: Resume experimental goal jobs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal resume command handled locally. Reply exactly: OK.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Show experimental goal status.
|
|
2
|
+
description: Show experimental goal status.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal status command handled locally. Reply exactly: OK.
|
package/commands/loop-goal.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: Experimental persistent goal mode. Usage: /loop-goal finish the feature and keep tests green
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
OpenCode Loop experimental goal 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/Codex style /loop and experimental goal mode for OpenCode:
|
|
3
|
+
"version": "0.5.9",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: reliable timers, stale-busy recovery, 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,6 +6,10 @@ 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 SESSION_STATUS_CACHE_MS = 1_500
|
|
11
|
+
const MIN_DUE_TIMER_MS = 250
|
|
12
|
+
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
9
13
|
const MAX_SCAN_FILES = 200
|
|
10
14
|
const MAX_SCAN_BYTES = 2_000_000
|
|
11
15
|
const GOAL_REPORT_DIR = "goals"
|
|
@@ -14,7 +18,9 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
|
14
18
|
const activeRuns = new Map()
|
|
15
19
|
const handledCommands = new Map()
|
|
16
20
|
const idleTimers = new Map()
|
|
21
|
+
const dueTimers = new Map()
|
|
17
22
|
const sessionStatuses = new Map()
|
|
23
|
+
const sessionStatusSeenAt = new Map()
|
|
18
24
|
|
|
19
25
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
20
26
|
|
|
@@ -385,16 +391,22 @@ async function executeTuiCommand(client, command) {
|
|
|
385
391
|
|
|
386
392
|
function compactTuiCommandName(command = "compact") {
|
|
387
393
|
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
388
|
-
if (normalized === "compact" || normalized === "summarize") return "
|
|
394
|
+
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
389
395
|
return undefined
|
|
390
396
|
}
|
|
391
397
|
|
|
392
398
|
async function compactSession(client, sessionID) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
399
|
+
// OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
|
|
400
|
+
// current builds, while some older docs/examples mention the event value
|
|
401
|
+
// (session.compact). Try the alias first, then the event value, then the
|
|
402
|
+
// session summarize endpoint as a last resort.
|
|
403
|
+
for (const command of ["session_compact", "session.compact"]) {
|
|
404
|
+
try {
|
|
405
|
+
await executeTuiCommand(client, command)
|
|
406
|
+
return true
|
|
407
|
+
} catch (error) {
|
|
408
|
+
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
409
|
+
}
|
|
398
410
|
}
|
|
399
411
|
try {
|
|
400
412
|
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
@@ -730,12 +742,16 @@ function updateSessionStatusFromEvent(event) {
|
|
|
730
742
|
if (typeof sessionID !== "string") return undefined
|
|
731
743
|
if (event?.type === "session.idle") {
|
|
732
744
|
sessionStatuses.set(sessionID, "idle")
|
|
745
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
733
746
|
return { sessionID, idle: true }
|
|
734
747
|
}
|
|
735
748
|
if (event?.type === "session.status") {
|
|
736
749
|
const status = event?.properties?.status
|
|
737
750
|
const type = status && typeof status === "object" ? status.type : undefined
|
|
738
|
-
if (typeof type === "string")
|
|
751
|
+
if (typeof type === "string") {
|
|
752
|
+
sessionStatuses.set(sessionID, type)
|
|
753
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
754
|
+
}
|
|
739
755
|
return { sessionID, idle: type === "idle" }
|
|
740
756
|
}
|
|
741
757
|
return undefined
|
|
@@ -743,18 +759,31 @@ function updateSessionStatusFromEvent(event) {
|
|
|
743
759
|
|
|
744
760
|
async function sessionStatusType(client, sessionID) {
|
|
745
761
|
const cached = sessionStatuses.get(sessionID)
|
|
746
|
-
|
|
762
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
763
|
+
|
|
764
|
+
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
765
|
+
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
766
|
+
// own short assistant turn, and some TUI builds do not always emit the final
|
|
767
|
+
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
768
|
+
// stuck at "due in every idle" until the user types another command.
|
|
769
|
+
if (cached === "idle") return cached
|
|
770
|
+
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
771
|
+
|
|
747
772
|
try {
|
|
748
773
|
const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
|
|
749
774
|
const status = data?.[sessionID]
|
|
750
775
|
const type = status && typeof status === "object" ? status.type : undefined
|
|
751
776
|
if (typeof type === "string") {
|
|
752
777
|
sessionStatuses.set(sessionID, type)
|
|
778
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
753
779
|
return type
|
|
754
780
|
}
|
|
755
781
|
} catch {}
|
|
756
|
-
|
|
757
|
-
|
|
782
|
+
|
|
783
|
+
const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
|
|
784
|
+
sessionStatuses.set(sessionID, fallback)
|
|
785
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
786
|
+
return fallback
|
|
758
787
|
}
|
|
759
788
|
|
|
760
789
|
async function sessionIsIdle(client, sessionID) {
|
|
@@ -768,9 +797,15 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
768
797
|
idleTimers.delete(sessionID)
|
|
769
798
|
Promise.resolve()
|
|
770
799
|
.then(async () => {
|
|
771
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
800
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
801
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
802
|
+
return
|
|
803
|
+
}
|
|
772
804
|
await finalizeActiveRun(directory, client, sessionID)
|
|
773
|
-
if (!await sessionIsIdle(client, sessionID))
|
|
805
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
806
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
774
809
|
await maybeRunDueJobs(directory, client, sessionID)
|
|
775
810
|
})
|
|
776
811
|
.catch((error) => {
|
|
@@ -781,6 +816,61 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
781
816
|
idleTimers.set(sessionID, timer)
|
|
782
817
|
}
|
|
783
818
|
|
|
819
|
+
function jobDueAt(job, current = now()) {
|
|
820
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
821
|
+
if (!job.enabled || job.paused) return Infinity
|
|
822
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
823
|
+
if (job.watchPaths?.length) return Infinity
|
|
824
|
+
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
825
|
+
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
826
|
+
if (job.intervalMs === 0) return current
|
|
827
|
+
if (!job.lastRunAt) return current
|
|
828
|
+
return job.lastRunAt + (job.intervalMs || 0)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function nextDueDelay(state) {
|
|
832
|
+
const current = now()
|
|
833
|
+
let soonest = Infinity
|
|
834
|
+
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
835
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
836
|
+
return Math.max(0, soonest - current)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
840
|
+
const previous = dueTimers.get(sessionID)
|
|
841
|
+
if (previous) clearTimeout(previous)
|
|
842
|
+
|
|
843
|
+
const state = await readState(directory, sessionID)
|
|
844
|
+
const delay = nextDueDelay(state)
|
|
845
|
+
if (!Number.isFinite(delay)) {
|
|
846
|
+
dueTimers.delete(sessionID)
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
851
|
+
const timer = setTimeout(() => {
|
|
852
|
+
dueTimers.delete(sessionID)
|
|
853
|
+
Promise.resolve()
|
|
854
|
+
.then(async () => {
|
|
855
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
856
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
860
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
861
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
865
|
+
})
|
|
866
|
+
.catch((error) => {
|
|
867
|
+
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
868
|
+
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
869
|
+
})
|
|
870
|
+
}, wait)
|
|
871
|
+
dueTimers.set(sessionID, timer)
|
|
872
|
+
}
|
|
873
|
+
|
|
784
874
|
function dueJobs(state, force = false) {
|
|
785
875
|
const current = now()
|
|
786
876
|
return (state.jobs || []).filter((job) => {
|
|
@@ -992,6 +1082,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
992
1082
|
await writeState(directory, sessionID, state)
|
|
993
1083
|
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
994
1084
|
await createCheckpoint(directory, sessionID, job, client)
|
|
1085
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
995
1086
|
}
|
|
996
1087
|
|
|
997
1088
|
async function fireAction(directory, client, sessionID, job) {
|
|
@@ -1010,7 +1101,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1010
1101
|
}
|
|
1011
1102
|
const tuiCommand = compactTuiCommandName(command)
|
|
1012
1103
|
if (tuiCommand) {
|
|
1013
|
-
await
|
|
1104
|
+
await compactSession(client, sessionID)
|
|
1014
1105
|
return { startsAssistantTurn: true }
|
|
1015
1106
|
}
|
|
1016
1107
|
await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
@@ -1039,19 +1130,29 @@ ${prompt}` }] } })
|
|
|
1039
1130
|
}
|
|
1040
1131
|
|
|
1041
1132
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1133
|
+
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1134
|
+
|
|
1042
1135
|
if (!await sessionIsIdle(client, sessionID)) {
|
|
1043
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle
|
|
1136
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1137
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1044
1138
|
return
|
|
1045
1139
|
}
|
|
1046
1140
|
const active = activeRuns.get(sessionID)
|
|
1047
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS))
|
|
1141
|
+
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1142
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1143
|
+
return
|
|
1144
|
+
}
|
|
1048
1145
|
|
|
1049
1146
|
const state = await readState(directory, sessionID)
|
|
1050
1147
|
for (const job of state.jobs || []) {
|
|
1051
1148
|
if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
|
|
1052
1149
|
}
|
|
1053
1150
|
let due = dueJobs(state, options.force)
|
|
1054
|
-
if (!due.length) {
|
|
1151
|
+
if (!due.length) {
|
|
1152
|
+
await writeState(directory, sessionID, state)
|
|
1153
|
+
await reschedule()
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1055
1156
|
let job = due[0]
|
|
1056
1157
|
|
|
1057
1158
|
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
@@ -1060,6 +1161,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1060
1161
|
await notifyJob(directory, job, "max_runtime_reached")
|
|
1061
1162
|
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1062
1163
|
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1164
|
+
await reschedule()
|
|
1063
1165
|
return
|
|
1064
1166
|
}
|
|
1065
1167
|
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
@@ -1067,6 +1169,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1067
1169
|
await writeState(directory, sessionID, state)
|
|
1068
1170
|
await notifyJob(directory, job, "stop_file")
|
|
1069
1171
|
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1172
|
+
await reschedule()
|
|
1070
1173
|
return
|
|
1071
1174
|
}
|
|
1072
1175
|
if (await untilReached(directory, job)) {
|
|
@@ -1074,6 +1177,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1074
1177
|
await writeState(directory, sessionID, state)
|
|
1075
1178
|
await notifyJob(directory, job, "until_reached")
|
|
1076
1179
|
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1180
|
+
await reschedule()
|
|
1077
1181
|
return
|
|
1078
1182
|
}
|
|
1079
1183
|
|
|
@@ -1083,6 +1187,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1083
1187
|
await writeState(directory, sessionID, state)
|
|
1084
1188
|
await notifyJob(directory, job, "preflight_blocked")
|
|
1085
1189
|
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1190
|
+
await reschedule()
|
|
1086
1191
|
return
|
|
1087
1192
|
}
|
|
1088
1193
|
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
@@ -1095,6 +1200,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1095
1200
|
await writeState(directory, sessionID, state)
|
|
1096
1201
|
await notifyJob(directory, job, "preflight_failed")
|
|
1097
1202
|
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1203
|
+
await reschedule()
|
|
1098
1204
|
return
|
|
1099
1205
|
}
|
|
1100
1206
|
}
|
|
@@ -1118,16 +1224,18 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1118
1224
|
const fresh = await readState(directory, sessionID)
|
|
1119
1225
|
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1120
1226
|
await writeState(directory, sessionID, fresh)
|
|
1227
|
+
await reschedule()
|
|
1228
|
+
return
|
|
1121
1229
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1230
|
+
let timer
|
|
1231
|
+
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)
|
|
1232
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1233
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1127
1234
|
} catch (error) {
|
|
1128
1235
|
clearActiveRun(sessionID)
|
|
1129
1236
|
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1130
1237
|
await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
|
|
1238
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1131
1239
|
}
|
|
1132
1240
|
}
|
|
1133
1241
|
|
|
@@ -1171,6 +1279,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1171
1279
|
|
|
1172
1280
|
state.jobs.push(parsed.job)
|
|
1173
1281
|
await writeState(directory, sessionID, state)
|
|
1282
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1174
1283
|
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
1175
1284
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
1176
1285
|
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
@@ -1178,11 +1287,18 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1178
1287
|
|
|
1179
1288
|
async function stopLoop(directory, client, sessionID, args) {
|
|
1180
1289
|
const target = String(args || "").trim()
|
|
1181
|
-
if (!target || target.toLowerCase() === "all") {
|
|
1290
|
+
if (!target || target.toLowerCase() === "all") {
|
|
1291
|
+
await removeState(directory, sessionID)
|
|
1292
|
+
clearActiveRun(sessionID)
|
|
1293
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1294
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1182
1297
|
const state = await readState(directory, sessionID)
|
|
1183
1298
|
const before = state.jobs.length
|
|
1184
1299
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
1185
1300
|
await writeState(directory, sessionID, state)
|
|
1301
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1186
1302
|
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
1187
1303
|
}
|
|
1188
1304
|
|
|
@@ -1192,6 +1308,7 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
|
|
|
1192
1308
|
let count = 0
|
|
1193
1309
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
1194
1310
|
await writeState(directory, sessionID, state)
|
|
1311
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1195
1312
|
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
1196
1313
|
}
|
|
1197
1314
|
|
|
@@ -1215,6 +1332,7 @@ async function pauseGoal(directory, client, sessionID, args) {
|
|
|
1215
1332
|
let count = 0
|
|
1216
1333
|
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1217
1334
|
await writeState(directory, sessionID, state)
|
|
1335
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1218
1336
|
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1219
1337
|
}
|
|
1220
1338
|
|
|
@@ -1229,7 +1347,10 @@ async function resumeGoal(directory, client, sessionID, args) {
|
|
|
1229
1347
|
})
|
|
1230
1348
|
await writeState(directory, sessionID, state)
|
|
1231
1349
|
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1232
|
-
if (count)
|
|
1350
|
+
if (count) {
|
|
1351
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1352
|
+
scheduleIdleWork(directory, client, sessionID)
|
|
1353
|
+
}
|
|
1233
1354
|
}
|
|
1234
1355
|
|
|
1235
1356
|
async function clearGoal(directory, client, sessionID, args) {
|
|
@@ -1238,6 +1359,7 @@ async function clearGoal(directory, client, sessionID, args) {
|
|
|
1238
1359
|
const before = state.jobs.length
|
|
1239
1360
|
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1240
1361
|
await writeState(directory, sessionID, state)
|
|
1362
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1241
1363
|
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1242
1364
|
}
|
|
1243
1365
|
|