@bybrawe/opencode-loop 0.5.16 → 0.5.17

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +26 -11
  3. package/agents/opencode-loop-local.md +10 -0
  4. package/commands/loop-ask.md +1 -0
  5. package/commands/loop-clear.md +1 -0
  6. package/commands/loop-cmd.md +1 -0
  7. package/commands/loop-command.md +1 -0
  8. package/commands/loop-compact.md +1 -0
  9. package/commands/loop-dev.md +1 -0
  10. package/commands/loop-doctor.md +1 -0
  11. package/commands/loop-export.md +1 -0
  12. package/commands/loop-goal-blocked.md +1 -0
  13. package/commands/loop-goal-clear.md +1 -0
  14. package/commands/loop-goal-done.md +1 -0
  15. package/commands/loop-goal-pause.md +1 -0
  16. package/commands/loop-goal-resume.md +1 -0
  17. package/commands/loop-goal-status.md +1 -0
  18. package/commands/loop-goal.md +1 -0
  19. package/commands/loop-help.md +1 -0
  20. package/commands/loop-init.md +1 -0
  21. package/commands/loop-logs.md +1 -0
  22. package/commands/loop-now.md +1 -0
  23. package/commands/loop-pause.md +1 -0
  24. package/commands/loop-progress.md +1 -0
  25. package/commands/loop-prompt.md +1 -0
  26. package/commands/loop-remove.md +1 -0
  27. package/commands/loop-resume.md +1 -0
  28. package/commands/loop-safe-dev.md +1 -0
  29. package/commands/loop-shell.md +1 -0
  30. package/commands/loop-status.md +1 -0
  31. package/commands/loop-stop.md +1 -0
  32. package/commands/loop-testfix.md +1 -0
  33. package/commands/loop.md +1 -0
  34. package/package.json +6 -5
  35. package/scripts/comprehensive-test.mjs +455 -0
  36. package/scripts/install-node.mjs +100 -4
  37. package/scripts/install-test.mjs +86 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/smoke-test.mjs +1 -1
  41. package/src/index.js +168 -42
@@ -0,0 +1,455 @@
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 testInitializationDoesNotWaitForLocalApi() {
429
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
430
+ const never = new Promise(() => {})
431
+ const client = {
432
+ app: { log: async () => never },
433
+ tui: { showToast: async () => ({ data: true }) },
434
+ session: { list: async () => never },
435
+ }
436
+ try {
437
+ const hooks = await Promise.race([
438
+ OpenCodeLoopPlugin({ client, directory }),
439
+ delay(500).then(() => { throw new Error("plugin initialization waited for the local OpenCode API") }),
440
+ ])
441
+ assert.equal(typeof hooks.event, "function")
442
+ await hooks.dispose?.()
443
+ } finally {
444
+ await fs.rm(directory, { recursive: true, force: true })
445
+ }
446
+ }
447
+
448
+ await testParserAndPresets()
449
+ await testLifecycleAndCommandDedupe()
450
+ await testWatchScheduling()
451
+ await testActionRoutingAndSafety()
452
+ await testStopsPreflightAndGoalLifecycle()
453
+ await testInitializationDoesNotWaitForLocalApi()
454
+
455
+ console.log("OpenCode Loop comprehensive test passed")
@@ -8,7 +8,87 @@ 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
+
15
+ function stripJsonComments(input) {
16
+ let output = ""
17
+ let quote = ""
18
+ let escaped = false
19
+ let lineComment = false
20
+ let blockComment = false
21
+ for (let index = 0; index < input.length; index++) {
22
+ const char = input[index]
23
+ const next = input[index + 1]
24
+ if (lineComment) {
25
+ if (char === "\n" || char === "\r") { lineComment = false; output += char }
26
+ continue
27
+ }
28
+ if (blockComment) {
29
+ if (char === "*" && next === "/") { blockComment = false; index++ }
30
+ else if (char === "\n" || char === "\r") output += char
31
+ continue
32
+ }
33
+ if (quote) {
34
+ output += char
35
+ if (escaped) escaped = false
36
+ else if (char === "\\") escaped = true
37
+ else if (char === quote) quote = ""
38
+ continue
39
+ }
40
+ if (char === '"') { quote = char; output += char; continue }
41
+ if (char === "/" && next === "/") { lineComment = true; index++; continue }
42
+ if (char === "/" && next === "*") { blockComment = true; index++; continue }
43
+ output += char
44
+ }
45
+ return output
46
+ }
47
+
48
+ function stripTrailingCommas(input) {
49
+ let output = ""
50
+ let quote = ""
51
+ let escaped = false
52
+ for (let index = 0; index < input.length; index++) {
53
+ const char = input[index]
54
+ if (quote) {
55
+ output += char
56
+ if (escaped) escaped = false
57
+ else if (char === "\\") escaped = true
58
+ else if (char === quote) quote = ""
59
+ continue
60
+ }
61
+ if (char === '"') { quote = char; output += char; continue }
62
+ if (char === ",") {
63
+ let lookahead = index + 1
64
+ while (/\s/.test(input[lookahead] || "")) lookahead++
65
+ if (input[lookahead] === "]" || input[lookahead] === "}") continue
66
+ }
67
+ output += char
68
+ }
69
+ return output
70
+ }
71
+
72
+ function parseJsonc(input) {
73
+ return JSON.parse(stripTrailingCommas(stripJsonComments(input)))
74
+ }
75
+
76
+ function isPackageSpec(value) {
77
+ const spec = String(value || "").trim()
78
+ return spec === packageName || spec.startsWith(`${packageName}@`)
79
+ }
80
+
81
+ async function packagePluginConfigured() {
82
+ for (const name of ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
83
+ try {
84
+ const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
85
+ if (Array.isArray(parsed?.plugin) && parsed.plugin.some(isPackageSpec)) return true
86
+ } catch (error) {
87
+ if (error?.code !== "ENOENT") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
88
+ }
89
+ }
90
+ return false
91
+ }
12
92
 
