@bybrawe/opencode-loop 0.5.15 → 0.5.17

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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +34 -13
  3. package/agents/opencode-loop-local.md +10 -0
  4. package/commands/loop-ask.md +1 -0
  5. package/commands/loop-clear.md +1 -0
  6. package/commands/loop-cmd.md +1 -0
  7. package/commands/loop-command.md +1 -0
  8. package/commands/loop-compact.md +1 -0
  9. package/commands/loop-dev.md +1 -0
  10. package/commands/loop-doctor.md +1 -0
  11. package/commands/loop-export.md +1 -0
  12. package/commands/loop-goal-blocked.md +1 -0
  13. package/commands/loop-goal-clear.md +1 -0
  14. package/commands/loop-goal-done.md +1 -0
  15. package/commands/loop-goal-pause.md +1 -0
  16. package/commands/loop-goal-resume.md +1 -0
  17. package/commands/loop-goal-status.md +1 -0
  18. package/commands/loop-goal.md +1 -0
  19. package/commands/loop-help.md +1 -0
  20. package/commands/loop-init.md +1 -0
  21. package/commands/loop-logs.md +1 -0
  22. package/commands/loop-now.md +1 -0
  23. package/commands/loop-pause.md +1 -0
  24. package/commands/loop-progress.md +1 -0
  25. package/commands/loop-prompt.md +1 -0
  26. package/commands/loop-remove.md +1 -0
  27. package/commands/loop-resume.md +1 -0
  28. package/commands/loop-safe-dev.md +1 -0
  29. package/commands/loop-shell.md +1 -0
  30. package/commands/loop-status.md +1 -0
  31. package/commands/loop-stop.md +1 -0
  32. package/commands/loop-testfix.md +1 -0
  33. package/commands/loop.md +1 -0
  34. package/package.json +6 -5
  35. package/scripts/comprehensive-test.mjs +455 -0
  36. package/scripts/install-node.mjs +100 -4
  37. package/scripts/install-test.mjs +86 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/smoke-test.mjs +45 -2
  41. package/src/index.js +297 -41
@@ -1,36 +1,10 @@
1
1
  $ErrorActionPreference = "Stop"
2
2
 
3
- $ConfigDir = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".config\opencode" }
4
- $PluginDir = Join-Path $ConfigDir "plugins"
5
- $CommandDir = Join-Path $ConfigDir "commands"
6
3
  $Root = Split-Path -Parent $PSScriptRoot
4
+ $Installer = Join-Path $Root "scripts\install-node.mjs"
5
+ $Runtime = Get-Command node -ErrorAction SilentlyContinue
6
+ if (-not $Runtime) { $Runtime = Get-Command bun -ErrorAction SilentlyContinue }
7
+ if (-not $Runtime) { throw "OpenCode Loop source installation requires Node.js or Bun." }
7
8
 
8
- New-Item -ItemType Directory -Force -Path $PluginDir | Out-Null
9
- New-Item -ItemType Directory -Force -Path $CommandDir | Out-Null
10
-
11
- $PackageFile = Join-Path $ConfigDir "package.json"
12
- if (Test-Path $PackageFile) {
13
- try {
14
- $PackageJson = Get-Content -Raw -Path $PackageFile | ConvertFrom-Json
15
- if (-not $PackageJson.dependencies) {
16
- $PackageJson | Add-Member -MemberType NoteProperty -Name dependencies -Value ([pscustomobject]@{})
17
- }
18
- if (-not $PackageJson.dependencies.PSObject.Properties["@opencode-ai/plugin"]) {
19
- $PackageJson.dependencies | Add-Member -MemberType NoteProperty -Name "@opencode-ai/plugin" -Value ">=1.4.0"
20
- $PackageJson | ConvertTo-Json -Depth 20 | Set-Content -Path $PackageFile -Encoding UTF8
21
- }
22
- } catch {
23
- Write-Warning "Could not update $PackageFile. Add '@opencode-ai/plugin': '>=1.4.0' if OpenCode cannot load the local plugin."
24
- }
25
- } else {
26
- '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' | Set-Content -Path $PackageFile -Encoding UTF8
27
- }
28
-
29
- Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
30
- Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
31
- Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
32
-
33
- Write-Host "Installed OpenCode Loop plugin." -ForegroundColor Green
34
- Write-Host "Plugin: $PluginDir\opencode-loop.ts"
35
- Write-Host "Commands: $CommandDir\loop*.md"
36
- Write-Host "Restart OpenCode, then run: /loop-help"
9
+ & $Runtime.Source $Installer
10
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -1,42 +1,14 @@
1
1
  #!/usr/bin/env sh
2
2
  set -eu
3
3
 
4
- CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
5
- PLUGIN_DIR="$CONFIG_DIR/plugins"
6
- COMMAND_DIR="$CONFIG_DIR/commands"
7
4
  ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
8
5
 
9
- mkdir -p "$PLUGIN_DIR" "$COMMAND_DIR"
10
- PACKAGE_FILE="$CONFIG_DIR/package.json"
11
6
  if command -v node >/dev/null 2>&1; then
