@bybrawe/opencode-loop 0.5.9 → 0.5.11

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.11
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
@@ -9,9 +9,14 @@ It is useful for long coding sessions, `progress.md` workflows, TODO automation,
9
9
  Repository: **ByBrawe/opencode-loop**
10
10
  NPM package name: **@bybrawe/opencode-loop**
11
11
 
12
+ ### Scheduler heartbeat note
13
+
14
+ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode TUI because local command hooks are event-driven: a normal `/loop-status` command can wake the plugin, but a timer-only loop should not depend on a user typing another command. The heartbeat periodically checks active loop jobs, clears stale completed runs, and starts due work only when the session appears idle.
15
+
16
+
12
17
  ## Current status
13
18
 
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.
19
+ **v0.5.11 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
20
 
16
21
  The known update-related symptoms from older builds are fixed:
17
22
 
@@ -27,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
27
32
 
28
33
  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
34
 
30
- ## v0.5.9 quick behavior guide
35
+ ## v0.5.11 quick behavior guide
31
36
 
32
37
  OpenCode Loop has two triggers now:
33
38
 
@@ -74,7 +79,7 @@ Run a shell command every 10 minutes when idle.
74
79
 
75
80
  Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
76
81
 
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.
82
+ > Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.11 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
83
 
79
84
  ## Why this exists
80
85
 
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.11",
4
+ "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, 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
@@ -4,12 +4,14 @@ import { spawn } from "node:child_process"
4
4
 
5
5
  const SERVICE = "opencode-loop"
6
6
  const STATE_DIR = ".opencode/opencode-loop"
7
- const DEFAULT_ACTIVE_GUARD_MS = 60_000
7
+ const DEFAULT_ACTIVE_GUARD_MS = 45_000
8
+ const STALE_ACTIVE_RECOVERY_MS = 45_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
11
12
  const MIN_DUE_TIMER_MS = 250
12
13
  const MAX_DUE_TIMER_MS = 2_147_000_000
14
+ const HEARTBEAT_MS = 2_500
13
15
  const MAX_SCAN_FILES = 200
14
16
  const MAX_SCAN_BYTES = 2_000_000
15
17
  const GOAL_REPORT_DIR = "goals"
@@ -19,6 +21,10 @@ const activeRuns = new Map()
19
21
  const handledCommands = new Map()
20
22
  const idleTimers = new Map()
21
23
  const dueTimers = new Map()
24
+ const watchdogTimers = new Map()
25
+ const runLocks = new Map()
26
+ const knownSessions = new Map()
27
+ let heartbeatTimer
22
28
  const sessionStatuses = new Map()
23
29
  const sessionStatusSeenAt = new Map()
24
30
 
@@ -442,7 +448,7 @@ async function say(client, sessionID, text) {
442
448
  } catch {}
443
449
  }
444
450
 
