@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +9 -3
  3. package/package.json +8 -4
  4. package/scripts/comprehensive-test.mjs +49 -4
  5. package/scripts/comprehensive-watchdog.mjs +32 -0
  6. package/scripts/host-adapter-contract-test.mjs +147 -0
  7. package/scripts/host-bridge-parity-test.mjs +22 -0
  8. package/scripts/host-loop-canary.mjs +357 -0
  9. package/scripts/loopd-test.mjs +45 -18
  10. package/scripts/loopd.mjs +92 -22
  11. package/scripts/opencode2-host-canary.mjs +194 -0
  12. package/scripts/runtime-contract-test.mjs +59 -0
  13. package/scripts/runtime-event-test.mjs +78 -0
  14. package/scripts/runtime-scope-test.mjs +46 -0
  15. package/scripts/session-manager-test.mjs +48 -0
  16. package/scripts/v2-event-bridge-test.mjs +72 -0
  17. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  18. package/scripts/v2-host-contract-test.mjs +55 -0
  19. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  20. package/src/index.js +2304 -1864
  21. package/src/opencode/v1.js +1 -0
  22. package/src/source/core/args.js +281 -0
  23. package/src/source/core/jobs.js +80 -0
  24. package/src/source/core/process.js +51 -0
  25. package/src/source/core/state.js +211 -0
  26. package/src/source/legacy-v1.js +1944 -0
  27. package/src/source/opencode/adapter.js +21 -0
  28. package/src/source/opencode/host.js +134 -0
  29. package/src/source/opencode/sdk.js +38 -0
  30. package/src/source/opencode/session-context.js +50 -0
  31. package/src/source/opencode2/capabilities.js +69 -0
  32. package/src/source/opencode2/event-bridge.js +156 -0
  33. package/src/source/opencode2/experimental.js +16 -0
  34. package/src/source/opencode2/host-contract.js +68 -0
  35. package/src/source/runtime/events.js +113 -0
  36. package/src/source/runtime/observer.js +23 -0
  37. package/src/source/runtime/scope.js +56 -0
  38. package/src/source/runtime/session-manager.js +100 -0
  39. package/src/source/runtime/session-registry.js +58 -0
  40. package/src/source/runtime/timers.js +45 -0
  41. 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
+ }
@@ -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