@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.23",
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
- summarize: async () => {
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()
@@ -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
- return JSON.parse(stripTrailingCommas(stripJsonComments(input)))
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 ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
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
- const specs = Array.isArray(parsed?.plugin) ? parsed.plugin.filter(isPackageSpec) : []
110
- if (!specs.length) continue
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
- // 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
- }
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") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
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
- await mkdir(pluginDir, { recursive: true })
153
- await mkdir(commandDir, { recursive: true })
154
- await mkdir(agentDir, { recursive: true })
155
- const packageConfig = await configurePackagePlugin()
156
- const useConfiguredPackage = packageConfig.configured
157
- if (useConfiguredPackage) {
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
- } 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 })
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
- for (const name of await readdir(join(root, "commands"))) {
167
- if (name.endsWith(".md")) {
168
- await copyFile(join(root, "commands", name), join(commandDir, name))
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, "agents"))) {
173
- if (name.endsWith(".md")) {
174
- await copyFile(join(root, "agents", name), join(agentDir, name))
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
- 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.`)
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
- 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}`)
187
- console.log("Restart OpenCode, then run: /loop-help")
338
+
339
+ if (uninstallRequested) await uninstall()
340
+ else await installOrUpdate()
@@ -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
- return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
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), 30)
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), 30)
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 })
@@ -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