@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.
- package/package.json +6 -3
- package/scripts/comprehensive-test.mjs +18 -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 +16 -2
- 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 +2278 -1918
- 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,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")
|