@bybrawe/opencode-loop 0.5.23 → 0.5.25
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 +8 -0
- package/README.md +176 -1004
- package/package.json +1 -1
- package/scripts/comprehensive-test.mjs +120 -2
- package/scripts/install-node.mjs +204 -51
- package/scripts/install-test.mjs +50 -4
- package/scripts/smoke-test.mjs +28 -0
- package/src/index.js +205 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.25",
|
|
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,10 +18,13 @@ async function createHarness(options = {}) {
|
|
|
18
18
|
logs: [],
|
|
19
19
|
prompts: [],
|
|
20
20
|
shells: [],
|
|
21
|
+
summaries: [],
|
|
22
|
+
messageReads: [],
|
|
21
23
|
toasts: [],
|
|
22
24
|
tuiCommands: [],
|
|
23
25
|
}
|
|
24
26
|
const statuses = new Map([[sessionID, "idle"]])
|
|
27
|
+
const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : []
|
|
25
28
|
|
|
26
29
|
const client = {
|
|
27
30
|
app: {
|
|
@@ -35,7 +38,7 @@ async function createHarness(options = {}) {
|
|
|
35
38
|
executeCommand: async (args) => {
|
|
36
39
|
assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape")
|
|
37
40
|
records.tuiCommands.push(args.body.command)
|
|
38
|
-
if (options.failCompact) throw new Error("simulated TUI compact failure")
|
|
41
|
+
if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure")
|
|
39
42
|
return { data: true }
|
|
40
43
|
},
|
|
41
44
|
showToast: async (args) => {
|
|
@@ -95,7 +98,15 @@ async function createHarness(options = {}) {
|
|
|
95
98
|
),
|
|
96
99
|
}
|
|
97
100
|
},
|
|
98
|
-
|
|
101
|
+
messages: async (args) => {
|
|
102
|
+
assert.equal(args?.path?.id, sessionID)
|
|
103
|
+
assert.equal(args?.query?.directory, directory)
|
|
104
|
+
records.messageReads.push(args)
|
|
105
|
+
return { data: structuredClone(messageHistory) }
|
|
106
|
+
},
|
|
107
|
+
summarize: async (args) => {
|
|
108
|
+
assert.equal(args?.path?.id, sessionID)
|
|
109
|
+
records.summaries.push(args?.body)
|
|
99
110
|
if (options.failCompact) throw new Error("simulated summarize failure")
|
|
100
111
|
return { data: true }
|
|
101
112
|
},
|
|
@@ -112,6 +123,7 @@ async function createHarness(options = {}) {
|
|
|
112
123
|
sessionID,
|
|
113
124
|
stateFile,
|
|
114
125
|
statuses,
|
|
126
|
+
messageHistory,
|
|
115
127
|
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
116
128
|
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
117
129
|
return output
|
|
@@ -378,6 +390,110 @@ async function testActionRoutingAndSafety() {
|
|
|
378
390
|
}
|
|
379
391
|
}
|
|
380
392
|
|
|
393
|
+
async function testNativeCompactionLifecycleAndFallback() {
|
|
394
|
+
let h = await createHarness({ failTuiCompact: true })
|
|
395
|
+
try {
|
|
396
|
+
await h.command("loop-compact", "0s --no-now")
|
|
397
|
+
await h.command("loop-now", "compact")
|
|
398
|
+
assert.deepEqual(h.records.summaries[0], {
|
|
399
|
+
providerID: "test-provider",
|
|
400
|
+
modelID: "test-model",
|
|
401
|
+
auto: false,
|
|
402
|
+
}, "headless compact fallback must satisfy the current OpenCode summarize payload")
|
|
403
|
+
assert.equal(typeof h.hooks["experimental.session.compacting"], "function")
|
|
404
|
+
const compactOutput = { context: [], prompt: undefined }
|
|
405
|
+
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
|
|
406
|
+
assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
|
|
407
|
+
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
408
|
+
await delay(20)
|
|
409
|
+
const state = await h.readState()
|
|
410
|
+
assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
|
|
411
|
+
} finally {
|
|
412
|
+
await h.cleanup()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
h = await createHarness()
|
|
416
|
+
try {
|
|
417
|
+
await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after compaction")
|
|
418
|
+
const seeded = await h.readState()
|
|
419
|
+
seeded.jobs[0].runCount = 1
|
|
420
|
+
seeded.jobs[0].lastRunAt = 0
|
|
421
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
422
|
+
|
|
423
|
+
await h.command("loop-now", "compact-chain")
|
|
424
|
+
assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction")
|
|
425
|
+
assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction")
|
|
426
|
+
assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run")
|
|
427
|
+
|
|
428
|
+
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined })
|
|
429
|
+
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
430
|
+
await delay(20)
|
|
431
|
+
assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action")
|
|
432
|
+
} finally {
|
|
433
|
+
await h.cleanup()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
h = await createHarness()
|
|
437
|
+
try {
|
|
438
|
+
await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after fallback compaction")
|
|
439
|
+
const seeded = await h.readState()
|
|
440
|
+
seeded.jobs[0].runCount = 1
|
|
441
|
+
seeded.jobs[0].lastRunAt = 0
|
|
442
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
443
|
+
await h.command("loop-now", "compact-idle-fallback")
|
|
444
|
+
assert.equal(h.actionTexts().length, 0)
|
|
445
|
+
h.statuses.set(h.sessionID, "idle")
|
|
446
|
+
await h.command("loop-now", "compact-idle-fallback")
|
|
447
|
+
const fallbackState = await h.readState()
|
|
448
|
+
assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic")
|
|
449
|
+
assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization")
|
|
450
|
+
} finally {
|
|
451
|
+
await h.cleanup()
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function testStaleBusyUsesCompletedAssistantTail() {
|
|
456
|
+
let h = await createHarness()
|
|
457
|
+
try {
|
|
458
|
+
await h.command("loop", "5m --no-now --name stale-complete continue safely")
|
|
459
|
+
await h.command("loop-now", "stale-complete")
|
|
460
|
+
h.statuses.set(h.sessionID, "busy")
|
|
461
|
+
const completedAt = Date.now() + 5
|
|
462
|
+
h.messageHistory.splice(0, h.messageHistory.length,
|
|
463
|
+
{ info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },
|
|
464
|
+
{ info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },
|
|
465
|
+
)
|
|
466
|
+
await h.command("loop-now", "stale-complete")
|
|
467
|
+
const state = await h.readState()
|
|
468
|
+
assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
|
|
469
|
+
assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
|
|
470
|
+
} finally {
|
|
471
|
+
await h.cleanup()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
h = await createHarness()
|
|
475
|
+
try {
|
|
476
|
+
await h.command("loop", "5m --no-now --name stale-incomplete continue safely")
|
|
477
|
+
const seeded = await h.readState()
|
|
478
|
+
seeded.jobs[0].staleActiveRecoveryMs = 1
|
|
479
|
+
await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
|
|
480
|
+
await h.command("loop-now", "stale-incomplete")
|
|
481
|
+
await delay(10)
|
|
482
|
+
h.statuses.set(h.sessionID, "busy")
|
|
483
|
+
const createdAt = Date.now()
|
|
484
|
+
h.messageHistory.splice(0, h.messageHistory.length,
|
|
485
|
+
{ info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] },
|
|
486
|
+
{ info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] },
|
|
487
|
+
)
|
|
488
|
+
await h.command("loop-now", "stale-incomplete")
|
|
489
|
+
const state = await h.readState()
|
|
490
|
+
assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed")
|
|
491
|
+
assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt")
|
|
492
|
+
} finally {
|
|
493
|
+
await h.cleanup()
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
381
497
|
async function testPromptDispatchFailureRecovery() {
|
|
382
498
|
const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
|
|
383
499
|
try {
|
|
@@ -593,6 +709,8 @@ await testParserAndPresets()
|
|
|
593
709
|
await testLifecycleAndCommandDedupe()
|
|
594
710
|
await testWatchScheduling()
|
|
595
711
|
await testActionRoutingAndSafety()
|
|
712
|
+
await testNativeCompactionLifecycleAndFallback()
|
|
713
|
+
await testStaleBusyUsesCompletedAssistantTail()
|
|
596
714
|
await testPromptDispatchFailureRecovery()
|
|
597
715
|
await testStopsPreflightAndGoalLifecycle()
|
|
598
716
|
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
package/scripts/install-node.mjs
CHANGED
|
@@ -13,17 +13,12 @@ const packagePath = join(config, "package.json")
|
|
|
13
13
|
const packageName = "@bybrawe/opencode-loop"
|
|
14
14
|
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
15
|
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
|
+
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
16
17
|
const installerArgs = process.argv.slice(2)
|
|
18
|
+
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
17
19
|
|
|
18
20
|
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
19
|
-
console.log(`OpenCode Loop installer
|
|
20
|
-
|
|
21
|
-
Usage:
|
|
22
|
-
opencode-loop
|
|
23
|
-
npx -y @bybrawe/opencode-loop@latest
|
|
24
|
-
|
|
25
|
-
Installs the plugin commands and local command agent into OPENCODE_CONFIG_DIR
|
|
26
|
-
or the default ~/.config/opencode directory.`)
|
|
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 copies the Loop command files and local command agent, and keeps an existing npm package entry pinned to the exact version.\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.`)
|
|
27
22
|
process.exit(0)
|
|
28
23
|
}
|
|
29
24
|
|
|
@@ -32,6 +27,11 @@ if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
|
|
|
32
27
|
process.exit(0)
|
|
33
28
|
}
|
|
34
29
|
|
|
30
|
+
if (installerArgs.length && !uninstallRequested) {
|
|
31
|
+
console.error(`Unknown installer option: ${installerArgs[0]}`)
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
35
|
function stripJsonComments(input) {
|
|
36
36
|
let output = ""
|
|
37
37
|
let quote = ""
|
|
@@ -90,7 +90,9 @@ function stripTrailingCommas(input) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function parseJsonc(input) {
|
|
93
|
-
|
|
93
|
+
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
94
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenCode config root must be a JSON object")
|
|
95
|
+
return parsed
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function isPackageSpec(value) {
|
|
@@ -98,33 +100,142 @@ function isPackageSpec(value) {
|
|
|
98
100
|
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
function skipTrivia(source, start) {
|
|
104
|
+
let index = start
|
|
105
|
+
while (index < source.length) {
|
|
106
|
+
const char = source[index] || ""
|
|
107
|
+
const next = source[index + 1] || ""
|
|
108
|
+
if (/\s/.test(char)) { index++; continue }
|
|
109
|
+
if (char === "/" && next === "/") {
|
|
110
|
+
index += 2
|
|
111
|
+
while (index < source.length && source[index] !== "\n" && source[index] !== "\r") index++
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (char === "/" && next === "*") {
|
|
115
|
+
const end = source.indexOf("*/", index + 2)
|
|
116
|
+
if (end < 0) throw new Error("unterminated block comment in OpenCode config")
|
|
117
|
+
index = end + 2
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
return index
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readJsonString(source, start) {
|
|
126
|
+
if (source[start] !== '"') throw new Error("expected JSON string")
|
|
127
|
+
let escaped = false
|
|
128
|
+
for (let index = start + 1; index < source.length; index++) {
|
|
129
|
+
const char = source[index] || ""
|
|
130
|
+
if (escaped) { escaped = false; continue }
|
|
131
|
+
if (char === "\\") { escaped = true; continue }
|
|
132
|
+
if (char === '"') {
|
|
133
|
+
const end = index + 1
|
|
134
|
+
return { value: JSON.parse(source.slice(start, end)), end }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw new Error("unterminated JSON string in OpenCode config")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function skipJsonValue(source, start) {
|
|
141
|
+
const valueStart = skipTrivia(source, start)
|
|
142
|
+
const first = source[valueStart]
|
|
143
|
+
if (first === '"') return readJsonString(source, valueStart).end
|
|
144
|
+
if (first === "{" || first === "[") {
|
|
145
|
+
const stack = []
|
|
146
|
+
let quoted = false
|
|
147
|
+
let escaped = false
|
|
148
|
+
let lineComment = false
|
|
149
|
+
let blockComment = false
|
|
150
|
+
for (let index = valueStart; index < source.length; index++) {
|
|
151
|
+
const char = source[index] || ""
|
|
152
|
+
const next = source[index + 1] || ""
|
|
153
|
+
if (lineComment) { if (char === "\n" || char === "\r") lineComment = false; continue }
|
|
154
|
+
if (blockComment) { if (char === "*" && next === "/") { blockComment = false; index++ }; continue }
|
|
155
|
+
if (quoted) {
|
|
156
|
+
if (escaped) escaped = false
|
|
157
|
+
else if (char === "\\") escaped = true
|
|
158
|
+
else if (char === '"') quoted = false
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (char === '"') { quoted = true; continue }
|
|
162
|
+
if (char === "/" && next === "/") { lineComment = true; index++; continue }
|
|
163
|
+
if (char === "/" && next === "*") { blockComment = true; index++; continue }
|
|
164
|
+
if (char === "{" || char === "[") stack.push(char)
|
|
165
|
+
else if (char === "}" || char === "]") {
|
|
166
|
+
const expected = char === "}" ? "{" : "["
|
|
167
|
+
if (stack.at(-1) !== expected) throw new Error("mismatched JSON delimiters in OpenCode config")
|
|
168
|
+
stack.pop()
|
|
169
|
+
if (!stack.length) return index + 1
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
throw new Error("unterminated JSON value in OpenCode config")
|
|
173
|
+
}
|
|
174
|
+
let index = valueStart
|
|
175
|
+
while (index < source.length && ![",", "}", "]"].includes(source[index])) index++
|
|
176
|
+
return index
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findRootProperty(source, propertyName) {
|
|
180
|
+
let index = skipTrivia(source, 0)
|
|
181
|
+
if (source[index] !== "{") throw new Error("OpenCode config must contain one root object")
|
|
182
|
+
index++
|
|
183
|
+
while (true) {
|
|
184
|
+
index = skipTrivia(source, index)
|
|
185
|
+
if (source[index] === "}") return null
|
|
186
|
+
const key = readJsonString(source, index)
|
|
187
|
+
index = skipTrivia(source, key.end)
|
|
188
|
+
if (source[index] !== ":") throw new Error(`expected ':' after config property ${key.value}`)
|
|
189
|
+
const valueStart = skipTrivia(source, index + 1)
|
|
190
|
+
const valueEnd = skipJsonValue(source, valueStart)
|
|
191
|
+
if (key.value === propertyName) {
|
|
192
|
+
const lineStart = Math.max(source.lastIndexOf("\n", valueStart - 1), source.lastIndexOf("\r", valueStart - 1)) + 1
|
|
193
|
+
const keyLineStart = Math.max(source.lastIndexOf("\n", key.end - 1), source.lastIndexOf("\r", key.end - 1)) + 1
|
|
194
|
+
const indent = source.slice(keyLineStart, key.end - key.value.length - 2).match(/^[\t ]*/)?.[0] || " "
|
|
195
|
+
return { valueStart, valueEnd, indent, lineStart }
|
|
196
|
+
}
|
|
197
|
+
const afterValue = skipTrivia(source, valueEnd)
|
|
198
|
+
if (source[afterValue] === ",") index = afterValue + 1
|
|
199
|
+
else if (source[afterValue] === "}") return null
|
|
200
|
+
else throw new Error(`expected ',' or '}' after config property ${key.value}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatPluginArray(values, indent, eol) {
|
|
205
|
+
if (!values.length) return "[]"
|
|
206
|
+
const childIndent = `${indent} `
|
|
207
|
+
return `[${eol}${values.map((value) => `${childIndent}${JSON.stringify(value)}`).join(`,${eol}`)}${eol}${indent}]`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rewriteExistingPluginArray(source, nextPlugins) {
|
|
211
|
+
const property = findRootProperty(source, "plugin")
|
|
212
|
+
if (!property) return source
|
|
213
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
214
|
+
const replacement = formatPluginArray(nextPlugins, property.indent, eol)
|
|
215
|
+
return `${source.slice(0, property.valueStart)}${replacement}${source.slice(property.valueEnd)}`
|
|
216
|
+
}
|
|
217
|
+
|
|
101
218
|
async function configurePackagePlugin() {
|
|
102
219
|
let configured = false
|
|
103
220
|
const updatedFiles = []
|
|
104
|
-
for (const name of
|
|
221
|
+
for (const name of configCandidates) {
|
|
222
|
+
const target = join(config, name)
|
|
105
223
|
try {
|
|
106
|
-
const target = join(config, name)
|
|
107
224
|
const source = await readFile(target, "utf8")
|
|
108
225
|
const parsed = parseJsonc(source)
|
|
109
|
-
|
|
110
|
-
|
|
226
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error("OpenCode config 'plugin' must be an array")
|
|
227
|
+
const plugins = parsed.plugin || []
|
|
228
|
+
if (!plugins.some(isPackageSpec)) continue
|
|
111
229
|
configured = true
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// release after npm installs a newer one. Pin the config entry to the
|
|
116
|
-
// installer package's exact version while preserving JSONC comments.
|
|
117
|
-
let updated = source
|
|
118
|
-
for (const spec of new Set(specs)) {
|
|
119
|
-
if (spec === packageSpec) continue
|
|
120
|
-
updated = updated.replaceAll(JSON.stringify(spec), JSON.stringify(packageSpec))
|
|
121
|
-
}
|
|
230
|
+
const nextPlugins = plugins.filter((value) => !isPackageSpec(value))
|
|
231
|
+
nextPlugins.push(packageSpec)
|
|
232
|
+
const updated = rewriteExistingPluginArray(source, nextPlugins)
|
|
122
233
|
if (updated !== source) {
|
|
123
234
|
await writeFile(target, updated, "utf8")
|
|
124
235
|
updatedFiles.push(target)
|
|
125
236
|
}
|
|
126
237
|
} catch (error) {
|
|
127
|
-
if (error?.code !== "ENOENT")
|
|
238
|
+
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
128
239
|
}
|
|
129
240
|
}
|
|
130
241
|
return { configured, updatedFiles }
|
|
@@ -149,39 +260,81 @@ async function ensureDependency() {
|
|
|
149
260
|
}
|
|
150
261
|
}
|
|
151
262
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
263
|
+
async function removePackagedFiles(sourceDir, targetDir) {
|
|
264
|
+
for (const name of await readdir(sourceDir)) {
|
|
265
|
+
if (name.endsWith(".md")) await rm(join(targetDir, name), { force: true })
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function uninstall() {
|
|
270
|
+
const plans = []
|
|
271
|
+
for (const name of configCandidates) {
|
|
272
|
+
const target = join(config, name)
|
|
273
|
+
try {
|
|
274
|
+
const source = await readFile(target, "utf8")
|
|
275
|
+
const parsed = parseJsonc(source)
|
|
276
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error("OpenCode config 'plugin' must be an array")
|
|
277
|
+
const plugins = parsed.plugin || []
|
|
278
|
+
const nextPlugins = plugins.filter((value) => !isPackageSpec(value))
|
|
279
|
+
const updated = nextPlugins.length === plugins.length ? source : rewriteExistingPluginArray(source, nextPlugins)
|
|
280
|
+
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")
|
|
288
|
+
}
|
|
158
289
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
159
290
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
160
|
-
|
|
161
|
-
await
|
|
162
|
-
|
|
163
|
-
|
|
291
|
+
await removePackagedFiles(join(root, "commands"), commandDir)
|
|
292
|
+
await removePackagedFiles(join(root, "agents"), agentDir)
|
|
293
|
+
|
|
294
|
+
const changedConfigs = plans.filter((plan) => plan.updated !== plan.source).length
|
|
295
|
+
console.log(changedConfigs
|
|
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.")
|
|
299
|
+
console.log("Project state under .opencode/opencode-loop is preserved.")
|
|
300
|
+
console.log("Restart OpenCode to finish unloading OpenCode Loop.")
|
|
164
301
|
}
|
|
165
302
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
303
|
+
async function installOrUpdate() {
|
|
304
|
+
await mkdir(pluginDir, { recursive: true })
|
|
305
|
+
await mkdir(commandDir, { recursive: true })
|
|
306
|
+
await mkdir(agentDir, { recursive: true })
|
|
307
|
+
const packageConfig = await configurePackagePlugin()
|
|
308
|
+
const useConfiguredPackage = packageConfig.configured
|
|
309
|
+
if (useConfiguredPackage) {
|
|
310
|
+
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
311
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
312
|
+
} else {
|
|
313
|
+
await ensureDependency()
|
|
314
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
315
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
169
316
|
}
|
|
170
|
-
}
|
|
171
317
|
|
|
172
|
-
for (const name of await readdir(join(root, "
|
|
173
|
-
|
|
174
|
-
|
|
318
|
+
for (const name of await readdir(join(root, "commands"))) {
|
|
319
|
+
if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
|
|
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))
|
|
175
324
|
}
|
|
176
|
-
}
|
|
177
325
|
|
|
178
|
-
if (useConfiguredPackage) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.`)
|
|
331
|
+
} else {
|
|
332
|
+
console.log(`Installed OpenCode Loop plugin to ${config}`)
|
|
333
|
+
}
|
|
334
|
+
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
335
|
+
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
336
|
+
console.log("Restart OpenCode, then run: /loop-help")
|
|
183
337
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
console.log("Restart OpenCode, then run: /loop-help")
|
|
338
|
+
|
|
339
|
+
if (uninstallRequested) await uninstall()
|
|
340
|
+
else await installOrUpdate()
|
package/scripts/install-test.mjs
CHANGED
|
@@ -10,6 +10,7 @@ const installer = path.join(root, "scripts", "install-node.mjs")
|
|
|
10
10
|
const packageVersion = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version
|
|
11
11
|
const expectedPackageSpec = `@bybrawe/opencode-loop@${packageVersion}`
|
|
12
12
|
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
|
|
13
|
+
const packagedCommandCount = (await fs.readdir(path.join(root, "commands"))).filter((name) => name.endsWith(".md")).length
|
|
13
14
|
|
|
14
15
|
async function runInstaller(config, cliArgs = []) {
|
|
15
16
|
return await new Promise((resolve, reject) => {
|
|
@@ -32,7 +33,12 @@ async function runInstaller(config, cliArgs = []) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
async function commandCount(config) {
|
|
35
|
-
|
|
36
|
+
try {
|
|
37
|
+
return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === "ENOENT") return 0
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
async function exists(target) {
|
|
@@ -43,7 +49,8 @@ try {
|
|
|
43
49
|
const helpConfig = path.join(temporaryRoot, "help-must-not-install")
|
|
44
50
|
const helpResult = await runInstaller(helpConfig, ["--help"])
|
|
45
51
|
assert.equal(helpResult.code, 0, helpResult.stderr)
|
|
46
|
-
assert.match(helpResult.stdout, /OpenCode Loop installer/)
|
|
52
|
+
assert.match(helpResult.stdout, /OpenCode Loop installer\/updater/)
|
|
53
|
+
assert.match(helpResult.stdout, /--uninstall/)
|
|
47
54
|
assert.equal(await exists(helpConfig), false, "--help must not mutate the OpenCode config directory")
|
|
48
55
|
|
|
49
56
|
const versionResult = await runInstaller(helpConfig, ["--version"])
|
|
@@ -55,7 +62,7 @@ try {
|
|
|
55
62
|
const localResult = await runInstaller(local)
|
|
56
63
|
assert.equal(localResult.code, 0, localResult.stderr)
|
|
57
64
|
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
58
|
-
assert.equal(await commandCount(local),
|
|
65
|
+
assert.equal(await commandCount(local), packagedCommandCount)
|
|
59
66
|
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
60
67
|
const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
|
|
61
68
|
assert.equal(localPackage.dependencies["@opencode-ai/plugin"], ">=1.4.0")
|
|
@@ -70,7 +77,7 @@ try {
|
|
|
70
77
|
assert.match(configuredResult.stdout, /removed the duplicate local plugin copy/i)
|
|
71
78
|
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.ts")), false)
|
|
72
79
|
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
|
|
73
|
-
assert.equal(await commandCount(configured),
|
|
80
|
+
assert.equal(await commandCount(configured), packagedCommandCount)
|
|
74
81
|
assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
|
|
75
82
|
const configuredJson = JSON.parse(await fs.readFile(path.join(configured, "opencode.json"), "utf8"))
|
|
76
83
|
assert.deepEqual(configuredJson.plugin, [expectedPackageSpec], "the installer must bust OpenCode's stale package cache with an exact version spec")
|
|
@@ -98,6 +105,45 @@ try {
|
|
|
98
105
|
assert.equal(lookalikeResult.code, 0, lookalikeResult.stderr)
|
|
99
106
|
assert.equal(await exists(path.join(lookalike, "plugins", "opencode-loop.ts")), true)
|
|
100
107
|
|
|
108
|
+
const uninstallConfig = path.join(temporaryRoot, "uninstall")
|
|
109
|
+
await fs.mkdir(path.join(uninstallConfig, "plugins"), { recursive: true })
|
|
110
|
+
await fs.mkdir(path.join(uninstallConfig, "commands"), { recursive: true })
|
|
111
|
+
await fs.mkdir(path.join(uninstallConfig, "agents"), { recursive: true })
|
|
112
|
+
await fs.writeFile(path.join(uninstallConfig, "opencode.jsonc"), `{
|
|
113
|
+
// Keep unrelated OpenCode configuration.
|
|
114
|
+
"plugin": [
|
|
115
|
+
"other-plugin",
|
|
116
|
+
"@bybrawe/opencode-loop@0.5.1",
|
|
117
|
+
],
|
|
118
|
+
"permission": { "read": "allow" },
|
|
119
|
+
}`, "utf8")
|
|
120
|
+
await fs.writeFile(path.join(uninstallConfig, "plugins", "opencode-loop.ts"), "local", "utf8")
|
|
121
|
+
for (const name of await fs.readdir(path.join(root, "commands"))) {
|
|
122
|
+
if (name.endsWith(".md")) await fs.copyFile(path.join(root, "commands", name), path.join(uninstallConfig, "commands", name))
|
|
123
|
+
}
|
|
124
|
+
for (const name of await fs.readdir(path.join(root, "agents"))) {
|
|
125
|
+
if (name.endsWith(".md")) await fs.copyFile(path.join(root, "agents", name), path.join(uninstallConfig, "agents", name))
|
|
126
|
+
}
|
|
127
|
+
const stateFile = path.join(temporaryRoot, "project", ".opencode", "opencode-loop", "state.json")
|
|
128
|
+
await fs.mkdir(path.dirname(stateFile), { recursive: true })
|
|
129
|
+
await fs.writeFile(stateFile, "preserve", "utf8")
|
|
130
|
+
|
|
131
|
+
const uninstallResult = await runInstaller(uninstallConfig, ["--uninstall"])
|
|
132
|
+
assert.equal(uninstallResult.code, 0, uninstallResult.stderr)
|
|
133
|
+
assert.match(uninstallResult.stdout, /Project state .* is preserved/)
|
|
134
|
+
const uninstallJsonc = await fs.readFile(path.join(uninstallConfig, "opencode.jsonc"), "utf8")
|
|
135
|
+
assert.match(uninstallJsonc, /Keep unrelated OpenCode configuration/)
|
|
136
|
+
assert.match(uninstallJsonc, /"other-plugin"/)
|
|
137
|
+
assert.match(uninstallJsonc, /"permission"/)
|
|
138
|
+
assert.doesNotMatch(uninstallJsonc, /@bybrawe\/opencode-loop/)
|
|
139
|
+
assert.equal(await exists(path.join(uninstallConfig, "plugins", "opencode-loop.ts")), false)
|
|
140
|
+
assert.equal(await commandCount(uninstallConfig), 0)
|
|
141
|
+
assert.equal(await exists(path.join(uninstallConfig, "agents", "opencode-loop-local.md")), false)
|
|
142
|
+
assert.equal(await fs.readFile(stateFile, "utf8"), "preserve")
|
|
143
|
+
|
|
144
|
+
const uninstallAgain = await runInstaller(uninstallConfig, ["--uninstall"])
|
|
145
|
+
assert.equal(uninstallAgain.code, 0, uninstallAgain.stderr)
|
|
146
|
+
|
|
101
147
|
console.log("OpenCode Loop installer test passed")
|
|
102
148
|
} finally {
|
|
103
149
|
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -122,6 +122,34 @@ try {
|
|
|
122
122
|
assert.equal(completedState.jobs[0].paused, true)
|
|
123
123
|
assert.equal(completedState.jobs[0].enabled, false)
|
|
124
124
|
|
|
125
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
126
|
+
await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
|
|
127
|
+
|
|
128
|
+
const promptCountBeforeAutoGoal = prompts.length
|
|
129
|
+
await hooks["command.execute.before"]({
|
|
130
|
+
command: "loop-goal",
|
|
131
|
+
sessionID,
|
|
132
|
+
arguments: "--no-now --check \"node -e process.exitCode=0\" --complete-when-checks-pass make the configured checks pass",
|
|
133
|
+
}, { parts: [] })
|
|
134
|
+
await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
|
|
135
|
+
assert.equal(
|
|
136
|
+
await waitFor(() => prompts.length === promptCountBeforeAutoGoal + 1),
|
|
137
|
+
true,
|
|
138
|
+
"a Goal Mode check-gated run must start when forced",
|
|
139
|
+
)
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
141
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
142
|
+
let autoCompletedState
|
|
143
|
+
for (let attempt = 0; attempt < 400; attempt++) {
|
|
144
|
+
autoCompletedState = JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
145
|
+
if (autoCompletedState.jobs[0]?.goalStatus === "completed") break
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
147
|
+
}
|
|
148
|
+
assert.equal(autoCompletedState.jobs[0].goalStatus, "completed", "--complete-when-checks-pass must complete the goal after successful configured checks")
|
|
149
|
+
assert.equal(autoCompletedState.jobs[0].paused, true)
|
|
150
|
+
assert.equal(autoCompletedState.jobs[0].enabled, false)
|
|
151
|
+
assert.ok(autoCompletedState.jobs[0].lastGoalChecks?.every((item) => item.code === 0), "auto-completed Goal Mode must persist passing check evidence")
|
|
152
|
+
|
|
125
153
|
await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
|
|
126
154
|
|
|
127
155
|
const promptCountBeforeBackgroundTool = prompts.length
|