@bybrawe/opencode-loop 0.6.3 → 0.6.4

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4 - 2026-10-02
4
+
5
+ - Persist OpenCode 2 native plugin configuration as one canonical plural `plugins` list instead of leaving valid legacy `plugin` entries in a parallel top-level block.
6
+ - Mirror OpenCode 2.0.21 normalization semantics: strings remain strings and valid legacy `[package, options]` tuples become native `{ package, options }` entries in order ahead of existing native registrations.
7
+ - Preserve malformed/unrecognized legacy entries and unrelated provider/model configuration instead of deleting or rewriting user-owned settings.
8
+ - Pin `@opencode/plugin` to exact 2.0.18 so package resolution and CI/release builds cannot drift to a newer SDK with a different peer graph.
9
+ - Replace a 25 ms blocked-shell test race with deterministic state waiting and update Goal+Loop installer regressions for the canonical single-list configuration.
10
+ - Remove the redundant post-publish `npm view` cache check; the retrying direct registry verifier already proves exact version, gitHead and latest tag.
11
+
3
12
  ## 0.6.3 - 2026-10-02
4
13
 
5
14
  - Fix native OpenCode 2 fresh installs on Windows/Linux by registering the exact published Loop package in plural `plugins` instead of copying a loose global `plugins/opencode-loop` directory.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # OpenCode Loop
2
2
 
3
- ## Native OpenCode 2 (0.6.3)
3
+ ## Native OpenCode 2 (0.6.4)
4
4
 
5
5
  The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
6
6
 
7
- The installer defaults to the native V2 package plugin. It needs no loose local V2 copy, V1 SDK, or legacy command files. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options, and 0.6.1/0.6.2 loose `plugins/opencode-loop` installs are removed on update. Use `--legacy-v1` explicitly for the compatibility installation.
7
+ The installer defaults to the native V2 package plugin. It needs no loose local V2 copy, V1 SDK, or legacy command files. Valid legacy `plugin` registrations are now persisted into the single native `plugins` list using OpenCode 2.0.21's own normalization semantics, so native installs no longer leave parallel `plugin` and `plugins` blocks. Object options and entry order are preserved, malformed legacy values are left untouched, and unrelated provider/model settings are not rewritten. Existing 0.6.1/0.6.2 loose `plugins/opencode-loop` installs are still removed on update. Use `--legacy-v1` explicitly for the compatibility installation.
8
8
 
9
9
  V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
10
10
 
@@ -17,7 +17,7 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
17
17
 
18
18
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
19
19
 
20
- > **Current stable release: `0.6.3`.**
20
+ > **Current stable release: `0.6.4`.**
21
21
 
22
22
  ## Install or update
23
23
 
@@ -42,7 +42,7 @@ This is the recommended split for OpenCode 2:
42
42
  - `--loop-only` prevents the Loop installer from installing/updating the Goal companion.
43
43
  - `--without-loop-goals` removes only Loop's older experimental `/loop-goal*` command files; it does not remove the dedicated OpenCode Goal plugin.
44
44
 
45
- Run the same two commands again to update both packages. Loop 0.6.3 also migrates the earlier 0.6.1/0.6.2 loose global V2 install that could fail with `Cannot find package '@opencode/plugin'`; the update removes that loose copy and pins the published package in `plugins` instead. Then **fully restart OpenCode** and verify:
45
+ Run the same two commands again to update both packages. Loop 0.6.4 also canonicalizes valid legacy `plugin` entries into the native `plugins` list; earlier 0.6.1/0.6.2 loose global V2 installs are removed and replaced by the pinned published package. Then **fully restart OpenCode** and verify:
46
46
 
