@bybrawe/opencode-loop 0.5.16 → 0.5.18

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 (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +36 -11
  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 +486 -0
  36. package/scripts/install-node.mjs +146 -4
  37. package/scripts/install-test.mjs +104 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/loopd-test.mjs +208 -0
  41. package/scripts/loopd.mjs +125 -41
  42. package/scripts/smoke-test.mjs +1 -1
  43. package/src/index.js +193 -52
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn } from "node:child_process"
3
+ import { promises as fs } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
+ const installer = path.join(root, "scripts", "install-node.mjs")
10
+ const packageVersion = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version
11
+ const expectedPackageSpec = `@bybrawe/opencode-loop@${packageVersion}`
12
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
13
+
14
+ async function runInstaller(config, cliArgs = []) {
15
+ return await new Promise((resolve, reject) => {
16
+ const child = spawn(process.execPath, [installer, ...cliArgs], {
17
+ cwd: root,
18
+ env: { ...process.env, OPENCODE_CONFIG_DIR: config },
19
+ windowsHide: true,
20
+ })
21
+ const stdout = []
22
+ const stderr = []
23
+ child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
24
+ child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
25
+ child.on("error", reject)
26
+ child.on("close", (code) => resolve({
27
+ code,
28
+ stdout: Buffer.concat(stdout).toString("utf8"),
29
+ stderr: Buffer.concat(stderr).toString("utf8"),
30
+ }))
31
+ })
32
+ }
33
+
34
+ async function commandCount(config) {
35
+ return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
36
+ }
37
+
38
+ async function exists(target) {
39
+ try { await fs.access(target); return true } catch { return false }
40
+ }
41
+
42
+ try {
43
+ const helpConfig = path.join(temporaryRoot, "help-must-not-install")
44
+ const helpResult = await runInstaller(helpConfig, ["--help"])
45
+ assert.equal(helpResult.code, 0, helpResult.stderr)
46
+ assert.match(helpResult.stdout, /OpenCode Loop installer/)
47
+ assert.equal(await exists(helpConfig), false, "--help must not mutate the OpenCode config directory")
48
+
49
+ const versionResult = await runInstaller(helpConfig, ["--version"])
50
+ assert.equal(versionResult.code, 0, versionResult.stderr)
51
+ assert.equal(versionResult.stdout.trim(), packageVersion)
52
+ assert.equal(await exists(helpConfig), false, "--version must not mutate the OpenCode config directory")
53
+
54
+ const local = path.join(temporaryRoot, "local")
55
+ const localResult = await runInstaller(local)
56
+ assert.equal(localResult.code, 0, localResult.stderr)
57
+ assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
58
+ assert.equal(await commandCount(local), 30)
59
+ assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
60
+ const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
61
+ assert.equal(localPackage.dependencies["@opencode-ai/plugin"], ">=1.4.0")
62
+
63
+ const configured = path.join(temporaryRoot, "configured")
64
+ await fs.mkdir(path.join(configured, "plugins"), { recursive: true })
65
+ await fs.writeFile(path.join(configured, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop@latest"] }), "utf8")
66
+ await fs.writeFile(path.join(configured, "plugins", "opencode-loop.ts"), "duplicate", "utf8")
67
+ await fs.writeFile(path.join(configured, "plugins", "opencode-loop.js"), "legacy duplicate", "utf8")
68
+ const configuredResult = await runInstaller(configured)
69
+ assert.equal(configuredResult.code, 0, configuredResult.stderr)
70
+ assert.match(configuredResult.stdout, /removed the duplicate local plugin copy/i)
71
+ assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.ts")), false)
72
+ assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
73
+ assert.equal(await commandCount(configured), 30)
74
+ assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
75
+ const configuredJson = JSON.parse(await fs.readFile(path.join(configured, "opencode.json"), "utf8"))
76
+ assert.deepEqual(configuredJson.plugin, [expectedPackageSpec], "the installer must bust OpenCode's stale package cache with an exact version spec")
77
+
78
+ const jsonc = path.join(temporaryRoot, "jsonc")
79
+ await fs.mkdir(jsonc, { recursive: true })
80
+ await fs.writeFile(path.join(jsonc, "opencode.jsonc"), `{
81
+ // A configured package is authoritative; a local copy would load twice.
82
+ "plugin": [
83
+ "other-plugin",
84
+ "@bybrawe/opencode-loop",
85
+ ],
86
+ }`, "utf8")
87
+ const jsoncResult = await runInstaller(jsonc)
88
+ assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
89
+ assert.equal(await exists(path.join(jsonc, "plugins", "opencode-loop.ts")), false)
90
+ const updatedJsonc = await fs.readFile(path.join(jsonc, "opencode.jsonc"), "utf8")
91
+ assert.match(updatedJsonc, /A configured package is authoritative/, "pinning must preserve JSONC comments")
92
+ assert.match(updatedJsonc, new RegExp(expectedPackageSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
93
+
94
+ const lookalike = path.join(temporaryRoot, "lookalike")
95
+ await fs.mkdir(lookalike, { recursive: true })
96
+ await fs.writeFile(path.join(lookalike, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop-extra"] }), "utf8")
97
+ const lookalikeResult = await runInstaller(lookalike)
98
+ assert.equal(lookalikeResult.code, 0, lookalikeResult.stderr)
99
+ assert.equal(await exists(path.join(lookalike, "plugins", "opencode-loop.ts")), true)
100
+
101
+ console.log("OpenCode Loop installer test passed")
102
+ } finally {
103
+ await fs.rm(temporaryRoot, { recursive: true, force: true })
104
+ }
@@ -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
@@ -0,0 +1,208 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn } from "node:child_process"
3
+ import { promises as fs } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
+ const loopd = path.join(root, "scripts", "loopd.mjs")
10
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-test-"))
11
+
12
+ function runCli(cliArgs, env = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(process.execPath, [loopd, ...cliArgs], {
15
+ cwd: root,
16
+ env: { ...process.env, ...env },
17
+ windowsHide: true,
18
+ })
19
+ const stdout = []
20
+ const stderr = []
21
+ child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
22
+ child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
23
+ child.on("error", reject)
24
+ child.on("close", (code, signal) => resolve({
25
+ code: code ?? (signal ? 1 : 0),
26
+ stdout: Buffer.concat(stdout).toString("utf8"),
27
+ stderr: Buffer.concat(stderr).toString("utf8"),
28
+ }))
29
+ })
30
+ }
31
+
32
+ async function makeFakeCommand(name) {
33
+ const script = path.join(temporaryRoot, `${name}.mjs`)
34
+ const log = path.join(temporaryRoot, `${name}.jsonl`)
35
+ await fs.writeFile(script, `
36
+ import fs from "node:fs"
37
+ const log = process.env.FAKE_COMMAND_LOG
38
+ let previous = []
39
+ try { previous = fs.readFileSync(log, "utf8").trim().split(/\\r?\\n/).filter(Boolean) } catch {}
40
+ fs.appendFileSync(log, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + "\\n")
41
+ const codes = String(process.env.FAKE_EXIT_CODES || "0").split(",").map((value) => Number(value.trim()))
42
+ const code = codes[Math.min(previous.length, codes.length - 1)]
43
+ process.exit(Number.isInteger(code) ? code : 0)
44
+ `, "utf8")
45
+
46
+ if (process.platform === "win32") {
47
+ const command = path.join(temporaryRoot, `${name}.cmd`)
48
+ const escapeCmd = (value) => String(value).replace(/%/g, "%%")
49
+ await fs.writeFile(command, `@echo off\r\n"${escapeCmd(process.execPath)}" "${escapeCmd(script)}" %*\r\nexit /b %errorlevel%\r\n`, "utf8")
50
+ return { command, log }
51
+ }
52
+
53
+ const command = path.join(temporaryRoot, name)
54
+ await fs.writeFile(command, `#!/bin/sh\nexec "${process.execPath.replace(/"/g, '\\"')}" "${script.replace(/"/g, '\\"')}" "$@"\n`, { mode: 0o755 })
55
+ return { command, log }
56
+ }
57
+
58
+ async function readLog(log) {
59
+ try {
60
+ return (await fs.readFile(log, "utf8")).trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
61
+ } catch (error) {
62
+ if (error?.code === "ENOENT") return []
63
+ throw error
64
+ }
65
+ }
66
+
67
+ try {
68
+ const project = path.join(temporaryRoot, "project with spaces")
69
+ await fs.mkdir(project, { recursive: true })
70
+ const fakeOpenCode = await makeFakeCommand("fake-opencode")
71
+
72
+ const inlinePrompt = 'continue "quoted text" & keep | characters literal'
73
+ let result = await runCli([
74
+ "--project", project,
75
+ "--every", "0s",
76
+ "--max-runs", "1",
77
+ "--model", "opencode/nemotron-3-ultra-free",
78
+ "--agent", "build",
79
+ "--prompt", inlinePrompt,
80
+ ], {
81
+ OPENCODE_BIN: fakeOpenCode.command,
82
+ FAKE_COMMAND_LOG: fakeOpenCode.log,
83
+ })
84
+ assert.equal(result.code, 0, result.stderr)
85
+ let calls = await readLog(fakeOpenCode.log)
86
+ assert.equal(calls.length, 1)
87
+ assert.equal(path.resolve(calls[0].cwd), path.resolve(project))
88
+ assert.deepEqual(calls[0].args, [
89
+ "run", "--continue",
90
+ "--model", "opencode/nemotron-3-ultra-free",
91
+ "--agent", "build",
92
+ inlinePrompt,
93
+ ], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
94
+
95
+ const promptFile = path.join(project, "goal prompt.md")
96
+ await fs.writeFile(promptFile, "\uFEFFPrompt loaded from a BOM file.", "utf8")
97
+ result = await runCli([
98
+ "--project", project,
99
+ "--every", "0s",
100
+ "--max-runs", "1",
101
+ "--prompt-file", path.basename(promptFile),
102
+ ], {
103
+ OPENCODE_BIN: fakeOpenCode.command,
104
+ FAKE_COMMAND_LOG: fakeOpenCode.log,
105
+ })
106
+ assert.equal(result.code, 0, result.stderr)
107
+ calls = await readLog(fakeOpenCode.log)
108
+ assert.equal(calls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
109
+
110
+ const failureLog = path.join(temporaryRoot, "failure.jsonl")
111
+ result = await runCli([
112
+ "--project", project,
113
+ "--every", "0s",
114
+ "--max-runs", "1",
115
+ "--prompt", "fail once",
116
+ ], {
117
+ OPENCODE_BIN: fakeOpenCode.command,
118
+ FAKE_COMMAND_LOG: failureLog,
119
+ FAKE_EXIT_CODES: "7",
120
+ OPENCODE_LOOPD_FAILED_RUN_RETRY_MS: "0",
121
+ })
122
+ assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
123
+
124
+ result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
125
+ OPENCODE_BIN: fakeOpenCode.command,
126
+ FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
127
+ })
128
+ assert.equal(result.code, 1)
129
+ assert.match(result.stderr, /Invalid --max-runs value/)
130
+
131
+ result = await runCli(["--project", path.join(temporaryRoot, "missing-project"), "--max-runs", "1", "--prompt", "test"], {
132
+ OPENCODE_BIN: fakeOpenCode.command,
133
+ FAKE_COMMAND_LOG: path.join(temporaryRoot, "missing-project.jsonl"),
134
+ })
135
+ assert.equal(result.code, 1)
136
+ assert.match(result.stderr, /Project directory does not exist/)
137
+
138
+ if (process.platform === "win32") {
139
+ const fakeTasks = await makeFakeCommand("fake-schtasks")
140
+ const taskName = "OpenCodeLoop-Test"
141
+ result = await runCli([
142
+ "install-task",
143
+ "--project", project,
144
+ "--every", "90s",
145
+ "--name", taskName,
146
+ "--prompt", "scheduled task prompt",
147
+ "--model", "opencode/nemotron-3-ultra-free",
148
+ "--agent", "build",
149
+ ], {
150
+ SCHTASKS_BIN: fakeTasks.command,
151
+ FAKE_COMMAND_LOG: fakeTasks.log,
152
+ OPENCODE_BIN: fakeOpenCode.command,
153
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
154
+ })
155
+ assert.equal(result.code, 0, result.stderr)
156
+ const taskCalls = await readLog(fakeTasks.log)
157
+ assert.equal(taskCalls.length, 1)
158
+ const taskArgs = taskCalls[0].args
159
+ assert.equal(taskArgs[0], "/Create")
160
+ assert.equal(taskArgs[taskArgs.indexOf("/MO") + 1], "2")
161
+ assert.equal(taskArgs[taskArgs.indexOf("/TN") + 1], taskName)
162
+ const taskCommand = taskArgs[taskArgs.indexOf("/TR") + 1]
163
+ assert.ok(taskCommand.length <= 261, "the Task Scheduler /TR command must stay within its documented limit")
164
+ assert.match(taskCommand, /^cmd\.exe \/d \/s \/c /)
165
+ const taskFiles = await fs.readdir(path.join(temporaryRoot, "task-files"))
166
+ const taskConfigFile = path.join(temporaryRoot, "task-files", taskFiles.find((name) => name.endsWith(".json")))
167
+ const taskConfig = JSON.parse(await fs.readFile(taskConfigFile, "utf8"))
168
+ assert.equal(taskConfig.project, project)
169
+ assert.equal(taskConfig.prompt, "scheduled task prompt")
170
+ assert.equal(taskConfig.model, "opencode/nemotron-3-ultra-free")
171
+ assert.equal(taskConfig.agent, "build")
172
+ assert.equal(taskConfig.opencodeBin, fakeOpenCode.command)
173
+
174
+ const taskRunLog = path.join(temporaryRoot, "task-run.jsonl")
175
+ result = await runCli(["task-run", "--config", taskConfigFile], {
176
+ FAKE_COMMAND_LOG: taskRunLog,
177
+ })
178
+ assert.equal(result.code, 0, result.stderr)
179
+ const taskRunCall = (await readLog(taskRunLog))[0]
180
+ assert.deepEqual(taskRunCall.args, [
181
+ "run", "--continue",
182
+ "--model", "opencode/nemotron-3-ultra-free",
183
+ "--agent", "build",
184
+ "scheduled task prompt",
185
+ ])
186
+
187
+ result = await runCli(["uninstall-task", "--name", taskName], {
188
+ SCHTASKS_BIN: fakeTasks.command,
189
+ FAKE_COMMAND_LOG: fakeTasks.log,
190
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
191
+ })
192
+ assert.equal(result.code, 0, result.stderr)
193
+ const uninstallArgs = (await readLog(fakeTasks.log)).at(-1).args
194
+ assert.deepEqual(uninstallArgs, ["/Delete", "/F", "/TN", taskName])
195
+ assert.deepEqual(await fs.readdir(path.join(temporaryRoot, "task-files")), [], "uninstall-task must remove its launcher and config")
196
+
197
+ result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
198
+ SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
199
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
200
+ })
201
+ assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
202
+ assert.match(result.stderr, /failed to start/i)
203
+ }
204
+
205
+ console.log("OpenCode Loop daemon test passed")
206
+ } finally {
207
+ await fs.rm(temporaryRoot, { recursive: true, force: true })
208
+ }