@bybrawe/opencode-loop 0.5.28 → 0.5.30

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/publish-workflow-test.mjs +25 -0
  21. package/scripts/scheduler-runtime-test.mjs +147 -0
  22. package/scripts/session-activity-test.mjs +95 -0
  23. package/scripts/session-status-test.mjs +220 -0
  24. package/scripts/v2-command-adapter-test.mjs +113 -0
  25. package/scripts/v2-command-runtime-test.mjs +98 -0
  26. package/scripts/v2-diagnostics-test.mjs +74 -0
  27. package/scripts/v2-host-contract-test.mjs +27 -0
  28. package/scripts/v2-plugin-sentinel-test.mjs +60 -8
  29. package/scripts/v2-prompt-interval-test.mjs +171 -0
  30. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  31. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  32. package/src/index.js +2751 -1594
  33. package/src/source/legacy-v1.js +168 -1860
  34. package/src/source/opencode/command-router.js +103 -0
  35. package/src/source/opencode/commands.js +83 -0
  36. package/src/source/opencode/goal-commands.js +107 -0
  37. package/src/source/opencode/loop-commands.js +195 -0
  38. package/src/source/opencode/loop-registration.js +100 -0
  39. package/src/source/opencode/messages.js +55 -0
  40. package/src/source/opencode2/capabilities.js +3 -1
  41. package/src/source/opencode2/commands.js +72 -0
  42. package/src/source/opencode2/diagnostics.js +80 -0
  43. package/src/source/opencode2/event-bridge.js +19 -1
  44. package/src/source/opencode2/events.js +64 -0
  45. package/src/source/opencode2/experimental.js +41 -1
  46. package/src/source/opencode2/host-contract.js +26 -0
  47. package/src/source/opencode2/prompt-runtime.js +381 -0
  48. package/src/source/opencode2/runtime-adapter.js +56 -0
  49. package/src/source/opencode2/status.js +40 -0
  50. package/src/source/runtime/compaction.js +122 -0
  51. package/src/source/runtime/executor.js +558 -0
  52. package/src/source/runtime/goal-policy.js +77 -0
  53. package/src/source/runtime/goal-runtime.js +231 -0
  54. package/src/source/runtime/goal-steering.js +198 -0
  55. package/src/source/runtime/job-workspace.js +148 -0
  56. package/src/source/runtime/scheduler.js +212 -0
  57. package/src/source/runtime/session-activity.js +112 -0
  58. package/src/source/runtime/session-status.js +197 -0
  59. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,212 @@
