@bybrawe/opencode-loop 0.5.38 → 0.6.0

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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +30 -0
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -9
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +12 -6
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/goal-compaction-safety-test.mjs +68 -0
  9. package/scripts/goal-companion-test.mjs +1 -1
  10. package/scripts/host-adapter-contract-test.mjs +1 -0
  11. package/scripts/install-node.mjs +105 -60
  12. package/scripts/install-test.mjs +3 -3
  13. package/scripts/install-with-goals.mjs +17 -4
  14. package/scripts/job-workspace-test.mjs +11 -1
  15. package/scripts/loopd-test.mjs +74 -4
  16. package/scripts/loopd.mjs +153 -25
  17. package/scripts/native-cli-host-canary.mjs +118 -0
  18. package/scripts/native-cli-io-test.mjs +66 -0
  19. package/scripts/native-cli-test.mjs +130 -0
  20. package/scripts/native-coexistence-test.mjs +38 -0
  21. package/scripts/native-entry-test.mjs +77 -0
  22. package/scripts/native-guide-test.mjs +45 -0
  23. package/scripts/native-install-pair-test.mjs +73 -0
  24. package/scripts/native-install-test.mjs +107 -0
  25. package/scripts/native-lifecycle-test.mjs +100 -0
  26. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  27. package/scripts/native-presets-test.mjs +227 -0
  28. package/scripts/native-process-tree-fixture.mjs +72 -0
  29. package/scripts/native-process-tree-test.mjs +40 -0
  30. package/scripts/native-release.mjs +121 -0
  31. package/scripts/native-session-location-test.mjs +86 -0
  32. package/scripts/native-shell-test.mjs +38 -0
  33. package/scripts/native-v2-test.mjs +262 -0
  34. package/scripts/opencode-host.mjs +47 -0
  35. package/scripts/opencode2-loop-canary.mjs +72 -4
  36. package/scripts/package-native-smoke.mjs +31 -0
  37. package/scripts/publish-workflow-test.mjs +60 -19
  38. package/scripts/pulse-check.mjs +246 -0
  39. package/scripts/session-status-test.mjs +12 -0
  40. package/src/index.js +87 -48
  41. package/src/native.js +3135 -0
  42. package/src/server.js +2164 -4560
  43. package/src/source/core/jobs.js +17 -0
  44. package/src/source/core/process.js +6 -4
  45. package/src/source/core/progress.js +58 -0
  46. package/src/source/legacy-v1.js +9 -3
  47. package/src/source/native.js +1 -0
  48. package/src/source/opencode/host.js +1 -0
  49. package/src/source/opencode/loop-commands.js +1 -27
  50. package/src/source/opencode/loop-registration.js +2 -12
  51. package/src/source/opencode/messages.js +1 -1
  52. package/src/source/opencode2/commands.js +9 -4
  53. package/src/source/opencode2/event-bridge.js +15 -3
  54. package/src/source/opencode2/events.js +20 -0
  55. package/src/source/opencode2/experimental.js +2 -0
  56. package/src/source/opencode2/native-companion.js +24 -0
  57. package/src/source/opencode2/native-plugin.js +153 -0
  58. package/src/source/opencode2/native-policy.js +106 -0
  59. package/src/source/opencode2/native-runtime.js +464 -0
  60. package/src/source/opencode2/native-shell.js +81 -0
  61. package/src/source/opencode2/prompt-runtime.js +2 -0
  62. package/src/source/opencode2/session-location.js +16 -0
  63. package/src/source/runtime/goal-steering.js +17 -3
  64. package/src/source/runtime/job-workspace.js +11 -2
  65. package/src/source/runtime/session-status.js +11 -1
  66. package/src/source/server.js +9 -1
  67. package/src/v1.js +1 -0
@@ -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"
@@ -14,11 +15,13 @@ const packageName = "@bybrawe/opencode-loop"
14
15
  const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
15
16
  const packageSpec = `${packageName}@${packageVersion}`
16
17
  const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
17
- const installerArgs = process.argv.slice(2)
18
+ const rawInstallerArgs = process.argv.slice(2)
19
+ const legacyV1 = rawInstallerArgs.includes("--legacy-v1")
20
+ const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
18
21
  const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
19
22
 
