@bybrawe/opencode-loop 0.5.27 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
|
|
2
|
+
if (!scope?.isActive?.()) return undefined
|
|
3
|
+
let active = true
|
|
4
|
+
let release
|
|
5
|
+
let handle
|
|
6
|
+
|
|
7
|
+
const invoke = scope.guard((...args) => {
|
|
8
|
+
if (!active) return
|
|
9
|
+
if (!repeat) {
|
|
10
|
+
active = false
|
|
11
|
+
release?.()
|
|
12
|
+
}
|
|
13
|
+
callback(...args)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay)
|
|
17
|
+
release = scope.track(() => {
|
|
18
|
+
if (repeat) api.clearInterval(handle)
|
|
19
|
+
else api.clearTimeout(handle)
|
|
20
|
+
})
|
|
21
|
+
if (!ref) handle?.unref?.()
|
|
22
|
+
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
handle,
|
|
25
|
+
cancel() {
|
|
26
|
+
if (!active) return false
|
|
27
|
+
active = false
|
|
28
|
+
if (repeat) api.clearInterval(handle)
|
|
29
|
+
else api.clearTimeout(handle)
|
|
30
|
+
release()
|
|
31
|
+
return true
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createRuntimeTimers(scope, api = globalThis) {
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
timeout(callback, delay, options = {}) {
|
|
39
|
+
return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false)
|
|
40
|
+
},
|
|
41
|
+
interval(callback, delay, options = {}) {
|
|
42
|
+
return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
package/src/source/v1.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import LegacyOpenCodeLoopPlugin from "./legacy-v1.js"
|
|
2
|
+
import { createSessionRuntimeManager } from "./runtime/session-manager.js"
|
|
3
|
+
import { observeRuntimeEvent } from "./runtime/observer.js"
|
|
4
|
+
|
|
5
|
+
const clientGenerations = new WeakMap()
|
|
6
|
+
|
|
7
|
+
function reserveClientGeneration(client, directory) {
|
|
8
|
+
if (!client || typeof client !== "object") return () => true
|
|
9
|
+
let generations = clientGenerations.get(client)
|
|
10
|
+
if (!generations) {
|
|
11
|
+
generations = new Map()
|
|
12
|
+
clientGenerations.set(client, generations)
|
|
13
|
+
}
|
|
14
|
+
const key = String(directory || "")
|
|
15
|
+
const generation = (generations.get(key) || 0) + 1
|
|
16
|
+
generations.set(key, generation)
|
|
17
|
+
return () => generations.get(key) === generation
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scopedClient(client) {
|
|
21
|
+
if (!client || typeof client !== "object") return client
|
|
22
|
+
return new Proxy(client, {
|
|
23
|
+
get(target, property) {
|
|
24
|
+
const value = Reflect.get(target, property, target)
|
|
25
|
+
return typeof value === "function" ? value.bind(target) : value
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
|
|
31
|
+
for (const delay of [0, 25, 100, 500, 2_000, 10_000, 30_000]) {
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
if (!isCurrentGeneration()) return
|
|
34
|
+
Promise.resolve(dispose()).catch(() => {})
|
|
35
|
+
}, delay)
|
|
36
|
+
timer.unref?.()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const OpenCodeLoopPlugin = async (input = {}) => {
|
|
41
|
+
const realClient = input?.client
|
|
42
|
+
const isCurrentGeneration = reserveClientGeneration(realClient, input?.directory)
|
|
43
|
+
const client = scopedClient(realClient)
|
|
44
|
+
const hooks = await LegacyOpenCodeLoopPlugin({ ...input, client })
|
|
45
|
+
const legacyDispose = typeof hooks?.dispose === "function" ? hooks.dispose.bind(hooks) : undefined
|
|
46
|
+
if (!legacyDispose) return hooks
|
|
47
|
+
|
|
48
|
+
const runtimeManager = createSessionRuntimeManager()
|
|
49
|
+
const legacyEvent = typeof hooks?.event === "function" ? hooks.event.bind(hooks) : undefined
|
|
50
|
+
let disposed = false
|
|
51
|
+
return {
|
|
52
|
+
...hooks,
|
|
53
|
+
...(legacyEvent ? {
|
|
54
|
+
event: async (payload) => {
|
|
55
|
+
observeRuntimeEvent(runtimeManager, payload)
|
|
56
|
+
return await legacyEvent(payload)
|
|
57
|
+
},
|
|
58
|
+
} : {}),
|
|
59
|
+
dispose: async () => {
|
|
60
|
+
if (disposed) return
|
|
61
|
+
disposed = true
|
|
62
|
+
try { runtimeManager.dispose("plugin-disposed") } catch {}
|
|
63
|
+
await legacyDispose()
|
|
64
|
+
scheduleLegacyCleanup(legacyDispose, isCurrentGeneration)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default OpenCodeLoopPlugin
|