445
- function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args || ""}` }
451
+ function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
446
452
  function markHandled(sessionID, name, args) {
447
453
  handledCommands.set(commandKey(sessionID, name, args), now())
448
454
  for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
@@ -454,6 +460,41 @@ function wasHandled(sessionID, name, args) {
454
460
 
455
461
  function commandName(name) { return String(name || "") }
456
462
  function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
463
+
464
+ function normalizeArgsForKey(args) {
465
+ if (args === undefined || args === null) return ""
466
+ if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
467
+ if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
468
+ try { return JSON.stringify(args) } catch { return String(args) }
469
+ }
470
+
471
+ function rememberSession(directory, client, sessionID) {
472
+ if (!sessionID) return
473
+ knownSessions.set(sessionID, { directory, client, seenAt: now() })
474
+ startHeartbeat()
475
+ }
476
+
477
+ function startHeartbeat() {
478
+ if (heartbeatTimer) return
479
+ heartbeatTimer = setInterval(() => {
480
+ for (const [sessionID, info] of [...knownSessions.entries()]) {
481
+ if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
482
+ knownSessions.delete(sessionID)
483
+ continue
484
+ }
485
+ Promise.resolve()
486
+ .then(async () => {
487
+ await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
488
+ await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
489
+ })
490
+ .catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
491
+ }
492
+ if (!knownSessions.size && heartbeatTimer) {
493
+ clearInterval(heartbeatTimer)
494
+ heartbeatTimer = undefined
495
+ }
496
+ }, HEARTBEAT_MS)
497
+ }
457
498
  function presetDefaults(name, args) {
458
499
  const [maybeDuration, rest] = splitFirst(args)
459
500
  const parsed = parseDuration(maybeDuration)
@@ -757,7 +798,34 @@ function updateSessionStatusFromEvent(event) {
757
798
  return undefined
758
799
  }
759
800
 
760
- async function sessionStatusType(client, sessionID) {
801
+ function staleActiveRun(sessionID) {
802
+ const active = activeRuns.get(sessionID)
803
+ if (!active) return false
804
+ const age = now() - (active.startedAt || 0)
805
+ const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
806
+ const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
807
+ return age >= threshold
808
+ }
809
+
810
+ async function readLiveSessionStatus(client, sessionID, directory) {
811
+ const argsList = []
812
+ if (directory) argsList.push({ workspace: directory }, { directory })
813
+ argsList.push({})
814
+ for (const args of argsList) {
815
+ try {
816
+ const result = await client.session.status(args)
817
+ const error = sdkError(result)
818
+ if (error) continue
819
+ const data = sdkData(result)
820
+ const status = data?.[sessionID]
821
+ const type = status && typeof status === "object" ? status.type : undefined
822
+ if (typeof type === "string") return { type, source: "sdk" }
823
+ } catch {}
824
+ }
825
+ return undefined
826
+ }
827
+
828
+ async function sessionStatusType(client, sessionID, directory, options = {}) {
761
829
  const cached = sessionStatuses.get(sessionID)
762
830
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
763
831
 
@@ -769,25 +837,30 @@ async function sessionStatusType(client, sessionID) {
769
837
  if (cached === "idle") return cached
770
838
  if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
771
839
 
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)
840
+ const live = await readLiveSessionStatus(client, sessionID, directory)
841
+ if (live?.type) {
842
+ // Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
843
+ // plugin-injected turn until the next user command touches the session.
844
+ // When the only reason we still think the session is busy is our own stale
845
+ // active-run guard, recover instead of waiting for another manual command.
846
+ if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
847
+ sessionStatuses.set(sessionID, "idle")
778
848
  sessionStatusSeenAt.set(sessionID, now())
779
- return type
849
+ return "idle"
780
850
  }
781
- } catch {}
851
+ sessionStatuses.set(sessionID, live.type)
852
+ sessionStatusSeenAt.set(sessionID, now())
853
+ return live.type
854
+ }
782
855
 
783
- const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
856
+ const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
784
857
  sessionStatuses.set(sessionID, fallback)
785
858
  sessionStatusSeenAt.set(sessionID, now())
786
859
  return fallback
787
860
  }
788
861
 
789
- async function sessionIsIdle(client, sessionID) {
790
- return await sessionStatusType(client, sessionID) === "idle"
862
+ async function sessionIsIdle(client, sessionID, directory, options = {}) {
863
+ return await sessionStatusType(client, sessionID, directory, options) === "idle"
791
864
  }
792
865
 
793
866
  function scheduleIdleWork(directory, client, sessionID) {
@@ -797,12 +870,12 @@ function scheduleIdleWork(directory, client, sessionID) {
797
870
  idleTimers.delete(sessionID)
798
871
  Promise.resolve()
799
872
  .then(async () => {
800
- if (!await sessionIsIdle(client, sessionID)) {
873
+ if (!await sessionIsIdle(client, sessionID, directory)) {
801
874
  await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
802
875
  return
803
876
  }
804
877
  await finalizeActiveRun(directory, client, sessionID)
805
- if (!await sessionIsIdle(client, sessionID)) {
878
+ if (!await sessionIsIdle(client, sessionID, directory)) {
806
879
  await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
807
880
  return
808
881
  }
@@ -836,6 +909,37 @@ function nextDueDelay(state) {
836
909
  return Math.max(0, soonest - current)
837
910
  }
838
911
 
912
+ async function startWatchdog(directory, client, sessionID) {
913
+ if (watchdogTimers.has(sessionID)) return
914
+ const timer = setInterval(() => {
915
+ Promise.resolve()
916
+ .then(async () => {
917
+ const state = await readState(directory, sessionID)
918
+ const delay = nextDueDelay(state)
919
+ const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
920
+ if (!hasJobs || !Number.isFinite(delay)) {
921
+ const existing = watchdogTimers.get(sessionID)
922
+ if (existing) clearInterval(existing)
923
+ watchdogTimers.delete(sessionID)
924
+ return
925
+ }
926
+ if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
927
+ else await scheduleDueWork(directory, client, sessionID)
928
+ })
929
+ .catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
930
+ }, Math.max(1_000, BUSY_RETRY_MS))
931
+ // Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
932
+ // are event-driven; a referenced watchdog helps scheduled loops wake up even
933
+ // when no manual /loop-status command is typed.
934
+ watchdogTimers.set(sessionID, timer)
935
+ }
936
+
937
+ function stopWatchdog(sessionID) {
938
+ const timer = watchdogTimers.get(sessionID)
939
+ if (timer) clearInterval(timer)
940
+ watchdogTimers.delete(sessionID)
941
+ }
942
+
839
943
  async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
840
944
  const previous = dueTimers.get(sessionID)
841
945
  if (previous) clearTimeout(previous)
@@ -852,12 +956,12 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
852
956
  dueTimers.delete(sessionID)
853
957
  Promise.resolve()
854
958
  .then(async () => {
855
- if (!await sessionIsIdle(client, sessionID)) {
959
+ if (!await sessionIsIdle(client, sessionID, directory)) {
856
960
  await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
857
961
  return
858
962
  }
859
963
  await finalizeActiveRun(directory, client, sessionID)
860
- if (!await sessionIsIdle(client, sessionID)) {
964
+ if (!await sessionIsIdle(client, sessionID, directory)) {
861
965
  await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
862
966
  return
863
967
  }
@@ -869,6 +973,7 @@ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
869
973
  })
870
974
  }, wait)
871
975
  dueTimers.set(sessionID, timer)
976
+ await startWatchdog(directory, client, sessionID)
872
977
  }
873
978
 
874
979
  function dueJobs(state, force = false) {
@@ -1130,112 +1235,128 @@ ${prompt}` }] } })
1130
1235
  }
