@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.
- package/CHANGELOG.md +17 -0
- package/README.md +34 -13
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +455 -0
- package/scripts/install-node.mjs +100 -4
- package/scripts/install-test.mjs +86 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/smoke-test.mjs +45 -2
- package/src/index.js +297 -41
package/scripts/install.ps1
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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 }
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 "
|
|
40
|
-
|
|
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
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -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:
|
|
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.
|
|
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)
|