@bybrawe/opencode-loop 0.5.15 → 0.5.16

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.16
4
+
5
+ - Treat active OpenCode tools, shell calls, and foreground or background subtasks as busy even if the parent session receives an idle/status event while they are still running.
6
+ - Prevent stale active-run recovery from finalizing a loop turn while any tracked tool call remains active.
7
+ - Added regression coverage proving that a due loop waits for long-running tools and background child sessions, then resumes only after that work finishes and the parent session becomes idle.
8
+
3
9
  ## 0.5.15
4
10
 
5
11
  - Hardened experimental Goal Mode completion: agent tool completion now requires concrete evidence instead of accepting empty or generic claims.
package/README.md CHANGED
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
16
16
 
17
17
  ## Current status
18
18
 
19
- **v0.5.15 is a Goal Mode and SDK compatibility hardening release.** It keeps the timer/watchdog reliability fixes from v0.5.11-v0.5.14, then tightens experimental goals with evidence-gated completion, passing-check requirements, no-progress pausing, user-message interruption, explicit workspace paths, and safer active-turn recovery. It also prefers the OpenCode plugin SDK argument shape while keeping fallbacks for older builds.
19
+ **v0.5.16 fixes idle detection during long-running tools and background subtasks.** The scheduler now treats OpenCode tool lifecycle activity and running child sessions as stronger busy signals than a premature parent idle/status event, so due prompts wait instead of filling the queue. It keeps the Goal Mode and SDK compatibility hardening from v0.5.15.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
@@ -32,14 +32,14 @@ The known update-related symptoms from older builds are fixed:
32
32
 
