@bybrawe/opencode-loop 0.5.37 → 0.6.0
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 +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { readFileSync } from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
8
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"))
|
|
9
|
+
assert.ok(["@bybrawe/opencode-loop", "@bybrawe/opencode-goal"].includes(pkg.name))
|
|
10
|
+
assert.ok(process.argv.length <= 3, "expected at most one consumer directory")
|
|
11
|
+
const loop = pkg.name === "@bybrawe/opencode-loop"
|
|
12
|
+
const cwd = process.argv[2] ? path.resolve(process.argv[2]) : root
|
|
13
|
+
const packageRoot = process.argv[2] ? path.join(cwd, "node_modules", ...pkg.name.split("/")) : root
|
|
14
|
+
const url = (relative) => pathToFileURL(path.join(packageRoot, relative)).href
|
|
15
|
+
const legacyEntries = loop ? [url("src/v1.js"), url("src/source/v1.js")] : [url("dist/legacy-v1.js")]
|
|
16
|
+
const blocked = loop
|
|
17
|
+
? [...legacyEntries, url("src/index.js"), url("src/source/legacy-v1.js")]
|
|
18
|
+
: [...legacyEntries, url("dist/index.js"), url("dist/opencode/plugin.js")]
|
|
19
|
+
const entries = loop ? [pkg.name, `${pkg.name}/server`, `${pkg.name}/v2`] : [`${pkg.name}/server`, `${pkg.name}/v2`]
|
|
20
|
+
if (loop) entries.push(url("src/source/server.js"))
|
|
21
|
+
const serverEntries = loop ? [`${pkg.name}/server`, url("src/source/server.js")] : [`${pkg.name}/server`]
|
|
22
|
+
|
|
23
|
+
function probe(mode) {
|
|
24
|
+
const stub = "globalThis.__legacyModuleLoads = (globalThis.__legacyModuleLoads || 0) + 1; export default async (...args) => { if (args[0]?.failure) throw args[0].failure; return { args }; }"
|
|
25
|
+
const loader = `
|
|
26
|
+
const blocked = ${JSON.stringify(blocked)};
|
|
27
|
+
const legacyEntries = ${JSON.stringify(legacyEntries)};
|
|
28
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
29
|
+
if (specifier.startsWith("@opencode-ai/")) throw new Error("V2_LEGACY_IMPORT: " + specifier);
|
|
30
|
+
const result = await nextResolve(specifier, context);
|
|
31
|
+
if (${JSON.stringify(mode)} === "delegate" && legacyEntries.includes(result.url)) {
|
|
32
|
+
return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(stub)}`)}, shortCircuit: true };
|
|
33
|
+
}
|
|
34
|
+
if (blocked.includes(result.url)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
const source = `
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import { register } from "node:module";
|
|
41
|
+
register(${JSON.stringify(`data:text/javascript,${encodeURIComponent(loader)}`)}, import.meta.url);
|
|
42
|
+
const entries = ${JSON.stringify(mode === "isolation" ? entries : serverEntries)};
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const { default: plugin } = await import(entry);
|
|
45
|
+
assert.equal(typeof plugin.setup, "function", entry);
|
|
46
|
+
assert.equal(plugin.id, "@bybrawe/opencode-loop", entry);
|
|
47
|
+
if (${JSON.stringify(mode)} === "isolation") {
|
|
48
|
+
assert.equal(globalThis.__legacyModuleLoads, undefined, "native import initialized legacy runtime");
|
|
49
|
+
if (entry.endsWith("/v2")) assert.equal("server" in plugin, false, "native entry must not expose V1 dispatch");
|
|
50
|
+
} else {
|
|
51
|
+
const before = globalThis.__legacyModuleLoads;
|
|
52
|
+
if (entry === entries[0]) assert.equal(before, undefined, "server eagerly initialized V1");
|
|
53
|
+
const input = { directory: "fixture-one" }, options = { fixture: true };
|
|
54
|
+
const first = await plugin.server(input, options);
|
|
55
|
+
assert.strictEqual(first.args[0], input, "legacy input was changed");
|
|
56
|
+
assert.strictEqual(first.args[1], options, "legacy options were lost");
|
|
57
|
+
const secondInput = { directory: "fixture-two" };
|
|
58
|
+
const second = await plugin.server(secondInput, options);
|
|
59
|
+
assert.strictEqual(second.args[0], secondInput, "plugin instances shared the first host context");
|
|
60
|
+
assert.notStrictEqual(first, second, "legacy hooks must be created for every invocation");
|
|
61
|
+
const failure = new Error("legacy sentinel");
|
|
62
|
+
await assert.rejects(plugin.server({ failure }), (error) => error === failure, "legacy failure was swallowed");
|
|
63
|
+
assert.equal(globalThis.__legacyModuleLoads, 1, "only the module, never per-host hooks, is cached");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
console.log(${JSON.stringify(`native entry ${mode} PASS`)});
|
|
67
|
+
`
|
|
68
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", source], {
|
|
69
|
+
cwd, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
70
|
+
})
|
|
71
|
+
if (result.error) throw result.error
|
|
72
|
+
assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n"))
|
|
73
|
+
process.stdout.write(result.stdout)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
probe("isolation")
|
|
77
|
+
probe("delegate")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
9
|
+
const temp = await mkdtemp(path.join(tmpdir(), "loop-guide-config-"))
|
|
10
|
+
const file = path.join(temp, "opencode.json")
|
|
11
|
+
const version = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")).version
|
|
12
|
+
const spec = `@bybrawe/opencode-loop@${version}`
|
|
13
|
+
function run(args = []) {
|
|
14
|
+
return spawnSync(process.execPath, [path.join(root, "scripts/install-node.mjs"), ...args], {
|
|
15
|
+
cwd: root, env: { ...process.env, OPENCODE_CONFIG_DIR: temp }, encoding: "utf8", timeout: 20_000,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const options = { quiet: false, nested: { count: 3 } }
|
|
20
|
+
const other = ["other-plugin", { enabled: false }]
|
|
21
|
+
await writeFile(file, JSON.stringify({ plugin: [["@bybrawe/opencode-loop@0.1.0", options], other] }))
|
|
22
|
+
let result = run(["--legacy-v1"])
|
|
23
|
+
assert.equal(result.status, 0, result.stderr)
|
|
24
|
+
assert.deepEqual(JSON.parse(await readFile(file, "utf8")).plugin, [other, [spec, options]])
|
|
25
|
+
result = run()
|
|
26
|
+
assert.equal(result.status, 0, result.stderr)
|
|
27
|
+
assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { plugin: [other], plugins: [{ package: spec, options }] })
|
|
28
|
+
const migrated = await readFile(file, "utf8")
|
|
29
|
+
assert.equal(run().status, 0)
|
|
30
|
+
assert.equal(await readFile(file, "utf8"), migrated)
|
|
31
|
+
for (const entries of [
|
|
32
|
+
[[spec, false]], [[spec, {}, "extra"]],
|
|
33
|
+
[[spec, { quiet: false }], { package: spec, options: { quiet: true } }],
|
|
34
|
+
]) {
|
|
35
|
+
const before = JSON.stringify({ plugin: entries })
|
|
36
|
+
await writeFile(file, before)
|
|
37
|
+
result = run()
|
|
38
|
+
assert.notEqual(result.status, 0)
|
|
39
|
+
assert.match(result.stderr, /Invalid Loop package\/options tuple|Conflicting Loop object registrations/)
|
|
40
|
+
assert.equal(await readFile(file, "utf8"), before)
|
|
41
|
+
assert.equal(run(["--uninstall"]).status, 0)
|
|
42
|
+
assert.deepEqual(JSON.parse(await readFile(file, "utf8")).plugin, [])
|
|
43
|
+
}
|
|
44
|
+
console.log("Official V1-to-V2 guide config regression passed")
|
|
45
|
+
} finally { await rm(temp, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 }) }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, access, rm } from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
9
|
+
const goalInstaller = process.env.OPENCODE_GOAL_INSTALLER
|
|
10
|
+
assert.ok(goalInstaller && path.isAbsolute(goalInstaller), "Set OPENCODE_GOAL_INSTALLER to the built companion dist/install.js")
|
|
11
|
+
await access(goalInstaller)
|
|
12
|
+
const temp = await mkdtemp(path.join(os.tmpdir(), "loop-goal-native-install-"))
|
|
13
|
+
const npm = path.join(temp, "npm-test-driver.cjs")
|
|
14
|
+
const targetLog = path.join(temp, "target.jsonl")
|
|
15
|
+
await writeFile(npm, `const { spawnSync } = require('node:child_process');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (JSON.stringify(args) !== JSON.stringify(['exec','--yes','--package=@bybrawe/opencode-goal@latest','--','opencode-goal'])) throw new Error('unexpected npm arguments');
|
|
19
|
+
fs.appendFileSync(process.env.TARGET_LOG, JSON.stringify(process.env.OPENCODE_GOAL_HOST_VERSION) + '\\n');
|
|
20
|
+
const result = spawnSync(process.execPath, [process.env.OPENCODE_GOAL_INSTALLER], { env: process.env, stdio: 'inherit' });
|
|
21
|
+
process.exit(result.status ?? 1);
|
|
22
|
+
`)
|
|
23
|
+
const exists = (file) => access(file).then(() => true, () => false)
|
|
24
|
+
function run(config, args, hostTarget = "1.18.15") {
|
|
25
|
+
const result = spawnSync(process.execPath, [path.join(root, "scripts/install-with-goals.mjs"), ...args], {
|
|
26
|
+
cwd: root, encoding: "utf8", timeout: 30_000,
|
|
27
|
+
env: { ...process.env, npm_execpath: npm, OPENCODE_CONFIG_DIR: config, OPENCODE_GOAL_HOST_VERSION: hostTarget, OPENCODE_GOAL_INSTALLER: goalInstaller, TARGET_LOG: targetLog },
|
|
28
|
+
})
|
|
29
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}\n${result.error || ""}`)
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const config = path.join(temp, "config")
|
|
34
|
+
await mkdir(path.join(config, "commands"), { recursive: true })
|
|
35
|
+
const options = { autonomous: false, nested: { retained: true } }
|
|
36
|
+
await writeFile(path.join(config, "opencode.json"), JSON.stringify({ plugin: [
|
|
37
|
+
{ package: "@bybrawe/opencode-goal@1.0.0", options },
|
|
38
|
+
{ package: "@bybrawe/opencode-loop@0.1.0", options: { quiet: true } },
|
|
39
|
+
"other-plugin",
|
|
40
|
+
] }))
|
|
41
|
+
await writeFile(path.join(config, "commands/goal.md"), "Custom Goal command\n")
|
|
42
|
+
await writeFile(path.join(config, "commands/loop-goal.md"), "Custom Loop Goal command\n")
|
|
43
|
+
const state = path.join(config, "project/.opencode/goals/keep.json")
|
|
44
|
+
await mkdir(path.dirname(state), { recursive: true })
|
|
45
|
+
await writeFile(state, '{"evidence":"unchanged"}\n')
|
|
46
|
+
|
|
47
|
+
run(config, ["--with-goals", "--without-loop-goals"])
|
|
48
|
+
let source = await readFile(path.join(config, "opencode.json"), "utf8")
|
|
49
|
+
let parsed = JSON.parse(source)
|
|
50
|
+
assert.deepEqual(parsed.plugin, ["other-plugin"])
|
|
51
|
+
assert.equal(parsed.plugins.length, 2)
|
|
52
|
+
assert.deepEqual(parsed.plugins.find((item) => item.package?.startsWith("@bybrawe/opencode-goal@")).options, options)
|
|
53
|
+
assert.deepEqual(parsed.plugins.find((item) => item.package?.startsWith("@bybrawe/opencode-loop@")).options, { quiet: true })
|
|
54
|
+
assert.equal(await readFile(path.join(config, "commands/goal.md"), "utf8"), "Custom Goal command\n")
|
|
55
|
+
assert.equal(await readFile(path.join(config, "commands/loop-goal.md"), "utf8"), "Custom Loop Goal command\n")
|
|
56
|
+
run(config, ["--with-goals", "--without-loop-goals"])
|
|
57
|
+
assert.equal(await readFile(path.join(config, "opencode.json"), "utf8"), source)
|
|
58
|
+
assert.equal(await readFile(state, "utf8"), '{"evidence":"unchanged"}\n')
|
|
59
|
+
|
|
60
|
+
const legacy = path.join(temp, "legacy")
|
|
61
|
+
run(legacy, ["--legacy-v1", "--with-goals"], "2.0.18")
|
|
62
|
+
parsed = JSON.parse(await readFile(path.join(legacy, "opencode.json"), "utf8"))
|
|
63
|
+
assert.ok(parsed.plugin.some((item) => String(item).startsWith("@bybrawe/opencode-goal@")))
|
|
64
|
+
assert.equal(await exists(path.join(legacy, "commands/goal.md")), true)
|
|
65
|
+
assert.equal(await exists(path.join(legacy, "commands/loop.md")), true)
|
|
66
|
+
assert.deepEqual((await readFile(targetLog, "utf8")).trim().split('\n').map((line) => JSON.parse(line)), ["2.0.0", "2.0.0", "1.0.0"])
|
|
67
|
+
run(config, ["--uninstall"])
|
|
68
|
+
parsed = JSON.parse(await readFile(path.join(config, "opencode.json"), "utf8"))
|
|
69
|
+
assert.equal(parsed.plugins.length, 1, "Loop uninstall cannot remove the companion")
|
|
70
|
+
assert.ok(parsed.plugins[0].package.startsWith("@bybrawe/opencode-goal@"))
|
|
71
|
+
assert.equal(await readFile(state, "utf8"), '{"evidence":"unchanged"}\n')
|
|
72
|
+
console.log("Native Goal+Loop installer pair: dialect, options, idempotence, custom files and uninstall PASS")
|
|
73
|
+
} finally { await rm(temp, { recursive: true, force: true }) }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, access, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
const installer = path.join(root, "scripts/install-node.mjs")
|
|
10
|
+
const temp = await mkdtemp(path.join(tmpdir(), "native-loop-installer-"))
|
|
11
|
+
const version = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")).version
|
|
12
|
+
const spec = `@bybrawe/opencode-loop@${version}`
|
|
13
|
+
let cases = 0
|
|
14
|
+
const exists = async (file) => { try { await access(file); return true } catch { return false } }
|
|
15
|
+
function run(config, args = [], expected = 0) {
|
|
16
|
+
const result = spawnSync(process.execPath, [installer, ...args], { cwd: root, env: { ...process.env, OPENCODE_CONFIG_DIR: config }, encoding: "utf8", timeout: 20_000 })
|
|
17
|
+
assert.equal(result.status, expected, `${result.stdout}\n${result.stderr}\n${result.error || ""}`)
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
async function config(name, content) {
|
|
21
|
+
const directory = path.join(temp, name)
|
|
22
|
+
await mkdir(directory, { recursive: true })
|
|
23
|
+
if (content !== undefined) await writeFile(path.join(directory, "opencode.json"), typeof content === "string" ? content : JSON.stringify(content))
|
|
24
|
+
return directory
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const fresh = await config("fresh")
|
|
28
|
+
run(fresh)
|
|
29
|
+
const native = await readFile(path.join(root, "src/native.js"), "utf8")
|
|
30
|
+
assert.equal(await readFile(path.join(fresh, "plugins/opencode-loop.ts"), "utf8"), native)
|
|
31
|
+
assert.doesNotMatch(native, /(?:from\s*|import\s*\()["']@opencode-ai\/plugin/)
|
|
32
|
+
assert.equal(await exists(path.join(fresh, "package.json")), false)
|
|
33
|
+
assert.equal(await exists(path.join(fresh, "commands/loop.md")), false)
|
|
34
|
+
assert.equal(await exists(path.join(fresh, "agents/opencode-loop-local.md")), false)
|
|
35
|
+
run(fresh)
|
|
36
|
+
cases++
|
|
37
|
+
|
|
38
|
+
const options = { package: "@bybrawe/opencode-loop@0.5.38", options: { quiet: true, nested: { value: "preserved" } }, enabled: true }
|
|
39
|
+
const obj = await config("object", { plugins: ["other-plugin", options] })
|
|
40
|
+
run(obj)
|
|
41
|
+
const readConfig = async (directory) => JSON.parse(await readFile(path.join(directory, "opencode.json"), "utf8"))
|
|
42
|
+
assert.deepEqual((await readConfig(obj)).plugins, ["other-plugin", { ...options, package: spec }])
|
|
43
|
+
const before = await readFile(path.join(obj, "opencode.json"), "utf8")
|
|
44
|
+
run(obj)
|
|
45
|
+
assert.equal(await readFile(path.join(obj, "opencode.json"), "utf8"), before)
|
|
46
|
+
assert.equal(await exists(path.join(obj, "plugins/opencode-loop.ts")), false)
|
|
47
|
+
cases++
|
|
48
|
+
|
|
49
|
+
const both = await config("both", { plugin: ["legacy-unrelated", "@bybrawe/opencode-loop@old"], plugins: [options, "native-unrelated"] })
|
|
50
|
+
run(both)
|
|
51
|
+
assert.deepEqual(await readConfig(both), { plugin: ["legacy-unrelated"], plugins: ["native-unrelated", { ...options, package: spec }] })
|
|
52
|
+
cases++
|
|
53
|
+
|
|
54
|
+
const jsonc = await config("jsonc", '{\r\n // keep-before\r\n "theme": "url://example",\r\n "plugin": ["@bybrawe/opencode-loop@old"] // keep-after\r\n}\r\n')
|
|
55
|
+
run(jsonc)
|
|
56
|
+
const text = await readFile(path.join(jsonc, "opencode.json"), "utf8")
|
|
57
|
+
assert.match(text, /keep-before/)
|
|
58
|
+
assert.match(text, /keep-after/)
|
|
59
|
+
assert.match(text, /"plugins"/)
|
|
60
|
+
assert.match(text, /url:\/\/example/)
|
|
61
|
+
run(jsonc)
|
|
62
|
+
assert.equal(await readFile(path.join(jsonc, "opencode.json"), "utf8"), text)
|
|
63
|
+
cases++
|
|
64
|
+
|
|
65
|
+
const conflict = await config("conflict", { plugins: [options, { ...options, enabled: false }] })
|
|
66
|
+
const conflictBefore = await readFile(path.join(conflict, "opencode.json"), "utf8")
|
|
67
|
+
assert.match(run(conflict, [], 1).stderr, /Conflicting/)
|
|
68
|
+
assert.equal(await readFile(path.join(conflict, "opencode.json"), "utf8"), conflictBefore)
|
|
69
|
+
cases++
|
|
70
|
+
|
|
71
|
+
const invalid = await config("invalid", { plugin: ["@bybrawe/opencode-loop@old"] })
|
|
72
|
+
await writeFile(path.join(invalid, "config.jsonc"), '{ "plugins": "not-an-array" }')
|
|
73
|
+
const invalidBefore = await readFile(path.join(invalid, "opencode.json"), "utf8")
|
|
74
|
+
run(invalid, [], 1)
|
|
75
|
+
assert.equal(await readFile(path.join(invalid, "opencode.json"), "utf8"), invalidBefore)
|
|
76
|
+
assert.equal(await exists(path.join(invalid, "plugins/opencode-loop.ts")), false)
|
|
77
|
+
cases++
|
|
78
|
+
|
|
79
|
+
const upgrade = await config("upgrade")
|
|
80
|
+
run(upgrade, ["--legacy-v1"])
|
|
81
|
+
assert.equal(await exists(path.join(upgrade, "commands/loop.md")), true)
|
|
82
|
+
await writeFile(path.join(upgrade, "commands/loop-help.md"), "This is a custom user command; keep it.")
|
|
83
|
+
run(upgrade)
|
|
84
|
+
assert.equal(await exists(path.join(upgrade, "commands/loop.md")), false)
|
|
85
|
+
assert.equal(await readFile(path.join(upgrade, "commands/loop-help.md"), "utf8"), "This is a custom user command; keep it.")
|
|
86
|
+
assert.equal(await readFile(path.join(upgrade, "plugins/opencode-loop.ts"), "utf8"), native)
|
|
87
|
+
cases++
|
|
88
|
+
|
|
89
|
+
await mkdir(path.join(obj, ".opencode/opencode-loop"), { recursive: true })
|
|
90
|
+
await writeFile(path.join(obj, ".opencode/opencode-loop/keep.json"), "{}")
|
|
91
|
+
run(obj, ["--uninstall"])
|
|
92
|
+
assert.deepEqual((await readConfig(obj)).plugins, ["other-plugin"])
|
|
93
|
+
assert.equal(await exists(path.join(obj, ".opencode/opencode-loop/keep.json")), true)
|
|
94
|
+
run(obj, ["--uninstall"])
|
|
95
|
+
run(upgrade, ["--uninstall"])
|
|
96
|
+
assert.equal(await exists(path.join(upgrade, "commands/loop-help.md")), true)
|
|
97
|
+
cases++
|
|
98
|
+
|
|
99
|
+
const info = path.join(temp, "informational-no-mutation")
|
|
100
|
+
run(info, ["--help"])
|
|
101
|
+
run(info, ["--version"])
|
|
102
|
+
assert.equal(await exists(info), false)
|
|
103
|
+
cases++
|
|
104
|
+
console.log(`Native installer: ${cases} scenarios passed`)
|
|
105
|
+
} finally { await rm(temp, { recursive: true, force: true }) }
|
|
106
|
+
|
|
107
|
+
await import("./native-guide-test.mjs")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import plugin from "../src/source/opencode2/native-plugin.js"
|
|
7
|
+
import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
|
|
8
|
+
|
|
9
|
+
async function fixture({ failDispose = false, failSubscribe = false } = {}) {
|
|
10
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-native-unload-"))
|
|
11
|
+
const cleaned = []
|
|
12
|
+
const cleanupError = new Error("command cleanup rejected")
|
|
13
|
+
const subscribeError = new Error("subscription rejected")
|
|
14
|
+
let signal, stopped = 0, release = () => {}
|
|
15
|
+
const registration = name => ({ dispose: async () => {
|
|
16
|
+
cleaned.push(name)
|
|
17
|
+
if (failDispose && name === "commands") throw cleanupError
|
|
18
|
+
} })
|
|
19
|
+
const ctx = {
|
|
20
|
+
location: { directory }, options: {},
|
|
21
|
+
session: {
|
|
22
|
+
prompt: async () => { throw new Error("cleanup must not prompt or abort a native session") },
|
|
23
|
+
hook: async name => registration(name),
|
|
24
|
+
},
|
|
25
|
+
command: { transform: async edit => { edit({ add() {} }); return registration("commands") } },
|
|
26
|
+
event: { subscribe(input) {
|
|
27
|
+
signal = input?.signal
|
|
28
|
+
if (failSubscribe) throw subscribeError
|
|
29
|
+
return (async function* () {
|
|
30
|
+
try {
|
|
31
|
+
await new Promise(resolve => {
|
|
32
|
+
release = resolve
|
|
33
|
+
if (signal?.aborted) resolve()
|
|
34
|
+
else signal?.addEventListener("abort", resolve, { once: true })
|
|
35
|
+
})
|
|
36
|
+
} finally { stopped++ }
|
|
37
|
+
})()
|
|
38
|
+
} },
|
|
39
|
+
}
|
|
40
|
+
return { ctx, cleaned, cleanupError, subscribeError, signal: () => signal, stopped: () => stopped,
|
|
41
|
+
release: () => release(), remove: () => rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }
|
|
42
|
+
}
|
|
43
|
+
async function bounded(promise) {
|
|
44
|
+
let timer
|
|
45
|
+
try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("native unload blocked on event next()")), 1500) })]) }
|
|
46
|
+
finally { clearTimeout(timer) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("native unload aborts a pending public event stream and shares cleanup", async () => {
|
|
50
|
+
const host = await fixture()
|
|
51
|
+
let dispose
|
|
52
|
+
try {
|
|
53
|
+
dispose = await plugin.setup(host.ctx)
|
|
54
|
+
assert.ok(host.signal() instanceof AbortSignal, "the native subscription needs a cancellation signal")
|
|
55
|
+
const first = dispose(), second = dispose()
|
|
56
|
+
assert.equal(first, second)
|
|
57
|
+
await bounded(first)
|
|
58
|
+
assert.equal(host.signal().aborted, true)
|
|
59
|
+
assert.equal(host.stopped(), 1)
|
|
60
|
+
assert.deepEqual(host.cleaned, ["commands", "compaction", "prompt"])
|
|
61
|
+
await dispose()
|
|
62
|
+
assert.equal(host.cleaned.length, 3)
|
|
63
|
+
} finally { host.release(); await dispose?.().catch(() => {}); await host.remove() }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("one failed native disposer does not skip remaining registrations", async () => {
|
|
67
|
+
const host = await fixture({ failDispose: true })
|
|
68
|
+
let dispose
|
|
69
|
+
try {
|
|
70
|
+
dispose = await plugin.setup(host.ctx)
|
|
71
|
+
host.release()
|
|
72
|
+
await assert.rejects(bounded(dispose()), error => error instanceof AggregateError && error.errors.includes(host.cleanupError))
|
|
73
|
+
assert.deepEqual(host.cleaned, ["commands", "compaction", "prompt"])
|
|
74
|
+
assert.equal(host.signal()?.aborted, true)
|
|
75
|
+
await assert.rejects(dispose(), AggregateError)
|
|
76
|
+
assert.equal(host.cleaned.length, 3)
|
|
77
|
+
} finally { host.release(); await dispose?.().catch(() => {}); await host.remove() }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("failed native setup preserves its error and attempts all cleanup", async () => {
|
|
81
|
+
const host = await fixture({ failDispose: true, failSubscribe: true })
|
|
82
|
+
try {
|
|
83
|
+
await assert.rejects(plugin.setup(host.ctx), error => error === host.subscribeError)
|
|
84
|
+
assert.deepEqual(host.cleaned, ["commands", "compaction", "prompt"])
|
|
85
|
+
assert.equal(host.signal()?.aborted, true)
|
|
86
|
+
} finally { host.release(); await host.remove() }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("event bridge disposes session scopes even when unsubscribe rejects", async () => {
|
|
90
|
+
const sentinel = new Error("unsubscribe failed")
|
|
91
|
+
const bridge = createOpenCode2EventBridge({ directory: "/project" })
|
|
92
|
+
let listener
|
|
93
|
+
await bridge.attach(async callback => { listener = callback; return { unsubscribe: async () => { throw sentinel } } })
|
|
94
|
+
await listener({ directory: "/project", payload: { type: "session.created", properties: { info: { id: "ses_cleanup", directory: "/project" } } } })
|
|
95
|
+
const runtime = bridge.runtimeManager.peek("ses_cleanup")
|
|
96
|
+
assert.ok(runtime?.scope.isActive())
|
|
97
|
+
await assert.rejects(bridge.dispose(), error => error === sentinel)
|
|
98
|
+
assert.equal(runtime.scope.isActive(), false)
|
|
99
|
+
assert.deepEqual(bridge.runtimeManager.entries(), [])
|
|
100
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import plugin from "../src/source/opencode2/native-plugin.js"
|
|
7
|
+
|
|
8
|
+
function fixture(directory, { failCleanup = false } = {}) {
|
|
9
|
+
const released = []
|
|
10
|
+
let releaseRead, signal
|
|
11
|
+
let streamClosed = false
|
|
12
|
+
const end = () => releaseRead?.()
|
|
13
|
+
const registration = (name) => ({ async dispose() {
|
|
14
|
+
released.push(name)
|
|
15
|
+
if (failCleanup && name === "commands") throw new Error("cleanup sentinel")
|
|
16
|
+
} })
|
|
17
|
+
const ctx = {
|
|
18
|
+
location: { directory }, options: {}, app: { version: "2.0.18" },
|
|
19
|
+
session: {
|
|
20
|
+
prompt: async () => { throw new Error("cleanup must not dispatch a prompt") },
|
|
21
|
+
hook: async (name) => registration(name),
|
|
22
|
+
},
|
|
23
|
+
command: { transform: async () => registration("commands") },
|
|
24
|
+
event: { subscribe(options = {}) {
|
|
25
|
+
signal = options.signal
|
|
26
|
+
return (async function* () {
|
|
27
|
+
try {
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
releaseRead = resolve
|
|
30
|
+
if (signal?.aborted) resolve()
|
|
31
|
+
else signal?.addEventListener("abort", resolve, { once: true })
|
|
32
|
+
})
|
|
33
|
+
} finally { streamClosed = true }
|
|
34
|
+
})()
|
|
35
|
+
} },
|
|
36
|
+
}
|
|
37
|
+
return { ctx, end, released, signal: () => signal, streamClosed: () => streamClosed }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function bounded(promise) {
|
|
41
|
+
let timer
|
|
42
|
+
try {
|
|
43
|
+
return await Promise.race([
|
|
44
|
+
promise.then(() => "closed"),
|
|
45
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), 500) }),
|
|
46
|
+
])
|
|
47
|
+
} finally { clearTimeout(timer) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("native Loop aborts an idle subscription before awaiting iterator return", async () => {
|
|
51
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-lifetime-"))
|
|
52
|
+
const host = fixture(directory)
|
|
53
|
+
let closing
|
|
54
|
+
try {
|
|
55
|
+
const dispose = await plugin.setup(host.ctx)
|
|
56
|
+
closing = dispose()
|
|
57
|
+
assert.equal(await bounded(closing), "closed", "idle subscription cleanup requires an AbortSignal")
|
|
58
|
+
assert.equal(host.signal()?.aborted, true)
|
|
59
|
+
assert.equal(host.streamClosed(), true)
|
|
60
|
+
assert.deepEqual(host.released, ["commands", "compaction", "prompt"])
|
|
61
|
+
} finally {
|
|
62
|
+
host.end()
|
|
63
|
+
await closing?.catch(() => {})
|
|
64
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("native Loop cleanup is shared and drains all registrations after an error", async () => {
|
|
69
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-cleanup-error-"))
|
|
70
|
+
const host = fixture(directory, { failCleanup: true })
|
|
71
|
+
let closing
|
|
72
|
+
try {
|
|
73
|
+
const dispose = await plugin.setup(host.ctx)
|
|
74
|
+
host.end()
|
|
75
|
+
closing = dispose()
|
|
76
|
+
const observed = closing.catch((error) => error)
|
|
77
|
+
assert.strictEqual(dispose(), closing, "concurrent unloads must await the same cleanup")
|
|
78
|
+
const error = await observed
|
|
79
|
+
assert.ok(error instanceof AggregateError)
|
|
80
|
+
assert.match(String(error.errors[0]), /cleanup sentinel/)
|
|
81
|
+
assert.deepEqual(host.released, ["commands", "compaction", "prompt"])
|
|
82
|
+
assert.equal(host.streamClosed(), true)
|
|
83
|
+
} finally {
|
|
84
|
+
host.end()
|
|
85
|
+
await closing?.catch(() => {})
|
|
86
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("native Loop uses the same public plugin identity as the package entry", () => {
|
|
91
|
+
assert.equal(plugin.id, "@bybrawe/opencode-loop", "native entry must share canonical plugin identity")
|
|
92
|
+
})
|