20
23
  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.`)
24
+ 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
25
  process.exit(0)
23
26
  }
24
27
 
@@ -96,7 +99,8 @@ function parseJsonc(input) {
96
99
  }
97
100
 
98
101
  function isPackageSpec(value) {
99
- const spec = String(value || "").trim()
102
+ if (Array.isArray(value)) value = value[0]
103
+ const spec = String(typeof value === "object" && value !== null ? value.package || "" : value || "").trim()
100
104
  return spec === packageName || spec.startsWith(`${packageName}@`)
101
105
  }
102
106
 
@@ -207,38 +211,86 @@ function formatPluginArray(values, indent, eol) {
207
211
  return `[${eol}${values.map((value) => `${childIndent}${JSON.stringify(value)}`).join(`,${eol}`)}${eol}${indent}]`
208
212
  }
209
213
 
210
- function rewriteExistingPluginArray(source, nextPlugins) {
211
- const property = findRootProperty(source, "plugin")
214
+ function rewriteExistingPluginArray(source, nextPlugins, key = "plugin") {
215
+ const property = findRootProperty(source, key)
212
216
  if (!property) return source
213
217
  const eol = source.includes("\r\n") ? "\r\n" : "\n"
214
218
  const replacement = formatPluginArray(nextPlugins, property.indent, eol)
215
219
  return `${source.slice(0, property.valueStart)}${replacement}${source.slice(property.valueEnd)}`
216
220
  }
217
221
 
222
+ function appendPluginProperty(source, values, key) {
223
+ const parsed = parseJsonc(source)
224
+ const keys = Object.keys(parsed)
225
+ if (keys.length) {
226
+ const last = findRootProperty(source, keys.at(-1))
227
+ const after = skipTrivia(source, last.valueEnd)
228
+ if (source[after] !== ",") source = source.slice(0, last.valueEnd) + "," + source.slice(last.valueEnd)
229
+ }
230
+ const end = skipJsonValue(source, 0) - 1
231
+ const eol = source.includes("\r\n") ? "\r\n" : "\n"
232
+ return source.slice(0, end) + `${eol} ${JSON.stringify(key)}: ${formatPluginArray(values, " ", eol)}${eol}` + source.slice(end)
233
+ }
234
+
235
+ function pinRegistration(entries) {
236
+ const objects = entries.map((entry) => {
237
+ if (!Array.isArray(entry)) return entry
238
+ if (entry.length !== 2 || !entry[1] || typeof entry[1] !== "object" || Array.isArray(entry[1])) {
239
+ throw new Error("Invalid Loop package/options tuple; no configuration was changed")
240
+ }
241
+ return { package: entry[0], options: entry[1] }
242
+ }).filter((entry) => typeof entry === "object" && entry !== null)
243
+ const optionsOf = (entry) => { const { package: _package, ...options } = entry; return options }
244
+ const configured = objects.filter((entry) => Object.keys(optionsOf(entry)).length > 0)
245
+ const preferred = configured[0]
246
+ if (!preferred) return packageSpec
247
+ if (configured.some((entry) => !isDeepStrictEqual(optionsOf(entry), optionsOf(preferred)))) {
248
+ throw new Error("Conflicting Loop object registrations; reconcile their options before updating")
249
+ }
250
+ const legacyTuple = entries.some((entry) => Array.isArray(entry) && isDeepStrictEqual(entry[1], preferred.options))
251
+ return legacyV1 && legacyTuple ? [packageSpec, preferred.options] : { ...preferred, package: packageSpec }
252
+ }
253
+
218
254
  async function configurePackagePlugin() {
255
+ const plans = []
219
256
  let configured = false
220
- const updatedFiles = []
221
257
  for (const name of configCandidates) {
222
258
  const target = join(config, name)
223
259
  try {
224
260
  const source = await readFile(target, "utf8")
225
261
  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)
262
+ for (const key of ["plugin", "plugins"]) {
263
+ if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
264
+ }
265
+ let updated = source
266
+ const own = [...(parsed.plugins || []), ...(parsed.plugin || [])].filter(isPackageSpec)
267
+ if (own.length) {
268
+ configured = true
269
+ if (legacyV1) {
270
+ for (const key of ["plugin", "plugins"]) {
271
+ const entries = parsed[key] || []
272
+ const matching = entries.filter(isPackageSpec)
273
+ if (!matching.length) continue
274
+ const pinned = pinRegistration(matching)
275
+ updated = rewriteExistingPluginArray(updated, [...entries.filter((entry) => !isPackageSpec(entry)), pinned], key)
276
+ }
277
+ } else {
278
+ const pinned = pinRegistration(own)
279
+ if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
280
+ const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
281
+ updated = parsed.plugins === undefined ? appendPluginProperty(updated, next, "plugins") : rewriteExistingPluginArray(updated, next, "plugins")
282
+ }
236
283
  }
284
+ parseJsonc(updated)
285
+ plans.push({ target, source, updated })
237
286
  } catch (error) {
238
287
  if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
239
288
  }
240
289
  }
241
- return { configured, updatedFiles }
290
+ // Validate every candidate before writing any config. Malformed later files
291
+ // must not leave the earlier file migrated and the plugin half-installed.
292
+ for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
293
+ return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
242
294
  }
243
295
 
244
296
  async function ensureDependency() {
@@ -262,7 +314,13 @@ async function ensureDependency() {
262
314
 
263
315
  async function removePackagedFiles(sourceDir, targetDir) {
264
316
  for (const name of await readdir(sourceDir)) {
265
- if (name.endsWith(".md")) await rm(join(targetDir, name), { force: true })
317
+ if (!name.endsWith(".md")) continue
318
+ try {
319
+ const installed = await readFile(join(targetDir, name), "utf8")
320
+ const packaged = await readFile(join(sourceDir, name), "utf8")
321
+ const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
322
+ if (installed === packaged || recognized) await rm(join(targetDir, name), { force: true })
323
+ } catch (error) { if (error?.code !== "ENOENT") throw error }
266
324
  }
267
325
  }
268
326
 
@@ -273,68 +331,55 @@ async function uninstall() {
273
331
  try {
274
332
  const source = await readFile(target, "utf8")
275
333
  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)
334
+ let updated = source
335
+ for (const key of ["plugin", "plugins"]) {
336
+ if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
337
+ const entries = parsed[key] || []
338
+ if (entries.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, entries.filter((entry) => !isPackageSpec(entry)), key)
339
+ }
280
340
  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")
341
+ } catch (error) { if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`) }
288
342
  }
