@bybrawe/opencode-loop 0.5.38 → 0.6.1

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 (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +33 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/examples/OPENCODE2-0.6.1.md +60 -0
  5. package/package.json +15 -9
  6. package/scripts/branch-hygiene.mjs +326 -0
  7. package/scripts/comprehensive-test.mjs +12 -6
  8. package/scripts/control-plane-until-test.mjs +39 -0
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +118 -60
  13. package/scripts/install-test.mjs +3 -3
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +114 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/opencode-host.mjs +47 -0
  36. package/scripts/opencode2-loop-canary.mjs +72 -4
  37. package/scripts/package-native-smoke.mjs +31 -0
  38. package/scripts/publish-workflow-test.mjs +60 -19
  39. package/scripts/pulse-check.mjs +246 -0
  40. package/scripts/session-status-test.mjs +12 -0
  41. package/src/index.js +87 -48
  42. package/src/native.js +3135 -0
  43. package/src/plugin.js +19 -0
  44. package/src/server.js +2164 -4560
  45. package/src/source/core/jobs.js +17 -0
  46. package/src/source/core/process.js +6 -4
  47. package/src/source/core/progress.js +58 -0
  48. package/src/source/legacy-v1.js +9 -3
  49. package/src/source/native.js +1 -0
  50. package/src/source/opencode/host.js +1 -0
  51. package/src/source/opencode/loop-commands.js +1 -27
  52. package/src/source/opencode/loop-registration.js +2 -12
  53. package/src/source/opencode/messages.js +1 -1
  54. package/src/source/opencode2/commands.js +9 -4
  55. package/src/source/opencode2/event-bridge.js +15 -3
  56. package/src/source/opencode2/events.js +20 -0
  57. package/src/source/opencode2/experimental.js +2 -0
  58. package/src/source/opencode2/native-companion.js +24 -0
  59. package/src/source/opencode2/native-plugin.js +153 -0
  60. package/src/source/opencode2/native-policy.js +106 -0
  61. package/src/source/opencode2/native-runtime.js +464 -0
  62. package/src/source/opencode2/native-shell.js +81 -0
  63. package/src/source/opencode2/prompt-runtime.js +2 -0
  64. package/src/source/opencode2/session-location.js +16 -0
  65. package/src/source/runtime/goal-steering.js +17 -3
  66. package/src/source/runtime/job-workspace.js +11 -2
  67. package/src/source/runtime/session-status.js +11 -1
  68. package/src/source/server.js +9 -1
  69. package/src/v1.js +1 -0
  70. package/src/v2.js +10 -0
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { createJobWorkspaceRuntime } from "../src/source/runtime/job-workspace.js"
7
+
8
+ const marker = "NATIVE_COMPLETION_FROM_WORK_ONLY_8fd8"
9
+ const runtime = createJobWorkspaceRuntime({ toast: async () => {} })
10
+ async function fixture(file, expected) {
11
+ const directory = await mkdtemp(path.join(os.tmpdir(), "loop-control-until-"))
12
+ try {
13
+ const target = path.join(directory, file)
14
+ await mkdir(path.dirname(target), { recursive: true })
15
+ await writeFile(target, JSON.stringify({ objective: marker }))
16
+ assert.equal(await runtime.untilReached(directory, { until: marker }), expected)
17
+ } finally {
18
+ await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
19
+ }
20
+ }
21
+
22
+ for (const file of [
23
+ ".opencode/goals/live.json",
24
+ ".opencode/goals/archive/history.json",
25
+ ".opencode/goal-sequences/session.json",
26
+ ".opencode/goal-locks/lease.json",
27
+ ".opencode/goal-handoff-locks/lease.json",
28
+ ".opencode/opencode-loop/session.json",
29
+ ]) {
30
+ test("Goal/Loop control state cannot satisfy --until: " + file, () => fixture(file, false))
31
+ }
32
+ for (const file of [
33
+ "progress.md",
34
+ ".opencode/opencode-loop/until.txt",
35
+ ".opencode/commands/project-task.md",
36
+ ".opencode/goals-project-notes/finished.md",
37
+ ]) {
38
+ test("explicit or project-owned completion marker stays supported: " + file, () => fixture(file, true))
39
+ }
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict"
2
+ import { createGoalSteeringRuntime } from "../src/source/runtime/goal-steering.js"
3
+
4
+ const sessionID = "ses-compaction-safety"
5
+ const goal = { id: "goal-1", name: "goal", kind: "goal", enabled: true, paused: false, goalStatus: "active" }
6
+ function fixture(abortHook) {
7
+ let active = { jobId: goal.id, job: { ...goal } }
8
+ let aborts = 0
9
+ let clears = 0
10
+ let reads = 0
11
+ const client = { session: { abort: async () => { aborts++; await abortHook?.(() => { active = { jobId: "replacement", job: { ...goal } } }) } } }
12
+ const runtime = createGoalSteeringRuntime({
13
+ getActiveRun: () => active,
14
+ clearActiveRun: () => { clears++; active = undefined },
15
+ readState: async () => { reads++; return { jobs: [{ ...goal }] } },
16
+ appendLoopLog: async () => {},
17
+ fireSdk: async (_client, _label, method) => method(),
18
+ now: () => 1000,
19
+ })
20
+ return { runtime, client, counts: () => ({ aborts, clears, reads }), active: () => active }
21
+ }
22
+ let cases = 0
23
+ for (const type of ["message.created", "message.updated"]) {
24
+ for (const parts of [undefined, [], [{ type: "compaction", auto: true }], [{ type: "text", synthetic: true, text: "Continue" }], [{ type: "text", text: "Persisted user message" }]]) {
25
+ const f = fixture()
26
+ const event = { type, properties: { info: { id: "msg-core", role: "user", sessionID }, ...(parts ? { parts } : {}) } }
27
+ const result = await f.runtime.handleEvent("/workspace", f.client, event)
28
+ assert.equal(result.handled, false)
29
+ assert.deepEqual(f.counts(), { aborts: 0, clears: 0, reads: 0 })
30
+ assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
31
+ cases++
32
+ }
33
+ }
34
+ {
35
+ const f = fixture()
36
+ await f.runtime.handleEvent("/workspace", f.client, { type: "message.updated", properties: { info: { role: "user", sessionID, id: "msg-real" } } })
37
+ const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-real" })
38
+ assert.equal(result.preempted, true, "untrusted event must not consume the later trusted admission")
39
+ assert.equal(f.counts().aborts, 1)
40
+ cases++
41
+ }
42
+ {
43
+ const f = fixture()
44
+ f.runtime.setCompacting(sessionID)
45
+ const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-during-summary" })
46
+ assert.equal(result.preempted, false)
47
+ assert.equal(f.counts().aborts, 0)
48
+ assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
49
+ f.runtime.setCompacting(sessionID, false)
50
+ assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-summary" })).preempted, true)
51
+ cases++
52
+ }
53
+ {
54
+ const f = fixture((replace) => replace())
55
+ await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-race" })
56
+ assert.equal(f.counts().aborts, 1)
57
+ assert.equal(f.counts().clears, 0, "the old abort must not clear a replacement run")
58
+ assert.equal(f.active().jobId, "replacement")
59
+ cases++
60
+ }
61
+ {
62
+ const f = fixture()
63
+ f.runtime.setCompacting(sessionID)
64
+ f.runtime.clearSession(sessionID)
65
+ assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-delete" })).preempted, true)
66
+ cases++
67
+ }
68
+ console.log(`Goal compaction safety: ${cases} scenarios passed`)
@@ -26,7 +26,7 @@ process.exit(Number(process.env.FAKE_NPM_EXIT || 0))
26
26
 
27
27
  async function runInstaller(config, cliArgs = [], env = {}) {
28
28
  return await new Promise((resolve, reject) => {
29
- const child = spawn(process.execPath, [installer, ...cliArgs], {
29
+ const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
30
30
  cwd: root,
31
31
  env: {
32
32
  ...process.env,
@@ -57,6 +57,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
57
57
  assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
58
58
 
59
59
  assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
60
+ assert.equal(await activeRunCompletionFromMessages("/tmp", messagesClient([]), "empty-history", { startedAt: 100 }), "unknown")
60
61
  assert.equal(await activeRunCompletionFromMessages(
61
62
  "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
62
63
  ), "incomplete")
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { isDeepStrictEqual } from "node:util"
2
3
  import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
3
4
  import { homedir } from "node:os"
4
5
  import { dirname, join } from "node:path"
@@ -7,6 +8,7 @@ import { fileURLToPath } from "node:url"
7
8
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
8
9
  const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
9
10
  const pluginDir = join(config, "plugins")
11
+ const nativePluginDir = join(pluginDir, "opencode-loop")
10
12
  const commandDir = join(config, "commands")
11
13
  const agentDir = join(config, "agents")
12
14
  const packagePath = join(config, "package.json")
@@ -14,11 +16,13 @@ const packageName = "@bybrawe/opencode-loop"
14
16
  const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
15
17
  const packageSpec = `${packageName}@${packageVersion}`
16
18
  const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
17
- const installerArgs = process.argv.slice(2)
19
+ const rawInstallerArgs = process.argv.slice(2)
20
+ const legacyV1 = rawInstallerArgs.includes("--legacy-v1")
21
+ const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
18
22
  const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
19
23
 
20
24
  if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
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 dual OpenCode 1/2 Loop plugin plus 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.`)
25
+ 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 defaults to native OpenCode 2: no V1 SDK or legacy command files are needed for a local install. Existing npm registrations are pinned to the exact version and migrated to plugins while retaining object options. Use --legacy-v1 to install the compatibility loader and legacy command files explicitly.\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.`)
22
26
  process.exit(0)
23
27
  }
24
28
 
@@ -96,7 +100,8 @@ function parseJsonc(input) {
96
100
  }
97
101
 
98
102
  function isPackageSpec(value) {
99
- const spec = String(value || "").trim()
103
+ if (Array.isArray(value)) value = value[0]
104
+ const spec = String(typeof value === "object" && value !== null ? value.package || "" : value || "").trim()
100
105
  return spec === packageName || spec.startsWith(`${packageName}@`)
101
106
  }
102
107
 
@@ -207,38 +212,86 @@ function formatPluginArray(values, indent, eol) {
207
212
  return `[${eol}${values.map((value) => `${childIndent}${JSON.stringify(value)}`).join(`,${eol}`)}${eol}${indent}]`
208
213
  }
209
214
 
210
- function rewriteExistingPluginArray(source, nextPlugins) {
211
- const property = findRootProperty(source, "plugin")
215
+ function rewriteExistingPluginArray(source, nextPlugins, key = "plugin") {
216
+ const property = findRootProperty(source, key)
212
217
  if (!property) return source
213
218
  const eol = source.includes("\r\n") ? "\r\n" : "\n"
214
219
  const replacement = formatPluginArray(nextPlugins, property.indent, eol)
215
220
  return `${source.slice(0, property.valueStart)}${replacement}${source.slice(property.valueEnd)}`
216
221
  }
217
222
 
223
+ function appendPluginProperty(source, values, key) {
224
+ const parsed = parseJsonc(source)
225
+ const keys = Object.keys(parsed)
226
+ if (keys.length) {
227
+ const last = findRootProperty(source, keys.at(-1))
228
+ const after = skipTrivia(source, last.valueEnd)
229
+ if (source[after] !== ",") source = source.slice(0, last.valueEnd) + "," + source.slice(last.valueEnd)
230
+ }
231
+ const end = skipJsonValue(source, 0) - 1
232
+ const eol = source.includes("\r\n") ? "\r\n" : "\n"
233
+ return source.slice(0, end) + `${eol} ${JSON.stringify(key)}: ${formatPluginArray(values, " ", eol)}${eol}` + source.slice(end)
234
+ }
235
+
236
+ function pinRegistration(entries) {
237
+ const objects = entries.map((entry) => {
238
+ if (!Array.isArray(entry)) return entry
239
+ if (entry.length !== 2 || !entry[1] || typeof entry[1] !== "object" || Array.isArray(entry[1])) {
240
+ throw new Error("Invalid Loop package/options tuple; no configuration was changed")
241
+ }
242
+ return { package: entry[0], options: entry[1] }
243
+ }).filter((entry) => typeof entry === "object" && entry !== null)
244
+ const optionsOf = (entry) => { const { package: _package, ...options } = entry; return options }
245
+ const configured = objects.filter((entry) => Object.keys(optionsOf(entry)).length > 0)
246
+ const preferred = configured[0]
247
+ if (!preferred) return packageSpec
248
+ if (configured.some((entry) => !isDeepStrictEqual(optionsOf(entry), optionsOf(preferred)))) {
249
+ throw new Error("Conflicting Loop object registrations; reconcile their options before updating")
250
+ }
251
+ const legacyTuple = entries.some((entry) => Array.isArray(entry) && isDeepStrictEqual(entry[1], preferred.options))
252
+ return legacyV1 && legacyTuple ? [packageSpec, preferred.options] : { ...preferred, package: packageSpec }
253
+ }
254
+
218
255
  async function configurePackagePlugin() {
256
+ const plans = []
219
257
  let configured = false
220
- const updatedFiles = []
221
258
  for (const name of configCandidates) {
222
259
  const target = join(config, name)
223
260
  try {
224
261
  const source = await readFile(target, "utf8")
225
262
  const parsed = parseJsonc(source)
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
229
- configured = true
230
- const nextPlugins = plugins.filter((value) => !isPackageSpec(value))
231
- nextPlugins.push(packageSpec)
232
- const updated = rewriteExistingPluginArray(source, nextPlugins)
233
- if (updated !== source) {
234
- await writeFile(target, updated, "utf8")
235
- updatedFiles.push(target)
263
+ for (const key of ["plugin", "plugins"]) {
264
+ if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
265
+ }
266
+ let updated = source
267
+ const own = [...(parsed.plugins || []), ...(parsed.plugin || [])].filter(isPackageSpec)
268
+ if (own.length) {
269
+ configured = true
270
+ if (legacyV1) {
271
+ for (const key of ["plugin", "plugins"]) {
272
+ const entries = parsed[key] || []
273
+ const matching = entries.filter(isPackageSpec)
274
+ if (!matching.length) continue
275
+ const pinned = pinRegistration(matching)
276
+ updated = rewriteExistingPluginArray(updated, [...entries.filter((entry) => !isPackageSpec(entry)), pinned], key)
277
+ }
278
+ } else {
279
+ const pinned = pinRegistration(own)
280
+ if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
281
+ const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
282
+ updated = parsed.plugins === undefined ? appendPluginProperty(updated, next, "plugins") : rewriteExistingPluginArray(updated, next, "plugins")
283
+ }
236
284
  }
285
+ parseJsonc(updated)
286
+ plans.push({ target, source, updated })
237
287
  } catch (error) {
238
288
  if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
239
289
  }
240
290
  }
241
- return { configured, updatedFiles }
291
+ // Validate every candidate before writing any config. Malformed later files
292
+ // must not leave the earlier file migrated and the plugin half-installed.
293
+ for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
294
+ return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
242
295
  }
243
296
 
244
297
  async function ensureDependency() {
@@ -262,7 +315,13 @@ async function ensureDependency() {
262
315
 
263
316
  async function removePackagedFiles(sourceDir, targetDir) {
264
317
  for (const name of await readdir(sourceDir)) {
265
- if (name.endsWith(".md")) await rm(join(targetDir, name), { force: true })
318
+ if (!name.endsWith(".md")) continue
319
+ try {
320
+ const installed = await readFile(join(targetDir, name), "utf8")
321
+ const packaged = await readFile(join(sourceDir, name), "utf8")
322
+ const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
323
+ if (installed === packaged || recognized) await rm(join(targetDir, name), { force: true })
324
+ } catch (error) { if (error?.code !== "ENOENT") throw error }
266
325
  }
267
326
  }
268
327
 
@@ -273,68 +332,67 @@ async function uninstall() {
273
332
  try {
274
333
  const source = await readFile(target, "utf8")
275
334
  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)
335
+ let updated = source
336
+ for (const key of ["plugin", "plugins"]) {
337
+ if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
338
+ const entries = parsed[key] || []
339
+ if (entries.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, entries.filter((entry) => !isPackageSpec(entry)), key)
340
+ }
280
341
  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")
342
+ } catch (error) { if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`) }
288
343
  }
344
+ for (const plan of plans) if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
289
345
  await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
290
346
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
347
+ await rm(nativePluginDir, { recursive: true, force: true })
291
348
  await removePackagedFiles(join(root, "commands"), commandDir)
292
349
  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.")
350
+ const changed = plans.filter((plan) => plan.source !== plan.updated).length
351
+ console.log(`Removed ${packageName} registrations from ${changed} OpenCode config file(s).`)
352
+ console.log("Removed known local Loop plugin and managed legacy command/agent files; custom files are preserved.")
299
353
  console.log("Project state under .opencode/opencode-loop is preserved.")
300
354
  console.log("Restart OpenCode to finish unloading OpenCode Loop.")
301
355
  }
302
356
 
303
357
  async function installOrUpdate() {
304
358
  await mkdir(pluginDir, { recursive: true })
305
- await mkdir(commandDir, { recursive: true })
306
- await mkdir(agentDir, { recursive: true })
307
359
  const packageConfig = await configurePackagePlugin()
308
- const useConfiguredPackage = packageConfig.configured
309
- if (useConfiguredPackage) {
360
+ if (packageConfig.configured) {
310
361
  await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
311
362
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
312
- } else {
363
+ await rm(nativePluginDir, { recursive: true, force: true })
364
+ } else if (legacyV1) {
313
365
  await ensureDependency()
314
- // Copy the dual contract bundle: OpenCode 1.x consumes default.server,
315
- // while OpenCode 2.x consumes default.setup from the same local file.
316
- await copyFile(join(root, "src", "server.js"), join(pluginDir, "opencode-loop.ts"))
366
+ await rm(nativePluginDir, { recursive: true, force: true })
367
+ await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
317
368
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
369
+ } else {
370
+ // V2 local plugins use the same Plugin.define entrypoint as the npm package.
371
+ // Keep the generated native runtime beside it as a supporting module instead
372
+ // of auto-discovering that implementation object as a second plugin.
373
+ await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
374
+ await rm(join(pluginDir, "opencode-loop.js"), { force: true })
375
+ await mkdir(nativePluginDir, { recursive: true })
376
+ await copyFile(join(root, "src", "v2.js"), join(nativePluginDir, "index.js"))
377
+ await copyFile(join(root, "src", "native.js"), join(nativePluginDir, "native.js"))
318
378
  }
319
-
320
- for (const name of await readdir(join(root, "commands"))) {
321
- if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
322
- }
323
-
324
- for (const name of await readdir(join(root, "agents"))) {
325
- if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
326
- }
327
-
328
- if (useConfiguredPackage) {
329
- const pinResult = packageConfig.updatedFiles.length
330
- ? `pinned the config entry to ${packageSpec}`
331
- : `the config entry is already pinned to ${packageSpec}`
332
- console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
379
+ if (legacyV1) {
380
+ await mkdir(commandDir, { recursive: true })
381
+ await mkdir(agentDir, { recursive: true })
382
+ for (const name of await readdir(join(root, "commands"))) if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
383
+ for (const name of await readdir(join(root, "agents"))) if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
333
384
  } else {
334
- console.log(`Installed OpenCode Loop plugin to ${config}`)
385
+ await removePackagedFiles(join(root, "commands"), commandDir)
386
+ await removePackagedFiles(join(root, "agents"), agentDir)
335
387
  }
336
- console.log(`Installed ${packageName} commands to ${commandDir}`)
337
- console.log(`Installed ${packageName} local command agent to ${agentDir}`)
388
+ if (packageConfig.configured) {
389
+ const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
390
+ console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
391
+ } else console.log(`Installed OpenCode Loop plugin to ${legacyV1 ? join(pluginDir, "opencode-loop.ts") : nativePluginDir}`)
392
+ if (legacyV1) {
393
+ console.log(`Installed ${packageName} commands to ${commandDir}`)
394
+ console.log(`Installed ${packageName} local command agent to ${agentDir}`)
395
+ } else console.log("Native OpenCode 2 commands are registered by the plugin; no legacy command files or V1 SDK are needed for a local install.")
338
396
  console.log("Restart OpenCode, then run: /loop-help")
339
397
  }
340
398
 
@@ -14,7 +14,7 @@ const packagedCommandCount = (await fs.readdir(path.join(root, "commands"))).fil
14
14
 
15
15
  async function runInstaller(config, cliArgs = []) {
16
16
  return await new Promise((resolve, reject) => {
17
- const child = spawn(process.execPath, [installer, ...cliArgs], {
17
+ const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
18
18
  cwd: root,
19
19
  env: { ...process.env, OPENCODE_CONFIG_DIR: config },
20
20
  windowsHide: true,
@@ -64,8 +64,8 @@ try {
64
64
  assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
65
65
  assert.equal(
66
66
  await fs.readFile(path.join(local, "plugins", "opencode-loop.ts"), "utf8"),
67
- await fs.readFile(path.join(root, "src", "server.js"), "utf8"),
68
- "local install must copy the dual OpenCode 1/2 server bundle",
67
+ await fs.readFile(path.join(root, "src", "index.js"), "utf8"),
68
+ "explicit legacy install must copy the standalone V1 bundle, not the lazy package facade",
69
69
  )
70
70
  assert.equal(await commandCount(local), packagedCommandCount)
71
71
  assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
@@ -18,7 +18,8 @@ const withGoals = rawArgs.includes("--with-goals")
18
18
  const loopOnly = rawArgs.includes("--loop-only")
19
19
  const withoutLoopGoals = rawArgs.includes("--without-loop-goals")
20
20
  const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--without-loop-goals"].includes(arg))
21
- const uninstallRequested = loopArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(loopArgs[0] || "")
21
+ const commandArgs = loopArgs.filter((arg) => arg !== "--legacy-v1")
22
+ const uninstallRequested = commandArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(commandArgs[0] || "")
22
23
  const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
23
24
  const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
24
25
  const informational = helpRequested || versionRequested
@@ -87,6 +88,8 @@ function parseJsonc(input) {
87
88
  }
88
89
 
89
90
  function isGoalPluginSpec(value) {
91
+ if (Array.isArray(value)) value = value[0]
92
+ if (value && typeof value === "object") value = value.package
90
93
  if (typeof value !== "string") return false
91
94
  const spec = value.trim()
92
95
  if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
@@ -101,7 +104,7 @@ async function goalsAlreadyInstalled() {
101
104
  for (const name of configCandidates) {
102
105
  try {
103
106
  const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
104
- if (Array.isArray(parsed.plugin) && parsed.plugin.some(isGoalPluginSpec)) return true
107
+ if ([parsed.plugin, parsed.plugins].some((entries) => Array.isArray(entries) && entries.some(isGoalPluginSpec))) return true
105
108
  } catch (error) {
106
109
  if (error?.code !== "ENOENT") {
107
110
  console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
@@ -125,6 +128,10 @@ async function removeLoopGoalCommands() {
125
128
  let removed = 0
126
129
  for (const name of names) {
127
130
  try {
131
+ const installed = await readFile(join(commandDir, name), "utf8")
132
+ const packaged = await readFile(join(root, "commands", name), "utf8")
133
+ const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
134
+ if (installed !== packaged && !recognized) continue
128
135
  await rm(join(commandDir, name))
129
136
  removed++
130
137
  } catch (error) {
@@ -152,7 +159,13 @@ function runGoalInstaller() {
152
159
 
153
160
  return spawnSync(command, args, {
154
161
  cwd: root,
155
- env: { ...process.env, OPENCODE_CONFIG_DIR: config },
162
+ // This is a target-dialect hint, not a claim about an installed binary.
163
+ // Older Goal installers already understand this variable, unlike new CLI flags.
164
+ env: {
165
+ ...process.env,
166
+ OPENCODE_CONFIG_DIR: config,
167
+ OPENCODE_GOAL_HOST_VERSION: rawArgs.includes("--legacy-v1") ? "1.0.0" : "2.0.0",
168
+ },
156
169
  stdio: "inherit",
157
170
  windowsHide: true,
158
171
  timeout: 180_000,
@@ -227,4 +240,4 @@ async function main() {
227
240
  main().catch((error) => {
228
241
  console.error(error?.stack || error)
229
242
  process.exitCode = 1
230
- })
243
+ })
@@ -72,7 +72,8 @@ try {
72
72
  processResults.push({ code: 1, stdout: "", stderr: "not a repo" })
73
73
  const nonRepo = { id: "nonrepo", branch: "feature/test" }
74
74
  await runtime.ensureBranch(directory, nonRepo, {}, "session")
75
- assert.equal(nonRepo.branchDone, true)
75
+ assert.equal(nonRepo.branchDone, false, "a non-repository cannot claim a successful branch switch")
76
+ assert.equal(nonRepo.branchUnavailable, true)
76
77
  assert.equal(processCalls.at(-1)[0], "git")
77
78
  assert.deepEqual(processCalls.at(-1)[1], ["rev-parse", "--is-inside-work-tree"])
78
79
  assert.equal(toasts.length, 0)
@@ -95,6 +96,15 @@ try {
95
96
  assert.deepEqual(toasts.at(-1), [client, "Loop branch active: feature-test", "success"])
96
97
  assert.deepEqual(logs.at(-1), [directory, "branch", { sessionID: "session", branch: "feature-test", code: 0 }])
97
98
 
99
+ processResults.push(
100
+ { code: 0, stdout: "true", stderr: "" },
101
+ { code: 1, stdout: "", stderr: "switch failed" },
102
+ { code: 1, stdout: "", stderr: "create failed" },
103
+ )
104
+ const failedBranch = { branch: "cannot-switch" }
105
+ await runtime.ensureBranch(directory, failedBranch, client, "session")
106
+ assert.equal(failedBranch.branchDone, false, "failed switch/create must not admit work on the wrong branch")
107
+
98
108
  const watched = path.join(directory, "watched.txt")
99
109
  await fs.writeFile(watched, "one", "utf8")
100
110
  const initialSnapshot = await runtime.snapshotPaths(directory, ["watched.txt", "missing.txt"])
@@ -11,7 +11,7 @@ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-te
11
11
 
12
12
  function runCli(cliArgs, env = {}) {
13
13
  return new Promise((resolve, reject) => {
14
- const child = spawn(process.execPath, [loopd, ...cliArgs], {
14
+ const child = spawn(process.execPath, [loopd, ...cliArgs, "--legacy-v1"], {
15
15
  cwd: root,
16
16
  env: { ...process.env, ...env },
17
17
  windowsHide: true,
@@ -32,7 +32,7 @@ function runCli(cliArgs, env = {}) {
32
32
  async function makeFakeCommand(name) {
33
33
  const script = path.join(temporaryRoot, `${name}.mjs`)
34
34
  const log = path.join(temporaryRoot, `${name}.jsonl`)
35
- await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
35
+ await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nif (argv[0] === \"--version\") { console.log(\"1.18.15\"); process.exit(0) }\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
36
36
 
37
37
  if (process.platform === "win32") {
38
38
  const command = path.join(temporaryRoot, `${name}.cmd`)
@@ -81,7 +81,7 @@ try {
81
81
  "run", "--title", runCalls[0].args[2],
82
82
  "--model", "opencode/nemotron-3-ultra-free",
83
83
  "--agent", "build",
84
- inlinePrompt,
84
+ "--", inlinePrompt,
85
85
  ], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
86
86
  assert.match(runCalls[0].args[2], /^OpenCode Loop daemon /)
87
87
 
@@ -146,6 +146,73 @@ try {
146
146
  })
147
147
  assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
148
148
 
149
+ const goalsDir = path.join(project, ".opencode", "goals")
150
+ await fs.mkdir(goalsDir, { recursive: true })
151
+ const goalFile = path.join(goalsDir, "goal-stale.json")
152
+ const staleGoal = {
153
+ schemaVersion: 1,
154
+ id: "goal-stale",
155
+ sessionID: "ses_goal",
156
+ status: "active",
157
+ progressRevision: 4,
158
+ revision: 1,
159
+ storageGeneration: 1,
160
+ updatedAt: Date.now() - 60 * 60_000,
161
+ }
162
+ await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
163
+ const originalGoal = await fs.readFile(goalFile, "utf8")
164
+ const pulseEnv = {
165
+ OPENCODE_BIN: fakeOpenCode.command,
166
+ FAKE_COMMAND_LOG: path.join(temporaryRoot, "pulse-opencode.jsonl"),
167
+ FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_goal", title: "Goal", updated: Date.now() - 1_000 }]),
168
+ }
169
+
170
+ result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
171
+ assert.equal(result.code, 0, result.stderr)
172
+ assert.match(result.stdout, /GOAL PULSE STALLED/)
173
+ assert.match(result.stdout, /goal=goal-stale/)
174
+ assert.match(result.stdout, /session=ses_goal/)
175
+ assert.match(result.stdout, /sessionUpdated=/)
176
+ assert.match(result.stdout, /action="\/goal resume"/)
177
+ assert.equal(await fs.readFile(goalFile, "utf8"), originalGoal, "pulse-check must never write dedicated Goal state")
178
+
179
+ result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
180
+ assert.equal(result.code, 0, result.stderr)
181
+ assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "one stall episode must alert only once")
182
+
183
+ staleGoal.status = "waiting_user"
184
+ staleGoal.storageGeneration += 1
185
+ staleGoal.updatedAt = Date.now() - 30 * 60_000
186
+ await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
187
+ result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
188
+ assert.equal(result.code, 0, result.stderr)
189
+ assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "waiting_user must suppress pulse alerts")
190
+
191
+ staleGoal.status = "active"
192
+ staleGoal.progressRevision += 1
193
+ staleGoal.storageGeneration += 1
194
+ staleGoal.updatedAt = Date.now() - 11 * 60_000
195
+ await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
196
+ result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
197
+ assert.equal(result.code, 0, result.stderr)
198
+ assert.match(result.stdout, /GOAL PULSE STALLED/, "new Goal activity/status must re-arm a later stall episode")
199
+
200
+ const pulseState = path.join(project, ".opencode", "opencode-loop", "pulse-check.json")
201
+ await fs.rm(pulseState, { force: true })
202
+ staleGoal.updatedAt = Date.now() - 60 * 60_000
203
+ staleGoal.storageGeneration += 1
204
+ await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
205
+ const concurrent = await Promise.all([
206
+ runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
207
+ runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
208
+ ])
209
+ assert.ok(concurrent.every((item) => item.code === 0), concurrent.map((item) => item.stderr).join("\n"))
210
+ const concurrentAlerts = concurrent.reduce((count, item) => count + (item.stdout.match(/GOAL PULSE STALLED/g) || []).length, 0)
211
+ assert.equal(concurrentAlerts, 1, "concurrent one-shot checks must share episode state and emit at most one alert")
212
+
213
+ const pulseCalls = await readLog(pulseEnv.FAKE_COMMAND_LOG)
214
+ assert.ok(pulseCalls.every((item) => item.args[0] === "session"), "pulse-check may inspect session telemetry but must never dispatch an OpenCode run")
215
+
149
216
  result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
150
217
  OPENCODE_BIN: fakeOpenCode.command,
151
218
  FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
@@ -208,7 +275,7 @@ try {
208
275
  "run", "--title", taskRunCalls[0].args[2],
209
276
  "--model", "opencode/nemotron-3-ultra-free",
210
277
  "--agent", "build",
211
- "scheduled task prompt",
278
+ "--", "scheduled task prompt",
212
279
  ])
213
280
 
214
281
  result = await runCli(["uninstall-task", "--name", taskName], {
@@ -223,6 +290,7 @@ try {
223
290
 
224
291
  result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
225
292
  SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
293
+ OPENCODE_BIN: fakeOpenCode.command,
226
294
  OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
227
295
  })
228
296
  assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
@@ -233,3 +301,5 @@ try {
233
301
  } finally {
234
302
  await fs.rm(temporaryRoot, { recursive: true, force: true })
235
303
  }
304
+
305
+ await import("./native-cli-test.mjs")