@bybrawe/opencode-loop 0.5.7 → 0.5.9

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.9
4
+
5
+ - 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.
6
+ - Improved due-timer behavior when OpenCode misses or delays the final idle event after a command turn.
7
+ - Fixed `/compact` routing for current OpenCode TUI builds by trying `session_compact` first, then `session.compact`, then the session summarize fallback.
8
+ - Kept the command markdown templates short; OpenCode still creates a tiny `OK` turn for custom commands because markdown commands are prompt templates.
9
+
10
+ ## 0.5.8
11
+
12
+ - Added a real due timer for TUI loop jobs, so delayed jobs can wake without waiting for another OpenCode event.
13
+ - Fixed `/loop 1m --no-now ...` style jobs so the first delayed run can fire on time when OpenCode is idle.
14
+ - Fixed scheduled action types such as `/loop-ask`, `/loop-command`, and `/loop-shell` so 0s and delayed jobs are scheduled even when their default mode is `--no-now`.
15
+ - Added busy retry behavior: if a due timer expires while OpenCode is busy, the job waits and retries instead of interrupting the active turn.
16
+ - Reschedules due timers after add, stop, pause, resume, run completion, goal pause/resume/clear, and post-run verification.
17
+ - Shortened command markdown templates so unavoidable custom command turns reply `OK` instead of producing misleading status text.
18
+ - Kept Experimental Goal Mode marked experimental.
19
+
3
20
  ## 0.5.7
4
21
 
5
22
  - Added experimental Goal Mode with `/loop-goal <objective>`.
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.7 adds experimental Goal Mode.** OpenCode Loop still fixes the recent OpenCode update compatibility issue and keeps the clear action types from v0.5.6. The TUI loop waits for the session to become idle, debounces idle events, and avoids starting a new run while OpenCode is still busy or queued. It separates prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, compact loops, and now experimental persistent goals so commands like `/compact` are not sent as normal chat text.
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.
15
15
 
16
16
  The known update-related symptoms from older builds are fixed:
17
17
 
@@ -22,9 +22,60 @@ The known update-related symptoms from older builds are fixed:
22
22
  - `/compact` accidentally being treated as a normal agent prompt
23
23
  - intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
24
24
  - experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
25
+ - `--no-now` interval jobs not waking until another OpenCode event happens
26
+ - `/loop-ask`, `/loop-command`, and `/loop-shell` being configured but not reliably firing on their own
25
27
 
