@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 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.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.
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.9 quick behavior guide
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.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.
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.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.",
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
- async function sessionStatusType(client, sessionID) {
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
- try {
773
- const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
774
- const status = data?.[sessionID]
775
- const type = status && typeof status === "object" ? status.type : undefined
776
- if (typeof type === "string") {
777
- sessionStatuses.set(sessionID, type)
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 type
810
+ return "idle"
780
811
  }
781
- } catch {}
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
  }