@bybrawe/opencode-loop 0.5.8 → 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 +15 -0
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/index.js +115 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
11
|
+
## 0.5.9
|
|
12
|
+
|
|
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.
|
|
14
|
+
- Improved due-timer behavior when OpenCode misses or delays the final idle event after a command turn.
|
|
15
|
+
- Fixed `/compact` routing for current OpenCode TUI builds by trying `session_compact` first, then `session.compact`, then the session summarize fallback.
|
|
16
|
+
- Kept the command markdown templates short; OpenCode still creates a tiny `OK` turn for custom commands because markdown commands are prompt templates.
|
|
17
|
+
|
|
3
18
|
## 0.5.8
|
|
4
19
|
|
|
5
20
|
- Added a real due timer for TUI loop jobs, so delayed jobs can wake without waiting for another OpenCode event.
|
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,8 +5,10 @@ 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
|
|
11
|
+
const SESSION_STATUS_CACHE_MS = 1_500
|
|
10
12
|
const MIN_DUE_TIMER_MS = 250
|
|
11
13
|
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
12
14
|
const MAX_SCAN_FILES = 200
|
|
@@ -18,7 +20,9 @@ const activeRuns = new Map()
|
|
|
18
20
|
const handledCommands = new Map()
|
|
19
21
|
const idleTimers = new Map()
|
|
20
22
|
const dueTimers = new Map()
|
|
23
|
+
const watchdogTimers = new Map()
|
|
21
24
|
const sessionStatuses = new Map()
|
|
25
|
+
const sessionStatusSeenAt = new Map()
|
|
22
26
|
|
|
23
27
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
24
28
|
|
|
@@ -389,16 +393,22 @@ async function executeTuiCommand(client, command) {
|
|
|
389
393
|
|
|
390
394
|
function compactTuiCommandName(command = "compact") {
|
|
391
395
|
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
392
|
-
if (normalized === "compact" || normalized === "summarize") return "
|
|
396
|
+
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
393
397
|
return undefined
|
|
394
398
|
}
|
|
395
399
|
|
|
396
400
|
async function compactSession(client, sessionID) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
// OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
|
|
402
|
+
// current builds, while some older docs/examples mention the event value
|
|
403
|
+
// (session.compact). Try the alias first, then the event value, then the
|
|
404
|
+
// session summarize endpoint as a last resort.
|
|
405
|
+
for (const command of ["session_compact", "session.compact"]) {
|
|
406
|
+
try {
|
|
407
|
+
await executeTuiCommand(client, command)
|
|
408
|
+
return true
|
|
409
|
+
} catch (error) {
|
|
410
|
+
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
411
|
+
}
|
|
402
412
|
}
|
|
403
413
|
try {
|
|
404
414
|
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
@@ -734,36 +744,84 @@ function updateSessionStatusFromEvent(event) {
|
|
|
734
744
|
if (typeof sessionID !== "string") return undefined
|
|
735
745
|
if (event?.type === "session.idle") {
|
|
736
746
|
sessionStatuses.set(sessionID, "idle")
|
|
747
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
737
748
|
return { sessionID, idle: true }
|
|
738
749
|
}
|
|
739
750
|
if (event?.type === "session.status") {
|
|
740
751
|
const status = event?.properties?.status
|
|
741
752
|
const type = status && typeof status === "object" ? status.type : undefined
|
|
742
|
-
if (typeof type === "string")
|
|
753
|
+
if (typeof type === "string") {
|
|
754
|
+
sessionStatuses.set(sessionID, type)
|
|
755
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
756
|
+
}
|
|
743
757
|
return { sessionID, idle: type === "idle" }
|
|
744
758
|
}
|
|
745
759
|
return undefined
|
|
746
760
|
}
|
|
747
761
|
|
|
748
|
-
|
|
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 = {}) {
|
|
749
790
|
const cached = sessionStatuses.get(sessionID)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
791
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
792
|
+
|
|
793
|
+
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
794
|
+
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
795
|
+
// own short assistant turn, and some TUI builds do not always emit the final
|
|
796
|
+
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
797
|
+
// stuck at "due in every idle" until the user types another command.
|
|
798
|
+
if (cached === "idle") return cached
|
|
799
|
+
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
800
|
+
|
|
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")
|
|
809
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
810
|
+
return "idle"
|
|
758
811
|
}
|
|
759
|
-
|
|
760
|
-
|
|
812
|
+
sessionStatuses.set(sessionID, live.type)
|
|
813
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
814
|
+
return live.type
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
761
818
|
sessionStatuses.set(sessionID, fallback)
|
|
819
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
762
820
|
return fallback
|
|
763
821
|
}
|
|
764
822
|
|
|
765
|
-
async function sessionIsIdle(client, sessionID) {
|
|
766
|
-
return await sessionStatusType(client, sessionID) === "idle"
|
|
823
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
824
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
767
825
|
}
|
|
768
826
|
|
|
769
827
|
function scheduleIdleWork(directory, client, sessionID) {
|
|
@@ -773,12 +831,12 @@ function scheduleIdleWork(directory, client, sessionID) {
|
|
|
773
831
|
idleTimers.delete(sessionID)
|
|
774
832
|
Promise.resolve()
|
|
775
833
|
.then(async () => {
|
|
776
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
834
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
777
835
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
778
836
|
return
|
|
779
837
|
}
|
|
780
838
|
await finalizeActiveRun(directory, client, sessionID)
|
|
781
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
839
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
782
840
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
783
841
|
return
|
|
784
842
|
}
|
|
@@ -812,6 +870,35 @@ function nextDueDelay(state) {
|
|
|
812
870
|
return Math.max(0, soonest - current)
|
|
813
871
|
}
|
|
814
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
|
+
|
|
815
902
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
816
903
|
const previous = dueTimers.get(sessionID)
|
|
817
904
|
if (previous) clearTimeout(previous)
|
|
@@ -828,12 +915,12 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
828
915
|
dueTimers.delete(sessionID)
|
|
829
916
|
Promise.resolve()
|
|
830
917
|
.then(async () => {
|
|
831
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
918
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
832
919
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
833
920
|
return
|
|
834
921
|
}
|
|
835
922
|
await finalizeActiveRun(directory, client, sessionID)
|
|
836
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
923
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
837
924
|
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
838
925
|
return
|
|
839
926
|
}
|
|
@@ -845,6 +932,7 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
|
845
932
|
})
|
|
846
933
|
}, wait)
|
|
847
934
|
dueTimers.set(sessionID, timer)
|
|
935
|
+
await startWatchdog(directory, client, sessionID)
|
|
848
936
|
}
|
|
849
937
|
|
|
850
938
|
function dueJobs(state, force = false) {
|
|
@@ -1077,7 +1165,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1077
1165
|
}
|
|
1078
1166
|
const tuiCommand = compactTuiCommandName(command)
|
|
1079
1167
|
if (tuiCommand) {
|
|
1080
|
-
await
|
|
1168
|
+
await compactSession(client, sessionID)
|
|
1081
1169
|
return { startsAssistantTurn: true }
|
|
1082
1170
|
}
|
|
1083
1171
|
await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
@@ -1108,7 +1196,7 @@ ${prompt}` }] } })
|
|
|
1108
1196
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1109
1197
|
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1110
1198
|
|
|
1111
|
-
if (!await sessionIsIdle(client, sessionID)) {
|
|
1199
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1112
1200
|
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1113
1201
|
await reschedule(BUSY_RETRY_MS)
|
|
1114
1202
|
return
|
|
@@ -1267,6 +1355,7 @@ async function stopLoop(directory, client, sessionID, args) {
|
|
|
1267
1355
|
await removeState(directory, sessionID)
|
|
1268
1356
|
clearActiveRun(sessionID)
|
|
1269
1357
|
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1358
|
+
stopWatchdog(sessionID)
|
|
1270
1359
|
await toast(client, "All loops stopped for this session.", "success")
|
|
1271
1360
|
return
|
|
1272
1361
|
}
|