@bybrawe/opencode-loop 0.5.16 → 0.5.18
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 +22 -0
- package/README.md +36 -11
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +486 -0
- package/scripts/install-node.mjs +146 -4
- package/scripts/install-test.mjs +104 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/loopd-test.mjs +208 -0
- package/scripts/loopd.mjs +125 -41
- package/scripts/smoke-test.mjs +1 -1
- package/src/index.js +193 -52
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import OpenCodeLoopPlugin from "../src/index.js"
|
|
7
|
+
|
|
8
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9
|
+
let harnessNumber = 0
|
|
10
|
+
|
|
11
|
+
async function createHarness(options = {}) {
|
|
12
|
+
harnessNumber++
|
|
13
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-loop-comprehensive-${harnessNumber}-`))
|
|
14
|
+
const sessionID = `ses_comprehensive_${harnessNumber}`
|
|
15
|
+
const records = {
|
|
16
|
+
aborts: [],
|
|
17
|
+
commands: [],
|
|
18
|
+
logs: [],
|
|
19
|
+
prompts: [],
|
|
20
|
+
shells: [],
|
|
21
|
+
toasts: [],
|
|
22
|
+
tuiCommands: [],
|
|
23
|
+
}
|
|
24
|
+
const statuses = new Map([[sessionID, "idle"]])
|
|
25
|
+
|
|
26
|
+
const client = {
|
|
27
|
+
app: {
|
|
28
|
+
log: async (args) => {
|
|
29
|
+
assert.ok(args?.body, "app.log must use the SDK body shape")
|
|
30
|
+
records.logs.push(args.body)
|
|
31
|
+
return { data: true }
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
tui: {
|
|
35
|
+
executeCommand: async (args) => {
|
|
36
|
+
assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape")
|
|
37
|
+
records.tuiCommands.push(args.body.command)
|
|
38
|
+
if (options.failCompact) throw new Error("simulated TUI compact failure")
|
|
39
|
+
return { data: true }
|
|
40
|
+
},
|
|
41
|
+
showToast: async (args) => {
|
|
42
|
+
assert.ok(args?.body?.message, "tui.showToast must use the SDK body shape")
|
|
43
|
+
records.toasts.push(args.body)
|
|
44
|
+
return { data: true }
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
session: {
|
|
48
|
+
list: async (args) => {
|
|
49
|
+
assert.equal(args?.query?.directory, directory)
|
|
50
|
+
return {
|
|
51
|
+
data: [...statuses.keys()].map((id) => ({
|
|
52
|
+
id,
|
|
53
|
+
directory,
|
|
54
|
+
projectID: "comprehensive",
|
|
55
|
+
title: id,
|
|
56
|
+
version: "test",
|
|
57
|
+
time: { created: Date.now(), updated: Date.now() },
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
get: async (args) => {
|
|
62
|
+
assert.equal(args?.path?.id, sessionID)
|
|
63
|
+
return { data: { id: sessionID, agent: "build", model: { id: "test-model", providerID: "test-provider" } } }
|
|
64
|
+
},
|
|
65
|
+
abort: async (args) => {
|
|
66
|
+
records.aborts.push(args?.path?.id)
|
|
67
|
+
return { data: true }
|
|
68
|
+
},
|
|
69
|
+
command: async (args) => {
|
|
70
|
+
assert.equal(args?.path?.id, sessionID)
|
|
71
|
+
records.commands.push(args.body)
|
|
72
|
+
return { data: true }
|
|
73
|
+
},
|
|
74
|
+
prompt: async (args) => {
|
|
75
|
+
assert.equal(args?.path?.id, sessionID)
|
|
76
|
+
const text = args.body.parts.map((part) => part.text || "").join("\n")
|
|
77
|
+
records.prompts.push({ text, noReply: args.body.noReply === true, agent: args.body.agent, model: args.body.model })
|
|
78
|
+
return { data: true }
|
|
79
|
+
},
|
|
80
|
+
shell: async (args) => {
|
|
81
|
+
assert.equal(args?.path?.id, sessionID)
|
|
82
|
+
records.shells.push(args.body)
|
|
83
|
+
return { data: true }
|
|
84
|
+
},
|
|
85
|
+
status: async (args) => {
|
|
86
|
+
assert.equal(args?.query?.directory, directory)
|
|
87
|
+
// Current OpenCode omits idle sessions from this response.
|
|
88
|
+
return {
|
|
89
|
+
data: Object.fromEntries(
|
|
90
|
+
[...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]),
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
summarize: async () => {
|
|
95
|
+
if (options.failCompact) throw new Error("simulated summarize failure")
|
|
96
|
+
return { data: true }
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hooks = await OpenCodeLoopPlugin({ client, directory })
|
|
102
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
103
|
+
return {
|
|
104
|
+
client,
|
|
105
|
+
directory,
|
|
106
|
+
hooks,
|
|
107
|
+
records,
|
|
108
|
+
sessionID,
|
|
109
|
+
statuses,
|
|
110
|
+
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
111
|
+
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
112
|
+
return output
|
|
113
|
+
},
|
|
114
|
+
async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
|
|
115
|
+
await hooks.event({ event: { type: "command.executed", properties: { name: command, sessionID, arguments: argumentsText, messageID } } })
|
|
116
|
+
},
|
|
117
|
+
async idle() {
|
|
118
|
+
statuses.set(sessionID, "idle")
|
|
119
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
120
|
+
},
|
|
121
|
+
async readState() {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
126
|
+
throw error
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
reportTexts() {
|
|
130
|
+
return records.prompts.filter((item) => item.noReply).map((item) => item.text)
|
|
131
|
+
},
|
|
132
|
+
actionTexts() {
|
|
133
|
+
return records.prompts.filter((item) => !item.noReply).map((item) => item.text)
|
|
134
|
+
},
|
|
135
|
+
async cleanup() {
|
|
136
|
+
await hooks.dispose?.()
|
|
137
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsonReport(harness) {
|
|
143
|
+
const report = [...harness.reportTexts()].reverse().find((text) => text.includes("```json"))
|
|
144
|
+
assert.ok(report, "expected a JSON report")
|
|
145
|
+
const match = report.match(/```json\s*([\s\S]*?)\s*```/)
|
|
146
|
+
assert.ok(match, "expected a fenced JSON object")
|
|
147
|
+
return JSON.parse(match[1])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function testParserAndPresets() {
|
|
151
|
+
const h = await createHarness()
|
|
152
|
+
try {
|
|
153
|
+
await h.command("loop", "3m --name matrix --no-now --allow-overlap --safe --quiet --ask-never --git-checkpoint --checkpoint-only --pause-on-verify-fail --multi --prompt --max-runs 4 --max-no-progress 2 --timeout 7s --max-runtime 2h --max-failures 3 --until \"2030-01-01T00:00:00Z\" --stop-file stop.txt --progress-file progress.md --prompt-file prompt.md --goal-file goal.md --evidence-file evidence.md --test \"npm test\" --verify \"npm run verify\" --preflight \"npm run preflight\" --postrun \"npm run postrun\" --notify \"node notify.js\" --branch feature/test --batch 2 --compact-every 5 --watch one.txt --watch two.txt --include-file context.md --acceptance \"criterion A\" --success \"criterion B\" --check \"npm test\" --dry-run perform matrix work")
|
|
154
|
+
const job = jsonReport(h)
|
|
155
|
+
assert.equal(job.intervalMs, 180_000)
|
|
156
|
+
assert.equal(job.name, "matrix")
|
|
157
|
+
assert.equal(job.action, "perform matrix work")
|
|
158
|
+
assert.equal(job.kind, "prompt")
|
|
159
|
+
assert.equal(job.immediate, false)
|
|
160
|
+
assert.equal(job.noOverlap, false)
|
|
161
|
+
assert.equal(job.safe, true)
|
|
162
|
+
assert.equal(job.quiet, true)
|
|
163
|
+
assert.equal(job.askNever, true)
|
|
164
|
+
assert.equal(job.gitCheckpoint, true)
|
|
165
|
+
assert.equal(job.checkpointOnly, true)
|
|
166
|
+
assert.equal(job.pauseOnVerifyFail, true)
|
|
167
|
+
assert.equal(job.multi, true)
|
|
168
|
+
assert.equal(job.maxRuns, 4)
|
|
169
|
+
assert.equal(job.maxNoProgress, 2)
|
|
170
|
+
assert.equal(job.timeoutMs, 7_000)
|
|
171
|
+
assert.equal(job.maxRuntimeMs, 7_200_000)
|
|
172
|
+
assert.equal(job.maxFailures, 3)
|
|
173
|
+
assert.equal(job.stopFile, "stop.txt")
|
|
174
|
+
assert.equal(job.testCommand, "npm test")
|
|
175
|
+
assert.equal(job.verifyCommand, "npm run verify")
|
|
176
|
+
assert.equal(job.preflightCommand, "npm run preflight")
|
|
177
|
+
assert.equal(job.postrunCommand, "npm run postrun")
|
|
178
|
+
assert.equal(job.notifyCommand, "node notify.js")
|
|
179
|
+
assert.equal(job.branch, "feature/test")
|
|
180
|
+
assert.equal(job.batch, 2)
|
|
181
|
+
assert.equal(job.compactEveryRuns, 5)
|
|
182
|
+
assert.deepEqual(job.watchPaths, ["one.txt", "two.txt"])
|
|
183
|
+
assert.deepEqual(job.includeFiles, ["context.md"])
|
|
184
|
+
assert.deepEqual(job.goalAcceptance, ["criterion A", "criterion B"])
|
|
185
|
+
assert.deepEqual(job.goalChecks, ["npm test"])
|
|
186
|
+
|
|
187
|
+
await h.command("loop-compact", "0s --dry-run")
|
|
188
|
+
let preset = jsonReport(h)
|
|
189
|
+
assert.equal(preset.intervalMs, 0, "explicit 0s must not become the 200m default")
|
|
190
|
+
assert.equal(preset.action, "/compact")
|
|
191
|
+
assert.equal(preset.kind, "compact")
|
|
192
|
+
|
|
193
|
+
await h.command("loop-compact", "--dry-run")
|
|
194
|
+
preset = jsonReport(h)
|
|
195
|
+
assert.equal(preset.intervalMs, 12_000_000)
|
|
196
|
+
assert.equal(preset.action, "/compact", "a flag-only preset must keep its real default action")
|
|
197
|
+
|
|
198
|
+
await h.command("loop-safe-dev", "--dry-run --safe")
|
|
199
|
+
preset = jsonReport(h)
|
|
200
|
+
assert.match(preset.action, /Develop the project/)
|
|
201
|
+
assert.notEqual(preset.action, "--safe")
|
|
202
|
+
|
|
203
|
+
await h.command("loop-testfix", "5m pnpm test --dry-run")
|
|
204
|
+
preset = jsonReport(h)
|
|
205
|
+
assert.equal(preset.intervalMs, 300_000)
|
|
206
|
+
assert.equal(preset.verifyCommand, "pnpm test")
|
|
207
|
+
assert.match(preset.action, /Test command hint: pnpm test/)
|
|
208
|
+
|
|
209
|
+
await h.command("loop-testfix", "--verify \"npm run ci\" --dry-run")
|
|
210
|
+
preset = jsonReport(h)
|
|
211
|
+
assert.equal(preset.verifyCommand, "npm run ci")
|
|
212
|
+
assert.match(preset.action, /Test command hint: npm run ci/)
|
|
213
|
+
|
|
214
|
+
const toastCount = h.records.toasts.length
|
|
215
|
+
await h.command("loop", "nonsense")
|
|
216
|
+
await h.command("loop", "5m")
|
|
217
|
+
assert.equal(h.records.toasts.length, toastCount + 2)
|
|
218
|
+
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
|
|
219
|
+
} finally {
|
|
220
|
+
await h.cleanup()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function testLifecycleAndCommandDedupe() {
|
|
225
|
+
const h = await createHarness()
|
|
226
|
+
try {
|
|
227
|
+
await h.command("loop", "10m --no-now --name same first action")
|
|
228
|
+
await h.command("loop", "10m --no-now --name same replacement action")
|
|
229
|
+
let state = await h.readState()
|
|
230
|
+
assert.equal(state.jobs.length, 1)
|
|
231
|
+
assert.equal(state.jobs[0].action, "replacement action")
|
|
232
|
+
|
|
233
|
+
await h.command("loop", "10m --no-now --multi --name extra additional action")
|
|
234
|
+
state = await h.readState()
|
|
235
|
+
assert.equal(state.jobs.length, 2)
|
|
236
|
+
|
|
237
|
+
await h.command("loop-pause", "same")
|
|
238
|
+
state = await h.readState()
|
|
239
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, true)
|
|
240
|
+
await h.command("loop-resume", "same")
|
|
241
|
+
state = await h.readState()
|
|
242
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, false)
|
|
243
|
+
|
|
244
|
+
const beforeReports = h.reportTexts().length
|
|
245
|
+
await h.command("loop-status")
|
|
246
|
+
await h.commandEvent("loop-status", "", "msg_status_1")
|
|
247
|
+
assert.equal(h.reportTexts().length, beforeReports + 1, "command.executed must not duplicate the before hook")
|
|
248
|
+
await h.command("loop-status")
|
|
249
|
+
await h.commandEvent("loop-status", "", "msg_status_2")
|
|
250
|
+
assert.equal(h.reportTexts().length, beforeReports + 2, "an intentional repeated command must not be swallowed")
|
|
251
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
252
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
253
|
+
assert.equal(h.reportTexts().length, beforeReports + 3, "the same event message must only be handled once")
|
|
254
|
+
|
|
255
|
+
await h.command("loop-stop", "same")
|
|
256
|
+
state = await h.readState()
|
|
257
|
+
assert.deepEqual(state.jobs.map((item) => item.name), ["extra"])
|
|
258
|
+
await h.command("loop-clear")
|
|
259
|
+
state = await h.readState()
|
|
260
|
+
assert.equal(state.jobs.length, 0)
|
|
261
|
+
|
|
262
|
+
await h.command("loop-init")
|
|
263
|
+
const progress = path.join(h.directory, "progress.md")
|
|
264
|
+
assert.match(await fs.readFile(progress, "utf8"), /Active TODO/)
|
|
265
|
+
await fs.writeFile(progress, "custom progress", "utf8")
|
|
266
|
+
await h.command("loop-init")
|
|
267
|
+
assert.equal(await fs.readFile(progress, "utf8"), "custom progress", "loop-init must not overwrite user progress")
|
|
268
|
+
|
|
269
|
+
for (const command of ["loop-help", "loop-doctor", "loop-export", "loop-logs"]) await h.command(command)
|
|
270
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop help")))
|
|
271
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop doctor")))
|
|
272
|
+
assert.ok(h.reportTexts().some((text) => text.includes("state export")))
|
|
273
|
+
} finally {
|
|
274
|
+
await h.cleanup()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function testWatchScheduling() {
|
|
279
|
+
const h = await createHarness()
|
|
280
|
+
try {
|
|
281
|
+
const watched = path.join(h.directory, "watched.txt")
|
|
282
|
+
await fs.writeFile(watched, "one", "utf8")
|
|
283
|
+
await h.command("loop", "0s --watch watched.txt --max-runs 1 react to watched file")
|
|
284
|
+
await h.idle()
|
|
285
|
+
await delay(1_400)
|
|
286
|
+
assert.equal(h.actionTexts().length, 0, "watch loop must not run until a watched file changes")
|
|
287
|
+
|
|
288
|
+
await fs.writeFile(watched, "two and changed size", "utf8")
|
|
289
|
+
await h.idle()
|
|
290
|
+
await delay(1_500)
|
|
291
|
+
assert.equal(h.actionTexts().length, 1, "watch loop must run after a watched file changes")
|
|
292
|
+
assert.match(h.actionTexts()[0], /react to watched file/)
|
|
293
|
+
const actionPrompt = h.records.prompts.find((item) => !item.noReply)
|
|
294
|
+
assert.equal(actionPrompt.agent, "build", "scheduled work must restore the normal coding agent")
|
|
295
|
+
assert.deepEqual(actionPrompt.model, { providerID: "test-provider", modelID: "test-model" })
|
|
296
|
+
} finally {
|
|
297
|
+
await h.cleanup()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function testActionRoutingAndSafety() {
|
|
302
|
+
let h = await createHarness()
|
|
303
|
+
try {
|
|
304
|
+
await h.command("loop-shell", "0s --safe npm run format")
|
|
305
|
+
await h.command("loop-now", "shell")
|
|
306
|
+
await delay(25)
|
|
307
|
+
assert.equal(h.records.shells.length, 1)
|
|
308
|
+
assert.equal(h.records.shells[0].command, "npm run format", "safe mode must allow a harmless format script")
|
|
309
|
+
} finally {
|
|
310
|
+
await h.cleanup()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
h = await createHarness()
|
|
314
|
+
try {
|
|
315
|
+
await h.command("loop-shell", "0s --safe Remove-Item -Recurse ./important")
|
|
316
|
+
await h.command("loop-now", "shell")
|
|
317
|
+
await delay(25)
|
|
318
|
+
assert.equal(h.records.shells.length, 0)
|
|
319
|
+
const state = await h.readState()
|
|
320
|
+
assert.equal(state.jobs[0].paused, true, "a blocked synchronous action must pause instead of retrying forever")
|
|
321
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
322
|
+
assert.equal(state.jobs[0].lastFailureReason, "safe_shell_blocked")
|
|
323
|
+
|
|
324
|
+
await h.command("loop-clear")
|
|
325
|
+
await h.command("loop-shell", "0s --safe rm -r -f ./important")
|
|
326
|
+
await h.command("loop-now", "shell")
|
|
327
|
+
const separateFlagsState = await h.readState()
|
|
328
|
+
assert.equal(h.records.shells.length, 0)
|
|
329
|
+
assert.equal(separateFlagsState.jobs[0].lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked")
|
|
330
|
+
} finally {
|
|
331
|
+
await h.cleanup()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
h = await createHarness()
|
|
335
|
+
try {
|
|
336
|
+
await fs.writeFile(path.join(h.directory, "empty.md"), "fallback context", "utf8")
|
|
337
|
+
await h.command("loop-command", "0s --prompt-file empty.md")
|
|
338
|
+
await h.command("loop-now", "command")
|
|
339
|
+
const state = await h.readState()
|
|
340
|
+
assert.equal(state.jobs[0].paused, true)
|
|
341
|
+
assert.equal(state.jobs[0].lastFailureReason, "empty_command")
|
|
342
|
+
} finally {
|
|
343
|
+
await h.cleanup()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
h = await createHarness({ failCompact: true })
|
|
347
|
+
try {
|
|
348
|
+
await h.command("loop-compact", "0s")
|
|
349
|
+
await h.command("loop-now", "compact")
|
|
350
|
+
const state = await h.readState()
|
|
351
|
+
assert.equal(state.jobs[0].paused, true)
|
|
352
|
+
assert.equal(state.jobs[0].lastFailureReason, "compact_failed")
|
|
353
|
+
} finally {
|
|
354
|
+
await h.cleanup()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
h = await createHarness()
|
|
358
|
+
try {
|
|
359
|
+
await h.command("loop-command", "0s /custom-command alpha beta")
|
|
360
|
+
await h.command("loop-now", "command")
|
|
361
|
+
await delay(25)
|
|
362
|
+
assert.deepEqual(h.records.commands[0], {
|
|
363
|
+
command: "custom-command",
|
|
364
|
+
arguments: "alpha beta",
|
|
365
|
+
agent: "build",
|
|
366
|
+
model: "test-provider/test-model",
|
|
367
|
+
})
|
|
368
|
+
} finally {
|
|
369
|
+
await h.cleanup()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function testStopsPreflightAndGoalLifecycle() {
|
|
374
|
+
let h = await createHarness()
|
|
375
|
+
try {
|
|
376
|
+
await h.command("loop", "5m --no-now --stop-file STOP continue")
|
|
377
|
+
await fs.writeFile(path.join(h.directory, "STOP"), "stop", "utf8")
|
|
378
|
+
await h.command("loop-now")
|
|
379
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
380
|
+
} finally {
|
|
381
|
+
await h.cleanup()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
h = await createHarness()
|
|
385
|
+
try {
|
|
386
|
+
await h.command("loop", "5m --no-now --preflight \"node -e process.exit(7)\" continue")
|
|
387
|
+
await h.command("loop-now")
|
|
388
|
+
const state = await h.readState()
|
|
389
|
+
assert.equal(state.jobs[0].paused, true)
|
|
390
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
391
|
+
assert.match(state.jobs[0].lastPreflightFailure, /exit=7/)
|
|
392
|
+
} finally {
|
|
393
|
+
await h.cleanup()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
h = await createHarness()
|
|
397
|
+
try {
|
|
398
|
+
await h.command("loop-goal", "Ship the feature --acceptance \"tests pass\" --max-turns 3")
|
|
399
|
+
await h.command("loop-now", "goal")
|
|
400
|
+
await delay(25)
|
|
401
|
+
assert.match(h.actionTexts()[0], /Goal objective:\nShip the feature/)
|
|
402
|
+
const progress = await h.hooks.tool.opencode_loop_goal_progress.execute(
|
|
403
|
+
{ summary: "Implemented the first part", next: "Run verification" },
|
|
404
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
405
|
+
)
|
|
406
|
+
assert.equal(progress.title, "Goal progress")
|
|
407
|
+
let state = await h.readState()
|
|
408
|
+
assert.equal(state.jobs[0].goalProgress.length, 1)
|
|
409
|
+
const blocked = await h.hooks.tool.opencode_loop_goal_blocked.execute(
|
|
410
|
+
{ reason: "Credential is missing", needed: "Provide a test credential" },
|
|
411
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
412
|
+
)
|
|
413
|
+
assert.equal(blocked.title, "Goal blocked")
|
|
414
|
+
state = await h.readState()
|
|
415
|
+
assert.equal(state.jobs[0].goalStatus, "blocked")
|
|
416
|
+
assert.equal(state.jobs[0].paused, true)
|
|
417
|
+
await h.command("loop-goal-resume")
|
|
418
|
+
state = await h.readState()
|
|
419
|
+
assert.equal(state.jobs[0].goalStatus, "active")
|
|
420
|
+
assert.equal(state.jobs[0].paused, false)
|
|
421
|
+
await h.command("loop-goal-clear")
|
|
422
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
423
|
+
} finally {
|
|
424
|
+
await h.cleanup()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
|
|
429
|
+
const h = await createHarness()
|
|
430
|
+
const originalDateNow = Date.now
|
|
431
|
+
let fakeNow = originalDateNow()
|
|
432
|
+
Date.now = () => fakeNow
|
|
433
|
+
try {
|
|
434
|
+
await h.command("loop-goal", "--no-now Keep working until the objective is complete")
|
|
435
|
+
const synthetic = {
|
|
436
|
+
type: "message.updated",
|
|
437
|
+
properties: { info: { id: "msg_loop_owned", sessionID: h.sessionID, role: "user" } },
|
|
438
|
+
}
|
|
439
|
+
await h.hooks.event({ event: synthetic })
|
|
440
|
+
|
|
441
|
+
fakeNow += 60_000
|
|
442
|
+
await h.hooks.event({ event: synthetic })
|
|
443
|
+
let state = await h.readState()
|
|
444
|
+
assert.equal(state.jobs[0].paused, false, "a delayed update for the same loop-owned user message must remain ignored")
|
|
445
|
+
|
|
446
|
+
await h.hooks.event({ event: {
|
|
447
|
+
type: "message.updated",
|
|
448
|
+
properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
|
|
449
|
+
} })
|
|
450
|
+
state = await h.readState()
|
|
451
|
+
assert.equal(state.jobs[0].paused, true, "a distinct real user message must still pause an active goal")
|
|
452
|
+
} finally {
|
|
453
|
+
Date.now = originalDateNow
|
|
454
|
+
await h.cleanup()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function testInitializationDoesNotWaitForLocalApi() {
|
|
459
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
|
|
460
|
+
const never = new Promise(() => {})
|
|
461
|
+
const client = {
|
|
462
|
+
app: { log: async () => never },
|
|
463
|
+
tui: { showToast: async () => ({ data: true }) },
|
|
464
|
+
session: { list: async () => never },
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const hooks = await Promise.race([
|
|
468
|
+
OpenCodeLoopPlugin({ client, directory }),
|
|
469
|
+
delay(500).then(() => { throw new Error("plugin initialization waited for the local OpenCode API") }),
|
|
470
|
+
])
|
|
471
|
+
assert.equal(typeof hooks.event, "function")
|
|
472
|
+
await hooks.dispose?.()
|
|
473
|
+
} finally {
|
|
474
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await testParserAndPresets()
|
|
479
|
+
await testLifecycleAndCommandDedupe()
|
|
480
|
+
await testWatchScheduling()
|
|
481
|
+
await testActionRoutingAndSafety()
|
|
482
|
+
await testStopsPreflightAndGoalLifecycle()
|
|
483
|
+
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
|
484
|
+
await testInitializationDoesNotWaitForLocalApi()
|
|
485
|
+
|
|
486
|
+
console.log("OpenCode Loop comprehensive test passed")
|
package/scripts/install-node.mjs
CHANGED
|
@@ -8,7 +8,127 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
|
8
8
|
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
9
9
|
const pluginDir = join(config, "plugins")
|
|
10
10
|
const commandDir = join(config, "commands")
|
|
11
|
+
const agentDir = join(config, "agents")
|
|
11
12
|
const packagePath = join(config, "package.json")
|
|
13
|
+
const packageName = "@bybrawe/opencode-loop"
|
|
14
|
+
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
|
+
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
|
+
const installerArgs = process.argv.slice(2)
|
|
17
|
+
|
|
18
|
+
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.`)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
|
|
31
|
+
console.log(packageVersion)
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripJsonComments(input) {
|
|
36
|
+
let output = ""
|
|
37
|
+
let quote = ""
|
|
38
|
+
let escaped = false
|
|
39
|
+
let lineComment = false
|
|
40
|
+
let blockComment = false
|
|
41
|
+
for (let index = 0; index < input.length; index++) {
|
|
42
|
+
const char = input[index]
|
|
43
|
+
const next = input[index + 1]
|
|
44
|
+
if (lineComment) {
|
|
45
|
+
if (char === "\n" || char === "\r") { lineComment = false; output += char }
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (blockComment) {
|
|
49
|
+
if (char === "*" && next === "/") { blockComment = false; index++ }
|
|
50
|
+
else if (char === "\n" || char === "\r") output += char
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (quote) {
|
|
54
|
+
output += char
|
|
55
|
+
if (escaped) escaped = false
|
|
56
|
+
else if (char === "\\") escaped = true
|
|
57
|
+
else if (char === quote) quote = ""
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
61
|
+
if (char === "/" && next === "/") { lineComment = true; index++; continue }
|
|
62
|
+
if (char === "/" && next === "*") { blockComment = true; index++; continue }
|
|
63
|
+
output += char
|
|
64
|
+
}
|
|
65
|
+
return output
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stripTrailingCommas(input) {
|
|
69
|
+
let output = ""
|
|
70
|
+
let quote = ""
|
|
71
|
+
let escaped = false
|
|
72
|
+
for (let index = 0; index < input.length; index++) {
|
|
73
|
+
const char = input[index]
|
|
74
|
+
if (quote) {
|
|
75
|
+
output += char
|
|
76
|
+
if (escaped) escaped = false
|
|
77
|
+
else if (char === "\\") escaped = true
|
|
78
|
+
else if (char === quote) quote = ""
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
82
|
+
if (char === ",") {
|
|
83
|
+
let lookahead = index + 1
|
|
84
|
+
while (/\s/.test(input[lookahead] || "")) lookahead++
|
|
85
|
+
if (input[lookahead] === "]" || input[lookahead] === "}") continue
|
|
86
|
+
}
|
|
87
|
+
output += char
|
|
88
|
+
}
|
|
89
|
+
return output
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseJsonc(input) {
|
|
93
|
+
return JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isPackageSpec(value) {
|
|
97
|
+
const spec = String(value || "").trim()
|
|
98
|
+
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function configurePackagePlugin() {
|
|
102
|
+
let configured = false
|
|
103
|
+
const updatedFiles = []
|
|
104
|
+
for (const name of ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
|
|
105
|
+
try {
|
|
106
|
+
const target = join(config, name)
|
|
107
|
+
const source = await readFile(target, "utf8")
|
|
108
|
+
const parsed = parseJsonc(source)
|
|
109
|
+
const specs = Array.isArray(parsed?.plugin) ? parsed.plugin.filter(isPackageSpec) : []
|
|
110
|
+
if (!specs.length) continue
|
|
111
|
+
configured = true
|
|
112
|
+
|
|
113
|
+
// OpenCode caches package plugins by the literal config spec. A bare
|
|
114
|
+
// package name or @latest can therefore keep loading an older cached
|
|
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
|
+
}
|
|
122
|
+
if (updated !== source) {
|
|
123
|
+
await writeFile(target, updated, "utf8")
|
|
124
|
+
updatedFiles.push(target)
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code !== "ENOENT") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { configured, updatedFiles }
|
|
131
|
+
}
|
|
12
132
|
|
|
13
133
|
async function ensureDependency() {
|
|
14
134
|
let pkg = {}
|
|
@@ -31,9 +151,17 @@ async function ensureDependency() {
|
|
|
31
151
|
|
|
32
152
|
await mkdir(pluginDir, { recursive: true })
|
|
33
153
|
await mkdir(commandDir, { recursive: true })
|
|
34
|
-
await
|
|
35
|
-
|
|
36
|
-
|
|
154
|
+
await mkdir(agentDir, { recursive: true })
|
|
155
|
+
const packageConfig = await configurePackagePlugin()
|
|
156
|
+
const useConfiguredPackage = packageConfig.configured
|
|
157
|
+
if (useConfiguredPackage) {
|
|
158
|
+
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
159
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
160
|
+
} else {
|
|
161
|
+
await ensureDependency()
|
|
162
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
163
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
164
|
+
}
|
|
37
165
|
|
|
38
166
|
for (const name of await readdir(join(root, "commands"))) {
|
|
39
167
|
if (name.endsWith(".md")) {
|
|
@@ -41,5 +169,19 @@ for (const name of await readdir(join(root, "commands"))) {
|
|
|
41
169
|
}
|
|
42
170
|
}
|
|
43
171
|
|
|
44
|
-
|
|
172
|
+
for (const name of await readdir(join(root, "agents"))) {
|
|
173
|
+
if (name.endsWith(".md")) {
|
|
174
|
+
await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (useConfiguredPackage) {
|
|
179
|
+
const pinResult = packageConfig.updatedFiles.length
|
|
180
|
+
? `pinned the config entry to ${packageSpec}`
|
|
181
|
+
: `the config entry is already pinned to ${packageSpec}`
|
|
182
|
+
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
183
|
+
}
|
|
184
|
+
else console.log(`Installed OpenCode Loop plugin to ${config}`)
|
|
185
|
+
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
186
|
+
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
45
187
|
console.log("Restart OpenCode, then run: /loop-help")
|