26
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`.
27
29
 
30
+ ## v0.5.9 quick behavior guide
31
+
32
+ OpenCode Loop has two triggers now:
33
+
34
+ 1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
35
+ 2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
36
+
37
+ All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, the job does not interrupt the active turn. It waits and retries until the session becomes idle.
38
+
39
+ Simple examples:
40
+
41
+ ```text
42
+ /loop 0s continue the project
43
+ ```
44
+
45
+ Run every time OpenCode becomes idle.
46
+
47
+ ```text
48
+ /loop 1m --no-now continue the project
49
+ ```
50
+
51
+ Wait 1 minute first, then run when OpenCode is idle.
52
+
53
+ ```text
54
+ /loop-command 200m /compact
55
+ ```
56
+
57
+ Run OpenCode compact about every 200 minutes, but only when OpenCode is idle.
58
+
59
+ ```text
60
+ /loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them and fix errors.
61
+ ```
62
+
63
+ Ask a recurring quality-control question every hour.
64
+
65
+ ```text
66
+ /loop-shell 10m npm test
67
+ ```
68
+
69
+ Run a shell command every 10 minutes when idle.
70
+
71
+ ```text
72
+ /loop-goal --max-turns 5 --check "npm run build" --complete-when-checks-pass make the project build cleanly
73
+ ```
74
+
75
+ Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
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.
78
+
28
79
  ## Why this exists
29
80
 
30
81
  Claude Code users often rely on a loop-like workflow where the agent finishes one step, then immediately continues with the next step.
@@ -2,4 +2,4 @@
2
2
  description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
3
3
  ---
4
4
 
5
- Loop ask scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop ask scheduler handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Clear all OpenCode Loop jobs for this session.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop clear command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
3
3
  ---
4
4
 
5
- Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop command scheduler handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
3
3
  ---
4
4
 
5
- Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop command scheduler handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Run OpenCode Loop diagnostics.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop doctor command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Export OpenCode Loop state.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop export command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Manually mark the active experimental goal as blocked. Usage: /loop-goal-blocked reason
2
+ description: Mark experimental goal blocked.
3
3
  ---
4
4
 
5
- Experimental goal blocked command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal blocked command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Clear experimental goal jobs. Usage: /loop-goal-clear
2
+ description: Clear experimental goal jobs.
3
3
  ---
4
4
 
5
- Experimental goal clear command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal clear command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Manually mark the active experimental goal as done. Usage: /loop-goal-done summary
2
+ description: Mark experimental goal done.
3
3
  ---
4
4
 
5
- Experimental goal done command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal done command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Pause the active experimental goal. Usage: /loop-goal-pause
2
+ description: Pause experimental goal jobs.
3
3
  ---
4
4
 
5
- Experimental goal pause command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal pause command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Resume the active experimental goal. Usage: /loop-goal-resume
2
+ description: Resume experimental goal jobs.
3
3
  ---
4
4
 
5
- Experimental goal resume command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal resume command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Show experimental goal status. Usage: /loop-goal-status
2
+ description: Show experimental goal status.
3
3
  ---
4
4
 
5
- Experimental goal status command received. The OpenCode Loop plugin handles this command locally.
5
+ OpenCode Loop experimental goal status command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Experimental persistent goal mode. Usage: /loop-goal finish the feature and keep tests green
3
3
  ---
4
4
 
5
- Experimental goal command received. The OpenCode Loop plugin handles this goal locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop experimental goal command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Show OpenCode Loop help.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop help command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Create a starter progress.md file.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop init command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Show OpenCode Loop logs.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop logs command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Run due OpenCode Loop jobs now when idle.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop run-now command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Pause OpenCode Loop jobs.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop pause command handled locally. Reply exactly: OK.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: OpenCode Loop plugin command.
2
+ description: Resume OpenCode Loop jobs.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop resume command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
3
3
  ---
4
4
 
5
- Loop shell scheduler received. The OpenCode Loop plugin handles this shell schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop shell scheduler handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Show OpenCode Loop status.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop status command handled locally. Reply exactly: OK.
@@ -2,4 +2,4 @@
2
2
  description: Stop OpenCode Loop jobs.
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop stop command handled locally. Reply exactly: OK.
package/commands/loop.md CHANGED
@@ -2,4 +2,4 @@
2
2
  description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
3
3
  ---
4
4
 
5
- Loop command received. The OpenCode Loop plugin handles this command. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
5
+ OpenCode Loop local command handled. Reply exactly: OK.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.7",
4
- "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: auto-continue, command loops, compact scheduling, verification, checkpoints, and safe autonomous coding goals.",
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.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -6,6 +6,10 @@ const SERVICE = "opencode-loop"
6
6
  const STATE_DIR = ".opencode/opencode-loop"
7
7
  const DEFAULT_ACTIVE_GUARD_MS = 60_000
8
8
  const IDLE_DEBOUNCE_MS = 1_200
9
+ const BUSY_RETRY_MS = 5_000
10
+ const SESSION_STATUS_CACHE_MS = 1_500
11
+ const MIN_DUE_TIMER_MS = 250
12
+ const MAX_DUE_TIMER_MS = 2_147_000_000
9
13
  const MAX_SCAN_FILES = 200
10
14
  const MAX_SCAN_BYTES = 2_000_000
11
15
  const GOAL_REPORT_DIR = "goals"
@@ -14,7 +18,9 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
14
18
  const activeRuns = new Map()
15
19
  const handledCommands = new Map()
16
20
  const idleTimers = new Map()
21
+ const dueTimers = new Map()
17
22
  const sessionStatuses = new Map()
23
+ const sessionStatusSeenAt = new Map()
18
24
 
19
25
  const DEFAULT_PROGRESS_MD = `# Progress
20
26
 
