@bybrawe/opencode-loop 0.5.12 → 0.5.14
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 +13 -0
- package/README.md +16 -23
- package/examples/opencode.json +0 -1
- package/package.json +4 -1
- package/scripts/loopd.mjs +56 -21
- package/src/index.js +81 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.14
|
|
4
|
+
|
|
5
|
+
- Guarded active-run finalization so heartbeat and retry checks do not treat a still-running assistant turn as finished.
|
|
6
|
+
- Made loop state writes atomic and backed up corrupt state files before falling back to an empty state.
|
|
7
|
+
- Changed `opencode-loopd` to pass prompts as process arguments instead of shell strings, preserving quotes in inline prompts.
|
|
8
|
+
- Stripped UTF-8 BOMs from daemon prompt files before sending them to OpenCode.
|
|
9
|
+
- Removed the package plugin entry from the example config to avoid duplicate loading after the recommended local installer path.
|
|
10
|
+
|
|
11
|
+
## 0.5.13
|
|
12
|
+
|
|
13
|
+
- Wrapped experimental goal tools with `tool()` and Zod schemas for current OpenCode tool validation.
|
|
14
|
+
- Added `@opencode-ai/plugin` as a dev dependency so fresh clones can resolve the tool helper during local development.
|
|
15
|
+
|
|
3
16
|
## 0.5.12
|
|
4
17
|
|
|
5
18
|
- Fixed local plugin installs to write `opencode-loop.ts`, matching OpenCode local plugin discovery.
|
package/README.md
CHANGED
|
@@ -151,32 +151,12 @@ OpenCode Loop is designed for developers searching for:
|
|
|
151
151
|
|
|
152
152
|
### Recommended: install from npm
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or Linux.
|
|
155
155
|
|
|
156
156
|
```bash
|
|
157
157
|
npx -y @bybrawe/opencode-loop@latest
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
Then fully restart OpenCode.
|
|
161
|
-
|
|
162
|
-
Install from npm with `npx`:
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
npx -y @bybrawe/opencode-loop
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
On Windows CMD:
|
|
169
|
-
|
|
170
|
-
```bat
|
|
171
|
-
npx -y @bybrawe/opencode-loop
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
On Windows PowerShell:
|
|
175
|
-
|
|
176
|
-
```powershell
|
|
177
|
-
npx -y @bybrawe/opencode-loop
|
|
178
|
-
```
|
|
179
|
-
|
|
180
160
|
The installer copies the plugin and slash command files into your OpenCode config directory.
|
|
181
161
|
It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
|
|
182
162
|
|
|
@@ -217,6 +197,7 @@ The `npx` installer installs both parts:
|
|
|
217
197
|
- the `/loop-*` command markdown files
|
|
218
198
|
|
|
219
199
|
Use the OpenCode config-only method only if you already installed the command files separately or you are only testing plugin loading.
|
|
200
|
+
Do not use the config package entry and the local `npx`-installed plugin at the same time unless you intentionally want to test duplicate plugin loading.
|
|
220
201
|
|
|
221
202
|
### Optional: OpenCode config package entry
|
|
222
203
|
|
|
@@ -239,6 +220,8 @@ If `/loop` does not appear after using only the config method, run the installer
|
|
|
239
220
|
npx -y @bybrawe/opencode-loop
|
|
240
221
|
```
|
|
241
222
|
|
|
223
|
+
If you later switch back to the recommended `npx` local install, remove `@bybrawe/opencode-loop` from the `plugin` array to avoid loading the package plugin and local plugin together.
|
|
224
|
+
|
|
242
225
|
### Install from GitHub
|
|
243
226
|
|
|
244
227
|
Use this if you want to install from source instead of npm.
|
|
@@ -1113,6 +1096,16 @@ Improve the application in small safe steps.
|
|
|
1113
1096
|
|
|
1114
1097
|
## Changelog highlights
|
|
1115
1098
|
|
|
1099
|
+
### v0.5.13
|
|
1100
|
+
|
|
1101
|
+
- Wrapped experimental goal tools with OpenCode's `tool()` helper and Zod schemas for current OpenCode tool validation.
|
|
1102
|
+
- Added `@opencode-ai/plugin` as a development dependency so fresh clones can resolve the tool helper.
|
|
1103
|
+
|
|
1104
|
+
### v0.5.12
|
|
1105
|
+
|
|
1106
|
+
- Changed local installs to write `opencode-loop.ts` and remove stale `opencode-loop.js` copies.
|
|
1107
|
+
- Fixed OpenCode CLI command argument parsing, current SDK call shapes, and `opencode-loopd` prompt passing.
|
|
1108
|
+
|
|
1116
1109
|
### v0.5.7
|
|
1117
1110
|
|
|
1118
1111
|
- Added experimental Goal Mode with `/loop-goal <objective>`.
|
|
@@ -1127,11 +1120,11 @@ Improve the application in small safe steps.
|
|
|
1127
1120
|
- Stabilized `/loop-command 200m /compact` by routing compact/summarize through OpenCode TUI compact handling instead of treating it as a custom prompt command.
|
|
1128
1121
|
- Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
|
|
1129
1122
|
|
|
1130
|
-
|
|
1123
|
+
### v0.5.5
|
|
1131
1124
|
|
|
1132
1125
|
- Added explicit loop action types: prompt, scheduled question, OpenCode slash command, and shell command.
|
|
1133
1126
|
- Added `/loop-command` / `/loop-cmd` for commands such as `/compact`; these wait for the first interval by default.
|
|
1134
|
-
- Added `/loop-ask` for recurring checks such as
|
|
1127
|
+
- Added `/loop-ask` for recurring checks such as "did you run tests, tsc --noEmit, and build?"; it waits for the first interval by default.
|
|
1135
1128
|
- Added `/loop-shell` for recurring shell commands.
|
|
1136
1129
|
- Added `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact` type flags for `/loop`.
|
|
1137
1130
|
- Clarified idle behavior: if a job becomes due while OpenCode is busy, it waits and runs on the next idle event.
|
package/examples/opencode.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
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",
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@opencode-ai/plugin": ">=1.4.0"
|
|
56
56
|
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@opencode-ai/plugin": "^1.4.0"
|
|
59
|
+
},
|
|
57
60
|
"bin": {
|
|
58
61
|
"opencode-loop": "scripts/install-node.mjs",
|
|
59
62
|
"opencode-loopd": "scripts/loopd.mjs"
|
package/scripts/loopd.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"
|
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2)
|
|
8
8
|
const FAILED_RUN_RETRY_MS = 5_000
|
|
9
|
+
const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
|
|
9
10
|
|
|
10
11
|
function arg(name, fallback = null) {
|
|
11
12
|
const i = args.indexOf(name)
|
|
@@ -39,25 +40,60 @@ function sleep(ms) {
|
|
|
39
40
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function
|
|
43
|
+
function spawnOnce(command, commandArgs, cwd) {
|
|
43
44
|
return new Promise((resolve) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
child
|
|
45
|
+
let settled = false
|
|
46
|
+
const done = (result) => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
resolve(result)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let child
|
|
53
|
+
try {
|
|
54
|
+
child = spawn(command, commandArgs, {
|
|
55
|
+
cwd,
|
|
56
|
+
shell: false,
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
env: process.env,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
done({ code: -1, error })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
child.on("error", (error) => done({ code: -1, error }))
|
|
67
|
+
child.on("exit", (code) => done({ code: code ?? 0 }))
|
|
52
68
|
})
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
function quoteWindowsArg(value) {
|
|
72
|
+
const text = String(value ?? "")
|
|
73
|
+
return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripBom(text) {
|
|
77
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function run(command, commandArgs, cwd) {
|
|
81
|
+
const direct = await spawnOnce(command, commandArgs, cwd)
|
|
82
|
+
if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct.code
|
|
83
|
+
|
|
84
|
+
const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd)
|
|
85
|
+
if (fallback.code === -1) {
|
|
86
|
+
console.error(`[opencode-loopd] failed to start ${command}: ${fallback.error?.message || direct.error?.message || "unknown error"}`)
|
|
87
|
+
}
|
|
88
|
+
return fallback.code
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
function readPrompt(project) {
|
|
56
92
|
const promptFile = arg("--prompt-file")
|
|
57
93
|
const promptArg = arg("--prompt")
|
|
58
94
|
|
|
59
95
|
if (promptFile) {
|
|
60
|
-
return fs.readFileSync(path.resolve(project, promptFile), "utf8")
|
|
96
|
+
return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
|
|
61
97
|
}
|
|
62
98
|
|
|
63
99
|
if (promptArg) return promptArg
|
|
@@ -99,8 +135,7 @@ async function daemon() {
|
|
|
99
135
|
console.log("")
|
|
100
136
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
101
137
|
|
|
102
|
-
const
|
|
103
|
-
const code = await run(command, project)
|
|
138
|
+
const code = await run(OPENCODE_BIN, ["run", "--continue", prompt], project)
|
|
104
139
|
|
|
105
140
|
if (code !== 0) {
|
|
106
141
|
console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
@@ -135,25 +170,25 @@ function installTask() {
|
|
|
135
170
|
const script = fileURLToPath(import.meta.url)
|
|
136
171
|
|
|
137
172
|
const commandParts = [
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
quoteWindowsArg(node),
|
|
174
|
+
quoteWindowsArg(script),
|
|
140
175
|
"daemon",
|
|
141
176
|
"--project",
|
|
142
|
-
|
|
177
|
+
quoteWindowsArg(project),
|
|
143
178
|
"--every",
|
|
144
179
|
"0s",
|
|
145
180
|
"--max-runs",
|
|
146
181
|
"1",
|
|
147
182
|
]
|
|
148
183
|
|
|
149
|
-
if (promptFile) commandParts.push("--prompt-file",
|
|
150
|
-
if (promptArg) commandParts.push("--prompt",
|
|
184
|
+
if (promptFile) commandParts.push("--prompt-file", quoteWindowsArg(promptFile))
|
|
185
|
+
if (promptArg) commandParts.push("--prompt", quoteWindowsArg(promptArg))
|
|
151
186
|
|
|
152
187
|
const taskCommand = commandParts.join(" ")
|
|
153
|
-
const
|
|
188
|
+
const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
|
|
154
189
|
|
|
155
|
-
console.log(schtasks)
|
|
156
|
-
const result = spawnSync(schtasks, { shell:
|
|
190
|
+
console.log(["schtasks", ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
|
|
191
|
+
const result = spawnSync("schtasks", taskArgs, { shell: false, stdio: "inherit" })
|
|
157
192
|
process.exit(result.status ?? 0)
|
|
158
193
|
}
|
|
159
194
|
|
|
@@ -163,7 +198,7 @@ function uninstallTask() {
|
|
|
163
198
|
}
|
|
164
199
|
|
|
165
200
|
const name = arg("--name", "OpenCodeLoop")
|
|
166
|
-
const result = spawnSync(
|
|
201
|
+
const result = spawnSync("schtasks", ["/Delete", "/F", "/TN", name], { shell: false, stdio: "inherit" })
|
|
167
202
|
process.exit(result.status ?? 0)
|
|
168
203
|
}
|
|
169
204
|
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { spawn } from "node:child_process"
|
|
4
|
+
import { tool } from "@opencode-ai/plugin/tool"
|
|
4
5
|
|
|
5
6
|
const SERVICE = "opencode-loop"
|
|
6
7
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
@@ -24,6 +25,7 @@ const dueTimers = new Map()
|
|
|
24
25
|
const watchdogTimers = new Map()
|
|
25
26
|
const runLocks = new Map()
|
|
26
27
|
const knownSessions = new Map()
|
|
28
|
+
const stateWriteLocks = new Map()
|
|
27
29
|
let heartbeatTimer
|
|
28
30
|
const sessionStatuses = new Map()
|
|
29
31
|
const sessionStatusSeenAt = new Map()
|
|
@@ -319,21 +321,61 @@ function statePath(directory, sessionID) { return path.join(stateDir(directory),
|
|
|
319
321
|
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
320
322
|
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
321
323
|
|
|
324
|
+
function stateLockKey(directory, sessionID) {
|
|
325
|
+
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function withStateWriteLock(directory, sessionID, fn) {
|
|
329
|
+
const key = stateLockKey(directory, sessionID)
|
|
330
|
+
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
331
|
+
let release
|
|
332
|
+
const current = new Promise((resolve) => { release = resolve })
|
|
333
|
+
const next = previous.catch(() => {}).then(() => current)
|
|
334
|
+
stateWriteLocks.set(key, next)
|
|
335
|
+
await previous.catch(() => {})
|
|
336
|
+
try {
|
|
337
|
+
return await fn()
|
|
338
|
+
} finally {
|
|
339
|
+
release()
|
|
340
|
+
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
322
344
|
async function readState(directory, sessionID) {
|
|
345
|
+
const target = statePath(directory, sessionID)
|
|
323
346
|
try {
|
|
324
|
-
const parsed = JSON.parse(await fs.readFile(
|
|
347
|
+
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
325
348
|
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
326
|
-
} catch {
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error?.code !== "ENOENT") {
|
|
351
|
+
try {
|
|
352
|
+
await ensureDir(stateDir(directory))
|
|
353
|
+
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
327
356
|
return { version: 4, jobs: [] }
|
|
328
357
|
}
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
async function writeState(directory, sessionID, state) {
|
|
332
|
-
await
|
|
333
|
-
|
|
361
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
362
|
+
await ensureDir(stateDir(directory))
|
|
363
|
+
const target = statePath(directory, sessionID)
|
|
364
|
+
const temp = `${target}.${process.pid}.${Date.now()}.tmp`
|
|
365
|
+
try {
|
|
366
|
+
await fs.writeFile(temp, JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
|
|
367
|
+
await fs.rename(temp, target)
|
|
368
|
+
} finally {
|
|
369
|
+
try { await fs.rm(temp, { force: true }) } catch {}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
334
372
|
}
|
|
335
373
|
|
|
336
|
-
async function removeState(directory, sessionID) {
|
|
374
|
+
async function removeState(directory, sessionID) {
|
|
375
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
376
|
+
try { await fs.unlink(statePath(directory, sessionID)) } catch {}
|
|
377
|
+
})
|
|
378
|
+
}
|
|
337
379
|
|
|
338
380
|
function sdkError(result) {
|
|
339
381
|
if (!result || typeof result !== "object") return undefined
|
|
@@ -496,7 +538,7 @@ function startHeartbeat() {
|
|
|
496
538
|
}
|
|
497
539
|
Promise.resolve()
|
|
498
540
|
.then(async () => {
|
|
499
|
-
await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
|
|
541
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
500
542
|
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
501
543
|
})
|
|
502
544
|
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
@@ -819,6 +861,19 @@ function staleActiveRun(sessionID) {
|
|
|
819
861
|
return age >= threshold
|
|
820
862
|
}
|
|
821
863
|
|
|
864
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
865
|
+
if (!options.requireIdle && !options.forceStale) return true
|
|
866
|
+
if (options.forceStale && staleActiveRun(sessionID)) return true
|
|
867
|
+
if (!options.requireIdle) return false
|
|
868
|
+
|
|
869
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
870
|
+
if (live?.type) return live.type === "idle"
|
|
871
|
+
|
|
872
|
+
const cached = sessionStatuses.get(sessionID)
|
|
873
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
874
|
+
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
875
|
+
}
|
|
876
|
+
|
|
822
877
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
823
878
|
const argsList = []
|
|
824
879
|
if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
|
|
@@ -1147,14 +1202,17 @@ async function runGoalChecks(directory, sessionID, job, client) {
|
|
|
1147
1202
|
return job
|
|
1148
1203
|
}
|
|
1149
1204
|
|
|
1150
|
-
async function finalizeActiveRun(directory, client, sessionID) {
|
|
1205
|
+
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1151
1206
|
const active = activeRuns.get(sessionID)
|
|
1152
1207
|
if (!active) return
|
|
1208
|
+
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1209
|
+
const recoveredStale = staleActiveRun(sessionID)
|
|
1153
1210
|
clearActiveRun(sessionID)
|
|
1154
1211
|
const state = await readState(directory, sessionID)
|
|
1155
1212
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1156
1213
|
if (!job) return
|
|
1157
1214
|
job.lastFinishedAt = now()
|
|
1215
|
+
if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
|
|
1158
1216
|
|
|
1159
1217
|
if (job.verifyCommand) {
|
|
1160
1218
|
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
@@ -1200,6 +1258,7 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
1200
1258
|
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
1201
1259
|
await createCheckpoint(directory, sessionID, job, client)
|
|
1202
1260
|
await scheduleDueWork(directory, client, sessionID)
|
|
1261
|
+
return true
|
|
1203
1262
|
}
|
|
1204
1263
|
|
|
1205
1264
|
async function fireAction(directory, client, sessionID, job) {
|
|
@@ -1257,7 +1316,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1257
1316
|
runLocks.set(sessionID, now())
|
|
1258
1317
|
let job
|
|
1259
1318
|
try {
|
|
1260
|
-
await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
|
|
1319
|
+
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
1261
1320
|
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1262
1321
|
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1263
1322
|
await reschedule(BUSY_RETRY_MS)
|
|
@@ -1360,6 +1419,8 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1360
1419
|
let timer
|
|
1361
1420
|
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1362
1421
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1422
|
+
sessionStatuses.set(sessionID, "busy")
|
|
1423
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1363
1424
|
await reschedule(BUSY_RETRY_MS)
|
|
1364
1425
|
} catch (error) {
|
|
1365
1426
|
clearActiveRun(sessionID)
|
|
@@ -1640,39 +1701,39 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1640
1701
|
|
|
1641
1702
|
function goalTools(defaultDirectory) {
|
|
1642
1703
|
return {
|
|
1643
|
-
opencode_loop_goal_complete: {
|
|
1704
|
+
opencode_loop_goal_complete: tool({
|
|
1644
1705
|
description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
1645
1706
|
args: {
|
|
1646
|
-
summary:
|
|
1647
|
-
evidence:
|
|
1707
|
+
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
1708
|
+
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
1648
1709
|
},
|
|
1649
1710
|
execute: async (args, context) => {
|
|
1650
1711
|
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1651
1712
|
return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
|
|
1652
1713
|
},
|
|
1653
|
-
},
|
|
1654
|
-
opencode_loop_goal_blocked: {
|
|
1714
|
+
}),
|
|
1715
|
+
opencode_loop_goal_blocked: tool({
|
|
1655
1716
|
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
1656
1717
|
args: {
|
|
1657
|
-
reason:
|
|
1658
|
-
needed:
|
|
1718
|
+
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
1719
|
+
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
1659
1720
|
},
|
|
1660
1721
|
execute: async (args, context) => {
|
|
1661
1722
|
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1662
1723
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1663
1724
|
},
|
|
1664
|
-
},
|
|
1665
|
-
opencode_loop_goal_progress: {
|
|
1725
|
+
}),
|
|
1726
|
+
opencode_loop_goal_progress: tool({
|
|
1666
1727
|
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
1667
1728
|
args: {
|
|
1668
|
-
summary:
|
|
1669
|
-
next:
|
|
1729
|
+
summary: tool.schema.string().describe("What useful progress was made."),
|
|
1730
|
+
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
1670
1731
|
},
|
|
1671
1732
|
execute: async (args, context) => {
|
|
1672
1733
|
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1673
1734
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1674
1735
|
},
|
|
1675
|
-
},
|
|
1736
|
+
}),
|
|
1676
1737
|
}
|
|
1677
1738
|
}
|
|
1678
1739
|
|