12
- PACKAGE_FILE="$PACKAGE_FILE" node --input-type=module - <<'NODE'
13
- import fs from "node:fs"
14
-
15
- const file = process.env.PACKAGE_FILE
16
- let pkg = {}
17
- try {
18
- pkg = JSON.parse(fs.readFileSync(file, "utf8"))
19
- } catch (error) {
20
- if (error.code !== "ENOENT") {
21
- console.warn(`Could not update ${file}: ${error.message}`)
22
- process.exit(0)
23
- }
24
- }
25
- if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
26
- if (!pkg.dependencies || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) pkg.dependencies = {}
27
- if (!pkg.dependencies["@opencode-ai/plugin"]) {
28
- pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
29
- fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n")
30
- }
31
- NODE
32
- elif [ ! -f "$PACKAGE_FILE" ]; then
33
- printf '%s\n' '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' > "$PACKAGE_FILE"
7
+ exec node "$ROOT_DIR/scripts/install-node.mjs"
8
+ fi
9
+ if command -v bun >/dev/null 2>&1; then
10
+ exec bun "$ROOT_DIR/scripts/install-node.mjs"
34
11
  fi
35
- cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
36
- rm -f "$PLUGIN_DIR/opencode-loop.js"
37
- cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
38
12
 
39
- echo "Installed OpenCode Loop plugin."
40
- echo "Plugin: $PLUGIN_DIR/opencode-loop.ts"
41
- echo "Commands: $COMMAND_DIR/loop*.md"
42
- echo "Restart OpenCode, then run: /loop-help"
13
+ echo "OpenCode Loop source installation requires Node.js or Bun." >&2
14
+ exit 1
@@ -9,6 +9,7 @@ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-smoke-"
9
9
  const sessionID = "ses_smoke_goal"
10
10
  const attempts = { log: 0, prompt: 0, status: 0, toast: 0 }
11
11
  const prompts = []
12
+ const liveStatuses = new Map([[sessionID, "idle"]])
12
13
  let hooks
13
14
 
14
15
  function legacyBody(name) {
@@ -31,6 +32,10 @@ const client = {
31
32
  showToast: legacyBody("toast"),
32
33
  },
33
34
  session: {
35
+ list: async (args) => {
36
+ assert.equal(args?.query?.directory, directory)
37
+ return { data: [{ id: sessionID, directory, projectID: "project", title: "smoke", version: "test", time: { created: Date.now(), updated: Date.now() } }] }
38
+ },
34
39
  abort: async (args) => {
35
40
  assert.equal(args?.path?.id, sessionID)
36
41
  return { data: true }
@@ -55,7 +60,7 @@ const client = {
55
60
  status: async (args) => {
56
61
  attempts.status++
57
62
  assert.equal(args?.query?.directory, directory, "session.status must use query.directory first")
58
- return { data: { [sessionID]: { type: "idle" } } }
63
+ return { data: Object.fromEntries([...liveStatuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }])) }
59
64
  },
60
65
  summarize: async (args) => {
61
66
  assert.equal(args?.path?.id, sessionID)
@@ -78,7 +83,7 @@ try {
78
83
  sessionID,
79
84
  arguments: "Create proof.txt and verify it --max-turns 3",
80
85
  }, output)
81
- assert.match(output.parts[0].text, /handled by the local plugin/i)
86
+ assert.equal(output.parts.length, 0, "a locally handled slash command must not start a placeholder model turn")
82
87
 
83
88
  await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
84
89
  await new Promise((resolve) => setTimeout(resolve, 0))
@@ -103,6 +108,44 @@ try {
103
108
  assert.equal(completedState.jobs[0].paused, true)
104
109
  assert.equal(completedState.jobs[0].enabled, false)
105
110
 
111
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
112
+
113
+ const promptCountBeforeBackgroundTool = prompts.length
114
+ await hooks["tool.execute.before"]({ tool: "bash", sessionID, callID: "call_background" }, { args: {} })
115
+ await hooks["command.execute.before"]({
116
+ command: "loop",
117
+ sessionID,
118
+ arguments: "0s --max-runs 1 continue after the background task",
119
+ }, { parts: [] })
120
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
121
+ assert.equal(prompts.length, promptCountBeforeBackgroundTool, "an active tool call must keep the loop busy even when session.status says idle")
122
+
123
+ await hooks["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_background", args: {} }, { title: "done", output: "", metadata: {} })
124
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
125
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
126
+ assert.equal(prompts.length, promptCountBeforeBackgroundTool + 1, "the due loop must resume after the active tool finishes and the session becomes idle")
127
+
128
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
129
+
130
+ const childSessionID = "ses_background_child"
131
+ liveStatuses.set(childSessionID, "busy")
132
+ await hooks.event({ event: { type: "session.created", properties: { info: { id: childSessionID, parentID: sessionID } } } })
133
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "busy" } } } })
134
+ const promptCountBeforeBackgroundChild = prompts.length
135
+ await hooks["command.execute.before"]({
136
+ command: "loop",
137
+ sessionID,
138
+ arguments: "0s --max-runs 1 continue after the background subtask",
139
+ }, { parts: [] })
140
+ await new Promise((resolve) => setTimeout(resolve, 2_700))
141
+ assert.equal(prompts.length, promptCountBeforeBackgroundChild, "a running background child session must keep its parent loop busy")
142
+
143
+ liveStatuses.set(childSessionID, "idle")
144
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "idle" } } } })
145
+ await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
146
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
147
+ assert.equal(prompts.length, promptCountBeforeBackgroundChild + 1, "the parent loop must resume after its background child becomes idle")
148
+
106
149
  await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
107
150
  assert.equal(attempts.log, 1)
108
151
  assert.ok(attempts.prompt >= 1)