1131
1236
 
1132
1237
  async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1238
+ rememberSession(directory, client, sessionID)
1133
1239
  const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
1134
1240
 
1135
- if (!await sessionIsIdle(client, sessionID)) {
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)
1138
- return
1139
- }
1140
- const active = activeRuns.get(sessionID)
1141
- if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
1241
+ if (runLocks.has(sessionID)) {
1142
1242
  await reschedule(BUSY_RETRY_MS)
1143
1243
  return
1144
1244
  }
1245
+ runLocks.set(sessionID, now())
1246
+ let job
1247
+ try {
1248
+ await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
1249
+ if (!await sessionIsIdle(client, sessionID, directory)) {
1250
+ if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
1251
+ await reschedule(BUSY_RETRY_MS)
1252
+ return
1253
+ }
1145
1254
 
1146
- const state = await readState(directory, sessionID)
1147
- for (const job of state.jobs || []) {
1148
- if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
1149
- }
1150
- let due = dueJobs(state, options.force)
1151
- if (!due.length) {
1152
- await writeState(directory, sessionID, state)
1153
- await reschedule()
1154
- return
1155
- }
1156
- let job = due[0]
1255
+ const active = activeRuns.get(sessionID)
1256
+ const activeAge = active ? now() - (active.startedAt || 0) : 0
1257
+ const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
1258
+ if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
1259
+ await reschedule(BUSY_RETRY_MS)
1260
+ return
1261
+ }
1262
+ if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
1157
1263
 
1158
- if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
1159
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1160
- await writeState(directory, sessionID, state)
1161
- await notifyJob(directory, job, "max_runtime_reached")
1162
- await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1163
- await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1164
- await reschedule()
1165
- return
1166
- }
1167
- if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
1168
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1169
- await writeState(directory, sessionID, state)
1170
- await notifyJob(directory, job, "stop_file")
1171
- await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
1172
- await reschedule()
1173
- return
1174
- }
1175
- if (await untilReached(directory, job)) {
1176
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1177
- await writeState(directory, sessionID, state)
1178
- await notifyJob(directory, job, "until_reached")
1179
- await toast(client, `Loop stopped by --until: ${job.until}`, "success")
1180
- await reschedule()
1181
- return
1182
- }
1264
+ const state = await readState(directory, sessionID)
1265
+ for (const candidate of state.jobs || []) {
1266
+ if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
1267
+ }
1268
+ const due = dueJobs(state, options.force)
1269
+ if (!due.length) {
1270
+ await writeState(directory, sessionID, state)
1271
+ await reschedule()
1272
+ return
1273
+ }
1274
+ job = due[0]
1183
1275
 
