@bybrawe/opencode-loop 0.4.2
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 +51 -0
- package/LICENSE +21 -0
- package/README.md +706 -0
- package/commands/loop-clear.md +5 -0
- package/commands/loop-compact.md +5 -0
- package/commands/loop-dev.md +5 -0
- package/commands/loop-doctor.md +5 -0
- package/commands/loop-export.md +5 -0
- package/commands/loop-help.md +5 -0
- package/commands/loop-init.md +5 -0
- package/commands/loop-logs.md +5 -0
- package/commands/loop-now.md +5 -0
- package/commands/loop-pause.md +5 -0
- package/commands/loop-progress.md +5 -0
- package/commands/loop-remove.md +5 -0
- package/commands/loop-resume.md +5 -0
- package/commands/loop-safe-dev.md +5 -0
- package/commands/loop-status.md +5 -0
- package/commands/loop-stop.md +5 -0
- package/commands/loop-testfix.md +5 -0
- package/commands/loop.md +5 -0
- package/examples/opencode.json +40 -0
- package/package.json +57 -0
- package/scripts/install-node.mjs +23 -0
- package/scripts/install.ps1 +17 -0
- package/scripts/install.sh +16 -0
- package/scripts/make-zip.mjs +7 -0
- package/src/index.js +846 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
|
|
5
|
+
const SERVICE = "opencode-loop"
|
|
6
|
+
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
|
+
const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
8
|
+
const MAX_SCAN_FILES = 200
|
|
9
|
+
const MAX_SCAN_BYTES = 2_000_000
|
|
10
|
+
|
|
11
|
+
const activeRuns = new Map()
|
|
12
|
+
const handledCommands = new Map()
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PROGRESS_MD = `# Progress
|
|
15
|
+
|
|
16
|
+
## Current Goal
|
|
17
|
+
Describe the current project goal here.
|
|
18
|
+
|
|
19
|
+
## Agent Rules
|
|
20
|
+
- Do not ask questions unless truly blocked.
|
|
21
|
+
- Make reasonable assumptions and continue.
|
|
22
|
+
- Work on unfinished TODOs in order.
|
|
23
|
+
- Mark completed TODOs with [x].
|
|
24
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
25
|
+
- Run tests, lint, or build when available.
|
|
26
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
27
|
+
|
|
28
|
+
## Active TODO
|
|
29
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
30
|
+
|
|
31
|
+
## Completed
|
|
32
|
+
- [x] Created progress.md.
|
|
33
|
+
|
|
34
|
+
## Backlog Ideas
|
|
35
|
+
- [ ] Add more project-specific tasks here.
|
|
36
|
+
|
|
37
|
+
## Blocked
|
|
38
|
+
- None.
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
function now() {
|
|
42
|
+
return Date.now()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function safeID(value) {
|
|
46
|
+
return String(value || "job")
|
|
47
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
48
|
+
.replace(/^-+|-+$/g, "")
|
|
49
|
+
.slice(0, 80) || "job"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseDuration(value) {
|
|
53
|
+
const input = String(value || "").trim()
|
|
54
|
+
if (input === "0") return 0
|
|
55
|
+
const match = input.match(/^(\d+)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i)
|
|
56
|
+
if (!match) return null
|
|
57
|
+
const amount = Number.parseInt(match[1], 10)
|
|
58
|
+
const unit = match[2].toLowerCase()
|
|
59
|
+
if (!Number.isFinite(amount) || amount < 0) return null
|
|
60
|
+
if (unit === "ms") return amount
|
|
61
|
+
if (unit.startsWith("s")) return amount * 1000
|
|
62
|
+
if (unit.startsWith("m")) return amount * 60_000
|
|
63
|
+
if (unit.startsWith("h")) return amount * 3_600_000
|
|
64
|
+
if (unit.startsWith("d")) return amount * 86_400_000
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function durationToText(ms) {
|
|
69
|
+
if (ms === 0) return "every idle"
|
|
70
|
+
if (!Number.isFinite(ms)) return "unknown"
|
|
71
|
+
if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`
|
|
72
|
+
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`
|
|
73
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`
|
|
74
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`
|
|
75
|
+
return `${ms}ms`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function splitFirst(input) {
|
|
79
|
+
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/)
|
|
80
|
+
if (!match) return ["", ""]
|
|
81
|
+
return [match[1], (match[2] || "").trim()]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stripOuterQuotes(value) {
|
|
85
|
+
const input = String(value || "").trim()
|
|
86
|
+
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
|
|
87
|
+
return input.slice(1, -1)
|
|
88
|
+
}
|
|
89
|
+
return input
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function escapeRegExp(value) {
|
|
93
|
+
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function takeFlag(rest, flag) {
|
|
97
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i")
|
|
98
|
+
const found = pattern.test(rest)
|
|
99
|
+
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function takeFlagValue(rest, flag) {
|
|
103
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(?:\"([^\"]*)\"|'([^']*)'|(\\S+))`, "i")
|
|
104
|
+
const match = rest.match(pattern)
|
|
105
|
+
if (!match) return [undefined, rest]
|
|
106
|
+
const value = match[2] ?? match[3] ?? match[4]
|
|
107
|
+
return [value, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function takeAllFlagValues(rest, flag) {
|
|
111
|
+
const values = []
|
|
112
|
+
let current = rest
|
|
113
|
+
while (true) {
|
|
114
|
+
const [value, next] = takeFlagValue(current, flag)
|
|
115
|
+
if (value === undefined) return [values, current]
|
|
116
|
+
values.push(value)
|
|
117
|
+
current = next
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parsePositiveInt(value, fallback = 0) {
|
|
122
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
123
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseCompactEvery(value) {
|
|
127
|
+
const duration = parseDuration(value)
|
|
128
|
+
if (duration !== null) return { compactEveryMs: duration }
|
|
129
|
+
const runs = parsePositiveInt(value, 0)
|
|
130
|
+
return runs > 0 ? { compactEveryRuns: runs } : {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseLoopArgs(raw, defaults = {}) {
|
|
134
|
+
let input = String(raw || "").trim()
|
|
135
|
+
let first = ""
|
|
136
|
+
let rest = input
|
|
137
|
+
let intervalMs = defaults.intervalMs ?? null
|
|
138
|
+
|
|
139
|
+
if (!input && defaults.action) {
|
|
140
|
+
rest = defaults.action
|
|
141
|
+
} else {
|
|
142
|
+
;[first, rest] = splitFirst(input)
|
|
143
|
+
if (first === "--watch") {
|
|
144
|
+
intervalMs = defaults.intervalMs ?? 0
|
|
145
|
+
rest = input
|
|
146
|
+
} else if (first) {
|
|
147
|
+
const parsedDuration = parseDuration(first)
|
|
148
|
+
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
149
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop 200m /compact | /loop 10m !npm test | /loop --watch progress.md <prompt>" }
|
|
150
|
+
else rest = input
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (intervalMs === null) intervalMs = 0
|
|
155
|
+
|
|
156
|
+
const job = {
|
|
157
|
+
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
158
|
+
name: defaults.name,
|
|
159
|
+
action: defaults.action || "",
|
|
160
|
+
intervalMs,
|
|
161
|
+
immediate: defaults.immediate ?? true,
|
|
162
|
+
maxRuns: defaults.maxRuns ?? 0,
|
|
163
|
+
maxRuntimeMs: defaults.maxRuntimeMs ?? 0,
|
|
164
|
+
maxFailures: defaults.maxFailures ?? 0,
|
|
165
|
+
timeoutMs: defaults.timeoutMs ?? 0,
|
|
166
|
+
until: defaults.until,
|
|
167
|
+
stopFile: defaults.stopFile,
|
|
168
|
+
progressFile: defaults.progressFile,
|
|
169
|
+
promptFile: defaults.promptFile,
|
|
170
|
+
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
171
|
+
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
172
|
+
compactEveryRuns: defaults.compactEveryRuns ?? 0,
|
|
173
|
+
compactEveryMs: defaults.compactEveryMs ?? 0,
|
|
174
|
+
testCommand: defaults.testCommand,
|
|
175
|
+
verifyCommand: defaults.verifyCommand,
|
|
176
|
+
preflightCommand: defaults.preflightCommand,
|
|
177
|
+
postrunCommand: defaults.postrunCommand,
|
|
178
|
+
notifyCommand: defaults.notifyCommand,
|
|
179
|
+
branch: defaults.branch,
|
|
180
|
+
branchDone: false,
|
|
181
|
+
noOverlap: defaults.noOverlap ?? true,
|
|
182
|
+
safe: defaults.safe ?? false,
|
|
183
|
+
quiet: defaults.quiet ?? false,
|
|
184
|
+
askNever: defaults.askNever ?? false,
|
|
185
|
+
pauseOnVerifyFail: defaults.pauseOnVerifyFail ?? false,
|
|
186
|
+
gitCheckpoint: defaults.gitCheckpoint ?? false,
|
|
187
|
+
checkpointOnly: defaults.checkpointOnly ?? false,
|
|
188
|
+
dryRun: defaults.dryRun ?? false,
|
|
189
|
+
batch: defaults.batch ?? 0,
|
|
190
|
+
runCount: 0,
|
|
191
|
+
failureCount: 0,
|
|
192
|
+
lastRunAt: 0,
|
|
193
|
+
lastCompactAt: 0,
|
|
194
|
+
lastCompactRunCount: 0,
|
|
195
|
+
watchSnapshot: {},
|
|
196
|
+
createdAt: new Date().toISOString(),
|
|
197
|
+
enabled: true,
|
|
198
|
+
paused: false,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let found
|
|
202
|
+
let value
|
|
203
|
+
|
|
204
|
+
;[found, rest] = takeFlag(rest, "--no-now"); if (found) job.immediate = false
|
|
205
|
+
;[found, rest] = takeFlag(rest, "--now"); if (found) job.immediate = true
|
|
206
|
+
;[found, rest] = takeFlag(rest, "--no-overlap"); if (found) job.noOverlap = true
|
|
207
|
+
;[found, rest] = takeFlag(rest, "--allow-overlap"); if (found) job.noOverlap = false
|
|
208
|
+
;[found, rest] = takeFlag(rest, "--safe"); if (found) job.safe = true
|
|
209
|
+
;[found, rest] = takeFlag(rest, "--quiet"); if (found) job.quiet = true
|
|
210
|
+
;[found, rest] = takeFlag(rest, "--ask-never"); if (found) job.askNever = true
|
|
211
|
+
;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
|
|
212
|
+
;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
|
|
213
|
+
;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
|
|
214
|
+
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
215
|
+
|
|
216
|
+
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
217
|
+
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
218
|
+
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
219
|
+
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
220
|
+
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
221
|
+
;[value, rest] = takeFlagValue(rest, "--until"); if (value !== undefined) job.until = stripOuterQuotes(value)
|
|
222
|
+
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
223
|
+
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
224
|
+
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
225
|
+
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
226
|
+
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
227
|
+
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
228
|
+
;[value, rest] = takeFlagValue(rest, "--postrun"); if (value !== undefined) job.postrunCommand = stripOuterQuotes(value)
|
|
229
|
+
;[value, rest] = takeFlagValue(rest, "--notify"); if (value !== undefined) job.notifyCommand = stripOuterQuotes(value)
|
|
230
|
+
;[value, rest] = takeFlagValue(rest, "--branch"); if (value !== undefined) job.branch = stripOuterQuotes(value)
|
|
231
|
+
;[value, rest] = takeFlagValue(rest, "--batch"); if (value !== undefined) job.batch = parsePositiveInt(value, 0)
|
|
232
|
+
;[value, rest] = takeFlagValue(rest, "--compact-every")
|
|
233
|
+
if (value !== undefined) Object.assign(job, parseCompactEvery(value))
|
|
234
|
+
|
|
235
|
+
const watch = takeAllFlagValues(rest, "--watch")
|
|
236
|
+
job.watchPaths.push(...watch[0].map(stripOuterQuotes).filter(Boolean))
|
|
237
|
+
rest = watch[1]
|
|
238
|
+
|
|
239
|
+
const includes = takeAllFlagValues(rest, "--include-file")
|
|
240
|
+
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
241
|
+
rest = includes[1]
|
|
242
|
+
|
|
243
|
+
job.action = stripOuterQuotes(rest || job.action || "")
|
|
244
|
+
job.watchPaths = [...new Set(job.watchPaths)]
|
|
245
|
+
job.includeFiles = [...new Set(job.includeFiles)]
|
|
246
|
+
job.lastRunAt = job.immediate ? 0 : now()
|
|
247
|
+
|
|
248
|
+
if (!job.action && !job.promptFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md or /loop 0s --prompt-file loop-prompt.md" }
|
|
249
|
+
return { ok: true, job }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function stateDir(directory) { return path.join(directory, STATE_DIR) }
|
|
253
|
+
function statePath(directory, sessionID) { return path.join(stateDir(directory), `${safeID(sessionID)}.json`) }
|
|
254
|
+
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
255
|
+
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
256
|
+
|
|
257
|
+
async function readState(directory, sessionID) {
|
|
258
|
+
try {
|
|
259
|
+
const parsed = JSON.parse(await fs.readFile(statePath(directory, sessionID), "utf8"))
|
|
260
|
+
return { version: 3, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
261
|
+
} catch {
|
|
262
|
+
return { version: 3, jobs: [] }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function writeState(directory, sessionID, state) {
|
|
267
|
+
await ensureDir(stateDir(directory))
|
|
268
|
+
await fs.writeFile(statePath(directory, sessionID), JSON.stringify({ version: 3, jobs: state.jobs || [] }, null, 2))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
272
|
+
|
|
273
|
+
async function log(client, level, message, extra) {
|
|
274
|
+
try { await client.app.log({ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } }) } catch {}
|
|
275
|
+
}
|
|
276
|
+
async function toast(client, message, variant = "info") { try { await client.tui.showToast({ body: { message, variant } }) } catch {} }
|
|
277
|
+
async function say(client, sessionID, text) { try { await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } }) } catch {} }
|
|
278
|
+
|
|
279
|
+
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args || ""}` }
|
|
280
|
+
function markHandled(sessionID, name, args) {
|
|
281
|
+
handledCommands.set(commandKey(sessionID, name, args), now())
|
|
282
|
+
for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
|
|
283
|
+
}
|
|
284
|
+
function wasHandled(sessionID, name, args) {
|
|
285
|
+
const time = handledCommands.get(commandKey(sessionID, name, args))
|
|
286
|
+
return typeof time === "number" && now() - time < 30_000
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function commandName(name) { return String(name || "") }
|
|
290
|
+
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev"].includes(name) }
|
|
291
|
+
function presetDefaults(name, args) {
|
|
292
|
+
const [maybeDuration, rest] = splitFirst(args)
|
|
293
|
+
const parsed = parseDuration(maybeDuration)
|
|
294
|
+
const intervalMs = parsed === null ? 0 : parsed
|
|
295
|
+
const extra = parsed === null ? String(args || "").trim() : rest
|
|
296
|
+
if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", name: "compact", immediate: false }
|
|
297
|
+
if (name === "loop-testfix") return { intervalMs, name: "testfix", safe: true, askNever: true, verifyCommand: extra || "npm test", action: `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${extra || "npm test"}` }
|
|
298
|
+
if (name === "loop-progress") return { intervalMs, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: extra || "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
299
|
+
if (name === "loop-safe-dev") return { intervalMs, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: extra || "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
300
|
+
return { intervalMs, name: "dev", askNever: true, progressFile: "progress.md", action: extra || "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function jobLabel(job) {
|
|
304
|
+
const title = job.name ? `${job.name}: ` : ""
|
|
305
|
+
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
306
|
+
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
307
|
+
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
308
|
+
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : ""
|
|
309
|
+
const verify = job.verifyCommand ? ", verify" : ""
|
|
310
|
+
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
311
|
+
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
312
|
+
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
313
|
+
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
314
|
+
const paused = job.paused ? ", paused" : ""
|
|
315
|
+
return `${title}${durationToText(job.intervalMs)} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function matchJob(job, target, index) {
|
|
319
|
+
const text = String(target || "").trim()
|
|
320
|
+
if (!text || text.toLowerCase() === "all") return true
|
|
321
|
+
return job.id === text || job.name === text || String(index + 1) === text
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function appendLoopLog(directory, line, extra = {}) {
|
|
325
|
+
try {
|
|
326
|
+
await ensureDir(stateDir(directory))
|
|
327
|
+
await fs.appendFile(path.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + "\n")
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function readSmallTextFile(filePath, maxBytes = 120_000) {
|
|
332
|
+
try {
|
|
333
|
+
const stat = await fs.stat(filePath)
|
|
334
|
+
if (!stat.isFile() || stat.size > maxBytes) return ""
|
|
335
|
+
return await fs.readFile(filePath, "utf8")
|
|
336
|
+
} catch { return "" }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
340
|
+
return await new Promise((resolve) => {
|
|
341
|
+
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
342
|
+
const stdout = []
|
|
343
|
+
const stderr = []
|
|
344
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
345
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
346
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
347
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
348
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
353
|
+
return await new Promise((resolve) => {
|
|
354
|
+
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
355
|
+
const stdout = []
|
|
356
|
+
const stderr = []
|
|
357
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
358
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
359
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
360
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
361
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function notifyJob(directory, job, reason) {
|
|
366
|
+
if (!job.notifyCommand) return
|
|
367
|
+
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""))
|
|
368
|
+
await runShellCommand(command, directory, 60_000)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function dangerousShell(command) {
|
|
372
|
+
const text = String(command || "").toLowerCase()
|
|
373
|
+
return [/\brm\s+-rf\b/, /\bgit\s+reset\b/, /\bgit\s+clean\b/, /\bgit\s+push\b/, /\bdel\s+\/s\b/, /\brmdir\s+\/s\b/, /\bformat\b/, /\bterraform\s+destroy\b/, /\bkubectl\s+delete\b/, /\bdeploy\b.*\bproduction\b/].some((pattern) => pattern.test(text))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function actionKind(action) {
|
|
377
|
+
const text = String(action || "").trim()
|
|
378
|
+
if (text === "/compact" || text === "/summarize") return "compact"
|
|
379
|
+
if (text.startsWith("/")) return "command"
|
|
380
|
+
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
381
|
+
return "prompt"
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function decoratePrompt(job) {
|
|
385
|
+
const additions = []
|
|
386
|
+
if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
|
|
387
|
+
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
388
|
+
if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
|
|
389
|
+
if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
|
|
390
|
+
if (job.batch > 0) additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`)
|
|
391
|
+
if (job.quiet) additions.push("Keep replies short. Summarize only what changed, tests run, and next step.")
|
|
392
|
+
if (job.testCommand) additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`)
|
|
393
|
+
if (job.checkpointOnly || job.gitCheckpoint) additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.")
|
|
394
|
+
if (!additions.length) return job.action
|
|
395
|
+
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function buildPrompt(directory, job) {
|
|
399
|
+
const sections = []
|
|
400
|
+
if (job.promptFile) {
|
|
401
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
402
|
+
if (text.trim()) sections.push(`Instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
403
|
+
else sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`)
|
|
404
|
+
}
|
|
405
|
+
if (job.action) sections.push(decoratePrompt(job))
|
|
406
|
+
for (const file of job.includeFiles || []) {
|
|
407
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
408
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
409
|
+
}
|
|
410
|
+
return sections.join("\n\n---\n\n") || decoratePrompt(job)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function ensureBranch(directory, job, client, sessionID) {
|
|
414
|
+
if (!job.branch || job.branchDone) return job
|
|
415
|
+
const branch = safeID(job.branch)
|
|
416
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
417
|
+
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
418
|
+
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
419
|
+
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
420
|
+
job.branchDone = true
|
|
421
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
422
|
+
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
423
|
+
return job
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function maybeCompact(client, sessionID, job) {
|
|
427
|
+
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
428
|
+
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
429
|
+
if (!dueRuns && !dueTime) return job
|
|
430
|
+
try {
|
|
431
|
+
await client.session.summarize({ path: { id: sessionID }, body: {} })
|
|
432
|
+
job.lastCompactAt = now()
|
|
433
|
+
job.lastCompactRunCount = job.runCount || 0
|
|
434
|
+
} catch {}
|
|
435
|
+
return job
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function snapshotPaths(directory, files) {
|
|
439
|
+
const snapshot = {}
|
|
440
|
+
for (const file of files || []) {
|
|
441
|
+
try {
|
|
442
|
+
const stat = await fs.stat(path.resolve(directory, file))
|
|
443
|
+
snapshot[file] = `${stat.mtimeMs}:${stat.size}`
|
|
444
|
+
} catch { snapshot[file] = "missing" }
|
|
445
|
+
}
|
|
446
|
+
return snapshot
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function watchChanged(directory, job) {
|
|
450
|
+
if (!job.watchPaths?.length) return false
|
|
451
|
+
const next = await snapshotPaths(directory, job.watchPaths)
|
|
452
|
+
const previous = job.watchSnapshot || {}
|
|
453
|
+
const changed = job.watchPaths.some((file) => previous[file] !== next[file])
|
|
454
|
+
if (changed) job.watchSnapshot = next
|
|
455
|
+
return changed
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function fileContains(filePath, needle) {
|
|
459
|
+
try {
|
|
460
|
+
const stat = await fs.stat(filePath)
|
|
461
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false
|
|
462
|
+
return (await fs.readFile(filePath, "utf8")).includes(needle)
|
|
463
|
+
} catch { return false }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function untilReached(directory, job) {
|
|
467
|
+
if (!job.until) return false
|
|
468
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
469
|
+
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
470
|
+
let scanned = 0
|
|
471
|
+
async function walk(current) {
|
|
472
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
473
|
+
let entries
|
|
474
|
+
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return false }
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
477
|
+
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
478
|
+
const full = path.join(current, entry.name)
|
|
479
|
+
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
480
|
+
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
481
|
+
}
|
|
482
|
+
return false
|
|
483
|
+
}
|
|
484
|
+
return await walk(directory)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function createCheckpoint(directory, sessionID, job, client) {
|
|
488
|
+
if (!job.checkpointOnly && !job.gitCheckpoint) return
|
|
489
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
490
|
+
if (inRepo.code !== 0) return
|
|
491
|
+
const status = await runProcess("git", ["status", "--short"], directory, 30_000)
|
|
492
|
+
if (!status.stdout.trim()) return
|
|
493
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
494
|
+
const checkpointDir = path.join(stateDir(directory), "checkpoints", safeID(sessionID))
|
|
495
|
+
await ensureDir(checkpointDir)
|
|
496
|
+
const diff = await runProcess("git", ["diff", "--binary"], directory, 120_000)
|
|
497
|
+
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120_000)
|
|
498
|
+
const prefix = `${timestamp}-${safeID(job.name || job.id)}`
|
|
499
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr)
|
|
500
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}\n${staged.stdout}`)
|
|
501
|
+
if (job.gitCheckpoint) {
|
|
502
|
+
await runProcess("git", ["add", "-A"], directory, 120_000)
|
|
503
|
+
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120_000)
|
|
504
|
+
}
|
|
505
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getIdleSessionID(event) {
|
|
509
|
+
const sessionID = event?.properties?.sessionID
|
|
510
|
+
if (event?.type === "session.idle" && typeof sessionID === "string") return sessionID
|
|
511
|
+
const status = event?.properties?.status
|
|
512
|
+
if (event?.type === "session.status" && typeof sessionID === "string" && status && typeof status === "object" && status.type === "idle") return sessionID
|
|
513
|
+
return undefined
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function dueJobs(state, force = false) {
|
|
517
|
+
const current = now()
|
|
518
|
+
return (state.jobs || []).filter((job) => {
|
|
519
|
+
if (!job.enabled || job.paused) return false
|
|
520
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
521
|
+
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
522
|
+
if (force) return true
|
|
523
|
+
if (job.watchPaths?.length) return false
|
|
524
|
+
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function clearActiveRun(sessionID) {
|
|
529
|
+
const active = activeRuns.get(sessionID)
|
|
530
|
+
if (active?.timer) clearTimeout(active.timer)
|
|
531
|
+
activeRuns.delete(sessionID)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function finalizeActiveRun(directory, client, sessionID) {
|
|
535
|
+
const active = activeRuns.get(sessionID)
|
|
536
|
+
if (!active) return
|
|
537
|
+
clearActiveRun(sessionID)
|
|
538
|
+
const state = await readState(directory, sessionID)
|
|
539
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
540
|
+
if (!job) return
|
|
541
|
+
job.lastFinishedAt = now()
|
|
542
|
+
|
|
543
|
+
if (job.verifyCommand) {
|
|
544
|
+
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
545
|
+
job.lastVerifyAt = now()
|
|
546
|
+
job.lastVerifyCode = verify.code
|
|
547
|
+
if (verify.code === 0) {
|
|
548
|
+
job.failureCount = 0
|
|
549
|
+
job.lastVerifyFailure = ""
|
|
550
|
+
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
551
|
+
} else {
|
|
552
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
553
|
+
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
554
|
+
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
555
|
+
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
556
|
+
job.paused = true
|
|
557
|
+
await notifyJob(directory, job, "verify_failed")
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 })
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (job.postrunCommand) {
|
|
564
|
+
if (job.safe && dangerousShell(job.postrunCommand)) await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand })
|
|
565
|
+
else {
|
|
566
|
+
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
567
|
+
job.lastPostrunCode = postrun.code
|
|
568
|
+
job.lastPostrunAt = now()
|
|
569
|
+
if (postrun.code !== 0) {
|
|
570
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
571
|
+
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
572
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
573
|
+
job.paused = true
|
|
574
|
+
await notifyJob(directory, job, "postrun_failed")
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code })
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false)
|
|
582
|
+
await writeState(directory, sessionID, state)
|
|
583
|
+
await createCheckpoint(directory, sessionID, job, client)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function fireAction(directory, client, sessionID, job) {
|
|
587
|
+
const action = String(job.action || "").trim()
|
|
588
|
+
const kind = actionKind(action)
|
|
589
|
+
if (kind === "compact") {
|
|
590
|
+
await client.session.summarize({ path: { id: sessionID }, body: {} })
|
|
591
|
+
return { startsAssistantTurn: false }
|
|
592
|
+
}
|
|
593
|
+
if (kind === "command") {
|
|
594
|
+
const [command, argumentsText] = splitFirst(action.slice(1))
|
|
595
|
+
client.session.command({ path: { id: sessionID }, body: { command, arguments: argumentsText } }).catch(() => {})
|
|
596
|
+
return { startsAssistantTurn: true }
|
|
597
|
+
}
|
|
598
|
+
if (kind === "shell") {
|
|
599
|
+
const command = action.slice(1).trim()
|
|
600
|
+
if (job.safe && dangerousShell(command)) {
|
|
601
|
+
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
602
|
+
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
603
|
+
return { startsAssistantTurn: false }
|
|
604
|
+
}
|
|
605
|
+
client.session.shell({ path: { id: sessionID }, body: { command } }).catch(() => {})
|
|
606
|
+
return { startsAssistantTurn: true }
|
|
607
|
+
}
|
|
608
|
+
const prompt = await buildPrompt(directory, job)
|
|
609
|
+
client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: `OpenCode loop continuation. Continue autonomously like Claude Code loop mode.\n\n${prompt}` }] } }).catch(() => {})
|
|
610
|
+
return { startsAssistantTurn: true }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
614
|
+
const active = activeRuns.get(sessionID)
|
|
615
|
+
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) return
|
|
616
|
+
|
|
617
|
+
const state = await readState(directory, sessionID)
|
|
618
|
+
for (const job of state.jobs || []) {
|
|
619
|
+
if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
|
|
620
|
+
}
|
|
621
|
+
let due = dueJobs(state, options.force)
|
|
622
|
+
if (!due.length) { await writeState(directory, sessionID, state); return }
|
|
623
|
+
let job = due[0]
|
|
624
|
+
|
|
625
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
626
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
627
|
+
await writeState(directory, sessionID, state)
|
|
628
|
+
await notifyJob(directory, job, "max_runtime_reached")
|
|
629
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
630
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
634
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
635
|
+
await writeState(directory, sessionID, state)
|
|
636
|
+
await notifyJob(directory, job, "stop_file")
|
|
637
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
if (await untilReached(directory, job)) {
|
|
641
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
642
|
+
await writeState(directory, sessionID, state)
|
|
643
|
+
await notifyJob(directory, job, "until_reached")
|
|
644
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (job.preflightCommand) {
|
|
649
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
650
|
+
job.paused = true
|
|
651
|
+
await writeState(directory, sessionID, state)
|
|
652
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
653
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
657
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
658
|
+
if (preflight.code !== 0) {
|
|
659
|
+
job.paused = true
|
|
660
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
661
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
662
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
663
|
+
await writeState(directory, sessionID, state)
|
|
664
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
665
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
job = await ensureBranch(directory, job, client, sessionID)
|
|
671
|
+
job = await maybeCompact(client, sessionID, job)
|
|
672
|
+
job.lastRunAt = now()
|
|
673
|
+
job.runCount = (job.runCount || 0) + 1
|
|
674
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
675
|
+
job.enabled = false
|
|
676
|
+
await notifyJob(directory, job, "max_runs_reached")
|
|
677
|
+
}
|
|
678
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
679
|
+
await writeState(directory, sessionID, state)
|
|
680
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
681
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
const result = await fireAction(directory, client, sessionID, job)
|
|
685
|
+
if (!result.startsAssistantTurn) {
|
|
686
|
+
const fresh = await readState(directory, sessionID)
|
|
687
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false)
|
|
688
|
+
await writeState(directory, sessionID, fresh)
|
|
689
|
+
}
|
|
690
|
+
if (result.startsAssistantTurn) {
|
|
691
|
+
let timer
|
|
692
|
+
if (job.timeoutMs > 0) timer = setTimeout(() => { client.session.abort?.({ path: { id: sessionID }, body: {} }).catch(() => {}); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
693
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
694
|
+
}
|
|
695
|
+
} catch (error) {
|
|
696
|
+
clearActiveRun(sessionID)
|
|
697
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
698
|
+
await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
703
|
+
const parsed = parseLoopArgs(args, defaults)
|
|
704
|
+
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
705
|
+
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
706
|
+
if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
|
|
707
|
+
const state = await readState(directory, sessionID)
|
|
708
|
+
state.jobs.push(parsed.job)
|
|
709
|
+
await writeState(directory, sessionID, state)
|
|
710
|
+
await toast(client, `Loop added: ${jobLabel(parsed.job)}`, "success")
|
|
711
|
+
await appendLoopLog(directory, "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function stopLoop(directory, client, sessionID, args) {
|
|
715
|
+
const target = String(args || "").trim()
|
|
716
|
+
if (!target || target.toLowerCase() === "all") { await removeState(directory, sessionID); clearActiveRun(sessionID); await toast(client, "All loops stopped for this session.", "success"); return }
|
|
717
|
+
const state = await readState(directory, sessionID)
|
|
718
|
+
const before = state.jobs.length
|
|
719
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
720
|
+
await writeState(directory, sessionID, state)
|
|
721
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
725
|
+
const target = String(args || "").trim() || "all"
|
|
726
|
+
const state = await readState(directory, sessionID)
|
|
727
|
+
let count = 0
|
|
728
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
729
|
+
await writeState(directory, sessionID, state)
|
|
730
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function statusLoop(directory, client, sessionID) {
|
|
734
|
+
const state = await readState(directory, sessionID)
|
|
735
|
+
const jobs = state.jobs || []
|
|
736
|
+
const lines = jobs.length ? jobs.map((job, index) => {
|
|
737
|
+
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
738
|
+
const flags = [job.paused ? "paused" : "active", job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
739
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
740
|
+
}) : ["No active loop jobs."]
|
|
741
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
742
|
+
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function logsLoop(directory, client, sessionID) {
|
|
746
|
+
let text = "No loop log found."
|
|
747
|
+
try { text = (await fs.readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
748
|
+
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function helpLoop(client, sessionID) {
|
|
752
|
+
await say(client, sessionID, [
|
|
753
|
+
"OpenCode Loop help:",
|
|
754
|
+
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
755
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous loop",
|
|
756
|
+
"/loop 200m --no-now /compact compact/summarize loop",
|
|
757
|
+
"/loop 10m !npm test shell loop",
|
|
758
|
+
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
759
|
+
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
760
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
761
|
+
"/loop-doctor | /loop-init | /loop-export",
|
|
762
|
+
].join("\n"))
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function runNow(directory, client, sessionID, args) {
|
|
766
|
+
const target = String(args || "").trim() || "all"
|
|
767
|
+
const state = await readState(directory, sessionID)
|
|
768
|
+
let count = 0
|
|
769
|
+
for (const [index, job] of (state.jobs || []).entries()) if (matchJob(job, target, index)) { job.lastRunAt = 0; job.paused = false; count++ }
|
|
770
|
+
await writeState(directory, sessionID, state)
|
|
771
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
772
|
+
await maybeRunDueJobs(directory, client, sessionID, { force: true })
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function doctorLoop(directory, client, sessionID) {
|
|
776
|
+
const state = await readState(directory, sessionID)
|
|
777
|
+
await say(client, sessionID, [
|
|
778
|
+
"OpenCode Loop doctor:",
|
|
779
|
+
`- plugin: ${SERVICE}`,
|
|
780
|
+
`- project directory: ${directory}`,
|
|
781
|
+
`- state directory: ${stateDir(directory)}`,
|
|
782
|
+
`- active jobs: ${(state.jobs || []).length}`,
|
|
783
|
+
`- node: ${process.version}`,
|
|
784
|
+
`- platform: ${process.platform}`,
|
|
785
|
+
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
786
|
+
].join("\n"))
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function initLoop(directory, client, sessionID, args) {
|
|
790
|
+
const target = String(args || "").trim() || "progress.md"
|
|
791
|
+
const full = path.resolve(directory, target)
|
|
792
|
+
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
793
|
+
await fs.writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
794
|
+
await toast(client, `Created ${target}.`, "success")
|
|
795
|
+
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function exportLoop(directory, client, sessionID) {
|
|
799
|
+
const state = await readState(directory, sessionID)
|
|
800
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function handleCommand(directory, client, input, fallbackName, fallbackArgs) {
|
|
804
|
+
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
805
|
+
const sessionID = input?.sessionID
|
|
806
|
+
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
807
|
+
if (!sessionID || !name) return false
|
|
808
|
+
if (wasHandled(sessionID, name, args)) return true
|
|
809
|
+
markHandled(sessionID, name, args)
|
|
810
|
+
|
|
811
|
+
if (name === "loop") return await addLoop(directory, client, sessionID, args), true
|
|
812
|
+
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), true
|
|
813
|
+
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), true
|
|
814
|
+
if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), true
|
|
815
|
+
if (name === "loop-status") return await statusLoop(directory, client, sessionID), true
|
|
816
|
+
if (name === "loop-logs") return await logsLoop(directory, client, sessionID), true
|
|
817
|
+
if (name === "loop-help") return await helpLoop(client, sessionID), true
|
|
818
|
+
if (name === "loop-now") return await runNow(directory, client, sessionID, args), true
|
|
819
|
+
if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), true
|
|
820
|
+
if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), true
|
|
821
|
+
if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), true
|
|
822
|
+
if (name === "loop-init") return await initLoop(directory, client, sessionID, args), true
|
|
823
|
+
if (name === "loop-export") return await exportLoop(directory, client, sessionID), true
|
|
824
|
+
handledCommands.delete(commandKey(sessionID, name, args))
|
|
825
|
+
return false
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
829
|
+
await log(client, "info", "Plugin initialized", { directory })
|
|
830
|
+
return {
|
|
831
|
+
"command.execute.before": async (input) => { await handleCommand(directory, client, input) },
|
|
832
|
+
event: async ({ event }) => {
|
|
833
|
+
if (event.type === "command.executed") {
|
|
834
|
+
const props = event.properties || {}
|
|
835
|
+
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
836
|
+
}
|
|
837
|
+
const idleSessionID = getIdleSessionID(event)
|
|
838
|
+
if (idleSessionID) {
|
|
839
|
+
await finalizeActiveRun(directory, client, idleSessionID)
|
|
840
|
+
await maybeRunDueJobs(directory, client, idleSessionID)
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export default OpenCodeLoopPlugin
|