@bybrawe/opencode-loop 0.5.26 → 0.5.28
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 +10 -0
- package/README.md +9 -3
- package/package.json +8 -4
- package/scripts/comprehensive-test.mjs +49 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +357 -0
- package/scripts/loopd-test.mjs +45 -18
- package/scripts/loopd.mjs +92 -22
- package/scripts/opencode2-host-canary.mjs +194 -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/session-manager-test.mjs +48 -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 +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2304 -1864
- 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 +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function createRuntimeScope() {
|
|
2
|
+
const controller = new AbortController()
|
|
3
|
+
const cleanups = new Set()
|
|
4
|
+
let disposed = false
|
|
5
|
+
|
|
6
|
+
function track(cleanup) {
|
|
7
|
+
if (typeof cleanup !== "function") throw new TypeError("runtime scope cleanup must be a function")
|
|
8
|
+
if (disposed) {
|
|
9
|
+
cleanup()
|
|
10
|
+
return () => false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const entry = { cleanup }
|
|
14
|
+
cleanups.add(entry)
|
|
15
|
+
let tracked = true
|
|
16
|
+
return () => {
|
|
17
|
+
if (!tracked) return false
|
|
18
|
+
tracked = false
|
|
19
|
+
return cleanups.delete(entry)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function guard(callback) {
|
|
24
|
+
if (typeof callback !== "function") throw new TypeError("runtime scope guard requires a function")
|
|
25
|
+
return function (...args) {
|
|
26
|
+
if (disposed) return undefined
|
|
27
|
+
return callback.apply(this, args)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dispose(reason) {
|
|
32
|
+
if (disposed) return false
|
|
33
|
+
disposed = true
|
|
34
|
+
controller.abort(reason)
|
|
35
|
+
|
|
36
|
+
const errors = []
|
|
37
|
+
for (const entry of [...cleanups].reverse()) {
|
|
38
|
+
cleanups.delete(entry)
|
|
39
|
+
try {
|
|
40
|
+
entry.cleanup()
|
|
41
|
+
} catch (error) {
|
|
42
|
+
errors.push(error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (errors.length) throw new AggregateError(errors, "runtime scope cleanup failed")
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
isActive: () => !disposed,
|
|
52
|
+
track,
|
|
53
|
+
guard,
|
|
54
|
+
dispose,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { DEFAULT_SESSION_STALE_MS, createSessionRegistry } from "./session-registry.js"
|
|
2
|
+
import { createRuntimeScope } from "./scope.js"
|
|
3
|
+
import { createRuntimeTimers } from "./timers.js"
|
|
4
|
+
|
|
5
|
+
function defaultRuntimeFactory({ sessionID, timerAPI }) {
|
|
6
|
+
const scope = createRuntimeScope()
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
sessionID,
|
|
9
|
+
scope,
|
|
10
|
+
timers: createRuntimeTimers(scope, timerAPI),
|
|
11
|
+
dispose: (reason) => scope.dispose(reason),
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateRuntime(runtime, sessionID) {
|
|
16
|
+
if (!runtime || runtime.sessionID !== sessionID) throw new TypeError("session runtime factory must preserve the session ID")
|
|
17
|
+
if (typeof runtime?.scope?.isActive !== "function") throw new TypeError("session runtime factory must provide an active scope")
|
|
18
|
+
if (typeof runtime.dispose !== "function") throw new TypeError("session runtime factory must provide dispose()")
|
|
19
|
+
return runtime
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSessionRuntimeManager({
|
|
23
|
+
now = Date.now,
|
|
24
|
+
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
25
|
+
timerAPI = globalThis,
|
|
26
|
+
runtimeFactory = defaultRuntimeFactory,
|
|
27
|
+
} = {}) {
|
|
28
|
+
if (typeof runtimeFactory !== "function") throw new TypeError("session runtime manager requires a runtime factory")
|
|
29
|
+
const registry = createSessionRegistry({ now, staleAfterMs })
|
|
30
|
+
let disposed = false
|
|
31
|
+
|
|
32
|
+
function observeExternal(sessionID) {
|
|
33
|
+
if (disposed) throw new Error("session runtime manager is disposed")
|
|
34
|
+
const key = String(sessionID || "").trim()
|
|
35
|
+
if (!key) throw new TypeError("session runtime manager requires a session ID")
|
|
36
|
+
const current = registry.peek(key)
|
|
37
|
+
const runtime = current?.runtime?.scope?.isActive?.()
|
|
38
|
+
? current.runtime
|
|
39
|
+
: validateRuntime(runtimeFactory({ sessionID: key, timerAPI }), key)
|
|
40
|
+
registry.observeExternal(key, runtime)
|
|
41
|
+
return runtime
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function peek(sessionID) {
|
|
45
|
+
return registry.peek(sessionID)?.runtime
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function entries() {
|
|
49
|
+
return registry.entries()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remove(sessionID, { expectedRuntime, reason } = {}) {
|
|
53
|
+
const current = registry.peek(sessionID)
|
|
54
|
+
if (!current) return false
|
|
55
|
+
if (expectedRuntime !== undefined && current.runtime !== expectedRuntime) return false
|
|
56
|
+
if (!registry.remove(sessionID, current.runtime)) return false
|
|
57
|
+
current.runtime.dispose(reason)
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pruneStale(at = now()) {
|
|
62
|
+
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]))
|
|
63
|
+
const removed = registry.pruneStale(at)
|
|
64
|
+
const errors = []
|
|
65
|
+
for (const sessionID of removed) {
|
|
66
|
+
try {
|
|
67
|
+
before.get(sessionID)?.dispose("stale-session")
|
|
68
|
+
} catch (error) {
|
|
69
|
+
errors.push(error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (errors.length) throw new AggregateError(errors, "stale session cleanup failed")
|
|
73
|
+
return removed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dispose(reason) {
|
|
77
|
+
if (disposed) return false
|
|
78
|
+
disposed = true
|
|
79
|
+
const errors = []
|
|
80
|
+
for (const entry of registry.entries()) {
|
|
81
|
+
registry.remove(entry.sessionID, entry.runtime)
|
|
82
|
+
try {
|
|
83
|
+
entry.runtime.dispose(reason)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
errors.push(error)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (errors.length) throw new AggregateError(errors, "session runtime manager cleanup failed")
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
observeExternal,
|
|
94
|
+
peek,
|
|
95
|
+
entries,
|
|
96
|
+
remove,
|
|
97
|
+
pruneStale,
|
|
98
|
+
dispose,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
function sessionKey(sessionID) {
|
|
4
|
+
return String(sessionID || "").trim()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
8
|
+
if (typeof now !== "function") throw new TypeError("session registry requires a clock function")
|
|
9
|
+
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new TypeError("session registry requires a non-negative staleAfterMs")
|
|
10
|
+
|
|
11
|
+
const sessions = new Map()
|
|
12
|
+
|
|
13
|
+
function observeExternal(sessionID, runtime) {
|
|
14
|
+
const key = sessionKey(sessionID)
|
|
15
|
+
if (!key) throw new TypeError("session registry requires a session ID")
|
|
16
|
+
const seenAt = Number(now())
|
|
17
|
+
if (!Number.isFinite(seenAt)) throw new TypeError("session registry clock must return a finite number")
|
|
18
|
+
const entry = Object.freeze({ sessionID: key, runtime, seenAt })
|
|
19
|
+
sessions.set(key, entry)
|
|
20
|
+
return entry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function peek(sessionID) {
|
|
24
|
+
return sessions.get(sessionKey(sessionID))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function remove(sessionID, expectedRuntime) {
|
|
28
|
+
const key = sessionKey(sessionID)
|
|
29
|
+
const current = sessions.get(key)
|
|
30
|
+
if (!current) return false
|
|
31
|
+
if (arguments.length > 1 && current.runtime !== expectedRuntime) return false
|
|
32
|
+
return sessions.delete(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pruneStale(at = now()) {
|
|
36
|
+
const timestamp = Number(at)
|
|
37
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("session registry prune time must be finite")
|
|
38
|
+
const removed = []
|
|
39
|
+
for (const [key, entry] of sessions) {
|
|
40
|
+
if (timestamp - entry.seenAt < staleAfterMs) continue
|
|
41
|
+
sessions.delete(key)
|
|
42
|
+
removed.push(key)
|
|
43
|
+
}
|
|
44
|
+
return removed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entries() {
|
|
48
|
+
return [...sessions.values()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
observeExternal,
|
|
53
|
+
peek,
|
|
54
|
+
remove,
|
|
55
|
+
pruneStale,
|
|
56
|
+
entries,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
@@ -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
|