1
+ import { now } from "../core/args.js"
2
+ import { isGoalJob } from "../core/jobs.js"
3
+ import { readState } from "../core/state.js"
4
+
5
+ const DEFAULT_IDLE_DEBOUNCE_MS = 1_200
6
+ const DEFAULT_BUSY_RETRY_MS = 5_000
7
+ const DEFAULT_MIN_DUE_TIMER_MS = 250
8
+ const DEFAULT_MAX_DUE_TIMER_MS = 2_147_000_000
9
+ const DEFAULT_HEARTBEAT_MS = 2_500
10
+ const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000
11
+
12
+ export function jobDueAt(job, current = now()) {
13
+ if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
14
+ if (!job.enabled || job.paused) return Infinity
15
+ if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
16
+ if (Number(job.runNowRequestedAt || 0) > 0) return current
17
+ if (job.watchPaths?.length) return Infinity
18
+ const created = Date.parse(job.createdAt || "")
19
+ if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs) return current
20
+ if (job.intervalMs === 0) return current
21
+ if (!job.lastRunAt) {
22
+ if (job.immediate === false) return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0)
23
+ return current
24
+ }
25
+ return job.lastRunAt + (job.intervalMs || 0)
26
+ }
27
+
28
+ export function nextDueDelay(state, current = now()) {
29
+ let soonest = Infinity
30
+ for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
31
+ if (!Number.isFinite(soonest)) return Infinity
32
+ return Math.max(0, soonest - current)
33
+ }
34
+
35
+ export function createSchedulerRuntime(options = {}) {
36
+ const idleTimers = new Map()
37
+ const dueTimers = new Map()
38
+ const watchdogTimers = new Map()
39
+ const knownSessions = new Map()
40
+ let heartbeatTimer
41
+
42
+ const clock = options.now || now
43
+ const readStateFn = options.readState || readState
44
+ const setTimeoutFn = options.setTimeout || setTimeout
45
+ const clearTimeoutFn = options.clearTimeout || clearTimeout
46
+ const setIntervalFn = options.setInterval || setInterval
47
+ const clearIntervalFn = options.clearInterval || clearInterval
48
+ const idleDebounceMs = options.idleDebounceMs ?? DEFAULT_IDLE_DEBOUNCE_MS
49
+ const busyRetryMs = options.busyRetryMs ?? DEFAULT_BUSY_RETRY_MS
50
+ const minDueTimerMs = options.minDueTimerMs ?? DEFAULT_MIN_DUE_TIMER_MS
51
+ const maxDueTimerMs = options.maxDueTimerMs ?? DEFAULT_MAX_DUE_TIMER_MS
52
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
53
+ const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS
54
+
55
+ const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : (error instanceof Error ? error.message : String(error || "unknown error"))
56
+ const appendLog = async (directory, event, extra) => { if (options.appendLoopLog) await options.appendLoopLog(directory, event, extra) }
57
+ const toast = async (client, message, level) => { if (options.toast) await options.toast(client, message, level) }
58
+
59
+ function stopHeartbeatIfIdle() {
60
+ if (!knownSessions.size && heartbeatTimer) {
61
+ clearIntervalFn(heartbeatTimer)
62
+ heartbeatTimer = undefined
63
+ }
64
+ }
65
+
66
+ function startHeartbeat() {
67
+ if (heartbeatTimer) return
68
+ heartbeatTimer = setIntervalFn(() => {
69
+ for (const [sessionID, info] of [...knownSessions.entries()]) {
70
+ if (!info || clock() - (info.seenAt || 0) > sessionTtlMs) {
71
+ knownSessions.delete(sessionID)
72
+ continue
73
+ }
74
+ Promise.resolve()
75
+ .then(async () => {
76
+ await options.finalizeActiveRun?.(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
77
+ await options.maybeRunDueJobs?.(info.directory, info.client, sessionID, { heartbeat: true })
78
+ })
79
+ .catch((error) => appendLog(info.directory, "heartbeat-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
80
+ }
81
+ stopHeartbeatIfIdle()
82
+ }, heartbeatMs)
83
+ }
84
+
85
+ function rememberSession(directory, client, sessionID) {
86
+ if (!sessionID) return
87
+ knownSessions.set(sessionID, { directory, client, seenAt: clock() })
88
+ startHeartbeat()
89
+ }
90
+
91
+ function cancelIdleWork(sessionID) {
92
+ const timer = idleTimers.get(sessionID)
93
+ if (timer) clearTimeoutFn(timer)
94
+ idleTimers.delete(sessionID)
95
+ }
96
+
97
+ function cancelDueWork(sessionID) {
98
+ const timer = dueTimers.get(sessionID)
99
+ if (timer) clearTimeoutFn(timer)
100
+ dueTimers.delete(sessionID)
101
+ }
102
+
103
+ function scheduleIdleWork(directory, client, sessionID) {
104
+ cancelIdleWork(sessionID)
105
+ const timer = setTimeoutFn(() => {
106
+ idleTimers.delete(sessionID)
107
+ Promise.resolve()
108
+ .then(async () => {
109
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
110
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
111
+ return
112
+ }
113
+ await options.finalizeActiveRun?.(directory, client, sessionID)
114
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
115
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
116
+ return
117
+ }
118
+ await options.maybeRunDueJobs?.(directory, client, sessionID)
119
+ })
120
+ .catch((error) => {
121
+ toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {})
122
+ appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {})
123
+ })
124
+ }, idleDebounceMs)
125
+ idleTimers.set(sessionID, timer)
126
+ }
127
+
128
+ async function startWatchdog(directory, client, sessionID) {
129
+ if (watchdogTimers.has(sessionID)) return
130
+ const timer = setIntervalFn(() => {
131
+ Promise.resolve()
132
+ .then(async () => {
133
+ const state = await readStateFn(directory, sessionID)
134
+ const delay = nextDueDelay(state, clock())
135
+ const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
136
+ if (!hasJobs || !Number.isFinite(delay)) {
137
+ stopWatchdog(sessionID)
138
+ return
139
+ }
140
+ if (delay <= 0) await options.maybeRunDueJobs?.(directory, client, sessionID)
141
+ else await scheduleDueWork(directory, client, sessionID)
142
+ })
143
+ .catch((error) => appendLog(directory, "watchdog-error", { sessionID, error: errorMessage(error) }).catch(() => {}))
144
+ }, Math.max(1_000, busyRetryMs))
145
+ watchdogTimers.set(sessionID, timer)
146
+ }
147
+
148
+ function stopWatchdog(sessionID) {
149
+ const timer = watchdogTimers.get(sessionID)
150
+ if (timer) clearIntervalFn(timer)
151
+ watchdogTimers.delete(sessionID)
152
+ }
153
+
154
+ async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
155
+ cancelDueWork(sessionID)
156
+
157
+ const state = await readStateFn(directory, sessionID)
158
+ const delay = nextDueDelay(state, clock())
159
+ if (!Number.isFinite(delay)) return
160
+
161
+ const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs)
162
+ const timer = setTimeoutFn(() => {
163
+ dueTimers.delete(sessionID)
164
+ Promise.resolve()
165
+ .then(async () => {
166
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
167
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
168
+ return
169
+ }
170
+ await options.finalizeActiveRun?.(directory, client, sessionID)
171
+ if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
172
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
173
+ return
174
+ }
175
+ await options.maybeRunDueJobs?.(directory, client, sessionID)
176
+ })
177
+ .catch((error) => {
178
+ toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {})
179
+ appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {})
180
+ })
181
+ }, wait)
182
+ dueTimers.set(sessionID, timer)
183
+ await startWatchdog(directory, client, sessionID)
184
+ }
185
+
186
+ function sessionIDsForHost(directory, client) {
187
+ return [...knownSessions.entries()]
188
+ .filter(([, info]) => info?.directory === directory && info?.client === client)
189
+ .map(([sessionID]) => sessionID)
190
+ }
191
+
192
+ function clearSessionScheduling(sessionID) {
193
+ cancelIdleWork(sessionID)
194
+ cancelDueWork(sessionID)
195
+ stopWatchdog(sessionID)
196
+ knownSessions.delete(sessionID)
197
+ stopHeartbeatIfIdle()
198
+ }
199
+
200
+ return {
201
+ rememberSession,
202
+ scheduleIdleWork,
203
+ scheduleDueWork,
204
+ startWatchdog,
205
+ stopWatchdog,
206
+ cancelIdleWork,
207
+ cancelDueWork,
208
+ sessionIDsForHost,
209
+ clearSessionScheduling,
210
+ knownSessionCount: () => knownSessions.size,
211
+ }
212
+ }
@@ -0,0 +1,112 @@
1
+ import { now } from "../core/args.js"
2
+ import { sdkCall } from "../opencode/sdk.js"
3
+ import { updateSessionExecutionContext, deleteSessionExecutionContext } from "../opencode/session-context.js"
4
+
5
+ export const activeToolCalls = new Map()
6
+ export const sessionParents = new Map()
7
+ export const sessionStatuses = new Map()
8
+ export const sessionStatusSeenAt = new Map()
9
+
10
+ export function hasActiveToolCalls(sessionID) {
11
+ return (activeToolCalls.get(sessionID)?.size || 0) > 0
12
+ }
13
+
14
+ export function markToolCallActive(input) {
15
+ const sessionID = input?.sessionID
16
+ const callID = input?.callID
17
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
18
+ const calls = activeToolCalls.get(sessionID) || new Set()
19
+ calls.add(callID)
20
+ activeToolCalls.set(sessionID, calls)
21
+ sessionStatuses.set(sessionID, "busy")
22
+ sessionStatusSeenAt.set(sessionID, now())
23
+ }
24
+
25
+ export function markToolCallFinished(input) {
26
+ const sessionID = input?.sessionID
27
+ const callID = input?.callID
28
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
29
+ const calls = activeToolCalls.get(sessionID)
30
+ if (!calls) return
31
+ calls.delete(callID)
32
+ if (!calls.size) activeToolCalls.delete(sessionID)
33
+ }
34
+
35
+ export function updateSessionRelationship(info, removed = false) {
36
+ const sessionID = info?.id
37
+ if (typeof sessionID !== "string") return
38
+ if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
39
+ else sessionParents.set(sessionID, info.parentID)
40
+ if (!removed) updateSessionExecutionContext(info)
41
+ else deleteSessionExecutionContext(sessionID)
42
+ }
43
+
44
+ export function updateSessionRelationshipFromEvent(event) {
45
+ if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
46
+ updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
47
+ }
48
+
49
+ export function isDescendantSession(sessionID, ancestorID) {
50
+ const visited = new Set()
51
+ let current = sessionID
52
+ while (sessionParents.has(current) && !visited.has(current)) {
53
+ visited.add(current)
54
+ current = sessionParents.get(current)
55
+ if (current === ancestorID) return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ export function hasBusyDescendant(sessionID) {
61
+ for (const childID of sessionParents.keys()) {
62
+ if (!isDescendantSession(childID, sessionID)) continue
63
+ const status = sessionStatuses.get(childID)
64
+ if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ export async function refreshSessionRelationships(client, directory) {
70
+ if (!client?.session?.list) return
71
+ try {
72
+ const sessions = await sdkCall(
73
+ client.session.list.bind(client.session),
74
+ { query: { directory } },
75
+ { directory },
76
+ {},
77
+ )
78
+ if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
79
+ } catch {}
80
+ }
81
+
82
+ export function updateToolActivityFromEvent(event) {
83
+ const props = event?.properties || {}
84
+ if (event?.type === "message.part.updated") {
85
+ const part = props.part
86
+ if (part?.type !== "tool") return
87
+ const sessionID = part.sessionID || props.sessionID
88
+ if (["pending", "running"].includes(part.state?.status)) {
89
+ markToolCallActive({ sessionID, callID: part.callID })
90
+ }
91
+ if (["completed", "error"].includes(part.state?.status)) {
92
+ // Task/subagent hooks have used the part id as their hook call id in some
93
+ // OpenCode versions, while normal tools use part.callID. Clear either.
94
+ const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
95
+ for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
96
+ }
97
+ return
98
+ }
99
+
100
+ const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
101
+ const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
102
+ if (started) markToolCallActive(props)
103
+ if (finished) markToolCallFinished(props)
104
+ }
105
+
106
+ export function clearSessionActivity(sessionID) {
107
+ activeToolCalls.delete(sessionID)
108
+ sessionParents.delete(sessionID)
109
+ sessionStatuses.delete(sessionID)
110
+ sessionStatusSeenAt.delete(sessionID)
111
+ deleteSessionExecutionContext(sessionID)
112
+ }
@@ -0,0 +1,197 @@
1
+ import { now as defaultNow } from "../core/args.js"
2
+ import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
3
+ import { sdkData, sdkError } from "../opencode/sdk.js"
4
+ import { activeRunCompletionFromMessages as defaultActiveRunCompletionFromMessages } from "../opencode/host.js"
5
+ import {
6
+ sessionParents,
7
+ sessionStatuses,
8
+ sessionStatusSeenAt,
9
+ hasActiveToolCalls,
10
+ isDescendantSession,
11
+ hasBusyDescendant,
12
+ } from "./session-activity.js"
13
+
14
+ const DEFAULT_STALE_ACTIVE_RECOVERY_MS = 45_000
15
+ const DEFAULT_SESSION_STATUS_CACHE_MS = 1_500
16
+
17
+ function positiveNumber(value, fallback) {
18
+ const number = Number(value)
19
+ return Number.isFinite(number) && number > 0 ? number : fallback
20
+ }
21
+
22
+ function nonNegativeNumber(value, fallback) {
23
+ const number = Number(value)
24
+ return Number.isFinite(number) && number >= 0 ? number : fallback
25
+ }
26
+
27
+ export function createSessionStatusRuntime(options = {}) {
28
+ const activeRuns = options.activeRuns
29
+ if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
30
+ const now = typeof options.now === "function" ? options.now : defaultNow
31
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
32
+ const activeRunCompletionFromMessages = typeof options.activeRunCompletionFromMessages === "function"
33
+ ? options.activeRunCompletionFromMessages
34
+ : defaultActiveRunCompletionFromMessages
35
+ const staleActiveRecoveryMs = positiveNumber(options.staleActiveRecoveryMs, DEFAULT_STALE_ACTIVE_RECOVERY_MS)
36
+ const sessionStatusCacheMs = nonNegativeNumber(options.sessionStatusCacheMs, DEFAULT_SESSION_STATUS_CACHE_MS)
37
+
38
+ function markSessionStatus(sessionID, type, observedAt = now()) {
39
+ if (typeof sessionID !== "string" || typeof type !== "string") return false
40
+ sessionStatuses.set(sessionID, type)
41
+ sessionStatusSeenAt.set(sessionID, observedAt)
42
+ return true
43
+ }
44
+
45
+ function clearSessionStatus(sessionID) {
46
+ sessionStatuses.delete(sessionID)
47
+ sessionStatusSeenAt.delete(sessionID)
48
+ }
49
+
50
+ function updateSessionStatusFromEvent(event) {
51
+ const sessionID = event?.properties?.sessionID
52
+ if (typeof sessionID !== "string") return undefined
53
+ if (event?.type === "session.idle") {
54
+ markSessionStatus(sessionID, "idle")
55
+ return { sessionID, idle: true }
56
+ }
57
+ if (event?.type === "session.status") {
58
+ const status = event?.properties?.status
59
+ const type = status && typeof status === "object" ? status.type : undefined
60
+ if (typeof type === "string") markSessionStatus(sessionID, type)
61
+ return { sessionID, idle: type === "idle" }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ function staleActiveRun(sessionID) {
67
+ const active = activeRuns.get(sessionID)
68
+ if (!active) return false
69
+ const age = now() - (active.startedAt || 0)
70
+ const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
71
+ const threshold = Number.isFinite(configured) && configured > 0 ? configured : staleActiveRecoveryMs
72
+ return age >= threshold
73
+ }
74
+
75
+ async function readLiveSessionStatus(client, sessionID, directory) {
76
+ const statusMethod = client?.session?.status
77
+ if (typeof statusMethod !== "function") return undefined
78
+ const argsList = []
79
+ if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
80
+ argsList.push({})
81
+ for (const args of argsList) {
82
+ try {
83
+ const result = await statusMethod.call(client.session, args)
84
+ const error = sdkError(result)
85
+ if (error) continue
86
+ const data = sdkData(result)
87
+ if (!data || typeof data !== "object" || Array.isArray(data)) continue
88
+ const observedAt = now()
89
+ for (const [observedSessionID, observedStatus] of Object.entries(data)) {
90
+ const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
91
+ if (typeof observedType !== "string") continue
92
+ markSessionStatus(observedSessionID, observedType, observedAt)
93
+ }
94
+ // OpenCode's status list contains active sessions; idle sessions are
95
+ // normally omitted. Clear a completed descendant that was previously busy.
96
+ for (const childID of sessionParents.keys()) {
97
+ if (!isDescendantSession(childID, sessionID) || data[childID]) continue
98
+ markSessionStatus(childID, "idle", observedAt)
99
+ }
100
+ if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
101
+ const status = data?.[sessionID]
102
+ const type = status && typeof status === "object" ? status.type : undefined
103
+ if (typeof type === "string") return { type, source: "sdk" }
104
+ return { type: "idle", source: "sdk" }
105
+ } catch {}
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
111
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
112
+ if (!options.requireIdle && !options.forceStale) return true
113
+
114
+ const completion = options.forceStale
115
+ ? await activeRunCompletionFromMessages(directory, client, sessionID, active)
116
+ : undefined
117
+ if (completion === "completed") return true
118
+ if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
119
+
120
+ const live = await readLiveSessionStatus(client, sessionID, directory)
121
+ if (live?.type === "idle") return true
122
+ if (live?.type) {
123
+ if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
124
+ return false
125
+ }
126
+
127
+ if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
128
+ const cached = sessionStatuses.get(sessionID)
129
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
130
+ return cached === "idle" && seenAt > (active.startedAt || 0)
131
+ }
132
+
133
+ async function sessionStatusType(client, sessionID, directory, options = {}) {
134
+ // OpenCode can briefly report an idle session while a long-running tool or
135
+ // subtask is still executing. Tool lifecycle hooks are the more specific
136
+ // signal here, so never enqueue another turn until every active call ends.
137
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
138
+ markSessionStatus(sessionID, "busy")
139
+ return "busy"
140
+ }
141
+
142
+ const cached = sessionStatuses.get(sessionID)
143
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
144
+
145
+ // Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
146
+ // trusted briefly: OpenCode custom commands such as /loop-status create their
147
+ // own short assistant turn, and some TUI builds do not always emit the final
148
+ // idle event after that turn. If we cache busy forever, due loop work can get
149
+ // stuck at "due in every idle" until the user types another command.
150
+ if (cached === "idle") return cached
151
+ if (cached && now() - seenAt < sessionStatusCacheMs) return cached
152
+
153
+ const live = await readLiveSessionStatus(client, sessionID, directory)
154
+ if (live?.type) {
155
+ // Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
156
+ // plugin-injected turn until the next user command touches the session.
157
+ // When the only reason we still think the session is busy is our own stale
158
+ // active-run guard, recover instead of waiting for another manual command.
159
+ if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
160
+ const active = activeRuns.get(sessionID)
161
+ if (active) {
162
+ const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
163
+ if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
164
+ markSessionStatus(sessionID, "idle")
165
+ await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
166
+ sessionID,
167
+ job: active.job?.name || active.jobId,
168
+ startedAt: active.startedAt,
169
+ })
170
+ return "idle"
171
+ }
172
+ }
173
+ }
174
+ markSessionStatus(sessionID, live.type)
175
+ return live.type
176
+ }
177
+
178
+ const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
179
+ markSessionStatus(sessionID, fallback)
180
+ return fallback
181
+ }
182
+
183
+ async function sessionIsIdle(client, sessionID, directory, options = {}) {
184
+ return await sessionStatusType(client, sessionID, directory, options) === "idle"
185
+ }
186
+
187
+ return {
188
+ markSessionStatus,
189
+ clearSessionStatus,
190
+ updateSessionStatusFromEvent,
191
+ staleActiveRun,
192
+ canFinalizeActiveRun,
193
+ readLiveSessionStatus,
194
+ sessionStatusType,
195
+ sessionIsIdle,
196
+ }
197
+ }
@@ -1,22 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { promises as fs } from "node:fs"
3
-
4
- const legacy = await fs.readFile("src/source/legacy-v1.js", "utf8")
5
- const host = await fs.readFile("src/source/opencode/host.js", "utf8")
6
-
7
- const legacyStart = legacy.indexOf("function fireSdk(")
8
- const legacyEnd = legacy.indexOf("function guardLoopOwnedUserMessage(")
9
- const hostStart = host.indexOf("export function fireSdk(")
10
- assert.ok(legacyStart >= 0 && legacyEnd > legacyStart, "legacy host bridge block not found")
11
- assert.ok(hostStart >= 0, "host bridge module block not found")
12
-
13
- const normalize = (text) => text
14
- .replace(/\bexport\s+(?=(?:async\s+)?function\b)/g, "")
15
- .replace(/\r\n/g, "\n")
16
- .trim()
17
-
18
- const legacyBlock = normalize(legacy.slice(legacyStart, legacyEnd))
19
- const hostBlock = normalize(host.slice(hostStart))
20
- assert.equal(hostBlock, legacyBlock, "OpenCode host bridge drifted from the stable V1 implementation")
21
-
22
- console.log("host bridge parity test passed")