47
47
  ```text
48
48
  /goal status
@@ -0,0 +1,42 @@
1
+ # OpenCode Loop 0.6.4
2
+
3
+ OpenCode 2 configuration canonicalization patch, paired with OpenCode Goal 1.3.43.
4
+
5
+ ## One native plugin list
6
+
7
+ OpenCode 2.0.21's runtime schema uses the plural `plugins` field and its
8
+ normalizer accepts the legacy singular `plugin` field for compatibility. Loop
9
+ now persists the same normalization during a native V2 install.
10
+
11
+ For valid legacy entries:
12
+
13
+ - strings stay strings;
14
+ - `[package, options]` tuples become `{ "package": "...", "options": { ... } }`;
15
+ - migrated legacy entries retain their order ahead of existing native entries;
16
+ - Loop's own registration is de-duplicated and pinned to the exact package version;
17
+ - the old `plugin` property is removed when it becomes empty.
18
+
19
+ Malformed or unrecognized legacy entries are preserved. Loop does not rewrite
20
+ unrelated `provider`, `providers`, `model`, or other user configuration.
21
+
22
+ ## Deterministic runtime dependency
23
+
24
+ The production `@opencode/plugin` dependency is pinned to exact 2.0.18. This
25
+ prevents npm dependency drift from silently changing Loop's V2 runtime contract.
26
+
27
+ ## Upgrade
28
+
29
+ ```sh
30
+ npx -y @bybrawe/opencode-goal@1.3.43
31
+ npx -y @bybrawe/opencode-loop@0.6.4 --loop-only --without-loop-goals
32
+ ```
33
+
34
+ Then fully restart OpenCode and verify:
35
+
36
+ ```text
37
+ /goal status
38
+ /loop-help
39
+ /loop-doctor
40
+ ```
41
+
42
+ The update preserves Loop project state and user-authored command files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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/plugin.js",
@@ -75,6 +75,6 @@
75
75
  "access": "public"
76
76
  },
77
77
  "dependencies": {
78
- "@opencode/plugin": "^2.0.18"
78
+ "@opencode/plugin": "2.0.18"
79
79
  }
80
80
  }
@@ -348,12 +348,14 @@ async function testActionRoutingAndSafety() {
348
348
  try {
349
349
  await h.command("loop-shell", "0s --safe Remove-Item -Recurse ./important")
350
350
  await h.command("loop-now", "shell")
351
- await delay(25)
351
+ const state = await waitForValue(async () => {
352
+ const candidate = await h.readState()
353
+ return candidate.jobs[0]?.paused === true && candidate.jobs[0]?.lastFailureReason === "safe_shell_blocked" ? candidate : undefined
354
+ }, 2_000)
352
355
  assert.equal(h.records.shells.length, 0)
353
- const state = await h.readState()
354
- assert.equal(state.jobs[0].paused, true, "a blocked synchronous action must pause instead of retrying forever")
355
- assert.equal(state.jobs[0].failureCount, 1)
356
- assert.equal(state.jobs[0].lastFailureReason, "safe_shell_blocked")
356
+ assert.equal(state?.jobs[0]?.paused, true, "a blocked synchronous action must pause instead of retrying forever")
357
+ assert.equal(state?.jobs[0]?.failureCount, 1)
358
+ assert.equal(state?.jobs[0]?.lastFailureReason, "safe_shell_blocked")
357
359
 
358
360
  await h.command("loop-clear")
359
361
  await h.command("loop-shell", "0s --safe rm -r -f ./important")
@@ -206,6 +206,53 @@ function findRootProperty(source, propertyName) {
206
206
  }
207
207
  }
208
208
 
209
+ function removeRootProperty(source, propertyName) {
210
+ let index = skipTrivia(source, 0)
211
+ if (source[index] !== "{") throw new Error("OpenCode config must contain one root object")
212
+ index++
213
+ let previousComma
214
+
215
+ while (true) {
216
+ index = skipTrivia(source, index)
217
+ if (source[index] === "}") return source
218
+
219
+ const keyStart = index
220
+ const key = readJsonString(source, keyStart)
221
+ index = skipTrivia(source, key.end)
222
+ if (source[index] !== ":") throw new Error(`expected ':' after config property ${key.value}`)
223
+ const valueStart = skipTrivia(source, index + 1)
224
+ const valueEnd = skipJsonValue(source, valueStart)
225
+ const afterValue = skipTrivia(source, valueEnd)
226
+ const hadComma = source[afterValue] === ","
227
+
228
+ if (key.value === propertyName) {
229
+ if (hadComma) return `${source.slice(0, keyStart)}${source.slice(afterValue + 1)}`
230
+ if (previousComma !== undefined) return `${source.slice(0, previousComma)}${source.slice(valueEnd)}`
231
+ return `${source.slice(0, keyStart)}${source.slice(valueEnd)}`
232
+ }
233
+
234
+ if (hadComma) {
235
+ previousComma = afterValue
236
+ index = afterValue + 1
237
+ continue
238
+ }
239
+ if (source[afterValue] === "}") return source
240
+ throw new Error(`expected ',' or '}' after config property ${key.value}`)
241
+ }
242
+ }
243
+
244
+ function migrateLegacyPluginEntry(value) {
245
+ // Mirrors OpenCode 2.0.21 config normalization: strings stay strings and
246
+ // legacy [package, options] tuples become native { package, options } entries.
247
+ if (typeof value === "string") return value
248
+ if (Array.isArray(value)) {
249
+ if (value.length !== 2 || typeof value[0] !== "string" || !value[1] || typeof value[1] !== "object" || Array.isArray(value[1])) return undefined
250
+ return { package: value[0], options: value[1] }
251
+ }
252
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.package === "string") return value
253
+ return undefined
254
+ }
255
+
209
256
  function formatPluginArray(values, indent, eol) {
210
257
  if (!values.length) return "[]"
211
258
  const childIndent = `${indent} `
@@ -263,11 +310,13 @@ async function configurePackagePlugin() {
263
310
  for (const key of ["plugin", "plugins"]) {
264
311
  if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
265
312
  }
313
+
266
314
  let updated = source
267
315
  const own = [...(parsed.plugins || []), ...(parsed.plugin || [])].filter(isPackageSpec)
268
- if (own.length) {
269
- configured = true
270
- if (legacyV1) {
316
+ if (own.length) configured = true
317
+
318
+ if (legacyV1) {
319
+ if (own.length) {
271
320
  for (const key of ["plugin", "plugins"]) {
272
321
  const entries = parsed[key] || []
273
322
  const matching = entries.filter(isPackageSpec)
@@ -275,17 +324,40 @@ async function configurePackagePlugin() {
275
324
  const pinned = pinRegistration(matching)
276
325
  updated = rewriteExistingPluginArray(updated, [...entries.filter((entry) => !isPackageSpec(entry)), pinned], key)
277
326
  }
278
- } else {
279
- const pinned = pinRegistration(own)
280
- if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) {
281
- updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
282
- }
283
- const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
284
- updated = parsed.plugins === undefined
327
+ }
328
+ } else {
329
+ // Persist OpenCode 2.0.21's compatibility normalization so users do not
330
+ // accumulate parallel plugin/plugins blocks. Valid legacy entries move
331
+ // to native plugins in the same order; invalid values stay untouched.
332
+ const legacyEntries = parsed.plugin || []
333
+ const migratedLegacy = []
334
+ const retainedLegacy = []
335
+ for (const entry of legacyEntries.filter((entry) => !isPackageSpec(entry))) {
336
+ const migrated = migrateLegacyPluginEntry(entry)
337
+ if (migrated === undefined) retainedLegacy.push(entry)
338
+ else migratedLegacy.push(migrated)
339
+ }
340
+ if (legacyEntries.length) {
341
+ updated = retainedLegacy.length
342
+ ? rewriteExistingPluginArray(updated, retainedLegacy, "plugin")
343
+ : removeRootProperty(updated, "plugin")
344
+ }
345
+
346
+ const current = parseJsonc(updated)
347
+ const nativeEntries = current.plugins || []
348
+ if (!Array.isArray(nativeEntries)) throw new Error("OpenCode config 'plugins' must be an array")
349
+ const next = [
350
+ ...migratedLegacy,
351
+ ...nativeEntries.filter((entry) => !isPackageSpec(entry)),
352
+ ...(own.length ? [pinRegistration(own)] : []),
353
+ ]
354
+ if (migratedLegacy.length || own.length) {
355
+ updated = current.plugins === undefined
285
356
  ? appendPluginProperty(updated, next, "plugins")
286
357
  : rewriteExistingPluginArray(updated, next, "plugins")
287
358
  }
288
359
  }
360
+
289
361
  parseJsonc(updated)
290
362
  plans.push({ target, source, updated })
291
363
  } catch (error) {
@@ -24,7 +24,10 @@ try {
24
24
  assert.deepEqual(JSON.parse(await readFile(file, "utf8")).plugin, [other, [spec, options]])
25
25
  result = run()
26
26
  assert.equal(result.status, 0, result.stderr)
27
- assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { plugin: [other], plugins: [{ package: spec, options }] })
27
+ assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { plugins: [
28
+ { package: "other-plugin", options: { enabled: false } },
29
+ { package: spec, options },
30
+ ] })
28
31
  const migrated = await readFile(file, "utf8")
29
32
  assert.equal(run().status, 0)
30
33
  assert.equal(await readFile(file, "utf8"), migrated)
@@ -47,8 +47,9 @@ try {
47
47
  run(config, ["--with-goals", "--without-loop-goals"])
48
48
  let source = await readFile(path.join(config, "opencode.json"), "utf8")
49
49
  let parsed = JSON.parse(source)
50
- assert.deepEqual(parsed.plugin, ["other-plugin"])
51
- assert.equal(parsed.plugins.length, 2)
50
+ assert.equal("plugin" in parsed, false)
51
+ assert.equal(parsed.plugins.length, 3)
52
+ assert.ok(parsed.plugins.includes("other-plugin"))
52
53
  assert.deepEqual(parsed.plugins.find((item) => item.package?.startsWith("@bybrawe/opencode-goal@")).options, options)
53
54
  assert.deepEqual(parsed.plugins.find((item) => item.package?.startsWith("@bybrawe/opencode-loop@")).options, { quiet: true })
54
55
  assert.equal(await readFile(path.join(config, "commands/goal.md"), "utf8"), "Custom Goal command\n")
@@ -66,8 +67,9 @@ try {
66
67
  assert.deepEqual((await readFile(targetLog, "utf8")).trim().split('\n').map((line) => JSON.parse(line)), ["2.0.0", "2.0.0", "1.0.0"])
67
68
  run(config, ["--uninstall"])
68
69
  parsed = JSON.parse(await readFile(path.join(config, "opencode.json"), "utf8"))
69
- assert.equal(parsed.plugins.length, 1, "Loop uninstall cannot remove the companion")
70
- assert.ok(parsed.plugins[0].package.startsWith("@bybrawe/opencode-goal@"))
70
+ assert.equal(parsed.plugins.length, 2, "Loop uninstall cannot remove the companion or unrelated plugins")
71
+ assert.ok(parsed.plugins.includes("other-plugin"))
72
+ assert.ok(parsed.plugins.some((item) => item?.package?.startsWith("@bybrawe/opencode-goal@")))
71
73
  assert.equal(await readFile(state, "utf8"), '{"evidence":"unchanged"}\n')
72
74
  console.log("Native Goal+Loop installer pair: dialect, options, idempotence, custom files and uninstall PASS")
73
75
  } finally { await rm(temp, { recursive: true, force: true }) }
@@ -64,7 +64,30 @@ try {
64
64
 
65
65
  const both = await config("both", { plugin: ["legacy-unrelated", "@bybrawe/opencode-loop@old"], plugins: [options, "native-unrelated"] })
66
66
  run(both)
67
- assert.deepEqual(await readConfig(both), { plugin: ["legacy-unrelated"], plugins: ["native-unrelated", { ...options, package: spec }] })
67
+ assert.deepEqual(await readConfig(both), { plugins: ["legacy-unrelated", "native-unrelated", { ...options, package: spec }] })
68
+ cases++
69
+
70
+ const userLegacy = await config("user-legacy", {
71
+ $schema: "https://opencode.ai/config.json",
72
+ plugin: [
73
+ "opencode-antigravity-auth@beta",
74
+ "@tarquinen/opencode-dcp@latest",
75
+ "opencode-power-pack@git+https://github.com/waybarrios/opencode-power-pack.git",
76
+ ],
77
+ provider: { google: { models: { demo: { name: "Demo" } } } },
78
+ model: "",
79
+ })
80
+ run(userLegacy)
81
+ const canonicalUser = await readConfig(userLegacy)
82
+ assert.equal("plugin" in canonicalUser, false)
83
+ assert.deepEqual(canonicalUser.plugins, [
84
+ "opencode-antigravity-auth@beta",
85
+ "@tarquinen/opencode-dcp@latest",
86
+ "opencode-power-pack@git+https://github.com/waybarrios/opencode-power-pack.git",
87
+ spec,
88
+ ])
89
+ assert.deepEqual(canonicalUser.provider, { google: { models: { demo: { name: "Demo" } } } })
90
+ assert.equal(canonicalUser.model, "")
68
91
  cases++
69
92
 
70
93
  const jsonc = await config("jsonc", '{\r\n // keep-before\r\n "theme": "url://example",\r\n "plugin": ["@bybrawe/opencode-loop@old"] // keep-after\r\n}\r\n')
@@ -7,12 +7,12 @@ export function validateRelease(request, pkg, env) {
7
7
  assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
8
8
  assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
9
9
  assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
10
- assert.equal(request?.version, "0.6.3", "release version is not authorized")
10
+ assert.equal(request?.version, "0.6.4", "release version is not authorized")
11
11
  assert.equal(request?.name, "@bybrawe/opencode-loop")
12
12
  assert.equal(pkg.name, request.name)
13
13
  assert.equal(pkg.version, request.version)
14
- assert.equal(request.predecessor, "0.6.2")
15
- assert.equal(request.goal?.version, "1.3.42")
14
+ assert.equal(request.predecessor, "0.6.3")
15
+ assert.equal(request.goal?.version, "1.3.43")
16
16
  assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
17
  return request
18
18
  }
@@ -24,7 +24,7 @@ assert.match(workflow, /windows-latest/)
24
24
  assert.match(workflow, /Decide whether npm publish is needed/)
25
25
  assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
26
26
  assert.match(workflow, /for attempt in \{1\.\.60\}/)
27
- assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/)
27
+ assert.doesNotMatch(workflow, /npm view @bybrawe\/opencode-loop@latest/, "direct registry verification already checks latest; do not add a stale npm-view cache gate")
28
28
  assert.match(workflow, /native-release\.mjs verify/)
29
29
  assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
30
30
  assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
@@ -44,14 +44,14 @@ const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.inde
44
44
  assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
45
45
 
46
46
  const sha = "a".repeat(40)
47
- const request = { name: "@bybrawe/opencode-loop", version: "0.6.3", predecessor: "0.6.2", goal: { version: "1.3.42", sha: "b".repeat(40) } }
47
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.4", predecessor: "0.6.3", goal: { version: "1.3.43", sha: "b".repeat(40) } }
48
48
  const pkg = { name: request.name, version: request.version }
49
49
  const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
50
50
  assert.strictEqual(validateRelease(request, pkg, env), request)
51
51
  for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
52
52
  assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
53
- assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.2" }, env))
54
- assert.equal(releaseDecision(404, null, request, sha, "0.6.2"), true)
53
+ assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.3" }, env))
54
+ assert.equal(releaseDecision(404, null, request, sha, "0.6.3"), true)
55
55
  for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
56
56
  assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
57
57
  const manifest = { ...pkg, gitHead: sha }