@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 +6 -0
- package/README.md +10 -4
- package/package.json +1 -1
- package/scripts/smoke-test.mjs +44 -1
- package/src/index.js +130 -0
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -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:
|
|
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)
|