@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
|
+
"build:plugin:npm": "npm run build:plugin",
|
|
23
|
+
"prepack": "node --check src/index.js",
|
|
24
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
|
|
25
|
+
"test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
23
26
|
"canary:host": "node scripts/host-loop-canary.mjs",
|
|
24
27
|
"install:global": "node scripts/install-node.mjs",
|
|
25
28
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
@@ -6,6 +6,18 @@ import path from "node:path"
|
|
|
6
6
|
import OpenCodeLoopPlugin from "../src/index.js"
|
|
7
7
|
|
|
8
8
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9
|
+
|
|
10
|
+
async function waitForValue(read, timeoutMs = 1_000, intervalMs = 10) {
|
|
11
|
+
const deadline = Date.now() + timeoutMs
|
|
12
|
+
let value
|
|
13
|
+
while (Date.now() < deadline) {
|
|
14
|
+
value = await read()
|
|
15
|
+
if (value) return value
|
|
16
|
+
await delay(intervalMs)
|
|
17
|
+
}
|
|
18
|
+
return await read()
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
let harnessNumber = 0
|
|
10
22
|
|
|
11
23
|
async function createHarness(options = {}) {
|
|
@@ -405,9 +417,11 @@ async function testNativeCompactionLifecycleAndFallback() {
|
|
|
405
417
|
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
|
|
406
418
|
assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
|
|
407
419
|
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
408
|
-
await
|
|
409
|
-
|
|
410
|
-
|
|
420
|
+
const state = await waitForValue(async () => {
|
|
421
|
+
const candidate = await h.readState()
|
|
422
|
+
return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
|
|
423
|
+
})
|
|
424
|
+
assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
|
|
411
425
|
} finally {
|
|
412
426
|
await h.cleanup()
|
|
413
427
|
}
|
|
@@ -750,4 +764,4 @@ await testWindowsStateRenameRetriesBeforeFallback()
|
|
|
750
764
|
await testStateReadRetriesTransientPartialJson()
|
|
751
765
|
await testStateRebasePreservesConcurrentPause()
|
|
752
766
|
|
|
753
|
-
console.log("OpenCode Loop comprehensive test passed")
|
|
767
|
+
console.log("OpenCode Loop comprehensive test passed")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
|
|
6
|
+
const scriptsDir = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const rootDir = path.resolve(scriptsDir, "..")
|
|
8
|
+
const sourcePath = path.join(scriptsDir, "comprehensive-test.mjs")
|
|
9
|
+
const timeoutMs = Math.max(5_000, Number(process.env.OPENCODE_LOOP_COMPREHENSIVE_TIMEOUT_MS) || 60_000)
|
|
10
|
+
const tempPath = path.join(os.tmpdir(), `opencode-loop-comprehensive-watchdog-${process.pid}-${Date.now()}.mjs`)
|
|
11
|
+
|
|
12
|
+
const source = await fs.readFile(sourcePath, "utf8")
|
|
13
|
+
const pluginURL = pathToFileURL(path.join(rootDir, "src", "index.js")).href
|
|
14
|
+
let instrumented = source.replace('import OpenCodeLoopPlugin from "../src/index.js"', `import OpenCodeLoopPlugin from ${JSON.stringify(pluginURL)}`)
|
|
15
|
+
instrumented = instrumented.replace(
|
|
16
|
+
/^await (test[A-Za-z0-9_]+)\(\)$/gm,
|
|
17
|
+
'globalThis.__opencodeLoopActiveCase = "$1"; await $1(); globalThis.__opencodeLoopActiveCase = undefined',
|
|
18
|
+
)
|
|
19
|
+
if (instrumented === source) throw new Error("failed to instrument comprehensive test")
|
|
20
|
+
|
|
21
|
+
await fs.writeFile(tempPath, instrumented, "utf8")
|
|
22
|
+
const watchdog = setTimeout(() => {
|
|
23
|
+
const active = globalThis.__opencodeLoopActiveCase
|
|
24
|
+
throw new Error(`OpenCode Loop comprehensive test timed out after ${timeoutMs}ms${active ? ` while running ${active}` : ""}`)
|
|
25
|
+
}, timeoutMs)
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await import(`${pathToFileURL(tempPath).href}?run=${Date.now()}`)
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(watchdog)
|
|
31
|
+
try { await fs.rm(tempPath, { force: true }) } catch {}
|
|
32
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createOpenCodeHostAdapter } from "../src/source/opencode/adapter.js"
|
|
3
|
+
import {
|
|
4
|
+
activeRunCompletionFromMessages,
|
|
5
|
+
compactSession,
|
|
6
|
+
compactTuiCommandName,
|
|
7
|
+
executeTuiCommand,
|
|
8
|
+
orderedSessionMessages,
|
|
9
|
+
readRecentSessionMessages,
|
|
10
|
+
resolveCompactionModel,
|
|
11
|
+
} from "../src/source/opencode/host.js"
|
|
12
|
+
import {
|
|
13
|
+
deleteSessionExecutionContext,
|
|
14
|
+
getSessionExecutionContext,
|
|
15
|
+
} from "../src/source/opencode/session-context.js"
|
|
16
|
+
|
|
17
|
+
function messagesClient(messages) {
|
|
18
|
+
return { session: { messages: async () => ({ data: structuredClone(messages) }) } }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
assert.equal(compactTuiCommandName("/compact"), "session_compact")
|
|
22
|
+
assert.equal(compactTuiCommandName("summarize"), "session_compact")
|
|
23
|
+
assert.equal(compactTuiCommandName("other"), undefined)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
const calls = []
|
|
27
|
+
const client = { tui: { executeCommand: async (args) => {
|
|
28
|
+
calls.push(args)
|
|
29
|
+
if (args?.body) return { error: { message: "body shape rejected" } }
|
|
30
|
+
return { data: args.command }
|
|
31
|
+
} } }
|
|
32
|
+
assert.equal(await executeTuiCommand(client, "session_compact"), "session_compact")
|
|
33
|
+
assert.deepEqual(calls, [
|
|
34
|
+
{ body: { command: "session_compact" } },
|
|
35
|
+
{ command: "session_compact" },
|
|
36
|
+
])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
const calls = []
|
|
41
|
+
const client = { session: { messages: async (args) => {
|
|
42
|
+
calls.push(args)
|
|
43
|
+
if (args?.path?.id) throw new Error("id shape rejected")
|
|
44
|
+
return { data: [{ info: { id: "m1", role: "assistant" } }] }
|
|
45
|
+
} } }
|
|
46
|
+
const result = await readRecentSessionMessages(client, "ses_messages", "/tmp/project", 7)
|
|
47
|
+
assert.equal(result[0].info.id, "m1")
|
|
48
|
+
assert.equal(calls.length, 2)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
const ordered = orderedSessionMessages([
|
|
53
|
+
{ info: { id: "b", time: { created: 20 } } },
|
|
54
|
+
{ info: { id: "a", time: { created: 10 } } },
|
|
55
|
+
{ info: { id: "c", time: { created: 20 } } },
|
|
56
|
+
])
|
|
57
|
+
assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
|
|
58
|
+
|
|
59
|
+
assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
|
|
60
|
+
assert.equal(await activeRunCompletionFromMessages(
|
|
61
|
+
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
|
|
62
|
+
), "incomplete")
|
|
63
|
+
assert.equal(await activeRunCompletionFromMessages(
|
|
64
|
+
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
|
|
65
|
+
), "completed")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
const sessionID = "ses_message_model_contract"
|
|
70
|
+
deleteSessionExecutionContext(sessionID)
|
|
71
|
+
const client = messagesClient([
|
|
72
|
+
{ info: { role: "assistant", time: { created: 10 }, model: { providerID: "old", modelID: "old" } } },
|
|
73
|
+
{ info: { role: "assistant", time: { created: 20 }, model: { providerID: "provider", modelID: "model" } } },
|
|
74
|
+
])
|
|
75
|
+
assert.deepEqual(await resolveCompactionModel("/tmp", client, sessionID), { providerID: "provider", modelID: "model" })
|
|
76
|
+
assert.deepEqual(getSessionExecutionContext(sessionID)?.model, { providerID: "provider", modelID: "model" })
|
|
77
|
+
deleteSessionExecutionContext(sessionID)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
const tuiCalls = []
|
|
82
|
+
const client = {
|
|
83
|
+
app: { log: async () => ({ data: true }) },
|
|
84
|
+
tui: { executeCommand: async (args) => {
|
|
85
|
+
const command = args?.body?.command || args?.command
|
|
86
|
+
tuiCalls.push(command)
|
|
87
|
+
if (command === "session.compact") return { error: { message: "unsupported" } }
|
|
88
|
+
return { data: true }
|
|
89
|
+
} },
|
|
90
|
+
}
|
|
91
|
+
assert.equal(await compactSession("/tmp", client, "ses_tui"), true)
|
|
92
|
+
assert.ok(tuiCalls.includes("session.compact"))
|
|
93
|
+
assert.ok(tuiCalls.includes("session_compact"))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
const summaries = []
|
|
98
|
+
const client = {
|
|
99
|
+
app: { log: async () => ({ data: true }) },
|
|
100
|
+
tui: { showToast: async () => ({ data: true }) },
|
|
101
|
+
session: { summarize: async (args) => { summaries.push(args); return { data: true } } },
|
|
102
|
+
}
|
|
103
|
+
assert.equal(await compactSession("/tmp", client, "ses_summary", "provider/model"), true)
|
|
104
|
+
assert.deepEqual(summaries[0], {
|
|
105
|
+
path: { id: "ses_summary" },
|
|
106
|
+
body: { providerID: "provider", modelID: "model", auto: false },
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
const calls = []
|
|
112
|
+
const client = {
|
|
113
|
+
app: { log: async (args) => { calls.push(["log", args]); return { data: true } } },
|
|
114
|
+
tui: {
|
|
115
|
+
executeCommand: async (args) => { calls.push(["command", args]); return { data: true } },
|
|
116
|
+
showToast: async (args) => { calls.push(["toast", args]); return { data: true } },
|
|
117
|
+
},
|
|
118
|
+
session: {
|
|
119
|
+
messages: async (args) => {
|
|
120
|
+
calls.push(["messages", args])
|
|
121
|
+
return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
const adapter = createOpenCodeHostAdapter(client, "/bound/project")
|
|
126
|
+
assert.equal(Object.isFrozen(adapter), true)
|
|
127
|
+
assert.deepEqual(Object.keys(adapter).sort(), [
|
|
128
|
+
"activeRunCompletion",
|
|
129
|
+
"compactSession",
|
|
130
|
+
"executeTuiCommand",
|
|
131
|
+
"log",
|
|
132
|
+
"readRecentMessages",
|
|
133
|
+
"resolveCompactionModel",
|
|
134
|
+
"toast",
|
|
135
|
+
])
|
|
136
|
+
await adapter.executeTuiCommand("session_compact")
|
|
137
|
+
await adapter.readRecentMessages("ses_adapter", 7)
|
|
138
|
+
assert.equal(await adapter.activeRunCompletion("ses_adapter", { startedAt: 5 }), "completed")
|
|
139
|
+
assert.deepEqual(await adapter.resolveCompactionModel("ses_adapter", "provider/model"), { providerID: "provider", modelID: "model" })
|
|
140
|
+
await adapter.log("info", "adapter log")
|
|
141
|
+
await adapter.toast("adapter toast", "warning")
|
|
142
|
+
const messageCall = calls.find(([kind]) => kind === "messages")?.[1]
|
|
143
|
+
assert.equal(messageCall?.query?.directory, "/bound/project")
|
|
144
|
+
assert.equal(messageCall?.query?.limit, 7)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("OpenCode host adapter contract test passed")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
|
|
4
|
+
const legacy = await fs.readFile("src/source/legacy-v1.js", "utf8")
|
|
5
|
+
const host = await fs.readFile("src/source/opencode/host.js", "utf8")
|
|
6
|
+
|
|
7
|
+
const legacyStart = legacy.indexOf("function fireSdk(")
|
|
8
|
+
const legacyEnd = legacy.indexOf("function guardLoopOwnedUserMessage(")
|
|
9
|
+
const hostStart = host.indexOf("export function fireSdk(")
|
|
10
|
+
assert.ok(legacyStart >= 0 && legacyEnd > legacyStart, "legacy host bridge block not found")
|
|
11
|
+
assert.ok(hostStart >= 0, "host bridge module block not found")
|
|
12
|
+
|
|
13
|
+
const normalize = (text) => text
|
|
14
|
+
.replace(/\bexport\s+(?=(?:async\s+)?function\b)/g, "")
|
|
15
|
+
.replace(/\r\n/g, "\n")
|
|
16
|
+
.trim()
|
|
17
|
+
|
|
18
|
+
const legacyBlock = normalize(legacy.slice(legacyStart, legacyEnd))
|
|
19
|
+
const hostBlock = normalize(host.slice(hostStart))
|
|
20
|
+
assert.equal(hostBlock, legacyBlock, "OpenCode host bridge drifted from the stable V1 implementation")
|
|
21
|
+
|
|
22
|
+
console.log("host bridge parity test passed")
|
|
@@ -213,6 +213,20 @@ async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000)
|
|
|
213
213
|
throw new Error(`timed out waiting for ${description}\n${diagnostics()}`)
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
function isFetchTimeout(error) {
|
|
217
|
+
return error?.name === "TimeoutError" || /aborted due to timeout/i.test(String(error?.message ?? error))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function bootstrapSessions(api, diagnostics) {
|
|
221
|
+
try {
|
|
222
|
+
return await api("/session", { method: "GET", signal: AbortSignal.timeout(30_000) })
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!isFetchTimeout(error)) throw error
|
|
225
|
+
console.error(`OpenCode session API was not ready after 30s; retrying once.\n${diagnostics()}`)
|
|
226
|
+
return await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
216
230
|
async function main() {
|
|
217
231
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-host-canary-"))
|
|
218
232
|
const home = path.join(workspace, ".home")
|
|
@@ -286,7 +300,7 @@ async function main() {
|
|
|
286
300
|
try { return JSON.parse(text) } catch { return text }
|
|
287
301
|
}
|
|
288
302
|
|
|
289
|
-
const sessionsBefore = await api
|
|
303
|
+
const sessionsBefore = await bootstrapSessions(api, () => `server log:\n${serverLog}`)
|
|
290
304
|
assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap did not return an array")
|
|
291
305
|
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-loop host canary" }) })
|
|
292
306
|
const session = createdPayload?.data ?? createdPayload
|
|
@@ -340,4 +354,4 @@ async function main() {
|
|
|
340
354
|
main().catch((error) => {
|
|
341
355
|
console.error(error?.stack || error)
|
|
342
356
|
process.exitCode = 1
|
|
343
|
-
})
|
|
357
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import process from "node:process"
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
9
|
+
const pluginID = "bybrawe.opencode-loop.v2.experimental"
|
|
10
|
+
const sentinelID = "bybrawe.opencode-loop.v2-canary-sentinel"
|
|
11
|
+
|
|
12
|
+
function run(command, args, { cwd, env, allowFailure = false, timeout = 60_000 } = {}) {
|
|
13
|
+
const result = spawnSync(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
env,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
18
|
+
timeout,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
})
|
|
21
|
+
if (result.error) throw result.error
|
|
22
|
+
if (!allowFailure && result.status !== 0) {
|
|
23
|
+
throw new Error([
|
|
24
|
+
`command failed (${result.status}): ${command} ${args.join(" ")}`,
|
|
25
|
+
String(result.stdout ?? ""),
|
|
26
|
+
String(result.stderr ?? ""),
|
|
27
|
+
].filter(Boolean).join("\n"))
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function output(result) {
|
|
33
|
+
return `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`.trim()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseJSONOutput(result, label) {
|
|
37
|
+
const text = String(result.stdout ?? "").trim()
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text)
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`${label} did not return JSON on stdout.\nstdout:\n${text}\nstderr:\n${String(result.stderr ?? "")}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectPluginIDs(value) {
|
|
46
|
+
if (Array.isArray(value)) return value.flatMap(collectPluginIDs)
|
|
47
|
+
if (typeof value === "string") return [value]
|
|
48
|
+
if (!value || typeof value !== "object") return []
|
|
49
|
+
const direct = [value.id, value.pluginID, value.name].filter((item) => typeof item === "string")
|
|
50
|
+
const nested = [value.data, value.plugin, value.plugins, value.items].flatMap((item) => collectPluginIDs(item))
|
|
51
|
+
return [...direct, ...nested]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function uniquePluginIDs(value) {
|
|
55
|
+
return [...new Set(collectPluginIDs(value))]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function failureLog(env) {
|
|
59
|
+
const candidates = [
|
|
60
|
+
path.join(env.XDG_DATA_HOME, "opencode", "log", "opencode.log"),
|
|
61
|
+
path.join(env.XDG_STATE_HOME, "opencode", "log", "opencode.log"),
|
|
62
|
+
]
|
|
63
|
+
for (const file of candidates) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(file, "utf8")
|
|
66
|
+
return raw.slice(-30_000)
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
return ""
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
const temp = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-host-"))
|
|
74
|
+
const project = path.join(temp, "project")
|
|
75
|
+
const home = path.join(temp, "home")
|
|
76
|
+
const config = path.join(home, ".config")
|
|
77
|
+
const data = path.join(home, ".local", "share")
|
|
78
|
+
const state = path.join(home, ".local", "state")
|
|
79
|
+
const pluginDirectory = path.join(project, ".opencode", "plugins")
|
|
80
|
+
const pluginFile = path.join(root, "src", "source", "opencode2", "experimental.js")
|
|
81
|
+
const adapterBridge = path.join(pluginDirectory, "opencode-loop-v2-canary.js")
|
|
82
|
+
const sentinelFile = path.join(pluginDirectory, "opencode-loop-v2-sentinel.js")
|
|
83
|
+
const sentinelURL = pathToFileURL(sentinelFile).href
|
|
84
|
+
const adapterURL = pathToFileURL(adapterBridge).href
|
|
85
|
+
|
|
86
|
+
await Promise.all([
|
|
87
|
+
mkdir(pluginDirectory, { recursive: true }),
|
|
88
|
+
mkdir(config, { recursive: true }),
|
|
89
|
+
mkdir(data, { recursive: true }),
|
|
90
|
+
mkdir(state, { recursive: true }),
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
await writeFile(sentinelFile, `export default { id: ${JSON.stringify(sentinelID)}, server: async () => ({ id: ${JSON.stringify(sentinelID)} }) }\n`)
|
|
94
|
+
await writeFile(adapterBridge, `export { default } from ${JSON.stringify(pathToFileURL(pluginFile).href)}\n`)
|
|
95
|
+
await writeFile(path.join(project, "README.md"), "# OpenCode Loop V2 host canary\n")
|
|
96
|
+
await writeFile(path.join(project, "opencode.json"), `${JSON.stringify({
|
|
97
|
+
$schema: "https://opencode.ai/config.json",
|
|
98
|
+
plugin: [
|
|
99
|
+
"./.opencode/plugins/opencode-loop-v2-sentinel.js",
|
|
100
|
+
"./.opencode/plugins/opencode-loop-v2-canary.js",
|
|
101
|
+
],
|
|
102
|
+
}, null, 2)}\n`)
|
|
103
|
+
|
|
104
|
+
const env = {
|
|
105
|
+
...process.env,
|
|
106
|
+
HOME: home,
|
|
107
|
+
USERPROFILE: home,
|
|
108
|
+
XDG_CONFIG_HOME: config,
|
|
109
|
+
XDG_DATA_HOME: data,
|
|
110
|
+
XDG_STATE_HOME: state,
|
|
111
|
+
OPENCODE_DB: path.join(data, "opencode", "opencode-loop-v2-canary.db"),
|
|
112
|
+
OPENCODE_LOG_LEVEL: "DEBUG",
|
|
113
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [sentinelURL, adapterURL] }),
|
|
114
|
+
CI: "true",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
run("git", ["init", "-q"], { cwd: project, env })
|
|
118
|
+
run("git", ["config", "user.name", "OpenCode Loop Canary"], { cwd: project, env })
|
|
119
|
+
run("git", ["config", "user.email", "opencode-loop-canary@example.invalid"], { cwd: project, env })
|
|
120
|
+
run("git", ["add", "."], { cwd: project, env })
|
|
121
|
+
run("git", ["commit", "-q", "-m", "initialize canary workspace"], { cwd: project, env })
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
|
|
125
|
+
|
|
126
|
+
const version = output(run("opencode2", ["--version"], { cwd: project, env, timeout: 30_000 }))
|
|
127
|
+
if (!version) throw new Error("opencode2 --version returned no output")
|
|
128
|
+
|
|
129
|
+
const health = output(run("opencode2", ["api", "get", "/api/health"], { cwd: project, env }))
|
|
130
|
+
if (!health) throw new Error("OpenCode 2 health API returned no output")
|
|
131
|
+
|
|
132
|
+
const plainPluginResult = run("opencode2", ["api", "get", "/api/plugin"], { cwd: project, env })
|
|
133
|
+
const plainResponse = parseJSONOutput(plainPluginResult, "GET /api/plugin from project cwd")
|
|
134
|
+
const plainIDs = uniquePluginIDs(plainResponse)
|
|
135
|
+
|
|
136
|
+
const pluginPath = `/api/plugin?location%5Bdirectory%5D=${encodeURIComponent(project)}`
|
|
137
|
+
const scopedPluginResult = run("opencode2", ["api", "get", pluginPath], { cwd: project, env })
|
|
138
|
+
const scopedResponse = parseJSONOutput(scopedPluginResult, "GET /api/plugin at project Location")
|
|
139
|
+
|
|
140
|
+
if (scopedResponse?._tag) {
|
|
141
|
+
throw new Error(`project-scoped /api/plugin rejected the Location: ${JSON.stringify(scopedResponse)}`)
|
|
142
|
+
}
|
|
143
|
+
if (scopedResponse?.location?.directory !== project) {
|
|
144
|
+
throw new Error(`OpenCode 2 resolved the wrong Location: expected ${project}, got ${String(scopedResponse?.location?.directory)}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scopedIDs = uniquePluginIDs(scopedResponse)
|
|
148
|
+
const ids = [...new Set([...plainIDs, ...scopedIDs])]
|
|
149
|
+
if (!ids.includes(sentinelID)) {
|
|
150
|
+
throw new Error([
|
|
151
|
+
`OpenCode 2 did not activate the current-loader sentinel. Active IDs: ${JSON.stringify(ids)}`,
|
|
152
|
+
`Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
|
|
153
|
+
`Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
|
|
154
|
+
`Plain response: ${String(plainPluginResult.stdout ?? "")}`,
|
|
155
|
+
`Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
|
|
156
|
+
].join("\n"))
|
|
157
|
+
}
|
|
158
|
+
if (!ids.includes(pluginID)) {
|
|
159
|
+
throw new Error([
|
|
160
|
+
`OpenCode 2 activated the current-loader sentinel but not ${pluginID}. Active IDs: ${JSON.stringify(ids)}`,
|
|
161
|
+
`Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
|
|
162
|
+
`Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
|
|
163
|
+
`Plain response: ${String(plainPluginResult.stdout ?? "")}`,
|
|
164
|
+
`Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
|
|
165
|
+
].join("\n"))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(JSON.stringify({
|
|
169
|
+
ok: true,
|
|
170
|
+
platform: process.platform,
|
|
171
|
+
node: process.version,
|
|
172
|
+
opencode2Version: version,
|
|
173
|
+
health,
|
|
174
|
+
projectDirectory: scopedResponse.location.directory,
|
|
175
|
+
sentinelID,
|
|
176
|
+
pluginID,
|
|
177
|
+
plainPluginIDs: plainIDs,
|
|
178
|
+
scopedPluginIDs: scopedIDs,
|
|
179
|
+
activePluginIDs: ids,
|
|
180
|
+
}, null, 2))
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const logs = await failureLog(env)
|
|
183
|
+
if (logs) console.error(`OpenCode 2 server log tail:\n${logs}`)
|
|
184
|
+
throw error
|
|
185
|
+
} finally {
|
|
186
|
+
run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
|
|
187
|
+
await rm(temp, { recursive: true, force: true })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
main().catch((error) => {
|
|
192
|
+
console.error(error?.stack || error)
|
|
193
|
+
process.exitCode = 1
|
|
194
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
|
|
3
|
+
import { createSessionRegistry } from "../src/source/runtime/session-registry.js"
|
|
4
|
+
|
|
5
|
+
let clock = 1000
|
|
6
|
+
const registry = createSessionRegistry({ now: () => clock, staleAfterMs: 100 })
|
|
7
|
+
const first = {}
|
|
8
|
+
const second = {}
|
|
9
|
+
|
|
10
|
+
registry.observeExternal("ses_a", first)
|
|
11
|
+
clock = 1050
|
|
12
|
+
assert.equal(registry.peek("ses_a").seenAt, 1000)
|
|
13
|
+
assert.equal(registry.entries()[0].seenAt, 1000)
|
|
14
|
+
assert.deepEqual(registry.pruneStale(), [])
|
|
15
|
+
|
|
16
|
+
registry.observeExternal("ses_a", first)
|
|
17
|
+
assert.equal(registry.peek("ses_a").seenAt, 1050)
|
|
18
|
+
clock = 1060
|
|
19
|
+
registry.observeExternal("ses_a", second)
|
|
20
|
+
assert.equal(registry.remove("ses_a", first), false)
|
|
21
|
+
assert.equal(registry.peek("ses_a").runtime, second)
|
|
22
|
+
|
|
23
|
+
clock = 1159
|
|
24
|
+
assert.deepEqual(registry.pruneStale(), [])
|
|
25
|
+
clock = 1160
|
|
26
|
+
assert.deepEqual(registry.pruneStale(), ["ses_a"])
|
|
27
|
+
assert.equal(registry.peek("ses_a"), undefined)
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
const factoryCalls = []
|
|
31
|
+
const timerAPI = {}
|
|
32
|
+
const manager = createSessionRuntimeManager({
|
|
33
|
+
timerAPI,
|
|
34
|
+
runtimeFactory({ sessionID, timerAPI: injectedTimerAPI }) {
|
|
35
|
+
let active = true
|
|
36
|
+
const runtime = {
|
|
37
|
+
sessionID,
|
|
38
|
+
scope: { isActive: () => active },
|
|
39
|
+
dispose() { active = false; return true },
|
|
40
|
+
}
|
|
41
|
+
factoryCalls.push({ injectedTimerAPI, runtime })
|
|
42
|
+
return runtime
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
assert.throws(() => manager.observeExternal(" "), /session ID/)
|
|
47
|
+
assert.equal(factoryCalls.length, 0)
|
|
48
|
+
const runtime = manager.observeExternal(" ses_custom ")
|
|
49
|
+
assert.equal(runtime.sessionID, "ses_custom")
|
|
50
|
+
assert.equal(factoryCalls[0].injectedTimerAPI, timerAPI)
|
|
51
|
+
assert.equal(manager.observeExternal("ses_custom"), runtime)
|
|
52
|
+
assert.equal(factoryCalls.length, 1)
|
|
53
|
+
runtime.dispose()
|
|
54
|
+
assert.notEqual(manager.observeExternal("ses_custom"), runtime)
|
|
55
|
+
assert.equal(factoryCalls.length, 2)
|
|
56
|
+
assert.equal(manager.dispose(), true)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log("OpenCode runtime contract test passed")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { normalizeOpenCodeEvent } from "../src/source/runtime/events.js"
|
|
3
|
+
|
|
4
|
+
assert.deepEqual(
|
|
5
|
+
normalizeOpenCodeEvent({ type: "session.idle", properties: { sessionID: "ses_idle" } }),
|
|
6
|
+
{ kind: "session", action: "idle", sessionID: "ses_idle", directory: undefined },
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
assert.deepEqual(
|
|
10
|
+
normalizeOpenCodeEvent({
|
|
11
|
+
directory: "/workspace",
|
|
12
|
+
payload: { type: "session.status", properties: { sessionID: "ses_busy", status: { type: "busy" } } },
|
|
13
|
+
}),
|
|
14
|
+
{ kind: "session", action: "status", sessionID: "ses_busy", directory: "/workspace", status: "busy" },
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
normalizeOpenCodeEvent({
|
|
19
|
+
type: "session.created",
|
|
20
|
+
properties: { info: { id: "ses_child", directory: "/project", parentID: "ses_parent" } },
|
|
21
|
+
}),
|
|
22
|
+
{ kind: "session", action: "created", sessionID: "ses_child", directory: "/project", parentID: "ses_parent" },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert.deepEqual(
|
|
26
|
+
normalizeOpenCodeEvent({
|
|
27
|
+
directory: "/workspace",
|
|
28
|
+
payload: {
|
|
29
|
+
type: "message.updated",
|
|
30
|
+
properties: {
|
|
31
|
+
info: {
|
|
32
|
+
id: "msg_done",
|
|
33
|
+
sessionID: "ses_message",
|
|
34
|
+
role: "assistant",
|
|
35
|
+
time: { created: 10, completed: 20 },
|
|
36
|
+
finish: "stop",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
{
|
|
42
|
+
kind: "message",
|
|
43
|
+
action: "updated",
|
|
44
|
+
sessionID: "ses_message",
|
|
45
|
+
directory: "/workspace",
|
|
46
|
+
messageID: "msg_done",
|
|
47
|
+
role: "assistant",
|
|
48
|
+
completedAt: 20,
|
|
49
|
+
finish: "stop",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
normalizeOpenCodeEvent({
|
|
55
|
+
type: "command.executed",
|
|
56
|
+
properties: { name: "loop-status", sessionID: "ses_command", arguments: "--json", messageID: "msg_command" },
|
|
57
|
+
}),
|
|
58
|
+
{
|
|
59
|
+
kind: "command",
|
|
60
|
+
action: "executed",
|
|
61
|
+
sessionID: "ses_command",
|
|
62
|
+
directory: undefined,
|
|
63
|
+
name: "loop-status",
|
|
64
|
+
arguments: "--json",
|
|
65
|
+
messageID: "msg_command",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
normalizeOpenCodeEvent({ type: "server.instance.disposed", properties: { directory: "/old" } }),
|
|
71
|
+
{ kind: "server", action: "disposed", directory: "/old" },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert.equal(normalizeOpenCodeEvent({ type: "session.idle", properties: {} }), undefined)
|
|
75
|
+
assert.equal(normalizeOpenCodeEvent({ payload: { properties: {} } }), undefined)
|
|
76
|
+
assert.equal(normalizeOpenCodeEvent({ type: "file.edited", properties: { file: "x.txt" } }), undefined)
|
|
77
|
+
|
|
78
|
+
console.log("OpenCode runtime event normalization test passed")
|