@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,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createGoalSteeringRuntime } from "../src/source/runtime/goal-steering.js"
|
|
3
|
+
|
|
4
|
+
const sessionID = "ses-compaction-safety"
|
|
5
|
+
const goal = { id: "goal-1", name: "goal", kind: "goal", enabled: true, paused: false, goalStatus: "active" }
|
|
6
|
+
function fixture(abortHook) {
|
|
7
|
+
let active = { jobId: goal.id, job: { ...goal } }
|
|
8
|
+
let aborts = 0
|
|
9
|
+
let clears = 0
|
|
10
|
+
let reads = 0
|
|
11
|
+
const client = { session: { abort: async () => { aborts++; await abortHook?.(() => { active = { jobId: "replacement", job: { ...goal } } }) } } }
|
|
12
|
+
const runtime = createGoalSteeringRuntime({
|
|
13
|
+
getActiveRun: () => active,
|
|
14
|
+
clearActiveRun: () => { clears++; active = undefined },
|
|
15
|
+
readState: async () => { reads++; return { jobs: [{ ...goal }] } },
|
|
16
|
+
appendLoopLog: async () => {},
|
|
17
|
+
fireSdk: async (_client, _label, method) => method(),
|
|
18
|
+
now: () => 1000,
|
|
19
|
+
})
|
|
20
|
+
return { runtime, client, counts: () => ({ aborts, clears, reads }), active: () => active }
|
|
21
|
+
}
|
|
22
|
+
let cases = 0
|
|
23
|
+
for (const type of ["message.created", "message.updated"]) {
|
|
24
|
+
for (const parts of [undefined, [], [{ type: "compaction", auto: true }], [{ type: "text", synthetic: true, text: "Continue" }], [{ type: "text", text: "Persisted user message" }]]) {
|
|
25
|
+
const f = fixture()
|
|
26
|
+
const event = { type, properties: { info: { id: "msg-core", role: "user", sessionID }, ...(parts ? { parts } : {}) } }
|
|
27
|
+
const result = await f.runtime.handleEvent("/workspace", f.client, event)
|
|
28
|
+
assert.equal(result.handled, false)
|
|
29
|
+
assert.deepEqual(f.counts(), { aborts: 0, clears: 0, reads: 0 })
|
|
30
|
+
assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
|
|
31
|
+
cases++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
{
|
|
35
|
+
const f = fixture()
|
|
36
|
+
await f.runtime.handleEvent("/workspace", f.client, { type: "message.updated", properties: { info: { role: "user", sessionID, id: "msg-real" } } })
|
|
37
|
+
const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-real" })
|
|
38
|
+
assert.equal(result.preempted, true, "untrusted event must not consume the later trusted admission")
|
|
39
|
+
assert.equal(f.counts().aborts, 1)
|
|
40
|
+
cases++
|
|
41
|
+
}
|
|
42
|
+
{
|
|
43
|
+
const f = fixture()
|
|
44
|
+
f.runtime.setCompacting(sessionID)
|
|
45
|
+
const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-during-summary" })
|
|
46
|
+
assert.equal(result.preempted, false)
|
|
47
|
+
assert.equal(f.counts().aborts, 0)
|
|
48
|
+
assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
|
|
49
|
+
f.runtime.setCompacting(sessionID, false)
|
|
50
|
+
assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-summary" })).preempted, true)
|
|
51
|
+
cases++
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
const f = fixture((replace) => replace())
|
|
55
|
+
await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-race" })
|
|
56
|
+
assert.equal(f.counts().aborts, 1)
|
|
57
|
+
assert.equal(f.counts().clears, 0, "the old abort must not clear a replacement run")
|
|
58
|
+
assert.equal(f.active().jobId, "replacement")
|
|
59
|
+
cases++
|
|
60
|
+
}
|
|
61
|
+
{
|
|
62
|
+
const f = fixture()
|
|
63
|
+
f.runtime.setCompacting(sessionID)
|
|
64
|
+
f.runtime.clearSession(sessionID)
|
|
65
|
+
assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-delete" })).preempted, true)
|
|
66
|
+
cases++
|
|
67
|
+
}
|
|
68
|
+
console.log(`Goal compaction safety: ${cases} scenarios passed`)
|
|
@@ -26,7 +26,7 @@ process.exit(Number(process.env.FAKE_NPM_EXIT || 0))
|
|
|
26
26
|
|
|
27
27
|
async function runInstaller(config, cliArgs = [], env = {}) {
|
|
28
28
|
return await new Promise((resolve, reject) => {
|
|
29
|
-
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
29
|
+
const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
|
|
30
30
|
cwd: root,
|
|
31
31
|
env: {
|
|
32
32
|
...process.env,
|
|
@@ -57,6 +57,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
|
|
|
57
57
|
assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
|
|
58
58
|
|
|
59
59
|
assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
|
|
60
|
+
assert.equal(await activeRunCompletionFromMessages("/tmp", messagesClient([]), "empty-history", { startedAt: 100 }), "unknown")
|
|
60
61
|
assert.equal(await activeRunCompletionFromMessages(
|
|
61
62
|
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
|
|
62
63
|
), "incomplete")
|
package/scripts/install-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { isDeepStrictEqual } from "node:util"
|
|
2
3
|
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
4
|
import { homedir } from "node:os"
|
|
4
5
|
import { dirname, join } from "node:path"
|
|
@@ -14,11 +15,13 @@ const packageName = "@bybrawe/opencode-loop"
|
|
|
14
15
|
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
16
|
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
17
|
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
17
|
-
const
|
|
18
|
+
const rawInstallerArgs = process.argv.slice(2)
|
|
19
|
+
const legacyV1 = rawInstallerArgs.includes("--legacy-v1")
|
|
20
|
+
const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
|
|
18
21
|
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
19
22
|
|
|
20
23
|
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
21
|
-
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update
|
|
24
|
+
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update defaults to native OpenCode 2: no V1 SDK or legacy command files are needed for a local install. Existing npm registrations are pinned to the exact version and migrated to plugins while retaining object options. Use --legacy-v1 to install the compatibility loader and legacy command files explicitly.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
|
|
22
25
|
process.exit(0)
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -96,7 +99,8 @@ function parseJsonc(input) {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
function isPackageSpec(value) {
|
|
99
|
-
|
|
102
|
+
if (Array.isArray(value)) value = value[0]
|
|
103
|
+
const spec = String(typeof value === "object" && value !== null ? value.package || "" : value || "").trim()
|
|
100
104
|
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -207,38 +211,86 @@ function formatPluginArray(values, indent, eol) {
|
|
|
207
211
|
return `[${eol}${values.map((value) => `${childIndent}${JSON.stringify(value)}`).join(`,${eol}`)}${eol}${indent}]`
|
|
208
212
|
}
|
|
209
213
|
|
|
210
|
-
function rewriteExistingPluginArray(source, nextPlugins) {
|
|
211
|
-
const property = findRootProperty(source,
|
|
214
|
+
function rewriteExistingPluginArray(source, nextPlugins, key = "plugin") {
|
|
215
|
+
const property = findRootProperty(source, key)
|
|
212
216
|
if (!property) return source
|
|
213
217
|
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
214
218
|
const replacement = formatPluginArray(nextPlugins, property.indent, eol)
|
|
215
219
|
return `${source.slice(0, property.valueStart)}${replacement}${source.slice(property.valueEnd)}`
|
|
216
220
|
}
|
|
217
221
|
|
|
222
|
+
function appendPluginProperty(source, values, key) {
|
|
223
|
+
const parsed = parseJsonc(source)
|
|
224
|
+
const keys = Object.keys(parsed)
|
|
225
|
+
if (keys.length) {
|
|
226
|
+
const last = findRootProperty(source, keys.at(-1))
|
|
227
|
+
const after = skipTrivia(source, last.valueEnd)
|
|
228
|
+
if (source[after] !== ",") source = source.slice(0, last.valueEnd) + "," + source.slice(last.valueEnd)
|
|
229
|
+
}
|
|
230
|
+
const end = skipJsonValue(source, 0) - 1
|
|
231
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
232
|
+
return source.slice(0, end) + `${eol} ${JSON.stringify(key)}: ${formatPluginArray(values, " ", eol)}${eol}` + source.slice(end)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function pinRegistration(entries) {
|
|
236
|
+
const objects = entries.map((entry) => {
|
|
237
|
+
if (!Array.isArray(entry)) return entry
|
|
238
|
+
if (entry.length !== 2 || !entry[1] || typeof entry[1] !== "object" || Array.isArray(entry[1])) {
|
|
239
|
+
throw new Error("Invalid Loop package/options tuple; no configuration was changed")
|
|
240
|
+
}
|
|
241
|
+
return { package: entry[0], options: entry[1] }
|
|
242
|
+
}).filter((entry) => typeof entry === "object" && entry !== null)
|
|
243
|
+
const optionsOf = (entry) => { const { package: _package, ...options } = entry; return options }
|
|
244
|
+
const configured = objects.filter((entry) => Object.keys(optionsOf(entry)).length > 0)
|
|
245
|
+
const preferred = configured[0]
|
|
246
|
+
if (!preferred) return packageSpec
|
|
247
|
+
if (configured.some((entry) => !isDeepStrictEqual(optionsOf(entry), optionsOf(preferred)))) {
|
|
248
|
+
throw new Error("Conflicting Loop object registrations; reconcile their options before updating")
|
|
249
|
+
}
|
|
250
|
+
const legacyTuple = entries.some((entry) => Array.isArray(entry) && isDeepStrictEqual(entry[1], preferred.options))
|
|
251
|
+
return legacyV1 && legacyTuple ? [packageSpec, preferred.options] : { ...preferred, package: packageSpec }
|
|
252
|
+
}
|
|
253
|
+
|
|
218
254
|
async function configurePackagePlugin() {
|
|
255
|
+
const plans = []
|
|
219
256
|
let configured = false
|
|
220
|
-
const updatedFiles = []
|
|
221
257
|
for (const name of configCandidates) {
|
|
222
258
|
const target = join(config, name)
|
|
223
259
|
try {
|
|
224
260
|
const source = await readFile(target, "utf8")
|
|
225
261
|
const parsed = parseJsonc(source)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
262
|
+
for (const key of ["plugin", "plugins"]) {
|
|
263
|
+
if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
|
|
264
|
+
}
|
|
265
|
+
let updated = source
|
|
266
|
+
const own = [...(parsed.plugins || []), ...(parsed.plugin || [])].filter(isPackageSpec)
|
|
267
|
+
if (own.length) {
|
|
268
|
+
configured = true
|
|
269
|
+
if (legacyV1) {
|
|
270
|
+
for (const key of ["plugin", "plugins"]) {
|
|
271
|
+
const entries = parsed[key] || []
|
|
272
|
+
const matching = entries.filter(isPackageSpec)
|
|
273
|
+
if (!matching.length) continue
|
|
274
|
+
const pinned = pinRegistration(matching)
|
|
275
|
+
updated = rewriteExistingPluginArray(updated, [...entries.filter((entry) => !isPackageSpec(entry)), pinned], key)
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
const pinned = pinRegistration(own)
|
|
279
|
+
if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
|
|
280
|
+
const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
|
|
281
|
+
updated = parsed.plugins === undefined ? appendPluginProperty(updated, next, "plugins") : rewriteExistingPluginArray(updated, next, "plugins")
|
|
282
|
+
}
|
|
236
283
|
}
|
|
284
|
+
parseJsonc(updated)
|
|
285
|
+
plans.push({ target, source, updated })
|
|
237
286
|
} catch (error) {
|
|
238
287
|
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
239
288
|
}
|
|
240
289
|
}
|
|
241
|
-
|
|
290
|
+
// Validate every candidate before writing any config. Malformed later files
|
|
291
|
+
// must not leave the earlier file migrated and the plugin half-installed.
|
|
292
|
+
for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
|
|
293
|
+
return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
|
|
242
294
|
}
|
|
243
295
|
|
|
244
296
|
async function ensureDependency() {
|
|
@@ -262,7 +314,13 @@ async function ensureDependency() {
|
|
|
262
314
|
|
|
263
315
|
async function removePackagedFiles(sourceDir, targetDir) {
|
|
264
316
|
for (const name of await readdir(sourceDir)) {
|
|
265
|
-
if (name.endsWith(".md"))
|
|
317
|
+
if (!name.endsWith(".md")) continue
|
|
318
|
+
try {
|
|
319
|
+
const installed = await readFile(join(targetDir, name), "utf8")
|
|
320
|
+
const packaged = await readFile(join(sourceDir, name), "utf8")
|
|
321
|
+
const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
|
|
322
|
+
if (installed === packaged || recognized) await rm(join(targetDir, name), { force: true })
|
|
323
|
+
} catch (error) { if (error?.code !== "ENOENT") throw error }
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
326
|
|
|
@@ -273,66 +331,55 @@ async function uninstall() {
|
|
|
273
331
|
try {
|
|
274
332
|
const source = await readFile(target, "utf8")
|
|
275
333
|
const parsed = parseJsonc(source)
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
334
|
+
let updated = source
|
|
335
|
+
for (const key of ["plugin", "plugins"]) {
|
|
336
|
+
if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
|
|
337
|
+
const entries = parsed[key] || []
|
|
338
|
+
if (entries.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, entries.filter((entry) => !isPackageSpec(entry)), key)
|
|
339
|
+
}
|
|
280
340
|
plans.push({ target, source, updated })
|
|
281
|
-
} catch (error) {
|
|
282
|
-
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
for (const plan of plans) {
|
|
287
|
-
if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
|
|
341
|
+
} catch (error) { if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`) }
|
|
288
342
|
}
|
|
343
|
+
for (const plan of plans) if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
|
|
289
344
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
290
345
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
291
346
|
await removePackagedFiles(join(root, "commands"), commandDir)
|
|
292
347
|
await removePackagedFiles(join(root, "agents"), agentDir)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
console.log(
|
|
296
|
-
? `Removed ${packageName} package registrations from ${changedConfigs} OpenCode config file(s).`
|
|
297
|
-
: `${packageName} was not registered as an npm package in the inspected OpenCode config files.`)
|
|
298
|
-
console.log("Removed known local OpenCode Loop plugin, slash-command, and local-agent files when present.")
|
|
348
|
+
const changed = plans.filter((plan) => plan.source !== plan.updated).length
|
|
349
|
+
console.log(`Removed ${packageName} registrations from ${changed} OpenCode config file(s).`)
|
|
350
|
+
console.log("Removed known local Loop plugin and managed legacy command/agent files; custom files are preserved.")
|
|
299
351
|
console.log("Project state under .opencode/opencode-loop is preserved.")
|
|
300
352
|
console.log("Restart OpenCode to finish unloading OpenCode Loop.")
|
|
301
353
|
}
|
|
302
354
|
|
|
303
355
|
async function installOrUpdate() {
|
|
304
356
|
await mkdir(pluginDir, { recursive: true })
|
|
305
|
-
await mkdir(commandDir, { recursive: true })
|
|
306
|
-
await mkdir(agentDir, { recursive: true })
|
|
307
357
|
const packageConfig = await configurePackagePlugin()
|
|
308
|
-
|
|
309
|
-
if (useConfiguredPackage) {
|
|
358
|
+
if (packageConfig.configured) {
|
|
310
359
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
311
360
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
312
361
|
} else {
|
|
313
|
-
await ensureDependency()
|
|
314
|
-
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
362
|
+
if (legacyV1) await ensureDependency()
|
|
363
|
+
await copyFile(join(root, "src", legacyV1 ? "index.js" : "native.js"), join(pluginDir, "opencode-loop.ts"))
|
|
315
364
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
316
365
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
for (const name of await readdir(join(root, "agents"))) {
|
|
323
|
-
if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (useConfiguredPackage) {
|
|
327
|
-
const pinResult = packageConfig.updatedFiles.length
|
|
328
|
-
? `pinned the config entry to ${packageSpec}`
|
|
329
|
-
: `the config entry is already pinned to ${packageSpec}`
|
|
330
|
-
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
366
|
+
if (legacyV1) {
|
|
367
|
+
await mkdir(commandDir, { recursive: true })
|
|
368
|
+
await mkdir(agentDir, { recursive: true })
|
|
369
|
+
for (const name of await readdir(join(root, "commands"))) if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
|
|
370
|
+
for (const name of await readdir(join(root, "agents"))) if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
331
371
|
} else {
|
|
332
|
-
|
|
372
|
+
await removePackagedFiles(join(root, "commands"), commandDir)
|
|
373
|
+
await removePackagedFiles(join(root, "agents"), agentDir)
|
|
333
374
|
}
|
|
334
|
-
|
|
335
|
-
|
|
375
|
+
if (packageConfig.configured) {
|
|
376
|
+
const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
|
|
377
|
+
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
378
|
+
} else console.log(`Installed OpenCode Loop plugin to ${config}`)
|
|
379
|
+
if (legacyV1) {
|
|
380
|
+
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
381
|
+
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
382
|
+
} else console.log("Native OpenCode 2 commands are registered by the plugin; no legacy command files or V1 SDK are needed for a local install.")
|
|
336
383
|
console.log("Restart OpenCode, then run: /loop-help")
|
|
337
384
|
}
|
|
338
385
|
|
package/scripts/install-test.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const packagedCommandCount = (await fs.readdir(path.join(root, "commands"))).fil
|
|
|
14
14
|
|
|
15
15
|
async function runInstaller(config, cliArgs = []) {
|
|
16
16
|
return await new Promise((resolve, reject) => {
|
|
17
|
-
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
17
|
+
const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
|
|
18
18
|
cwd: root,
|
|
19
19
|
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
20
20
|
windowsHide: true,
|
|
@@ -62,6 +62,11 @@ try {
|
|
|
62
62
|
const localResult = await runInstaller(local)
|
|
63
63
|
assert.equal(localResult.code, 0, localResult.stderr)
|
|
64
64
|
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
65
|
+
assert.equal(
|
|
66
|
+
await fs.readFile(path.join(local, "plugins", "opencode-loop.ts"), "utf8"),
|
|
67
|
+
await fs.readFile(path.join(root, "src", "index.js"), "utf8"),
|
|
68
|
+
"explicit legacy install must copy the standalone V1 bundle, not the lazy package facade",
|
|
69
|
+
)
|
|
65
70
|
assert.equal(await commandCount(local), packagedCommandCount)
|
|
66
71
|
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
67
72
|
const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
|
|
@@ -18,7 +18,8 @@ const withGoals = rawArgs.includes("--with-goals")
|
|
|
18
18
|
const loopOnly = rawArgs.includes("--loop-only")
|
|
19
19
|
const withoutLoopGoals = rawArgs.includes("--without-loop-goals")
|
|
20
20
|
const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--without-loop-goals"].includes(arg))
|
|
21
|
-
const
|
|
21
|
+
const commandArgs = loopArgs.filter((arg) => arg !== "--legacy-v1")
|
|
22
|
+
const uninstallRequested = commandArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(commandArgs[0] || "")
|
|
22
23
|
const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
|
|
23
24
|
const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
|
|
24
25
|
const informational = helpRequested || versionRequested
|
|
@@ -87,6 +88,8 @@ function parseJsonc(input) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function isGoalPluginSpec(value) {
|
|
91
|
+
if (Array.isArray(value)) value = value[0]
|
|
92
|
+
if (value && typeof value === "object") value = value.package
|
|
90
93
|
if (typeof value !== "string") return false
|
|
91
94
|
const spec = value.trim()
|
|
92
95
|
if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
|
|
@@ -101,7 +104,7 @@ async function goalsAlreadyInstalled() {
|
|
|
101
104
|
for (const name of configCandidates) {
|
|
102
105
|
try {
|
|
103
106
|
const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
|
|
104
|
-
if (Array.isArray(
|
|
107
|
+
if ([parsed.plugin, parsed.plugins].some((entries) => Array.isArray(entries) && entries.some(isGoalPluginSpec))) return true
|
|
105
108
|
} catch (error) {
|
|
106
109
|
if (error?.code !== "ENOENT") {
|
|
107
110
|
console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
|
|
@@ -125,6 +128,10 @@ async function removeLoopGoalCommands() {
|
|
|
125
128
|
let removed = 0
|
|
126
129
|
for (const name of names) {
|
|
127
130
|
try {
|
|
131
|
+
const installed = await readFile(join(commandDir, name), "utf8")
|
|
132
|
+
const packaged = await readFile(join(root, "commands", name), "utf8")
|
|
133
|
+
const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
|
|
134
|
+
if (installed !== packaged && !recognized) continue
|
|
128
135
|
await rm(join(commandDir, name))
|
|
129
136
|
removed++
|
|
130
137
|
} catch (error) {
|
|
@@ -152,7 +159,13 @@ function runGoalInstaller() {
|
|
|
152
159
|
|
|
153
160
|
return spawnSync(command, args, {
|
|
154
161
|
cwd: root,
|
|
155
|
-
|
|
162
|
+
// This is a target-dialect hint, not a claim about an installed binary.
|
|
163
|
+
// Older Goal installers already understand this variable, unlike new CLI flags.
|
|
164
|
+
env: {
|
|
165
|
+
...process.env,
|
|
166
|
+
OPENCODE_CONFIG_DIR: config,
|
|
167
|
+
OPENCODE_GOAL_HOST_VERSION: rawArgs.includes("--legacy-v1") ? "1.0.0" : "2.0.0",
|
|
168
|
+
},
|
|
156
169
|
stdio: "inherit",
|
|
157
170
|
windowsHide: true,
|
|
158
171
|
timeout: 180_000,
|
|
@@ -227,4 +240,4 @@ async function main() {
|
|
|
227
240
|
main().catch((error) => {
|
|
228
241
|
console.error(error?.stack || error)
|
|
229
242
|
process.exitCode = 1
|
|
230
|
-
})
|
|
243
|
+
})
|
|
@@ -72,7 +72,8 @@ try {
|
|
|
72
72
|
processResults.push({ code: 1, stdout: "", stderr: "not a repo" })
|
|
73
73
|
const nonRepo = { id: "nonrepo", branch: "feature/test" }
|
|
74
74
|
await runtime.ensureBranch(directory, nonRepo, {}, "session")
|
|
75
|
-
assert.equal(nonRepo.branchDone,
|
|
75
|
+
assert.equal(nonRepo.branchDone, false, "a non-repository cannot claim a successful branch switch")
|
|
76
|
+
assert.equal(nonRepo.branchUnavailable, true)
|
|
76
77
|
assert.equal(processCalls.at(-1)[0], "git")
|
|
77
78
|
assert.deepEqual(processCalls.at(-1)[1], ["rev-parse", "--is-inside-work-tree"])
|
|
78
79
|
assert.equal(toasts.length, 0)
|
|
@@ -95,6 +96,15 @@ try {
|
|
|
95
96
|
assert.deepEqual(toasts.at(-1), [client, "Loop branch active: feature-test", "success"])
|
|
96
97
|
assert.deepEqual(logs.at(-1), [directory, "branch", { sessionID: "session", branch: "feature-test", code: 0 }])
|
|
97
98
|
|
|
99
|
+
processResults.push(
|
|
100
|
+
{ code: 0, stdout: "true", stderr: "" },
|
|
101
|
+
{ code: 1, stdout: "", stderr: "switch failed" },
|
|
102
|
+
{ code: 1, stdout: "", stderr: "create failed" },
|
|
103
|
+
)
|
|
104
|
+
const failedBranch = { branch: "cannot-switch" }
|
|
105
|
+
await runtime.ensureBranch(directory, failedBranch, client, "session")
|
|
106
|
+
assert.equal(failedBranch.branchDone, false, "failed switch/create must not admit work on the wrong branch")
|
|
107
|
+
|
|
98
108
|
const watched = path.join(directory, "watched.txt")
|
|
99
109
|
await fs.writeFile(watched, "one", "utf8")
|
|
100
110
|
const initialSnapshot = await runtime.snapshotPaths(directory, ["watched.txt", "missing.txt"])
|
package/scripts/loopd-test.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-te
|
|
|
11
11
|
|
|
12
12
|
function runCli(cliArgs, env = {}) {
|
|
13
13
|
return new Promise((resolve, reject) => {
|
|
14
|
-
const child = spawn(process.execPath, [loopd, ...cliArgs], {
|
|
14
|
+
const child = spawn(process.execPath, [loopd, ...cliArgs, "--legacy-v1"], {
|
|
15
15
|
cwd: root,
|
|
16
16
|
env: { ...process.env, ...env },
|
|
17
17
|
windowsHide: true,
|
|
@@ -32,7 +32,7 @@ function runCli(cliArgs, env = {}) {
|
|
|
32
32
|
async function makeFakeCommand(name) {
|
|
33
33
|
const script = path.join(temporaryRoot, `${name}.mjs`)
|
|
34
34
|
const log = path.join(temporaryRoot, `${name}.jsonl`)
|
|
35
|
-
await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
|
|
35
|
+
await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nif (argv[0] === \"--version\") { console.log(\"1.18.15\"); process.exit(0) }\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
|
|
36
36
|
|
|
37
37
|
if (process.platform === "win32") {
|
|
38
38
|
const command = path.join(temporaryRoot, `${name}.cmd`)
|
|
@@ -81,7 +81,7 @@ try {
|
|
|
81
81
|
"run", "--title", runCalls[0].args[2],
|
|
82
82
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
83
83
|
"--agent", "build",
|
|
84
|
-
inlinePrompt,
|
|
84
|
+
"--", inlinePrompt,
|
|
85
85
|
], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
|
|
86
86
|
assert.match(runCalls[0].args[2], /^OpenCode Loop daemon /)
|
|
87
87
|
|
|
@@ -146,6 +146,73 @@ try {
|
|
|
146
146
|
})
|
|
147
147
|
assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
|
|
148
148
|
|
|
149
|
+
const goalsDir = path.join(project, ".opencode", "goals")
|
|
150
|
+
await fs.mkdir(goalsDir, { recursive: true })
|
|
151
|
+
const goalFile = path.join(goalsDir, "goal-stale.json")
|
|
152
|
+
const staleGoal = {
|
|
153
|
+
schemaVersion: 1,
|
|
154
|
+
id: "goal-stale",
|
|
155
|
+
sessionID: "ses_goal",
|
|
156
|
+
status: "active",
|
|
157
|
+
progressRevision: 4,
|
|
158
|
+
revision: 1,
|
|
159
|
+
storageGeneration: 1,
|
|
160
|
+
updatedAt: Date.now() - 60 * 60_000,
|
|
161
|
+
}
|
|
162
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
163
|
+
const originalGoal = await fs.readFile(goalFile, "utf8")
|
|
164
|
+
const pulseEnv = {
|
|
165
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
166
|
+
FAKE_COMMAND_LOG: path.join(temporaryRoot, "pulse-opencode.jsonl"),
|
|
167
|
+
FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_goal", title: "Goal", updated: Date.now() - 1_000 }]),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
171
|
+
assert.equal(result.code, 0, result.stderr)
|
|
172
|
+
assert.match(result.stdout, /GOAL PULSE STALLED/)
|
|
173
|
+
assert.match(result.stdout, /goal=goal-stale/)
|
|
174
|
+
assert.match(result.stdout, /session=ses_goal/)
|
|
175
|
+
assert.match(result.stdout, /sessionUpdated=/)
|
|
176
|
+
assert.match(result.stdout, /action="\/goal resume"/)
|
|
177
|
+
assert.equal(await fs.readFile(goalFile, "utf8"), originalGoal, "pulse-check must never write dedicated Goal state")
|
|
178
|
+
|
|
179
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
180
|
+
assert.equal(result.code, 0, result.stderr)
|
|
181
|
+
assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "one stall episode must alert only once")
|
|
182
|
+
|
|
183
|
+
staleGoal.status = "waiting_user"
|
|
184
|
+
staleGoal.storageGeneration += 1
|
|
185
|
+
staleGoal.updatedAt = Date.now() - 30 * 60_000
|
|
186
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
187
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
188
|
+
assert.equal(result.code, 0, result.stderr)
|
|
189
|
+
assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "waiting_user must suppress pulse alerts")
|
|
190
|
+
|
|
191
|
+
staleGoal.status = "active"
|
|
192
|
+
staleGoal.progressRevision += 1
|
|
193
|
+
staleGoal.storageGeneration += 1
|
|
194
|
+
staleGoal.updatedAt = Date.now() - 11 * 60_000
|
|
195
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
196
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
197
|
+
assert.equal(result.code, 0, result.stderr)
|
|
198
|
+
assert.match(result.stdout, /GOAL PULSE STALLED/, "new Goal activity/status must re-arm a later stall episode")
|
|
199
|
+
|
|
200
|
+
const pulseState = path.join(project, ".opencode", "opencode-loop", "pulse-check.json")
|
|
201
|
+
await fs.rm(pulseState, { force: true })
|
|
202
|
+
staleGoal.updatedAt = Date.now() - 60 * 60_000
|
|
203
|
+
staleGoal.storageGeneration += 1
|
|
204
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
205
|
+
const concurrent = await Promise.all([
|
|
206
|
+
runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
|
|
207
|
+
runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
|
|
208
|
+
])
|
|
209
|
+
assert.ok(concurrent.every((item) => item.code === 0), concurrent.map((item) => item.stderr).join("\n"))
|
|
210
|
+
const concurrentAlerts = concurrent.reduce((count, item) => count + (item.stdout.match(/GOAL PULSE STALLED/g) || []).length, 0)
|
|
211
|
+
assert.equal(concurrentAlerts, 1, "concurrent one-shot checks must share episode state and emit at most one alert")
|
|
212
|
+
|
|
213
|
+
const pulseCalls = await readLog(pulseEnv.FAKE_COMMAND_LOG)
|
|
214
|
+
assert.ok(pulseCalls.every((item) => item.args[0] === "session"), "pulse-check may inspect session telemetry but must never dispatch an OpenCode run")
|
|
215
|
+
|
|
149
216
|
result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
|
|
150
217
|
OPENCODE_BIN: fakeOpenCode.command,
|
|
151
218
|
FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
|
|
@@ -208,7 +275,7 @@ try {
|
|
|
208
275
|
"run", "--title", taskRunCalls[0].args[2],
|
|
209
276
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
210
277
|
"--agent", "build",
|
|
211
|
-
"scheduled task prompt",
|
|
278
|
+
"--", "scheduled task prompt",
|
|
212
279
|
])
|
|
213
280
|
|
|
214
281
|
result = await runCli(["uninstall-task", "--name", taskName], {
|
|
@@ -223,6 +290,7 @@ try {
|
|
|
223
290
|
|
|
224
291
|
result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
|
|
225
292
|
SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
|
|
293
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
226
294
|
OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
|
|
227
295
|
})
|
|
228
296
|
assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
|
|
@@ -233,3 +301,5 @@ try {
|
|
|
233
301
|
} finally {
|
|
234
302
|
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
235
303
|
}
|
|
304
|
+
|
|
305
|
+
await import("./native-cli-test.mjs")
|