@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +36 -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 +486 -0
  36. package/scripts/install-node.mjs +146 -4
  37. package/scripts/install-test.mjs +104 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/loopd-test.mjs +208 -0
  41. package/scripts/loopd.mjs +125 -41
  42. package/scripts/smoke-test.mjs +1 -1
  43. 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")
@@ -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 ensureDependency()
35
- await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
36
- await rm(join(pluginDir, "opencode-loop.js"), { force: true })
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
- console.log(`Installed OpenCode Loop plugin to ${config}`)
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")