@bybrawe/opencode-loop 0.5.17 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +13 -3
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +31 -0
- package/scripts/install-node.mjs +52 -6
- package/scripts/install-test.mjs +20 -2
- package/scripts/loopd-test.mjs +208 -0
- package/scripts/loopd.mjs +125 -41
- package/src/index.js +25 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.18
|
|
4
|
+
|
|
5
|
+
- Fixed package-based installs loading stale OpenCode cache entries after npm had installed a newer release. The installer now pins an existing package config entry to its exact installed version while preserving JSONC comments.
|
|
6
|
+
- Fixed Goal Mode mistaking delayed updates for its own scheduler-created user message as a real user interruption. Loop-owned message IDs remain recognized without hiding a distinct user message.
|
|
7
|
+
- Added a dedicated `opencode-loopd` regression suite for literal argument passing, BOM prompt files, model/agent forwarding, finite-run exit codes, invalid options, and Windows scheduled-task lifecycle behavior.
|
|
8
|
+
- Made finite daemon runs propagate OpenCode failures instead of reporting exit code 0, and reject invalid `--max-runs` values and missing project directories.
|
|
9
|
+
- Added `--model` and `--agent` forwarding to daemon and scheduled-task runs.
|
|
10
|
+
- Fixed Windows Task Scheduler installation failing when `/TR` exceeded its 261-character limit. Scheduled tasks now use a short launcher plus a task-specific JSON config under the local app-data directory, and uninstall removes both artifacts.
|
|
11
|
+
- Made Task Scheduler command launch failures return a nonzero exit code instead of silently succeeding.
|
|
12
|
+
- Added non-mutating `--help` and `--version` handling to the `opencode-loop` installer CLI.
|
|
13
|
+
|
|
3
14
|
## 0.5.17
|
|
4
15
|
|
|
5
16
|
- Prevented OpenCode project bootstrap deadlocks by deferring local SDK logging and session discovery until after plugin hooks are returned.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
|
|
|
16
16
|
|
|
17
17
|
## Current status
|
|
18
18
|
|
|
19
|
-
**v0.5.
|
|
19
|
+
**v0.5.18 hardens Goal Mode, package updates, and the background daemon.** Package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, and Windows scheduled tasks use a short launcher that stays below the `/TR` limit.
|
|
20
20
|
|
|
21
21
|
The known update-related symptoms from older builds are fixed:
|
|
22
22
|
|
|
@@ -32,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
32
32
|
|
|
33
33
|
The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
|
|
34
34
|
|
|
35
|
-
## v0.5.
|
|
35
|
+
## v0.5.18 quick behavior guide
|
|
36
36
|
|
|
37
37
|
OpenCode Loop has two triggers now:
|
|
38
38
|
|
|
@@ -157,7 +157,7 @@ Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or
|
|
|
157
157
|
npx -y @bybrawe/opencode-loop@latest
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
The installer copies the slash command files and the tool-denied local command agent into your OpenCode config directory. If `@bybrawe/opencode-loop` is already present in the OpenCode `plugin` array, that package entry remains authoritative
|
|
160
|
+
The installer copies the slash command files and the tool-denied local command agent into your OpenCode config directory. If `@bybrawe/opencode-loop` is already present in the OpenCode `plugin` array, that package entry remains authoritative, is pinned to the installer's exact version to bypass stale OpenCode package caches, and duplicate local plugin copies are removed. Otherwise it installs `opencode-loop.ts` locally and ensures the config directory has the `@opencode-ai/plugin` dependency required by `tool()`.
|
|
161
161
|
|
|
162
162
|
Windows target paths:
|
|
163
163
|
|
|
@@ -531,6 +531,14 @@ Limit runs:
|
|
|
531
531
|
opencode-loopd --project . --every 5m --max-runs 20 --prompt-file loop-prompt.md
|
|
532
532
|
```
|
|
533
533
|
|
|
534
|
+
Select the OpenCode model and agent explicitly:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
opencode-loopd --project . --every 0s --max-runs 1 --model opencode/big-pickle --agent build --prompt-file loop-prompt.md
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
When `--max-runs` is finite, the daemon exits with the final OpenCode run's nonzero status so scripts and CI can detect failures.
|
|
541
|
+
|
|
534
542
|
Wait before the first run:
|
|
535
543
|
|
|
536
544
|
```bash
|
|
@@ -568,6 +576,8 @@ Install a scheduled task:
|
|
|
568
576
|
opencode-loopd install-task --project "C:\path\to\project" --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop
|
|
569
577
|
```
|
|
570
578
|
|
|
579
|
+
You can also pass `--model <provider/model>` and `--agent <name>`. The installer stores long task options in a task-specific JSON file and registers only a short launcher with Task Scheduler, avoiding Windows' 261-character `/TR` limit. `uninstall-task` removes that launcher and JSON config after the task is deleted.
|
|
580
|
+
|
|
571
581
|
Remove it:
|
|
572
582
|
|
|
573
583
|
```powershell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.18",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
|
|
22
|
-
"test": "node scripts/install-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
|
|
21
|
+
"check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
|
|
22
|
+
"test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
|
|
23
23
|
"install:global": "node scripts/install-node.mjs",
|
|
24
24
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
25
25
|
},
|
|
@@ -425,6 +425,36 @@ async function testStopsPreflightAndGoalLifecycle() {
|
|
|
425
425
|
}
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
|
|
429
|
+
const h = await createHarness()
|
|
430
|
+
const originalDateNow = Date.now
|
|
431
|
+
let fakeNow = originalDateNow()
|
|
432
|
+
Date.now = () => fakeNow
|
|
433
|
+
try {
|
|
434
|
+
await h.command("loop-goal", "--no-now Keep working until the objective is complete")
|
|
435
|
+
const synthetic = {
|
|
436
|
+
type: "message.updated",
|
|
437
|
+
properties: { info: { id: "msg_loop_owned", sessionID: h.sessionID, role: "user" } },
|
|
438
|
+
}
|
|
439
|
+
await h.hooks.event({ event: synthetic })
|
|
440
|
+
|
|
441
|
+
fakeNow += 60_000
|
|
442
|
+
await h.hooks.event({ event: synthetic })
|
|
443
|
+
let state = await h.readState()
|
|
444
|
+
assert.equal(state.jobs[0].paused, false, "a delayed update for the same loop-owned user message must remain ignored")
|
|
445
|
+
|
|
446
|
+
await h.hooks.event({ event: {
|
|
447
|
+
type: "message.updated",
|
|
448
|
+
properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
|
|
449
|
+
} })
|
|
450
|
+
state = await h.readState()
|
|
451
|
+
assert.equal(state.jobs[0].paused, true, "a distinct real user message must still pause an active goal")
|
|
452
|
+
} finally {
|
|
453
|
+
Date.now = originalDateNow
|
|
454
|
+
await h.cleanup()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
428
458
|
async function testInitializationDoesNotWaitForLocalApi() {
|
|
429
459
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
|
|
430
460
|
const never = new Promise(() => {})
|
|
@@ -450,6 +480,7 @@ await testLifecycleAndCommandDedupe()
|
|
|
450
480
|
await testWatchScheduling()
|
|
451
481
|
await testActionRoutingAndSafety()
|
|
452
482
|
await testStopsPreflightAndGoalLifecycle()
|
|
483
|
+
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
|
453
484
|
await testInitializationDoesNotWaitForLocalApi()
|
|
454
485
|
|
|
455
486
|
console.log("OpenCode Loop comprehensive test passed")
|
package/scripts/install-node.mjs
CHANGED
|
@@ -11,6 +11,26 @@ const commandDir = join(config, "commands")
|
|
|
11
11
|
const agentDir = join(config, "agents")
|
|
12
12
|
const packagePath = join(config, "package.json")
|
|
13
13
|
const packageName = "@bybrawe/opencode-loop"
|
|
14
|
+
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
|
+
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
|
+
const installerArgs = process.argv.slice(2)
|
|
17
|
+
|
|
18
|
+
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
19
|
+
console.log(`OpenCode Loop installer
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
opencode-loop
|
|
23
|
+
npx -y @bybrawe/opencode-loop@latest
|
|
24
|
+
|
|
25
|
+
Installs the plugin commands and local command agent into OPENCODE_CONFIG_DIR
|
|
26
|
+
or the default ~/.config/opencode directory.`)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
|
|
31
|
+
console.log(packageVersion)
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
14
34
|
|
|
15
35
|
function stripJsonComments(input) {
|
|
16
36
|
let output = ""
|
|
@@ -78,16 +98,36 @@ function isPackageSpec(value) {
|
|
|
78
98
|
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
async function
|
|
101
|
+
async function configurePackagePlugin() {
|
|
102
|
+
let configured = false
|
|
103
|
+
const updatedFiles = []
|
|
82
104
|
for (const name of ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
|
|
83
105
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
106
|
+
const target = join(config, name)
|
|
107
|
+
const source = await readFile(target, "utf8")
|
|
108
|
+
const parsed = parseJsonc(source)
|
|
109
|
+
const specs = Array.isArray(parsed?.plugin) ? parsed.plugin.filter(isPackageSpec) : []
|
|
110
|
+
if (!specs.length) continue
|
|
111
|
+
configured = true
|
|
112
|
+
|
|
113
|
+
// OpenCode caches package plugins by the literal config spec. A bare
|
|
114
|
+
// package name or @latest can therefore keep loading an older cached
|
|
115
|
+
// release after npm installs a newer one. Pin the config entry to the
|
|
116
|
+
// installer package's exact version while preserving JSONC comments.
|
|
117
|
+
let updated = source
|
|
118
|
+
for (const spec of new Set(specs)) {
|
|
119
|
+
if (spec === packageSpec) continue
|
|
120
|
+
updated = updated.replaceAll(JSON.stringify(spec), JSON.stringify(packageSpec))
|
|
121
|
+
}
|
|
122
|
+
if (updated !== source) {
|
|
123
|
+
await writeFile(target, updated, "utf8")
|
|
124
|
+
updatedFiles.push(target)
|
|
125
|
+
}
|
|
86
126
|
} catch (error) {
|
|
87
127
|
if (error?.code !== "ENOENT") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
|
|
88
128
|
}
|
|
89
129
|
}
|
|
90
|
-
return
|
|
130
|
+
return { configured, updatedFiles }
|
|
91
131
|
}
|
|
92
132
|
|
|
93
133
|
async function ensureDependency() {
|
|
@@ -112,7 +152,8 @@ async function ensureDependency() {
|
|
|
112
152
|
await mkdir(pluginDir, { recursive: true })
|
|
113
153
|
await mkdir(commandDir, { recursive: true })
|
|
114
154
|
await mkdir(agentDir, { recursive: true })
|
|
115
|
-
const
|
|
155
|
+
const packageConfig = await configurePackagePlugin()
|
|
156
|
+
const useConfiguredPackage = packageConfig.configured
|
|
116
157
|
if (useConfiguredPackage) {
|
|
117
158
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
118
159
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
@@ -134,7 +175,12 @@ for (const name of await readdir(join(root, "agents"))) {
|
|
|
134
175
|
}
|
|
135
176
|
}
|
|
136
177
|
|
|
137
|
-
if (useConfiguredPackage)
|
|
178
|
+
if (useConfiguredPackage) {
|
|
179
|
+
const pinResult = packageConfig.updatedFiles.length
|
|
180
|
+
? `pinned the config entry to ${packageSpec}`
|
|
181
|
+
: `the config entry is already pinned to ${packageSpec}`
|
|
182
|
+
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
183
|
+
}
|
|
138
184
|
else console.log(`Installed OpenCode Loop plugin to ${config}`)
|
|
139
185
|
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
140
186
|
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
package/scripts/install-test.mjs
CHANGED
|
@@ -7,11 +7,13 @@ import { fileURLToPath } from "node:url"
|
|
|
7
7
|
|
|
8
8
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
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}`
|
|
10
12
|
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
|
|
11
13
|
|
|
12
|
-
async function runInstaller(config) {
|
|
14
|
+
async function runInstaller(config, cliArgs = []) {
|
|
13
15
|
return await new Promise((resolve, reject) => {
|
|
14
|
-
const child = spawn(process.execPath, [installer], {
|
|
16
|
+
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
15
17
|
cwd: root,
|
|
16
18
|
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
17
19
|
windowsHide: true,
|
|
@@ -38,6 +40,17 @@ async function exists(target) {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
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
|
+
|
|
41
54
|
const local = path.join(temporaryRoot, "local")
|
|
42
55
|
const localResult = await runInstaller(local)
|
|
43
56
|
assert.equal(localResult.code, 0, localResult.stderr)
|
|
@@ -59,6 +72,8 @@ try {
|
|
|
59
72
|
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
|
|
60
73
|
assert.equal(await commandCount(configured), 30)
|
|
61
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")
|
|
62
77
|
|
|
63
78
|
const jsonc = path.join(temporaryRoot, "jsonc")
|
|
64
79
|
await fs.mkdir(jsonc, { recursive: true })
|
|
@@ -72,6 +87,9 @@ try {
|
|
|
72
87
|
const jsoncResult = await runInstaller(jsonc)
|
|
73
88
|
assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
|
|
74
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, "\\$&")))
|
|
75
93
|
|
|
76
94
|
const lookalike = path.join(temporaryRoot, "lookalike")
|
|
77
95
|
await fs.mkdir(lookalike, { recursive: true })
|
|
@@ -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
|
+
}
|
package/scripts/loopd.mjs
CHANGED
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process"
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { homedir } from "node:os"
|
|
5
7
|
import { fileURLToPath } from "node:url"
|
|
6
8
|
|
|
7
9
|
const args = process.argv.slice(2)
|
|
8
|
-
const
|
|
10
|
+
const configuredRetryMs = Number(process.env.OPENCODE_LOOPD_FAILED_RUN_RETRY_MS)
|
|
11
|
+
const FAILED_RUN_RETRY_MS = Number.isFinite(configuredRetryMs) && configuredRetryMs >= 0 ? configuredRetryMs : 5_000
|
|
9
12
|
const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
|
|
13
|
+
const SCHTASKS_BIN = process.env.SCHTASKS_BIN || "schtasks"
|
|
14
|
+
const TASK_ROOT = process.env.OPENCODE_LOOPD_TASK_DIR || path.join(process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), "opencode-loop", "tasks")
|
|
10
15
|
|
|
11
16
|
function arg(name, fallback = null) {
|
|
12
17
|
const i = args.indexOf(name)
|
|
13
|
-
|
|
18
|
+
if (i < 0) return fallback
|
|
19
|
+
if (i + 1 >= args.length) throw new Error(`Missing value for ${name}`)
|
|
20
|
+
return args[i + 1]
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
function has(name) {
|
|
@@ -18,7 +25,7 @@ function has(name) {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function parseMs(value) {
|
|
21
|
-
const v = String(value
|
|
28
|
+
const v = String(value ?? "0s").trim().toLowerCase()
|
|
22
29
|
if (v === "0" || v === "0s" || v === "now") return 0
|
|
23
30
|
|
|
24
31
|
const m = v.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
|
|
@@ -36,6 +43,20 @@ function parseMs(value) {
|
|
|
36
43
|
return n
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
function parseMaxRuns(value) {
|
|
47
|
+
const text = String(value ?? "0").trim()
|
|
48
|
+
if (!/^\d+$/.test(text)) throw new Error(`Invalid --max-runs value: ${value}`)
|
|
49
|
+
const parsed = Number(text)
|
|
50
|
+
if (!Number.isSafeInteger(parsed)) throw new Error(`Invalid --max-runs value: ${value}`)
|
|
51
|
+
return parsed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateProject(project) {
|
|
55
|
+
let stat
|
|
56
|
+
try { stat = fs.statSync(project) } catch { throw new Error(`Project directory does not exist: ${project}`) }
|
|
57
|
+
if (!stat.isDirectory()) throw new Error(`Project path is not a directory: ${project}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function sleep(ms) {
|
|
40
61
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
41
62
|
}
|
|
@@ -64,7 +85,7 @@ function spawnOnce(command, commandArgs, cwd) {
|
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
child.on("error", (error) => done({ code: -1, error }))
|
|
67
|
-
child.on("exit", (code) => done({ code: code ?? 0 }))
|
|
88
|
+
child.on("exit", (code, signal) => done({ code: code ?? (signal ? 1 : 0), signal }))
|
|
68
89
|
})
|
|
69
90
|
}
|
|
70
91
|
|
|
@@ -88,9 +109,17 @@ async function run(command, commandArgs, cwd) {
|
|
|
88
109
|
return fallback.code
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
const
|
|
112
|
+
function runSync(command, commandArgs) {
|
|
113
|
+
const options = { shell: false, stdio: "inherit", windowsHide: true }
|
|
114
|
+
const direct = spawnSync(command, commandArgs, options)
|
|
115
|
+
const extension = path.extname(String(command || "")).toLowerCase()
|
|
116
|
+
if (!direct.error || process.platform !== "win32" || direct.error.code !== "EINVAL" || ![".cmd", ".bat"].includes(extension)) return direct
|
|
117
|
+
return spawnSync("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], options)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readPrompt(project, options = {}) {
|
|
121
|
+
const promptFile = options.promptFile ?? arg("--prompt-file")
|
|
122
|
+
const promptArg = options.prompt ?? arg("--prompt")
|
|
94
123
|
|
|
95
124
|
if (promptFile) {
|
|
96
125
|
return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
|
|
@@ -110,13 +139,17 @@ function readPrompt(project) {
|
|
|
110
139
|
].join(" ")
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
async function daemon() {
|
|
114
|
-
const project = path.resolve(arg("--project", process.cwd()))
|
|
115
|
-
|
|
142
|
+
async function daemon(options = {}) {
|
|
143
|
+
const project = path.resolve(options.project ?? arg("--project", process.cwd()))
|
|
144
|
+
validateProject(project)
|
|
145
|
+
const every = options.every ?? arg("--every", "0s")
|
|
116
146
|
const delay = parseMs(every)
|
|
117
|
-
const maxRuns =
|
|
118
|
-
const sleepFirst = has("--sleep-first")
|
|
119
|
-
const prompt = readPrompt(project)
|
|
147
|
+
const maxRuns = parseMaxRuns(options.maxRuns ?? arg("--max-runs", "0"))
|
|
148
|
+
const sleepFirst = options.sleepFirst ?? has("--sleep-first")
|
|
149
|
+
const prompt = readPrompt(project, options)
|
|
150
|
+
const model = options.model ?? arg("--model")
|
|
151
|
+
const agent = options.agent ?? arg("--agent")
|
|
152
|
+
const opencodeBin = options.opencodeBin || OPENCODE_BIN
|
|
120
153
|
|
|
121
154
|
console.log("OpenCode Loop daemon")
|
|
122
155
|
console.log(`project: ${project}`)
|
|
@@ -135,7 +168,11 @@ async function daemon() {
|
|
|
135
168
|
console.log("")
|
|
136
169
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
137
170
|
|
|
138
|
-
const
|
|
171
|
+
const runArgs = ["run", "--continue"]
|
|
172
|
+
if (model) runArgs.push("--model", model)
|
|
173
|
+
if (agent) runArgs.push("--agent", agent)
|
|
174
|
+
runArgs.push(prompt)
|
|
175
|
+
const code = await run(opencodeBin, runArgs, project)
|
|
139
176
|
|
|
140
177
|
if (code !== 0) {
|
|
141
178
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
@@ -146,7 +183,7 @@ async function daemon() {
|
|
|
146
183
|
|
|
147
184
|
if (maxRuns > 0 && count >= maxRuns) {
|
|
148
185
|
console.log("[opencode-loopd] max runs reached")
|
|
149
|
-
|
|
186
|
+
return Number.isInteger(code) && code > 0 ? code : code < 0 ? 1 : 0
|
|
150
187
|
}
|
|
151
188
|
|
|
152
189
|
if (delay > 0) {
|
|
@@ -155,41 +192,75 @@ async function daemon() {
|
|
|
155
192
|
}
|
|
156
193
|
}
|
|
157
194
|
|
|
195
|
+
function taskArtifacts(name) {
|
|
196
|
+
const id = createHash("sha256").update(String(name || "OpenCodeLoop")).digest("hex").slice(0, 16)
|
|
197
|
+
return {
|
|
198
|
+
config: path.join(TASK_ROOT, `${id}.json`),
|
|
199
|
+
launcher: path.join(TASK_ROOT, `${id}.cmd`),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function removeTaskArtifacts(artifacts) {
|
|
204
|
+
for (const target of [artifacts.launcher, artifacts.config]) {
|
|
205
|
+
try { fs.rmSync(target, { force: true }) } catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function taskRun() {
|
|
210
|
+
const configFile = path.resolve(arg("--config"))
|
|
211
|
+
let config
|
|
212
|
+
try { config = JSON.parse(fs.readFileSync(configFile, "utf8")) } catch (error) { throw new Error(`Could not read task config ${configFile}: ${error.message}`) }
|
|
213
|
+
return await daemon(config)
|
|
214
|
+
}
|
|
215
|
+
|
|
158
216
|
function installTask() {
|
|
159
217
|
if (process.platform !== "win32") {
|
|
160
218
|
throw new Error("install-task is currently implemented for Windows Task Scheduler only. Use daemon mode on macOS/Linux.")
|
|
161
219
|
}
|
|
162
220
|
|
|
163
221
|
const project = path.resolve(arg("--project", process.cwd()))
|
|
222
|
+
validateProject(project)
|
|
164
223
|
const every = arg("--every", "10m")
|
|
165
224
|
const minutes = Math.max(1, Math.round(parseMs(every) / 60_000))
|
|
166
225
|
const name = arg("--name", "OpenCodeLoop")
|
|
167
226
|
const promptFile = arg("--prompt-file")
|
|
168
227
|
const promptArg = arg("--prompt")
|
|
228
|
+
const model = arg("--model")
|
|
229
|
+
const agent = arg("--agent")
|
|
169
230
|
const node = process.execPath
|
|
170
231
|
const script = fileURLToPath(import.meta.url)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
232
|
+
const artifacts = taskArtifacts(name)
|
|
233
|
+
fs.mkdirSync(TASK_ROOT, { recursive: true })
|
|
234
|
+
const taskConfig = {
|
|
235
|
+
project,
|
|
236
|
+
every: "0s",
|
|
237
|
+
maxRuns: 1,
|
|
238
|
+
promptFile: promptFile ? path.resolve(project, promptFile) : undefined,
|
|
239
|
+
prompt: promptArg || undefined,
|
|
240
|
+
model: model || undefined,
|
|
241
|
+
agent: agent || undefined,
|
|
242
|
+
opencodeBin: OPENCODE_BIN,
|
|
243
|
+
}
|
|
244
|
+
fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
|
|
245
|
+
const launcherCommand = [quoteWindowsArg(node), quoteWindowsArg(script), "task-run", "--config", quoteWindowsArg(artifacts.config)].join(" ")
|
|
246
|
+
fs.writeFileSync(artifacts.launcher, `@echo off\r\n${launcherCommand}\r\nexit /b %errorlevel%\r\n`, "utf8")
|
|
247
|
+
const taskCommand = `cmd.exe /d /s /c "${quoteWindowsArg(artifacts.launcher)}"`
|
|
248
|
+
if (taskCommand.length > 261) {
|
|
249
|
+
removeTaskArtifacts(artifacts)
|
|
250
|
+
throw new Error(`Task Scheduler command is still too long (${taskCommand.length} characters). Set OPENCODE_LOOPD_TASK_DIR to a shorter directory.`)
|
|
251
|
+
}
|
|
188
252
|
const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
|
|
189
253
|
|
|
190
|
-
console.log([
|
|
191
|
-
const result =
|
|
192
|
-
|
|
254
|
+
console.log([SCHTASKS_BIN, ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
|
|
255
|
+
const result = runSync(SCHTASKS_BIN, taskArgs)
|
|
256
|
+
if (result.error) {
|
|
257
|
+
removeTaskArtifacts(artifacts)
|
|
258
|
+
console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
|
|
259
|
+
return 1
|
|
260
|
+
}
|
|
261
|
+
const status = Number.isInteger(result.status) ? result.status : 1
|
|
262
|
+
if (status !== 0) removeTaskArtifacts(artifacts)
|
|
263
|
+
return status
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
function uninstallTask() {
|
|
@@ -198,8 +269,14 @@ function uninstallTask() {
|
|
|
198
269
|
}
|
|
199
270
|
|
|
200
271
|
const name = arg("--name", "OpenCodeLoop")
|
|
201
|
-
const result =
|
|
202
|
-
|
|
272
|
+
const result = runSync(SCHTASKS_BIN, ["/Delete", "/F", "/TN", name])
|
|
273
|
+
if (result.error) {
|
|
274
|
+
console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
|
|
275
|
+
return 1
|
|
276
|
+
}
|
|
277
|
+
const status = Number.isInteger(result.status) ? result.status : 1
|
|
278
|
+
if (status === 0) removeTaskArtifacts(taskArtifacts(name))
|
|
279
|
+
return status
|
|
203
280
|
}
|
|
204
281
|
|
|
205
282
|
function help() {
|
|
@@ -217,6 +294,8 @@ Options:
|
|
|
217
294
|
--every <duration> 0s, 5m, 1h, etc.
|
|
218
295
|
--prompt <text> Prompt text
|
|
219
296
|
--prompt-file <file> Read prompt from file relative to the project
|
|
297
|
+
--model <provider/id> OpenCode model used for each run
|
|
298
|
+
--agent <name> OpenCode agent used for each run
|
|
220
299
|
--max-runs <n> Stop after n runs
|
|
221
300
|
--sleep-first Wait before first run
|
|
222
301
|
`)
|
|
@@ -225,20 +304,25 @@ Options:
|
|
|
225
304
|
const command = args[0]
|
|
226
305
|
|
|
227
306
|
try {
|
|
307
|
+
let exitCode = 0
|
|
228
308
|
if (command === "daemon" || command === "loopd") {
|
|
229
309
|
args.shift()
|
|
230
|
-
await daemon()
|
|
310
|
+
exitCode = await daemon()
|
|
231
311
|
} else if (command === "install-task") {
|
|
232
312
|
args.shift()
|
|
233
|
-
installTask()
|
|
313
|
+
exitCode = installTask()
|
|
234
314
|
} else if (command === "uninstall-task") {
|
|
235
315
|
args.shift()
|
|
236
|
-
uninstallTask()
|
|
316
|
+
exitCode = uninstallTask()
|
|
317
|
+
} else if (command === "task-run") {
|
|
318
|
+
args.shift()
|
|
319
|
+
exitCode = await taskRun()
|
|
237
320
|
} else if (has("--help") || has("-h") || command === "help") {
|
|
238
321
|
help()
|
|
239
322
|
} else {
|
|
240
|
-
await daemon()
|
|
323
|
+
exitCode = await daemon()
|
|
241
324
|
}
|
|
325
|
+
if (exitCode) process.exitCode = exitCode
|
|
242
326
|
} catch (error) {
|
|
243
327
|
console.error(error?.message || error)
|
|
244
328
|
process.exit(1)
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
|
20
20
|
const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
|
|
21
21
|
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
22
22
|
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
23
|
+
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
23
24
|
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
24
25
|
|
|
25
26
|
const activeRuns = new Map()
|
|
@@ -509,18 +510,31 @@ async function toast(client, message, variant = "info") {
|
|
|
509
510
|
|
|
510
511
|
function guardLoopOwnedUserMessage(sessionID) {
|
|
511
512
|
if (!sessionID) return
|
|
512
|
-
loopOwnedUserMessageGuards.
|
|
513
|
-
|
|
513
|
+
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
514
|
+
current.pending += 1
|
|
515
|
+
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
516
|
+
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
517
|
+
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
518
|
+
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
519
|
+
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
520
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
521
|
+
}
|
|
514
522
|
}
|
|
515
523
|
|
|
516
|
-
function loopOwnedUserMessageGuardActive(sessionID) {
|
|
517
|
-
const
|
|
518
|
-
if (typeof
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
524
|
+
function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
525
|
+
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
526
|
+
if (!entry || typeof entry !== "object") return false
|
|
527
|
+
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
528
|
+
const id = typeof messageID === "string" ? messageID : ""
|
|
529
|
+
if (id && entry.messageIDs.has(id)) return true
|
|
530
|
+
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
531
|
+
entry.pending -= 1
|
|
532
|
+
if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
533
|
+
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
534
|
+
return true
|
|
522
535
|
}
|
|
523
|
-
|
|
536
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
537
|
+
return false
|
|
524
538
|
}
|
|
525
539
|
|
|
526
540
|
async function say(client, sessionID, text) {
|
|
@@ -1106,8 +1120,9 @@ function userInterruptSessionFromEvent(event) {
|
|
|
1106
1120
|
const info = props.info || props.message || props
|
|
1107
1121
|
const role = info?.role
|
|
1108
1122
|
const sessionID = info?.sessionID || props.sessionID
|
|
1123
|
+
const messageID = info?.id || props.messageID
|
|
1109
1124
|
if (role !== "user" || typeof sessionID !== "string") return undefined
|
|
1110
|
-
if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
|
|
1125
|
+
if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
|
|
1111
1126
|
return sessionID
|
|
1112
1127
|
}
|
|
1113
1128
|
|