@bybrawe/opencode-loop 0.5.9 → 0.5.10
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 +8 -0
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/index.js +82 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.10
|
|
4
|
+
|
|
5
|
+
- Added a watchdog loop in addition to one-shot due timers, so recurring jobs keep checking due work even if OpenCode misses an idle event or a due timeout gets effectively stuck behind a stale busy state.
|
|
6
|
+
- Added stronger stale-active-run recovery. If a plugin-injected run is still marked active after the recovery window, the scheduler treats it as finished, finalizes it, and continues without waiting for another manual command such as `/loop-status`.
|
|
7
|
+
- Refreshed session status with workspace-aware calls before trusting cached busy/retry state.
|
|
8
|
+
- Kept `/compact` routing from v0.5.9: `session_compact` first, then `session.compact`, then summarize fallback.
|
|
9
|
+
- This targets the observed case where `/loop 1m ...` only continued after manually running `/loop-status`.
|
|
10
|
+
|
|
3
11
|
## 0.5.9
|
|
4
12
|
|
|
5
13
|
- 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.
|
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.10 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
|
|
15
15
|
|
|
16
16
|
The known update-related symptoms from older builds are fixed:
|
|
17
17
|
|
|
@@ -27,7 +27,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
27
27
|
|
|
28
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`.
|
|
29
29
|
|
|
30
|
-
## v0.5.
|
|
30
|
+
## v0.5.10 quick behavior guide
|
|
31
31
|
|
|
32
32
|
OpenCode Loop has two triggers now:
|
|
33
33
|
|
|
@@ -74,7 +74,7 @@ Run a shell command every 10 minutes when idle.
|
|
|
74
74
|
|
|
75
75
|
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
76
|
|
|
77
|
-
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.
|
|
77
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.10 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
|
|
78
78
|
|
|
79
79
|
## Why this exists
|
|
80
80
|
|
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.10",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: watchdog 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
|
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process"
|
|
|
5
5
|
const SERVICE = "opencode-loop"
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
7
|
const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
8
|
+
const STALE_ACTIVE_RECOVERY_MS = 60_000
|
|
8
9
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
9
10
|
const BUSY_RETRY_MS = 5_000
|
|
10
11
|
const SESSION_STATUS_CACHE_MS = 1_500
|
|
@@ -19,6 +20,7 @@ const activeRuns = new Map()
|
|
|
19
20
|
const handledCommands = new Map()
|
|
20
21
|
const idleTimers = new Map()
|
|
21
22
|
const dueTimers = new Map()
|
|
23
|
+
const watchdogTimers = new Map()
|
|
22
24
|
const sessionStatuses = new Map()
|
|
23
25
|
const sessionStatusSeenAt = new Map()
|
|
24
26
|
|
|
@@ -757,7 +759,34 @@ function updateSessionStatusFromEvent(event) {
|
|
|
757
759
|
return undefined
|
|
758
760
|
}
|
|
759
761
|
|
|
760
|
-
|
|
762
|
+
function staleActiveRun(sessionID) {
|
|
763
|
+
const active = activeRuns.get(sessionID)
|
|
764
|
+
if (!active) return false
|
|
765
|
+
const age = now() - (active.startedAt || 0)
|
|
766
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
767
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
|
|
768
|
+
return age >= threshold
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
772
|
+
const argsList = []
|
|
773
|
+
if (directory) argsList.push({ workspace: directory }, { directory })
|
|
774
|
+
argsList.push({})
|
|
775
|
+
for (const args of argsList) {
|
|
776
|
+
try {
|
|
777
|
+
const result = await client.session.status(args)
|
|
778
|
+
const error = sdkError(result)
|
|
779
|
+
if (error) continue
|
|
780
|
+
const data = sdkData(result)
|
|
781
|
+
const status = data?.[sessionID]
|
|
782
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
783
|
+
if (typeof type === "string") return { type, source: "sdk" }
|
|
784
|
+
} catch {}
|
|
785
|
+
}
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
761
790
|
const cached = sessionStatuses.get(sessionID)
|
|
762
791
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
763
792
|
|
|
@@ -769,25 +798,30 @@ async function sessionStatusType(client, sessionID) {
|
|
|
769
798
|
if (cached === "idle") return cached
|
|
770
799
|
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
771
800
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
801
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
802
|
+
if (live?.type) {
|
|
803
|
+
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
804
|
+
// plugin-injected turn until the next user command touches the session.
|
|
805
|
+
// When the only reason we still think the session is busy is our own stale
|
|
806
|
+
// active-run guard, recover instead of waiting for another manual command.
|
|
807
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
|
|
808
|
+
sessionStatuses.set(sessionID, "idle")
|
|
778
809
|
sessionStatusSeenAt.set(sessionID, now())
|
|
779
|
-
return
|
|
810
|
+
return "idle"
|
|
780
811
|
}
|
|
781
|
-
|
|
812
|
+
sessionStatuses.set(sessionID, live.type)
|
|
813
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
814
|
+
return live.type
|
|
815
|
+
}
|
|
782
816
|
|
|
783
|
-
const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
|
|
817
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
784
818
|
sessionStatuses.set(sessionID, fallback)
|
|
785
819
|
sessionStatusSeenAt.set(sessionID, now())
|
|
786
820
|
return fallback
|
|
787
821
|
}
|
|
788
822
|
|
|
789
|
-
async function sessionIsIdle(client, sessionID) {
|
|
790
|
-
return await sessionStatusType(client, sessionID) === "idle"
|
|
823
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
824
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
791
825
|
}
|
|
792
826
|
|
|
793
827
|
function scheduleIdleWork(directory, client, sessionID) {
|
|
@@ -797,12 +831,12 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
797
831
|
idleTimers.delete(sessionID)
|
|
798
832
|
Promise.resolve()
|
|
799
833
|
.then(async () => {
|
|
800
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
834
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
801
835
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
802
836
|
return
|
|
803
837
|
}
|
|
804
838
|
await finalizeActiveRun(directory, client, sessionID)
|
|
805
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
839
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
806
840
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
807
841
|
return
|
|
808
842
|
}
|
|
@@ -836,6 +870,35 @@ function nextDueDelay(state) {
|
|
|
836
870
|
return Math.max(0, soonest - current)
|
|
837
871
|
}
|
|
838
872
|
|
|
873
|
+
async function startWatchdog(directory, client, sessionID) {
|
|
874
|
+
if (watchdogTimers.has(sessionID)) return
|
|
875
|
+
const timer = setInterval(() => {
|
|
876
|
+
Promise.resolve()
|
|
877
|
+
.then(async () => {
|
|
878
|
+
const state = await readState(directory, sessionID)
|
|
879
|
+
const delay = nextDueDelay(state)
|
|
880
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
881
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
882
|
+
const existing = watchdogTimers.get(sessionID)
|
|
883
|
+
if (existing) clearInterval(existing)
|
|
884
|
+
watchdogTimers.delete(sessionID)
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
|
|
888
|
+
else await scheduleDueWork(directory, client, sessionID)
|
|
889
|
+
})
|
|
890
|
+
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
891
|
+
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
892
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
893
|
+
watchdogTimers.set(sessionID, timer)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function stopWatchdog(sessionID) {
|
|
897
|
+
const timer = watchdogTimers.get(sessionID)
|
|
898
|
+
if (timer) clearInterval(timer)
|
|
899
|
+
watchdogTimers.delete(sessionID)
|
|
900
|
+
}
|
|
901
|
+
|
|
839
902
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
840
903
|
const previous = dueTimers.get(sessionID)
|
|
841
904
|
if (previous) clearTimeout(previous)
|
|
@@ -852,12 +915,12 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
852
915
|
dueTimers.delete(sessionID)
|
|
853
916
|
Promise.resolve()
|
|
854
917
|
.then(async () => {
|
|
855
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
918
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
856
919
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
857
920
|
return
|
|
858
921
|
}
|
|
859
922
|
await finalizeActiveRun(directory, client, sessionID)
|
|
860
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
923
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
861
924
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
862
925
|
return
|
|
863
926
|
}
|
|
@@ -869,6 +932,7 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
869
932
|
})
|
|
870
933
|
}, wait)
|
|
871
934
|
dueTimers.set(sessionID, timer)
|
|
935
|
+
await startWatchdog(directory, client, sessionID)
|
|
872
936
|
}
|
|
873
937
|
|
|
874
938
|
function dueJobs(state, force = false) {
|
|
@@ -1132,7 +1196,7 @@ ${prompt}` }] } })
|
|
|
1132
1196
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1133
1197
|
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1134
1198
|
|
|
1135
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
1199
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1136
1200
|
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1137
1201
|
await reschedule(BUSY_RETRY_MS)
|
|
1138
1202
|
return
|
|
@@ -1291,6 +1355,7 @@ async function stopLoop(directory, client, sessionID, args) {
|
|
|
1291
1355
|
await removeState(directory, sessionID)
|
|
1292
1356
|
clearActiveRun(sessionID)
|
|
1293
1357
|
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1358
|
+
stopWatchdog(sessionID)
|
|
1294
1359
|
await toast(client, "All loops stopped for this session.", "success")
|
|
1295
1360
|
return
|
|
1296
1361
|
}
|