343
+ for (const plan of plans) if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
289
344
  await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
290
345
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
291
346
  await removePackagedFiles(join(root, "commands"), commandDir)
292
347
  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.")
348
+ const changed = plans.filter((plan) => plan.source !== plan.updated).length
349
+ console.log(`Removed ${packageName} registrations from ${changed} OpenCode config file(s).`)
350
+ console.log("Removed known local Loop plugin and managed legacy command/agent files; custom files are preserved.")
299
351
  console.log("Project state under .opencode/opencode-loop is preserved.")
300
352
  console.log("Restart OpenCode to finish unloading OpenCode Loop.")
301
353
  }
302
354
 
303
355
  async function installOrUpdate() {
304
356
  await mkdir(pluginDir, { recursive: true })
305
- await mkdir(commandDir, { recursive: true })
306
- await mkdir(agentDir, { recursive: true })
307
357
  const packageConfig = await configurePackagePlugin()
308
- const useConfiguredPackage = packageConfig.configured
309
- if (useConfiguredPackage) {
358
+ if (packageConfig.configured) {
310
359
  await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
311
360
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
312
361
  } else {
313
- 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"))
362
+ if (legacyV1) await ensureDependency()
363
+ await copyFile(join(root, "src", legacyV1 ? "index.js" : "native.js"), join(pluginDir, "opencode-loop.ts"))
317
364
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
318
365
  }
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.`)
366
+ if (legacyV1) {
367
+ await mkdir(commandDir, { recursive: true })
368
+ await mkdir(agentDir, { recursive: true })
369
+ for (const name of await readdir(join(root, "commands"))) if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
370
+ for (const name of await readdir(join(root, "agents"))) if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
333
371
  } else {
334
- console.log(`Installed OpenCode Loop plugin to ${config}`)
372
+ await removePackagedFiles(join(root, "commands"), commandDir)
373
+ await removePackagedFiles(join(root, "agents"), agentDir)
335
374
  }
336
- console.log(`Installed ${packageName} commands to ${commandDir}`)
337
- console.log(`Installed ${packageName} local command agent to ${agentDir}`)
375
+ if (packageConfig.configured) {
376
+ const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
377
+ console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
378
+ } else console.log(`Installed OpenCode Loop plugin to ${config}`)
379
+ if (legacyV1) {
380
+ console.log(`Installed ${packageName} commands to ${commandDir}`)
381
+ console.log(`Installed ${packageName} local command agent to ${agentDir}`)
382
+ } 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
383
  console.log("Restart OpenCode, then run: /loop-help")
339
384
  }
340
385
 
@@ -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")