@@ -385,16 +391,22 @@ async function executeTuiCommand(client, command) {
385
391
 
386
392
  function compactTuiCommandName(command = "compact") {
387
393
  const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
388
- if (normalized === "compact" || normalized === "summarize") return "session.compact"
394
+ if (normalized === "compact" || normalized === "summarize") return "session_compact"
389
395
  return undefined
390
396
  }
391
397
 
392
398
  async function compactSession(client, sessionID) {
393
- try {
394
- await executeTuiCommand(client, "session.compact")
395
- return true
396
- } catch (error) {
397
- await log(client, "warn", "tui session.compact failed", { error: sdkErrorMessage(error) })
399
+ // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
400
+ // current builds, while some older docs/examples mention the event value
401
+ // (session.compact). Try the alias first, then the event value, then the
402
+ // session summarize endpoint as a last resort.
403
+ for (const command of ["session_compact", "session.compact"]) {
404
+ try {
405
+ await executeTuiCommand(client, command)
406
+ return true
407
+ } catch (error) {
408
+ await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
409
+ }
398
410
  }
399
411
  try {
400
412
  await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
@@ -730,12 +742,16 @@ function updateSessionStatusFromEvent(event) {
730
742
  if (typeof sessionID !== "string") return undefined
731
743
  if (event?.type === "session.idle") {
732
744
  sessionStatuses.set(sessionID, "idle")
745
+ sessionStatusSeenAt.set(sessionID, now())
733
746
  return { sessionID, idle: true }
734
747
  }
735
748
  if (event?.type === "session.status") {
736
749
  const status = event?.properties?.status
737
750
  const type = status && typeof status === "object" ? status.type : undefined
738
- if (typeof type === "string") sessionStatuses.set(sessionID, type)
751
+ if (typeof type === "string") {
752
+ sessionStatuses.set(sessionID, type)
753
+ sessionStatusSeenAt.set(sessionID, now())
754
+ }
739
755
  return { sessionID, idle: type === "idle" }
740
756
  }
741
757
  return undefined
@@ -743,18 +759,31 @@ function updateSessionStatusFromEvent(event) {
743
759
 
744
760
  async function sessionStatusType(client, sessionID) {
745
761
  const cached = sessionStatuses.get(sessionID)
746
- if (cached && cached !== "idle") return cached
762
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
763
+
764
+ // Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
765
+ // trusted briefly: OpenCode custom commands such as /loop-status create their
766
+ // own short assistant turn, and some TUI builds do not always emit the final
767
+ // idle event after that turn. If we cache busy forever, due loop work can get
768
+ // stuck at "due in every idle" until the user types another command.
769
+ if (cached === "idle") return cached
770
+ if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
771
+
747
772
  try {
748
773
  const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
749
774
  const status = data?.[sessionID]
750
775
  const type = status && typeof status === "object" ? status.type : undefined
751
776
  if (typeof type === "string") {
752
777
  sessionStatuses.set(sessionID, type)
778
+ sessionStatusSeenAt.set(sessionID, now())
753
779
  return type
754
780
  }
755
781
  } catch {}
756
- sessionStatuses.set(sessionID, "idle")
757
- return "idle"
782
+
783
+ const fallback = activeRuns.has(sessionID) ? "busy" : "idle"
784
+ sessionStatuses.set(sessionID, fallback)
785
+ sessionStatusSeenAt.set(sessionID, now())
786
+ return fallback
758
787
  }
759
788
 
760
789
  async function sessionIsIdle(client, sessionID) {
@@ -768,9 +797,15 @@ function scheduleIdleWork(directory, client, sessionID) {
768
797
  idleTimers.delete(sessionID)
769
798
  Promise.resolve()
770
799
  .then(async () => {
771
- if (!await sessionIsIdle(client, sessionID)) return
800
+ if (!await sessionIsIdle(client, sessionID)) {
801
+ await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
802
+ return
803
+ }
772
804
  await finalizeActiveRun(directory, client, sessionID)
773
- if (!await sessionIsIdle(client, sessionID)) return
805
+ if (!await sessionIsIdle(client, sessionID)) {
806
+ await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
807
+ return
808
+ }
774
809
  await maybeRunDueJobs(directory, client, sessionID)
775
810
  })
776
811
  .catch((error) => {
@@ -781,6 +816,61 @@ function scheduleIdleWork(directory, client, sessionID) {
781
816
  idleTimers.set(sessionID, timer)
782
817
  }
783
818
 
819
+ function jobDueAt(job, current = now()) {
820
+ if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
821
+ if (!job.enabled || job.paused) return Infinity
822
+ if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
823
+ if (job.watchPaths?.length) return Infinity
824
+ const created = Date.parse(job.createdAt || new Date().toISOString())
825
+ if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
826
+ if (job.intervalMs === 0) return current
827
+ if (!job.lastRunAt) return current
828
+ return job.lastRunAt + (job.intervalMs || 0)
829
+ }
830
+
831
+ function nextDueDelay(state) {
832
+ const current = now()
833
+ let soonest = Infinity
834
+ for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
835
+ if (!Number.isFinite(soonest)) return Infinity
836
+ return Math.max(0, soonest - current)
837
+ }
838
+
839
+ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
840
+ const previous = dueTimers.get(sessionID)
841
+ if (previous) clearTimeout(previous)
842
+
843
+ const state = await readState(directory, sessionID)
844
+ const delay = nextDueDelay(state)
845
+ if (!Number.isFinite(delay)) {
846
+ dueTimers.delete(sessionID)
847
+ return
848
+ }
849
+
850
+ const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
851
+ const timer = setTimeout(() => {
852
+ dueTimers.delete(sessionID)
853
+ Promise.resolve()
854
+ .then(async () => {
855
+ if (!await sessionIsIdle(client, sessionID)) {
856
+ await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
857
+ return
858
+ }
859
+ await finalizeActiveRun(directory, client, sessionID)
860
+ if (!await sessionIsIdle(client, sessionID)) {
861
+ await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
862
+ return
863
+ }
864
+ await maybeRunDueJobs(directory, client, sessionID)
865
+ })
866
+ .catch((error) => {
867
+ toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
868
+ appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
869
+ })
870
+ }, wait)
871
+ dueTimers.set(sessionID, timer)
872
+ }
873
+
784
874
  function dueJobs(state, force = false) {
785
875
  const current = now()
786
876
  return (state.jobs || []).filter((job) => {
@@ -992,6 +1082,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
992
1082
  await writeState(directory, sessionID, state)
993
1083
  if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
994
1084
  await createCheckpoint(directory, sessionID, job, client)
1085
+ await scheduleDueWork(directory, client, sessionID)
995
1086
  }
996
1087
 
997
1088
  async function fireAction(directory, client, sessionID, job) {
@@ -1010,7 +1101,7 @@ async function fireAction(directory, client, sessionID, job) {
1010
1101
  }
1011
1102
  const tuiCommand = compactTuiCommandName(command)
1012
1103
  if (tuiCommand) {
1013
- await executeTuiCommand(client, tuiCommand)
1104
+ await compactSession(client, sessionID)
1014
1105
  return { startsAssistantTurn: true }
1015
1106
  }
1016
1107
  await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
@@ -1039,19 +1130,29 @@ ${prompt}` }] } })
1039
1130
  }
1040
1131
 
1041
1132
  async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1133
+ const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
1134
+
1042
1135
  if (!await sessionIsIdle(client, sessionID)) {
1043
- if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle event.", "info")
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)
1044
1138
  return
1045
1139
  }
1046
1140
  const active = activeRuns.get(sessionID)
1047
- if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) return
1141
+ if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
1142
+ await reschedule(BUSY_RETRY_MS)
1143
+ return
1144
+ }
1048
1145
 
1049
1146
  const state = await readState(directory, sessionID)
1050
1147
  for (const job of state.jobs || []) {
1051
1148
  if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
1052
1149
  }
1053
1150
  let due = dueJobs(state, options.force)
1054
- if (!due.length) { await writeState(directory, sessionID, state); return }
1151
+ if (!due.length) {
1152
+ await writeState(directory, sessionID, state)
1153
+ await reschedule()
1154
+ return
1155
+ }
1055
1156
  let job = due[0]
1056
1157
 
1057
1158
  if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
@@ -1060,6 +1161,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1060
1161
  await notifyJob(directory, job, "max_runtime_reached")
1061
1162
  await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1062
1163
  await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1164
+ await reschedule()
1063
1165
  return
1064
1166
  }
1065
1167
  if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
@@ -1067,6 +1169,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1067
1169
  await writeState(directory, sessionID, state)
1068
1170
  await notifyJob(directory, job, "stop_file")
1069
1171
  await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
1172
+ await reschedule()
1070
1173
  return
1071
1174
  }
1072
1175
  if (await untilReached(directory, job)) {
@@ -1074,6 +1177,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1074
1177
  await writeState(directory, sessionID, state)
1075
1178
  await notifyJob(directory, job, "until_reached")
1076
1179
  await toast(client, `Loop stopped by --until: ${job.until}`, "success")
1180
+ await reschedule()
1077
1181
  return
1078
1182
  }
1079
1183
 
@@ -1083,6 +1187,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1083
1187
  await writeState(directory, sessionID, state)
1084
1188
  await notifyJob(directory, job, "preflight_blocked")
1085
1189
  await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
1190
+ await reschedule()
1086
1191
  return
1087
1192
  }
1088
1193
  const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
@@ -1095,6 +1200,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1095
1200
  await writeState(directory, sessionID, state)
1096
1201
  await notifyJob(directory, job, "preflight_failed")
1097
1202
  await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
1203
+ await reschedule()
1098
1204
  return
1099
1205
  }
1100
1206
  }
@@ -1118,16 +1224,18 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1118
1224
  const fresh = await readState(directory, sessionID)
1119
1225
  fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1120
1226
  await writeState(directory, sessionID, fresh)
1227
+ await reschedule()
1228
+ return
1121
1229
  }
1122
- if (result.startsAssistantTurn) {
1123
- let timer
1124
- 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)
1125
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1126
- }
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)
1127
1234
  } catch (error) {
1128
1235
  clearActiveRun(sessionID)
1129
1236
  await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
1130
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)
1131
1239
  }
1132
1240
  }
1133
1241
 
@@ -1171,6 +1279,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1171
1279
 
1172
1280
  state.jobs.push(parsed.job)
1173
1281
  await writeState(directory, sessionID, state)
1282
+ await scheduleDueWork(directory, client, sessionID)
1174
1283
  if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
1175
1284
  await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
1176
1285
  await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
@@ -1178,11 +1287,18 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1178
1287
 
1179
1288
  async function stopLoop(directory, client, sessionID, args) {
1180
1289
  const target = String(args || "").trim()
1181
- if (!target || target.toLowerCase() === "all") { await removeState(directory, sessionID); clearActiveRun(sessionID); await toast(client, "All loops stopped for this session.", "success"); return }
1290
+ if (!target || target.toLowerCase() === "all") {
1291
+ await removeState(directory, sessionID)
1292
+ clearActiveRun(sessionID)
1293
+ const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
1294
+ await toast(client, "All loops stopped for this session.", "success")
1295
+ return
1296
+ }
1182
1297
  const state = await readState(directory, sessionID)
1183
1298
  const before = state.jobs.length
1184
1299
  state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
1185
1300
  await writeState(directory, sessionID, state)
1301
+ await scheduleDueWork(directory, client, sessionID)
1186
1302
  await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
1187
1303
  }
1188
1304
 
@@ -1192,6 +1308,7 @@ async function updateJobState(directory, client, sessionID, args, updater, messa
1192
1308
  let count = 0
1193
1309
  state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
1194
1310
  await writeState(directory, sessionID, state)
1311
+ await scheduleDueWork(directory, client, sessionID)
1195
1312
  await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
1196
1313
  }
1197
1314
 
@@ -1215,6 +1332,7 @@ async function pauseGoal(directory, client, sessionID, args) {
1215
1332
  let count = 0
1216
1333
  state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
1217
1334
  await writeState(directory, sessionID, state)
1335
+ await scheduleDueWork(directory, client, sessionID)
1218
1336
  await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
1219
1337
  }
1220
1338
 
@@ -1229,7 +1347,10 @@ async function resumeGoal(directory, client, sessionID, args) {
1229
1347
  })
1230
1348
  await writeState(directory, sessionID, state)
1231
1349
  await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
1232
- if (count) scheduleIdleWork(directory, client, sessionID)
1350
+ if (count) {
1351
+ await scheduleDueWork(directory, client, sessionID)
1352
+ scheduleIdleWork(directory, client, sessionID)
1353
+ }
1233
1354
  }
1234
1355
 
1235
1356
  async function clearGoal(directory, client, sessionID, args) {
@@ -1238,6 +1359,7 @@ async function clearGoal(directory, client, sessionID, args) {
1238
1359
  const before = state.jobs.length
1239
1360
  state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
1240
1361
  await writeState(directory, sessionID, state)
1362
+ await scheduleDueWork(directory, client, sessionID)
1241
1363
  await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
1242
1364
  }
1243
1365