33
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`.
34
34
 
35
- ## v0.5.15 quick behavior guide
35
+ ## v0.5.16 quick behavior guide
36
36
 
37
37
  OpenCode Loop has two triggers now:
38
38
 
39
39
  1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
40
40
  2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
41
41
 
42
- 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.
42
+ All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, or while a tool/background child session is still executing, the job does not interrupt the active turn. It waits and retries until that work finishes and the parent session becomes idle.
43
43
 
44
44
  Simple examples:
45
45
 
@@ -352,7 +352,7 @@ Experimental goal mode: keep working until the objective is actually done, block
352
352
 
353
353
  ### What “when idle” means
354
354
 
355
- OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
355
+ OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy. Active tool calls and running child sessions also count as busy, even if OpenCode emits a premature idle/status signal for the parent during long-running background work.
356
356
 
357
357
  Example:
358
358
 
@@ -1112,6 +1112,12 @@ Improve the application in small safe steps.
1112
1112
 
1113
1113
  ## Changelog highlights
1114
1114
 
1115
+ ### v0.5.16
1116
+
1117
+ - Treat active tools, shell calls, and running foreground/background child sessions as busy even when the parent session appears idle.
1118
+ - Prevent stale-run recovery from enqueueing another continuation while tracked background work is still active.
1119
+ - Added regression tests for long-running tool calls and background child sessions.
1120
+
1115
1121
  ### v0.5.15
1116
1122
 
1117
1123
  - Hardened experimental Goal Mode completion with concrete evidence, passing-check requirements, no-progress pausing, and user-message interruption.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
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",
@@ -9,6 +9,7 @@ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-smoke-"
9
9
  const sessionID = "ses_smoke_goal"
10
10
  const attempts = { log: 0, prompt: 0, status: 0, toast: 0 }
11
11
  const prompts = []
12
+ const liveStatuses = new Map([[sessionID, "idle"]])
12
13
  let hooks
13
14
 
14
15
  function legacyBody(name) {
@@ -31,6 +32,10 @@ const client = {
31
32
  showToast: legacyBody("toast"),
32
33
  },
33
34
  session: {
35
+ list: async (args) => {
36
+ assert.equal(args?.query?.directory, directory)
37
+ return { data: [{ id: sessionID, directory, projectID: "project", title: "smoke", version: "test", time: { created: Date.now(), updated: Date.now() } }] }
38
+ },
34
39
  abort: async (args) => {
35
40
  assert.equal(args?.path?.id, sessionID)
36
41
  return { data: true }
@@ -55,7 +60,7 @@ const client = {
55
60
  status: async (args) => {
56
61
  attempts.status++
57
62
  assert.equal(args?.query?.directory, directory, "session.status must use query.directory first")
58
- return { data: { [sessionID]: { type: "idle" } } }
63
+ return { data: Object.fromEntries([...liveStatuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }])) }
59
64
  },
60
65
  summarize: async (args) => {
61
66
  assert.equal(args?.path?.id, sessionID)
@@ -103,6 +108,44 @@ try {
103
108
  assert.equal(completedState.jobs[0].paused, true)
104
109
  assert.equal(completedState.jobs[0].enabled, false)
105
110
 
111
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
112
+
113
+ const promptCountBeforeBackgroundTool = prompts.length
114
+ await hooks["tool.execute.before"]({ tool: "bash", sessionID, callID: "call_background" }, { args: {} })
115
+ await hooks["command.execute.before"]({
116
+ command: "loop",
117
+ sessionID,
118
+ arguments: "0s --max-runs 1 continue after the background task",
119
+ }, { parts: [] })
120
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
121
+ assert.equal(prompts.length, promptCountBeforeBackgroundTool, "an active tool call must keep the loop busy even when session.status says idle")
122
+
123
+ await hooks["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_background", args: {} }, { title: "done", output: "", metadata: {} })
124
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
125
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
126
+ assert.equal(prompts.length, promptCountBeforeBackgroundTool + 1, "the due loop must resume after the active tool finishes and the session becomes idle")
127
+
128
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
129
+
130
+ const childSessionID = "ses_background_child"
131
+ liveStatuses.set(childSessionID, "busy")
132
+ await hooks.event({ event: { type: "session.created", properties: { info: { id: childSessionID, parentID: sessionID } } } })
133
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "busy" } } } })
134
+ const promptCountBeforeBackgroundChild = prompts.length
135
+ await hooks["command.execute.before"]({
136
+ command: "loop",
137
+ sessionID,
138
+ arguments: "0s --max-runs 1 continue after the background subtask",
139
+ }, { parts: [] })
140
+ await new Promise((resolve) => setTimeout(resolve, 2_700))
141
+ assert.equal(prompts.length, promptCountBeforeBackgroundChild, "a running background child session must keep its parent loop busy")
142
+
143
+ liveStatuses.set(childSessionID, "idle")
144
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "idle" } } } })
145
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
146
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
147
+ assert.equal(prompts.length, promptCountBeforeBackgroundChild + 1, "the parent loop must resume after its background child becomes idle")
148
+
106
149
  await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
107
150
  assert.equal(attempts.log, 1)
108
151
  assert.ok(attempts.prompt >= 1)
package/src/index.js CHANGED
@@ -30,6 +30,8 @@ const watchdogTimers = new Map()
30
30
  const runLocks = new Map()
31
31
  const knownSessions = new Map()
32
32
  const stateWriteLocks = new Map()
33
+ const activeToolCalls = new Map()
34
+ const sessionParents = new Map()
33
35
  let heartbeatTimer
34
36
  const sessionStatuses = new Map()
35
37
  const sessionStatusSeenAt = new Map()
@@ -570,6 +572,100 @@ function rememberSession(directory, client, sessionID) {
570
572
  startHeartbeat()
571
573
  }
572
574
 
575
+ function hasActiveToolCalls(sessionID) {
576
+ return (activeToolCalls.get(sessionID)?.size || 0) > 0
577
+ }
578
+
579
+ function markToolCallActive(input) {
580
+ const sessionID = input?.sessionID
581
+ const callID = input?.callID
582
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
583
+ const calls = activeToolCalls.get(sessionID) || new Set()
584
+ calls.add(callID)
585
+ activeToolCalls.set(sessionID, calls)
586
+ sessionStatuses.set(sessionID, "busy")
587
+ sessionStatusSeenAt.set(sessionID, now())
588
+ }
589
+
590
+ function markToolCallFinished(input) {
591
+ const sessionID = input?.sessionID
592
+ const callID = input?.callID
593
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
594
+ const calls = activeToolCalls.get(sessionID)
595
+ if (!calls) return
596
+ calls.delete(callID)
597
+ if (!calls.size) activeToolCalls.delete(sessionID)
598
+ }
599
+
600
+ function updateSessionRelationship(info, removed = false) {
601
+ const sessionID = info?.id
602
+ if (typeof sessionID !== "string") return
603
+ if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
604
+ else sessionParents.set(sessionID, info.parentID)
605
+ }
606
+
607
+ function updateSessionRelationshipFromEvent(event) {
608
+ if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
609
+ updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
610
+ }
611
+
612
+ function isDescendantSession(sessionID, ancestorID) {
613
+ const visited = new Set()
614
+ let current = sessionID
615
+ while (sessionParents.has(current) && !visited.has(current)) {
616
+ visited.add(current)
617
+ current = sessionParents.get(current)
618
+ if (current === ancestorID) return true
619
+ }
620
+ return false
621
+ }
622
+
623
+ function hasBusyDescendant(sessionID) {
624
+ for (const childID of sessionParents.keys()) {
625
+ if (!isDescendantSession(childID, sessionID)) continue
626
+ const status = sessionStatuses.get(childID)
627
+ if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
628
+ }
629
+ return false
630
+ }
631
+
632
+ async function refreshSessionRelationships(client, directory) {
633
+ if (!client?.session?.list) return
634
+ try {
635
+ const sessions = await sdkCall(
636
+ client.session.list.bind(client.session),
637
+ { query: { directory } },
638
+ { directory },
639
+ {},
640
+ )
641
+ if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
642
+ } catch {}
643
+ }
644
+
645
+ function updateToolActivityFromEvent(event) {
646
+ const props = event?.properties || {}
647
+ if (event?.type === "message.part.updated") {
648
+ const part = props.part
649
+ if (part?.type !== "tool") return
650
+ const sessionID = part.sessionID || props.sessionID
651
+ if (["pending", "running"].includes(part.state?.status)) {
652
+ markToolCallActive({ sessionID, callID: part.callID })
653
+ }
654
+ if (["completed", "error"].includes(part.state?.status)) {
655
+ // Task/subagent hooks have used the part id as their hook call id in some
656
+ // OpenCode versions, while normal tools use part.callID. Clear either.
657
+ const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
658
+ for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
659
+ }
660
+ return
661
+ }
662
+
663
+ const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
664
+ const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
665
+ if (started) markToolCallActive(props)
666
+ if (finished) markToolCallFinished(props)
667
+ }
668
+
573
669
  function startHeartbeat() {
574
670
  if (heartbeatTimer) return
575
671
  heartbeatTimer = setInterval(() => {
@@ -607,6 +703,8 @@ function disposeRuntime(directory, client) {
607
703
  runLocks.delete(sessionID)
608
704
  knownSessions.delete(sessionID)
609
705
  loopOwnedUserMessageGuards.delete(sessionID)
706
+ activeToolCalls.delete(sessionID)
707
+ sessionParents.delete(sessionID)
610
708
  sessionStatuses.delete(sessionID)
611
709
  sessionStatusSeenAt.delete(sessionID)
612
710
  for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
@@ -970,6 +1068,7 @@ function staleActiveRun(sessionID) {
970
1068
  }
971
1069
 
972
1070
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
1071
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
973
1072
  if (!options.requireIdle && !options.forceStale) return true
974
1073
  if (options.forceStale && staleActiveRun(sessionID)) return true
975
1074
  if (!options.requireIdle) return false
@@ -992,15 +1091,41 @@ async function readLiveSessionStatus(client, sessionID, directory) {
992
1091
  const error = sdkError(result)
993
1092
  if (error) continue
994
1093
  const data = sdkData(result)
1094
+ if (!data || typeof data !== "object" || Array.isArray(data)) continue
1095
+ const observedAt = now()
1096
+ for (const [observedSessionID, observedStatus] of Object.entries(data)) {
1097
+ const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
1098
+ if (typeof observedType !== "string") continue
1099
+ sessionStatuses.set(observedSessionID, observedType)
1100
+ sessionStatusSeenAt.set(observedSessionID, observedAt)
1101
+ }
1102
+ // OpenCode's status list contains active sessions; idle sessions are
1103
+ // normally omitted. Clear a completed descendant that was previously busy.
1104
+ for (const childID of sessionParents.keys()) {
1105
+ if (!isDescendantSession(childID, sessionID) || data[childID]) continue
1106
+ sessionStatuses.set(childID, "idle")
1107
+ sessionStatusSeenAt.set(childID, observedAt)
1108
+ }
1109
+ if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
995
1110
  const status = data?.[sessionID]
996
1111
  const type = status && typeof status === "object" ? status.type : undefined
997
1112
  if (typeof type === "string") return { type, source: "sdk" }
1113
+ return { type: "idle", source: "sdk" }
998
1114
  } catch {}
999
1115
  }
1000
1116
  return undefined
1001
1117
  }
1002
1118
 
1003
1119
  async function sessionStatusType(client, sessionID, directory, options = {}) {
1120
+ // OpenCode can briefly report an idle session while a long-running tool or
1121
+ // subtask is still executing. Tool lifecycle hooks are the more specific
1122
+ // signal here, so never enqueue another turn until every active call ends.
1123
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
1124
+ sessionStatuses.set(sessionID, "busy")
1125
+ sessionStatusSeenAt.set(sessionID, now())
1126
+ return "busy"
1127
+ }
1128
+
1004
1129
  const cached = sessionStatuses.get(sessionID)
1005
1130
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
1006
1131
 
@@ -1971,11 +2096,16 @@ function goalTools(defaultDirectory) {
1971
2096
 
1972
2097
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1973
2098
  await log(client, "info", "Plugin initialized", { directory })
2099
+ await refreshSessionRelationships(client, directory)
1974
2100
  return {
1975
2101
  dispose: async () => { disposeRuntime(directory, client) },
1976
2102
  tool: goalTools(directory),
1977
2103
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
2104
+ "tool.execute.before": async (input) => { markToolCallActive(input) },
2105
+ "tool.execute.after": async (input) => { markToolCallFinished(input) },
1978
2106
  event: async ({ event }) => {
2107
+ updateSessionRelationshipFromEvent(event)
2108
+ updateToolActivityFromEvent(event)
1979
2109
  if (event.type === "command.executed") {
1980
2110
  const props = event.properties || {}
1981
2111
  await handleCommand(directory, client, props, props.name, props.arguments)