@bybrawe/opencode-loop 0.5.27 → 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 (37) hide show
  1. package/package.json +6 -3
  2. package/scripts/comprehensive-test.mjs +18 -4
  3. package/scripts/comprehensive-watchdog.mjs +32 -0
  4. package/scripts/host-adapter-contract-test.mjs +147 -0
  5. package/scripts/host-bridge-parity-test.mjs +22 -0
  6. package/scripts/host-loop-canary.mjs +16 -2
  7. package/scripts/opencode2-host-canary.mjs +194 -0
  8. package/scripts/runtime-contract-test.mjs +59 -0
  9. package/scripts/runtime-event-test.mjs +78 -0
  10. package/scripts/runtime-scope-test.mjs +46 -0
  11. package/scripts/session-manager-test.mjs +48 -0
  12. package/scripts/v2-event-bridge-test.mjs +72 -0
  13. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  14. package/scripts/v2-host-contract-test.mjs +55 -0
  15. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  16. package/src/index.js +2278 -1918
  17. package/src/opencode/v1.js +1 -0
  18. package/src/source/core/args.js +281 -0
  19. package/src/source/core/jobs.js +80 -0
  20. package/src/source/core/process.js +51 -0
  21. package/src/source/core/state.js +211 -0
  22. package/src/source/legacy-v1.js +1944 -0
  23. package/src/source/opencode/adapter.js +21 -0
  24. package/src/source/opencode/host.js +134 -0
  25. package/src/source/opencode/sdk.js +38 -0
  26. package/src/source/opencode/session-context.js +50 -0
  27. package/src/source/opencode2/capabilities.js +69 -0
  28. package/src/source/opencode2/event-bridge.js +156 -0
  29. package/src/source/opencode2/experimental.js +16 -0
  30. package/src/source/opencode2/host-contract.js +68 -0
  31. package/src/source/runtime/events.js +113 -0
  32. package/src/source/runtime/observer.js +23 -0
  33. package/src/source/runtime/scope.js +56 -0
  34. package/src/source/runtime/session-manager.js +100 -0
  35. package/src/source/runtime/session-registry.js +58 -0
  36. package/src/source/runtime/timers.js +45 -0
  37. package/src/source/v1.js +69 -0
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict"
2
+ import { createRuntimeScope } from "../src/source/runtime/scope.js"
3
+ import { createRuntimeTimers } from "../src/source/runtime/timers.js"
4
+
5
+ const scope = createRuntimeScope()
6
+ const events = []
7
+ scope.track(() => events.push("first"))
8
+ const release = scope.track(() => events.push("released"))
9
+ scope.track(() => events.push("last"))
10
+ assert.equal(release(), true)
11
+
12
+ let calls = 0
13
+ const guarded = scope.guard(() => ++calls)
14
+ assert.equal(guarded(), 1)
15
+
16
+ const cleared = []
17
+ const api = {
18
+ setTimeout(callback) { return { callback } },
19
+ clearTimeout(handle) { cleared.push(handle) },
20
+ setInterval(callback) { return { callback } },
21
+ clearInterval(handle) { cleared.push(handle) },
22
+ }
23
+ const timers = createRuntimeTimers(scope, api)
24
+ const repeating = timers.interval(() => ++calls, 1)
25
+ repeating.handle.callback()
26
+ assert.equal(calls, 2)
27
+ assert.equal(repeating.cancel(), true)
28
+ repeating.handle.callback()
29
+ assert.equal(calls, 2)
30
+ const pending = timers.interval(() => ++calls, 1)
31
+
32
+ assert.equal(scope.dispose("done"), true)
33
+ assert.deepEqual(events, ["last", "first"])
34
+ assert.equal(scope.signal.aborted, true)
35
+ assert.equal(scope.isActive(), false)
36
+ pending.handle.callback()
37
+ assert.equal(calls, 2)
38
+ assert.ok(cleared.includes(pending.handle))
39
+ assert.equal(timers.timeout(() => {}, 1), undefined)
40
+ assert.equal(scope.dispose(), false)
41
+ assert.equal(guarded(), undefined)
42
+
43
+ scope.track(() => events.push("late"))
44
+ assert.deepEqual(events, ["last", "first", "late"])
45
+
46
+ console.log("OpenCode runtime scope test passed")
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
3
+
4
+ let clock = 1000
5
+ const cleared = []
6
+ const timerAPI = {
7
+ setTimeout(callback) { return { callback } },
8
+ clearTimeout(handle) { cleared.push(handle) },
9
+ setInterval(callback) { return { callback } },
10
+ clearInterval(handle) { cleared.push(handle) },
11
+ }
12
+ const manager = createSessionRuntimeManager({ now: () => clock, staleAfterMs: 100, timerAPI })
13
+
14
+ const first = manager.observeExternal("ses_a")
15
+ assert.equal(manager.entries()[0].seenAt, 1000)
16
+ clock = 1050
17
+ assert.equal(manager.peek("ses_a"), first)
18
+ assert.equal(manager.entries()[0].seenAt, 1000, "reads must not refresh external activity")
19
+ assert.equal(manager.observeExternal("ses_a"), first)
20
+ assert.equal(manager.entries()[0].seenAt, 1050)
21
+
22
+ first.dispose("replace")
23
+ clock = 1060
24
+ const second = manager.observeExternal("ses_a")
25
+ assert.notEqual(second, first)
26
+ assert.equal(manager.remove("ses_a", { expectedRuntime: first }), false)
27
+ assert.equal(manager.peek("ses_a"), second)
28
+
29
+ let calls = 0
30
+ const pending = second.timers.interval(() => ++calls, 10)
31
+ clock = 1159
32
+ assert.deepEqual(manager.pruneStale(), [])
33
+ clock = 1160
34
+ assert.deepEqual(manager.pruneStale(), ["ses_a"])
35
+ assert.equal(manager.peek("ses_a"), undefined)
36
+ assert.ok(cleared.includes(pending.handle))
37
+ pending.handle.callback()
38
+ assert.equal(calls, 0)
39
+
40
+ const third = manager.observeExternal("ses_a")
41
+ assert.notEqual(third, second)
42
+ assert.equal(manager.remove("ses_a", { expectedRuntime: third, reason: "done" }), true)
43
+ assert.equal(third.scope.isActive(), false)
44
+ assert.equal(manager.dispose(), true)
45
+ assert.equal(manager.dispose(), false)
46
+ assert.throws(() => manager.observeExternal("ses_b"), /disposed/)
47
+
48
+ console.log("OpenCode session runtime manager test passed")
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const seen = []
5
+ let listener
6
+ let unsubscribeCalls = 0
7
+
8
+ const bridge = createOpenCode2EventBridge({
9
+ directory: "/project",
10
+ onEvent: async (event, runtime) => seen.push({ event, runtime }),
11
+ })
12
+
13
+ await bridge.attach(async (callback) => {
14
+ listener = callback
15
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
16
+ })
17
+
18
+ await listener({
19
+ directory: "/project",
20
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
21
+ })
22
+ const first = bridge.runtimeManager.peek("ses_a")
23
+ assert.ok(first)
24
+ assert.equal(first.scope.isActive(), true)
25
+
26
+ await listener({
27
+ directory: "/project",
28
+ payload: { type: "session.status", properties: { sessionID: "ses_a", status: { type: "busy" } } },
29
+ })
30
+ assert.equal(bridge.runtimeManager.peek("ses_a"), first)
31
+
32
+ await listener({
33
+ directory: "/project",
34
+ payload: { type: "session.created", properties: { info: { id: "ses_b", directory: "/project" } } },
35
+ })
36
+ const second = bridge.runtimeManager.peek("ses_b")
37
+ assert.ok(second)
38
+ assert.notEqual(second, first)
39
+
40
+ await listener({
41
+ directory: "/other-project",
42
+ payload: { type: "session.status", properties: { sessionID: "ses_other", status: { type: "busy" } } },
43
+ })
44
+ assert.equal(bridge.runtimeManager.peek("ses_other"), undefined)
45
+
46
+ await listener({
47
+ directory: "/project",
48
+ payload: { type: "session.deleted", properties: { info: { id: "ses_a", directory: "/project" } } },
49
+ })
50
+ assert.equal(bridge.runtimeManager.peek("ses_a"), undefined)
51
+ assert.equal(first.scope.isActive(), false)
52
+ assert.equal(second.scope.isActive(), true)
53
+
54
+ await listener({
55
+ directory: "/project",
56
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
57
+ })
58
+ assert.equal(second.scope.isActive(), false)
59
+ assert.deepEqual(bridge.runtimeManager.entries(), [])
60
+
61
+ const seenCount = seen.length
62
+ await listener({
63
+ directory: "/project",
64
+ payload: { type: "session.status", properties: { sessionID: "ses_late", status: { type: "busy" } } },
65
+ })
66
+ assert.equal(seen.length, seenCount)
67
+
68
+ assert.equal(await bridge.dispose(), true)
69
+ assert.equal(await bridge.dispose(), false)
70
+ assert.equal(unsubscribeCalls, 1)
71
+
72
+ console.log("OpenCode 2 event bridge lifecycle passed")
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
5
+ const seen = []
6
+ let subscribeArgs = -1
7
+ let cleanupCalls = 0
8
+
9
+ async function* stream() {
10
+ yield {
11
+ directory: "/project",
12
+ payload: {
13
+ type: "session.created",
14
+ properties: { info: { id: "ses_stream", directory: "/project" } },
15
+ },
16
+ }
17
+ }
18
+
19
+ const bridge = createOpenCode2EventBridge({
20
+ directory: "/project",
21
+ onEvent: async (event) => seen.push(event),
22
+ })
23
+
24
+ await bridge.attach(async (...args) => {
25
+ subscribeArgs = args.length
26
+ return { stream: stream(), dispose: async () => { cleanupCalls += 1 } }
27
+ })
28
+
29
+ for (let attempt = 0; attempt < 50 && seen.length === 0; attempt++) await delay(5)
30
+ assert.equal(subscribeArgs, 0)
31
+ assert.equal(seen[0]?.action, "created")
32
+ assert.ok(bridge.runtimeManager.peek("ses_stream"))
33
+
34
+ assert.equal(await bridge.dispose(), true)
35
+ assert.equal(cleanupCalls, 1)
36
+ assert.equal(bridge.runtimeManager.peek("ses_stream"), undefined)
37
+
38
+ console.log("OpenCode 2 event stream contract passed")
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js"
3
+
4
+ let listener
5
+ let unsubscribeCalls = 0
6
+ const prompts = []
7
+ const events = []
8
+ const host = createOpenCode2HostContract({
9
+ directory: "/project",
10
+ subscribe: async (callback) => {
11
+ listener = callback
12
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
13
+ },
14
+ sendPrompt: async (request) => {
15
+ prompts.push(request)
16
+ return { accepted: true }
17
+ },
18
+ onEvent: async (event, runtime) => events.push({ event, runtime }),
19
+ })
20
+
21
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/)
22
+ assert.equal(await host.start(), true)
23
+ assert.equal(await host.start(), false)
24
+ assert.equal(typeof listener, "function")
25
+
26
+ await listener({
27
+ directory: "/project",
28
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
29
+ })
30
+ const runtime = host.runtimeManager.peek("ses_a")
31
+ assert.ok(runtime)
32
+
33
+ const result = await host.prompt({ sessionID: "ses_a", text: "continue" })
34
+ assert.deepEqual(result, { accepted: true })
35
+ assert.equal(prompts.length, 1)
36
+ assert.equal(prompts[0].sessionID, "ses_a")
37
+ assert.equal(prompts[0].text, "continue")
38
+ assert.equal(prompts[0].runtime, runtime)
39
+
40
+ await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/)
41
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/)
42
+
43
+ await listener({
44
+ directory: "/project",
45
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
46
+ })
47
+ assert.equal(host.isHostDisposed(), true)
48
+ assert.deepEqual(host.runtimeManager.entries(), [])
49
+ assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed"))
50
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/)
51
+
52
+ assert.equal(await host.dispose(), true)
53
+ assert.equal(await host.dispose(), false)
54
+ assert.equal(unsubscribeCalls, 1)
55
+ console.log("OpenCode 2 host contract passed")
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict"
2
+ import plugin, { OPENCODE_LOOP_V2_PLUGIN_ID } from "../src/source/opencode2/experimental.js"
3
+ import {
4
+ OPENCODE_LOOP_V2_COMMAND_SOURCE,
5
+ OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
6
+ OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED,
7
+ OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS,
8
+ inspectOpenCode2CommandDraft,
9
+ inspectOpenCode2Context,
10
+ openCode2LoopRuntimeStatus,
11
+ } from "../src/source/opencode2/capabilities.js"
12
+
13
+ assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID)
14
+ assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental")
15
+ assert.equal(typeof plugin.setup, "function")
16
+ assert.equal(OPENCODE_LOOP_V2_COMMAND_SOURCE, "file-definitions")
17
+ assert.equal(OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED, false)
18
+ assert.deepEqual(OPENCODE_LOOP_V2_HOST_REQUIREMENTS, ["event.subscribe", "session.prompt"])
19
+ assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["event.subscribe", "session.prompt", "runtime.adapter"])
20
+
21
+ let transforms = 0
22
+ let registered
23
+ const currentContext = {
24
+ command: {
25
+ transform: async (callback) => {
26
+ transforms += 1
27
+ registered = callback
28
+ },
29
+ },
30
+ }
31
+ const setupResult = await plugin.setup(currentContext)
32
+ assert.equal(setupResult, undefined)
33
+ assert.equal(transforms, 1)
34
+ assert.equal(typeof registered, "function")
35
+
36
+ const currentDraft = {
37
+ list: () => [],
38
+ get: () => undefined,
39
+ update: () => {},
40
+ remove: () => {},
41
+ }
42
+ await registered(currentDraft)
43
+
44
+ assert.deepEqual(inspectOpenCode2Context(currentContext), {
45
+ commandTransform: true,
46
+ eventSubscribe: false,
47
+ sessionHook: false,
48
+ sessionPrompt: false,
49
+ toolTransform: false,
50
+ toolHook: false,
51
+ })
52
+ assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
53
+ list: true,
54
+ get: true,
55
+ update: true,
56
+ remove: true,
57
+ })
58
+
59
+ const currentStatus = openCode2LoopRuntimeStatus(currentContext, currentDraft)
60
+ assert.deepEqual(currentStatus.hostBlockers, ["event.subscribe", "session.prompt"])
61
+ assert.deepEqual(currentStatus.blockers, ["event.subscribe", "session.prompt", "runtime.adapter"])
62
+ assert.equal(currentStatus.hostReady, false)
63
+ assert.equal(currentStatus.implementationReady, false)
64
+ assert.equal(currentStatus.ready, false)
65
+ assert.equal(currentStatus.commandSource, "file-definitions")
66
+
67
+ const futureContext = {
68
+ command: { transform: async () => {} },
69
+ event: { subscribe: async () => {} },
70
+ session: { prompt: async () => ({}) },
71
+ }
72
+ const futureStatus = openCode2LoopRuntimeStatus(futureContext, currentDraft)
73
+ assert.deepEqual(futureStatus.hostBlockers, [])
74
+ assert.deepEqual(futureStatus.blockers, ["runtime.adapter"])
75
+ assert.equal(futureStatus.hostReady, true)
76
+ assert.equal(futureStatus.implementationReady, false)
77
+ assert.equal(futureStatus.ready, false)
78
+
79
+ let missingCapabilityFailed = false
80
+ try {
81
+ await plugin.setup({})
82
+ } catch (error) {
83
+ missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error))
84
+ }
85
+ assert.equal(missingCapabilityFailed, true)
86
+
87
+ console.log("OpenCode 2 plugin sentinel contract passed")