1184
- if (job.preflightCommand) {
1185
- if (job.safe && dangerousShell(job.preflightCommand)) {
1186
- job.paused = true
1276
+ if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
1277
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1187
1278
  await writeState(directory, sessionID, state)
1188
- await notifyJob(directory, job, "preflight_blocked")
1189
- await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
1279
+ await notifyJob(directory, job, "max_runtime_reached")
1280
+ await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1281
+ await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1190
1282
  await reschedule()
1191
1283
  return
1192
1284
  }
1193
- const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
1194
- await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
1195
- if (preflight.code !== 0) {
1196
- job.paused = true
1197
- job.failureCount = (job.failureCount || 0) + 1
1198
- job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
1199
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1285
+ if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
1286
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1200
1287
  await writeState(directory, sessionID, state)
1201
- await notifyJob(directory, job, "preflight_failed")
1202
- await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
1288
+ await notifyJob(directory, job, "stop_file")
1289
+ await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
1203
1290
  await reschedule()
1204
1291
  return
1205
1292
  }
1206
- }
1207
-
1208
- job = await ensureBranch(directory, job, client, sessionID)
1209
- job = await maybeCompact(client, sessionID, job)
1210
- job.lastRunAt = now()
1211
- job.runCount = (job.runCount || 0) + 1
1212
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
1213
- job.enabled = false
1214
- await notifyJob(directory, job, "max_runs_reached")
1215
- }
1216
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1217
- await writeState(directory, sessionID, state)
1218
- await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
1219
- await toast(client, `Loop running: ${job.name || job.id}`, "info")
1220
-
1221
- try {
1222
- const result = await fireAction(directory, client, sessionID, job)
1223
- if (!result.startsAssistantTurn) {
1224
- const fresh = await readState(directory, sessionID)
1225
- fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1226
- await writeState(directory, sessionID, fresh)
1293
+ if (await untilReached(directory, job)) {
1294
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1295
+ await writeState(directory, sessionID, state)
1296
+ await notifyJob(directory, job, "until_reached")
1297
+ await toast(client, `Loop stopped by --until: ${job.until}`, "success")
1227
1298
  await reschedule()
1228
1299
  return
1229
1300
  }
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)
1234
- } catch (error) {
1235
- clearActiveRun(sessionID)
1236
- await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
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)
1301
+
1302
+ if (job.preflightCommand) {
1303
+ if (job.safe && dangerousShell(job.preflightCommand)) {
1304
+ job.paused = true
1305
+ await writeState(directory, sessionID, state)
1306
+ await notifyJob(directory, job, "preflight_blocked")
1307
+ await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
1308
+ await reschedule()
1309
+ return
1310
+ }
1311
+ const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
1312
+ await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
1313
+ if (preflight.code !== 0) {
1314
+ job.paused = true
1315
+ job.failureCount = (job.failureCount || 0) + 1
1316
+ job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
1317
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1318
+ await writeState(directory, sessionID, state)
1319
+ await notifyJob(directory, job, "preflight_failed")
1320
+ await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
1321
+ await reschedule()
1322
+ return
1323
+ }
1324
+ }
1325
+
1326
+ job = await ensureBranch(directory, job, client, sessionID)
1327
+ job = await maybeCompact(client, sessionID, job)
1328
+ job.lastRunAt = now()
1329
+ job.runCount = (job.runCount || 0) + 1
1330
+ if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
1331
+ job.enabled = false
1332
+ await notifyJob(directory, job, "max_runs_reached")
1333
+ }
1334
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1335
+ await writeState(directory, sessionID, state)
1336
+ await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
1337
+ await toast(client, `Loop running: ${job.name || job.id}`, "info")
1338
+
1339
+ try {
1340
+ const result = await fireAction(directory, client, sessionID, job)
1341
+ if (!result.startsAssistantTurn) {
1342
+ const fresh = await readState(directory, sessionID)
1343
+ fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1344
+ await writeState(directory, sessionID, fresh)
1345
+ await reschedule()
1346
+ return
1347
+ }
1348
+ let timer
1349
+ 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)
1350
+ activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1351
+ await reschedule(BUSY_RETRY_MS)
1352
+ } catch (error) {
1353
+ clearActiveRun(sessionID)
1354
+ await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
1355
+ await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
1356
+ await reschedule(BUSY_RETRY_MS)
1357
+ }
1358
+ } finally {
1359
+ runLocks.delete(sessionID)
1239
1360
  }
1240
1361
  }
1241
1362
 
@@ -1256,6 +1377,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1256
1377
  const parsed = parseLoopArgs(args, defaults)
1257
1378
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1258
1379
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1380
+ if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1259
1381
  if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
1260
1382
  const state = await readState(directory, sessionID)
1261
1383
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -1291,6 +1413,7 @@ async function stopLoop(directory, client, sessionID, args) {
1291
1413
  await removeState(directory, sessionID)
1292
1414
  clearActiveRun(sessionID)
1293
1415
  const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
1416
+ stopWatchdog(sessionID)
1294
1417
  await toast(client, "All loops stopped for this session.", "success")
1295
1418
  return
1296
1419
  }
@@ -1467,6 +1590,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1467
1590
  const sessionID = input?.sessionID
1468
1591
  const args = input?.arguments ?? fallbackArgs ?? ""
1469
1592
  if (!sessionID || !name) return false
1593
+ rememberSession(directory, client, sessionID)
1470
1594
  if (wasHandled(sessionID, name, args)) return true
1471
1595
  markHandled(sessionID, name, args)
1472
1596
 
@@ -1551,6 +1675,7 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1551
1675
  await handleCommand(directory, client, props, props.name, props.arguments)
1552
1676
  }
1553
1677
  const statusUpdate = updateSessionStatusFromEvent(event)
1678
+ if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
1554
1679
  if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
1555
1680
  },
1556
1681
  }