13
93
  async function ensureDependency() {
14
94
  let pkg = {}
@@ -31,9 +111,16 @@ async function ensureDependency() {
31
111
 
32
112
  await mkdir(pluginDir, { recursive: true })
33
113
  await mkdir(commandDir, { recursive: true })
34
- await ensureDependency()
35
- await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
36
- await rm(join(pluginDir, "opencode-loop.js"), { force: true })
114
+ await mkdir(agentDir, { recursive: true })
115
+ const useConfiguredPackage = await packagePluginConfigured()
116
+ if (useConfiguredPackage) {
117
+ await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
118
+ await rm(join(pluginDir, "opencode-loop.js"), { force: true })
119
+ } else {
120
+ await ensureDependency()
121
+ await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
122
+ await rm(join(pluginDir, "opencode-loop.js"), { force: true })
123
+ }
37
124
 
38
125
  for (const name of await readdir(join(root, "commands"))) {
39
126
  if (name.endsWith(".md")) {
@@ -41,5 +128,14 @@ for (const name of await readdir(join(root, "commands"))) {
41
128
  }
42
129
  }
43
130
 
44
- console.log(`Installed OpenCode Loop plugin to ${config}`)
131
+ for (const name of await readdir(join(root, "agents"))) {
132
+ if (name.endsWith(".md")) {
133
+ await copyFile(join(root, "agents", name), join(agentDir, name))
134
+ }
135
+ }
136
+
137
+ if (useConfiguredPackage) console.log(`OpenCode Loop is already configured as a package in ${config}; removed the duplicate local plugin copy.`)
138
+ else console.log(`Installed OpenCode Loop plugin to ${config}`)
139
+ console.log(`Installed ${packageName} commands to ${commandDir}`)
140
+ console.log(`Installed ${packageName} local command agent to ${agentDir}`)
45
141
  console.log("Restart OpenCode, then run: /loop-help")
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn } from "node:child_process"
3
+ import { promises as fs } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
+ const installer = path.join(root, "scripts", "install-node.mjs")
10
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
11
+
12
+ async function runInstaller(config) {
13
+ return await new Promise((resolve, reject) => {
14
+ const child = spawn(process.execPath, [installer], {
15
+ cwd: root,
16
+ env: { ...process.env, OPENCODE_CONFIG_DIR: config },
17
+ windowsHide: true,
18
+ })
19
+ const stdout = []
20
+ const stderr = []
21
+ child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
22
+ child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
23
+ child.on("error", reject)
24
+ child.on("close", (code) => resolve({
25
+ code,
26
+ stdout: Buffer.concat(stdout).toString("utf8"),
27
+ stderr: Buffer.concat(stderr).toString("utf8"),
28
+ }))
29
+ })
30
+ }
31
+
32
+ async function commandCount(config) {
33
+ return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
34
+ }
35
+
36
+ async function exists(target) {
37
+ try { await fs.access(target); return true } catch { return false }
38
+ }
39
+
40
+ try {
41
+ const local = path.join(temporaryRoot, "local")
42
+ const localResult = await runInstaller(local)
43
+ assert.equal(localResult.code, 0, localResult.stderr)
44
+ assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
45
+ assert.equal(await commandCount(local), 30)
46
+ assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
47
+ const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
48
+ assert.equal(localPackage.dependencies["@opencode-ai/plugin"], ">=1.4.0")
49
+
50
+ const configured = path.join(temporaryRoot, "configured")
51
+ await fs.mkdir(path.join(configured, "plugins"), { recursive: true })
52
+ await fs.writeFile(path.join(configured, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop@latest"] }), "utf8")
53
+ await fs.writeFile(path.join(configured, "plugins", "opencode-loop.ts"), "duplicate", "utf8")
54
+ await fs.writeFile(path.join(configured, "plugins", "opencode-loop.js"), "legacy duplicate", "utf8")
55
+ const configuredResult = await runInstaller(configured)
56
+ assert.equal(configuredResult.code, 0, configuredResult.stderr)
57
+ assert.match(configuredResult.stdout, /removed the duplicate local plugin copy/i)
58
+ assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.ts")), false)
59
+ assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
60
+ assert.equal(await commandCount(configured), 30)
61
+ assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
62
+
63
+ const jsonc = path.join(temporaryRoot, "jsonc")
64
+ await fs.mkdir(jsonc, { recursive: true })
65
+ await fs.writeFile(path.join(jsonc, "opencode.jsonc"), `{
66
+ // A configured package is authoritative; a local copy would load twice.
67
+ "plugin": [
68
+ "other-plugin",
69
+ "@bybrawe/opencode-loop",
70
+ ],
71
+ }`, "utf8")
72
+ const jsoncResult = await runInstaller(jsonc)
73
+ assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
74
+ assert.equal(await exists(path.join(jsonc, "plugins", "opencode-loop.ts")), false)
75
+
76
+ const lookalike = path.join(temporaryRoot, "lookalike")
77
+ await fs.mkdir(lookalike, { recursive: true })
78
+ await fs.writeFile(path.join(lookalike, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop-extra"] }), "utf8")
79
+ const lookalikeResult = await runInstaller(lookalike)
80
+ assert.equal(lookalikeResult.code, 0, lookalikeResult.stderr)
81
+ assert.equal(await exists(path.join(lookalike, "plugins", "opencode-loop.ts")), true)
82
+
83
+ console.log("OpenCode Loop installer test passed")
84
+ } finally {
85
+ await fs.rm(temporaryRoot, { recursive: true, force: true })
86
+ }