@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 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.8 is a reliability hotfix for the new loop types and Experimental Goal Mode.** It adds a real due timer in addition to OpenCode idle/status events, so delayed jobs such as `/loop 1m --no-now ...`, `/loop-command 1m --no-now /compact`, `/loop-ask 1h ...`, and `/loop-shell 10m ...` do not depend on another user action to wake the scheduler. The TUI loop still waits for the session to become idle before starting work, debounces idle events, and avoids starting a new run while OpenCode is busy or queued.
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.8 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.8 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin.
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.8",
4
- "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: reliable timers, 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,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 "session.compact"
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
- try {
398
- await executeTuiCommand(client, "session.compact")
399
- return true
400
- } catch (error) {
401
- await log(client, "warn", "tui session.compact failed", { error: sdkErrorMessage(error) })
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") sessionStatuses.set(sessionID, type)
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
- 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 = {}) {
749
790
  const cached = sessionStatuses.get(sessionID)
750
- if (cached && cached !== "idle") return cached
751
- try {
752
- const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
753
- const status = data?.[sessionID]
754
- const type = status && typeof status === "object" ? status.type : undefined
755
- if (typeof type === "string") {
756
- sessionStatuses.set(sessionID, type)
757
- return type
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
- } catch {}
760
- const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
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 executeTuiCommand(client, tuiCommand)
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
  }