@bybrawe/opencode-loop 0.5.27 → 0.5.28
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/package.json +6 -3
- package/scripts/comprehensive-test.mjs +18 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +16 -2
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2278 -1918
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenCodeLoopPlugin, default } from "../index.js"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Pure Loop command parsing helpers. Keep host/runtime state out of this module.
|
|
2
|
+
export const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
|
|
3
|
+
|
|
4
|
+
export function now() {
|
|
5
|
+
return Date.now()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function safeID(value) {
|
|
9
|
+
return String(value || "job")
|
|
10
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "")
|
|
12
|
+
.slice(0, 80) || "job"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseDuration(value) {
|
|
16
|
+
const input = String(value || "").trim()
|
|
17
|
+
if (input === "0") return 0
|
|
18
|
+
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)
|
|
19
|
+
if (!match) return null
|
|
20
|
+
const amount = Number.parseInt(match[1], 10)
|
|
21
|
+
const unit = match[2].toLowerCase()
|
|
22
|
+
if (!Number.isFinite(amount) || amount < 0) return null
|
|
23
|
+
if (unit === "ms") return amount
|
|
24
|
+
if (unit.startsWith("s")) return amount * 1000
|
|
25
|
+
if (unit.startsWith("m")) return amount * 60_000
|
|
26
|
+
if (unit.startsWith("h")) return amount * 3_600_000
|
|
27
|
+
if (unit.startsWith("d")) return amount * 86_400_000
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function durationToText(ms) {
|
|
32
|
+
if (ms === 0) return "every idle"
|
|
33
|
+
if (!Number.isFinite(ms)) return "unknown"
|
|
34
|
+
if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`
|
|
35
|
+
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`
|
|
36
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`
|
|
37
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`
|
|
38
|
+
return `${ms}ms`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function splitFirst(input) {
|
|
42
|
+
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/)
|
|
43
|
+
if (!match) return ["", ""]
|
|
44
|
+
return [match[1], (match[2] || "").trim()]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stripOuterQuotes(value) {
|
|
48
|
+
const input = String(value || "").trim()
|
|
49
|
+
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
|
|
50
|
+
return input.slice(1, -1)
|
|
51
|
+
}
|
|
52
|
+
return input
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function escapeRegExp(value) {
|
|
56
|
+
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function takeFlag(rest, flag) {
|
|
60
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i")
|
|
61
|
+
const found = pattern.test(rest)
|
|
62
|
+
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function takeFlagValue(rest, flag) {
|
|
66
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(?:\"([^\"]*)\"|'([^']*)'|(\\S+))`, "i")
|
|
67
|
+
const match = rest.match(pattern)
|
|
68
|
+
if (!match) return [undefined, rest]
|
|
69
|
+
const value = match[2] ?? match[3] ?? match[4]
|
|
70
|
+
return [value, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function takeAllFlagValues(rest, flag) {
|
|
74
|
+
const values = []
|
|
75
|
+
let current = rest
|
|
76
|
+
while (true) {
|
|
77
|
+
const [value, next] = takeFlagValue(current, flag)
|
|
78
|
+
if (value === undefined) return [values, current]
|
|
79
|
+
values.push(value)
|
|
80
|
+
current = next
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parsePositiveInt(value, fallback = 0) {
|
|
85
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
86
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseNonNegativeInt(value, fallback = 0) {
|
|
90
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
91
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCompactEvery(value) {
|
|
95
|
+
const duration = parseDuration(value)
|
|
96
|
+
if (duration !== null) return { compactEveryMs: duration }
|
|
97
|
+
const runs = parsePositiveInt(value, 0)
|
|
98
|
+
return runs > 0 ? { compactEveryRuns: runs } : {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseLoopArgs(raw, defaults = {}) {
|
|
102
|
+
let input = stripOuterQuotes(String(raw || "").trim())
|
|
103
|
+
let first = ""
|
|
104
|
+
let rest = input
|
|
105
|
+
let intervalMs = defaults.intervalMs ?? null
|
|
106
|
+
|
|
107
|
+
if (!input && defaults.action) {
|
|
108
|
+
rest = defaults.action
|
|
109
|
+
} else {
|
|
110
|
+
;[first, rest] = splitFirst(input)
|
|
111
|
+
if (first === "--watch") {
|
|
112
|
+
intervalMs = defaults.intervalMs ?? 0
|
|
113
|
+
rest = input
|
|
114
|
+
} else if (first) {
|
|
115
|
+
const parsedDuration = parseDuration(first)
|
|
116
|
+
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
117
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
118
|
+
else rest = input
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (intervalMs === null) intervalMs = 0
|
|
123
|
+
|
|
124
|
+
const job = {
|
|
125
|
+
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
126
|
+
name: defaults.name,
|
|
127
|
+
action: defaults.action || "",
|
|
128
|
+
kind: defaults.kind || undefined,
|
|
129
|
+
intervalMs,
|
|
130
|
+
immediate: defaults.immediate ?? true,
|
|
131
|
+
maxRuns: defaults.maxRuns ?? 0,
|
|
132
|
+
maxRuntimeMs: defaults.maxRuntimeMs ?? 0,
|
|
133
|
+
maxFailures: defaults.maxFailures ?? 0,
|
|
134
|
+
timeoutMs: defaults.timeoutMs ?? 0,
|
|
135
|
+
until: defaults.until,
|
|
136
|
+
stopFile: defaults.stopFile,
|
|
137
|
+
progressFile: defaults.progressFile,
|
|
138
|
+
promptFile: defaults.promptFile,
|
|
139
|
+
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
140
|
+
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
141
|
+
compactEveryRuns: defaults.compactEveryRuns ?? 0,
|
|
142
|
+
compactEveryMs: defaults.compactEveryMs ?? 0,
|
|
143
|
+
testCommand: defaults.testCommand,
|
|
144
|
+
verifyCommand: defaults.verifyCommand,
|
|
145
|
+
preflightCommand: defaults.preflightCommand,
|
|
146
|
+
postrunCommand: defaults.postrunCommand,
|
|
147
|
+
notifyCommand: defaults.notifyCommand,
|
|
148
|
+
branch: defaults.branch,
|
|
149
|
+
branchDone: false,
|
|
150
|
+
goalStatus: defaults.goalStatus,
|
|
151
|
+
goalFile: defaults.goalFile,
|
|
152
|
+
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
153
|
+
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
154
|
+
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
155
|
+
goalRequireEvidence: defaults.goalRequireEvidence,
|
|
156
|
+
goalRequireChecksPass: defaults.goalRequireChecksPass,
|
|
157
|
+
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
158
|
+
goalSummary: defaults.goalSummary || "",
|
|
159
|
+
goalEvidence: defaults.goalEvidence || "",
|
|
160
|
+
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
161
|
+
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
162
|
+
maxNoProgress: defaults.maxNoProgress,
|
|
163
|
+
noProgressCount: defaults.noProgressCount ?? 0,
|
|
164
|
+
lastProgressAt: defaults.lastProgressAt ?? 0,
|
|
165
|
+
noOverlap: defaults.noOverlap ?? true,
|
|
166
|
+
safe: defaults.safe ?? false,
|
|
167
|
+
quiet: defaults.quiet ?? false,
|
|
168
|
+
askNever: defaults.askNever ?? false,
|
|
169
|
+
pauseOnVerifyFail: defaults.pauseOnVerifyFail ?? false,
|
|
170
|
+
gitCheckpoint: defaults.gitCheckpoint ?? false,
|
|
171
|
+
checkpointOnly: defaults.checkpointOnly ?? false,
|
|
172
|
+
dryRun: defaults.dryRun ?? false,
|
|
173
|
+
multi: defaults.multi ?? false,
|
|
174
|
+
batch: defaults.batch ?? 0,
|
|
175
|
+
runCount: 0,
|
|
176
|
+
failureCount: 0,
|
|
177
|
+
lastRunAt: 0,
|
|
178
|
+
lastCompactAt: 0,
|
|
179
|
+
lastCompactRunCount: 0,
|
|
180
|
+
watchSnapshot: {},
|
|
181
|
+
watchTriggered: false,
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
enabled: true,
|
|
184
|
+
paused: false,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let found
|
|
188
|
+
let value
|
|
189
|
+
|
|
190
|
+
;[found, rest] = takeFlag(rest, "--no-now"); if (found) job.immediate = false
|
|
191
|
+
;[found, rest] = takeFlag(rest, "--now"); if (found) job.immediate = true
|
|
192
|
+
;[found, rest] = takeFlag(rest, "--no-overlap"); if (found) job.noOverlap = true
|
|
193
|
+
;[found, rest] = takeFlag(rest, "--allow-overlap"); if (found) job.noOverlap = false
|
|
194
|
+
;[found, rest] = takeFlag(rest, "--safe"); if (found) job.safe = true
|
|
195
|
+
;[found, rest] = takeFlag(rest, "--quiet"); if (found) job.quiet = true
|
|
196
|
+
;[found, rest] = takeFlag(rest, "--ask-never"); if (found) job.askNever = true
|
|
197
|
+
;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
|
|
198
|
+
;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
|
|
199
|
+
;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
|
|
200
|
+
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
201
|
+
;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
|
|
202
|
+
;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
|
|
203
|
+
;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
|
|
204
|
+
;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
|
|
205
|
+
;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
|
|
206
|
+
;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
|
|
207
|
+
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
208
|
+
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
209
|
+
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
210
|
+
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
211
|
+
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
212
|
+
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
213
|
+
;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
|
|
214
|
+
;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
|
|
215
|
+
;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
|
|
216
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
|
|
217
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
|
|
218
|
+
|
|
219
|
+
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
220
|
+
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
221
|
+
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
222
|
+
;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
223
|
+
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
224
|
+
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
225
|
+
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
226
|
+
;[value, rest] = takeFlagValue(rest, "--until"); if (value !== undefined) job.until = stripOuterQuotes(value)
|
|
227
|
+
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
228
|
+
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
229
|
+
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
230
|
+
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
231
|
+
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
232
|
+
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
233
|
+
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
234
|
+
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
235
|
+
;[value, rest] = takeFlagValue(rest, "--postrun"); if (value !== undefined) job.postrunCommand = stripOuterQuotes(value)
|
|
236
|
+
;[value, rest] = takeFlagValue(rest, "--notify"); if (value !== undefined) job.notifyCommand = stripOuterQuotes(value)
|
|
237
|
+
;[value, rest] = takeFlagValue(rest, "--branch"); if (value !== undefined) job.branch = stripOuterQuotes(value)
|
|
238
|
+
;[value, rest] = takeFlagValue(rest, "--batch"); if (value !== undefined) job.batch = parsePositiveInt(value, 0)
|
|
239
|
+
;[value, rest] = takeFlagValue(rest, "--compact-every")
|
|
240
|
+
if (value !== undefined) Object.assign(job, parseCompactEvery(value))
|
|
241
|
+
|
|
242
|
+
const watch = takeAllFlagValues(rest, "--watch")
|
|
243
|
+
job.watchPaths.push(...watch[0].map(stripOuterQuotes).filter(Boolean))
|
|
244
|
+
rest = watch[1]
|
|
245
|
+
|
|
246
|
+
const includes = takeAllFlagValues(rest, "--include-file")
|
|
247
|
+
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
248
|
+
rest = includes[1]
|
|
249
|
+
|
|
250
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
251
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
252
|
+
rest = acceptances[1]
|
|
253
|
+
|
|
254
|
+
const success = takeAllFlagValues(rest, "--success")
|
|
255
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
256
|
+
rest = success[1]
|
|
257
|
+
|
|
258
|
+
const checks = takeAllFlagValues(rest, "--check")
|
|
259
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
260
|
+
rest = checks[1]
|
|
261
|
+
|
|
262
|
+
job.action = stripOuterQuotes(rest || job.action || "")
|
|
263
|
+
job.watchPaths = [...new Set(job.watchPaths)]
|
|
264
|
+
job.includeFiles = [...new Set(job.includeFiles)]
|
|
265
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
266
|
+
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
267
|
+
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
268
|
+
job.name = job.name || "goal"
|
|
269
|
+
job.goalStatus = job.goalStatus || "active"
|
|
270
|
+
job.safe = job.safe !== false
|
|
271
|
+
job.askNever = job.askNever !== false
|
|
272
|
+
job.noOverlap = job.noOverlap !== false
|
|
273
|
+
job.goalRequireEvidence = job.goalRequireEvidence !== false
|
|
274
|
+
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
|
|
275
|
+
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
|
|
276
|
+
}
|
|
277
|
+
job.lastRunAt = job.immediate ? 0 : now()
|
|
278
|
+
|
|
279
|
+
if (!job.action && !job.promptFile && !job.goalFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" }
|
|
280
|
+
return { ok: true, job }
|
|
281
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS, parseDuration, durationToText } from "./args.js"
|
|
2
|
+
|
|
3
|
+
export function presetDefaults(name) {
|
|
4
|
+
// parseLoopArgs owns duration/flag/action parsing. Presets only provide real
|
|
5
|
+
// defaults; deriving an action from the raw string made flag-only invocations
|
|
6
|
+
// such as `/loop-compact --dry-run` use "--dry-run" as the action.
|
|
7
|
+
if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
|
|
8
|
+
if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
|
|
9
|
+
if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
|
|
10
|
+
if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
|
|
11
|
+
if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
|
|
12
|
+
if (name === "loop-testfix") return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" }
|
|
13
|
+
if (name === "loop-progress") return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
14
|
+
if (name === "loop-safe-dev") return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "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." }
|
|
15
|
+
return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function jobLabel(job) {
|
|
19
|
+
const title = job.name ? `${job.name}: ` : ""
|
|
20
|
+
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
21
|
+
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
22
|
+
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
23
|
+
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
24
|
+
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : ""
|
|
25
|
+
const verify = job.verifyCommand ? ", verify" : ""
|
|
26
|
+
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
27
|
+
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
28
|
+
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
29
|
+
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
30
|
+
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
31
|
+
const paused = job.paused ? ", paused" : ""
|
|
32
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function matchJob(job, target, index) {
|
|
36
|
+
const text = String(target || "").trim()
|
|
37
|
+
if (!text || text.toLowerCase() === "all") return true
|
|
38
|
+
return job.id === text || job.name === text || String(index + 1) === text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function actionKind(action, job = {}) {
|
|
42
|
+
const text = String(action || "").trim()
|
|
43
|
+
const forced = String(job.kind || "").trim().toLowerCase()
|
|
44
|
+
if (forced === "compact") return "compact"
|
|
45
|
+
if (forced === "goal") return "goal"
|
|
46
|
+
if (text === "/compact" || text === "/summarize") return "compact"
|
|
47
|
+
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
48
|
+
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
49
|
+
if (forced === "shell") return "shell"
|
|
50
|
+
if (text.startsWith("/")) return "command"
|
|
51
|
+
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
52
|
+
return "prompt"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function decoratePrompt(job) {
|
|
56
|
+
const additions = []
|
|
57
|
+
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.`)
|
|
58
|
+
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
59
|
+
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.")
|
|
60
|
+
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.")
|
|
61
|
+
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].`)
|
|
62
|
+
if (job.quiet) additions.push("Keep replies short. Summarize only what changed, tests run, and next step.")
|
|
63
|
+
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.`)
|
|
64
|
+
if (job.checkpointOnly || job.gitCheckpoint) additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.")
|
|
65
|
+
if (!additions.length) return job.action
|
|
66
|
+
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isGoalJob(job) {
|
|
70
|
+
return String(job?.kind || "").toLowerCase() === "goal"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function goalStatusText(job) {
|
|
74
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
75
|
+
if (!status) return ""
|
|
76
|
+
if (status === "completed") return "completed"
|
|
77
|
+
if (status === "blocked") return "blocked"
|
|
78
|
+
if (job?.paused) return "paused"
|
|
79
|
+
return status
|
|
80
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { stateDir, ensureDir } from "./state.js"
|
|
5
|
+
|
|
6
|
+
export async function appendLoopLog(directory, line, extra = {}) {
|
|
7
|
+
try {
|
|
8
|
+
await ensureDir(stateDir(directory))
|
|
9
|
+
await fs.appendFile(path.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + "\n")
|
|
10
|
+
} catch {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readSmallTextFile(filePath, maxBytes = 120_000) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = await fs.stat(filePath)
|
|
16
|
+
if (!stat.isFile() || stat.size > maxBytes) return ""
|
|
17
|
+
return await fs.readFile(filePath, "utf8")
|
|
18
|
+
} catch { return "" }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
22
|
+
return await new Promise((resolve) => {
|
|
23
|
+
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
24
|
+
const stdout = []
|
|
25
|
+
const stderr = []
|
|
26
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
27
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
28
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
29
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
30
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
35
|
+
return await new Promise((resolve) => {
|
|
36
|
+
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
37
|
+
const stdout = []
|
|
38
|
+
const stderr = []
|
|
39
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
40
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
41
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
42
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
43
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function notifyJob(directory, job, reason) {
|
|
48
|
+
if (!job.notifyCommand) return
|
|
49
|
+
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""))
|
|
50
|
+
await runShellCommand(command, directory, 60_000)
|
|
51
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { safeID } from "./args.js"
|
|
5
|
+
|
|
6
|
+
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
|
+
const STATE_BASELINE = Symbol("opencode-loop-state-baseline")
|
|
8
|
+
const stateWriteLocks = new Map()
|
|
9
|
+
|
|
10
|
+
export function stateDir(directory) { return path.join(directory, STATE_DIR) }
|
|
11
|
+
function statePath(directory, sessionID) { return path.join(stateDir(directory), `${safeID(sessionID)}.json`) }
|
|
12
|
+
export async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
13
|
+
export async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
14
|
+
|
|
15
|
+
function stateLockKey(directory, sessionID) {
|
|
16
|
+
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function withStateWriteLock(directory, sessionID, fn) {
|
|
20
|
+
const key = stateLockKey(directory, sessionID)
|
|
21
|
+
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
22
|
+
let release
|
|
23
|
+
const current = new Promise((resolve) => { release = resolve })
|
|
24
|
+
const next = previous.catch(() => {}).then(() => current)
|
|
25
|
+
stateWriteLocks.set(key, next)
|
|
26
|
+
await previous.catch(() => {})
|
|
27
|
+
try {
|
|
28
|
+
return await fn()
|
|
29
|
+
} finally {
|
|
30
|
+
release()
|
|
31
|
+
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readStateFile(directory, sessionID) {
|
|
36
|
+
const target = statePath(directory, sessionID)
|
|
37
|
+
const attempts = 5
|
|
38
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
41
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
44
|
+
const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
|
|
45
|
+
if (!transient || attempt === attempts - 1) break
|
|
46
|
+
await delay(25 * (attempt + 1))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
await ensureDir(stateDir(directory))
|
|
51
|
+
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
52
|
+
} catch {}
|
|
53
|
+
return { version: 4, jobs: [] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readState(directory, sessionID) {
|
|
57
|
+
const state = await readStateFile(directory, sessionID)
|
|
58
|
+
Object.defineProperty(state, STATE_BASELINE, {
|
|
59
|
+
value: structuredClone(state.jobs || []),
|
|
60
|
+
enumerable: false,
|
|
61
|
+
configurable: false,
|
|
62
|
+
writable: true,
|
|
63
|
+
})
|
|
64
|
+
return state
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRetriableStateWriteError(error) {
|
|
68
|
+
const code = error?.code
|
|
69
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function delay(ms) {
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Windows often fails POSIX-style "write temp next to target, then rename over it":
|
|
77
|
+
// existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or
|
|
78
|
+
// OpenCode's own snapshotter briefly hold the state file. Temp files next to the
|
|
79
|
+
// target also show up in project git snapshots as *.tmp pathspec noise.
|
|
80
|
+
//
|
|
81
|
+
// Write the payload outside the project first, then replace the target with
|
|
82
|
+
// rename when possible and a copy/unlink fallback with short retries.
|
|
83
|
+
async function writeFileAtomically(target, contents, options = {}) {
|
|
84
|
+
const encoding = options.encoding || "utf8"
|
|
85
|
+
const attempts = Math.max(1, Number(options.attempts) || 5)
|
|
86
|
+
const temp = path.join(
|
|
87
|
+
os.tmpdir(),
|
|
88
|
+
`opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
89
|
+
)
|
|
90
|
+
await fs.writeFile(temp, contents, encoding)
|
|
91
|
+
try {
|
|
92
|
+
let lastError
|
|
93
|
+
// Prefer an atomic rename and retry transient Windows destination locks
|
|
94
|
+
// before falling back to a non-atomic copy/overwrite.
|
|
95
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
96
|
+
try {
|
|
97
|
+
await fs.rename(temp, target)
|
|
98
|
+
return
|
|
99
|
+
} catch (error) {
|
|
100
|
+
lastError = error
|
|
101
|
+
if (error?.code === "EXDEV") break
|
|
102
|
+
if (!isRetriableStateWriteError(error)) throw error
|
|
103
|
+
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
await fs.copyFile(temp, target)
|
|
110
|
+
return
|
|
111
|
+
} catch (error) {
|
|
112
|
+
lastError = error
|
|
113
|
+
if (!isRetriableStateWriteError(error)) throw error
|
|
114
|
+
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await fs.writeFile(target, contents, encoding)
|
|
120
|
+
return
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (lastError && !error.cause) error.cause = lastError
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
try { await fs.rm(temp, { force: true }) } catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stateValuesEqual(left, right) {
|
|
131
|
+
if (Object.is(left, right)) return true
|
|
132
|
+
try { return JSON.stringify(left) === JSON.stringify(right) } catch { return false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mergeStateJob(baseJob, intendedJob, currentJob) {
|
|
136
|
+
const merged = structuredClone(currentJob || {})
|
|
137
|
+
const keys = new Set([
|
|
138
|
+
...Object.keys(baseJob || {}),
|
|
139
|
+
...Object.keys(intendedJob || {}),
|
|
140
|
+
])
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const baseHas = Object.prototype.hasOwnProperty.call(baseJob || {}, key)
|
|
143
|
+
const intendedHas = Object.prototype.hasOwnProperty.call(intendedJob || {}, key)
|
|
144
|
+
const currentHas = Object.prototype.hasOwnProperty.call(currentJob || {}, key)
|
|
145
|
+
const intendedChanged = baseHas !== intendedHas || !stateValuesEqual(baseJob?.[key], intendedJob?.[key])
|
|
146
|
+
if (!intendedChanged) continue
|
|
147
|
+
const currentChanged = baseHas !== currentHas || !stateValuesEqual(baseJob?.[key], currentJob?.[key])
|
|
148
|
+
const sameResult = intendedHas === currentHas && stateValuesEqual(intendedJob?.[key], currentJob?.[key])
|
|
149
|
+
// First committed writer wins a true same-field conflict. A stale snapshot
|
|
150
|
+
// can still apply changes to unrelated fields without erasing newer state.
|
|
151
|
+
if (currentChanged && !sameResult) continue
|
|
152
|
+
if (intendedHas) merged[key] = structuredClone(intendedJob[key])
|
|
153
|
+
else delete merged[key]
|
|
154
|
+
}
|
|
155
|
+
return merged
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
159
|
+
const byID = (jobs) => new Map((jobs || []).filter((job) => job?.id).map((job) => [job.id, job]))
|
|
160
|
+
const base = byID(baseJobs)
|
|
161
|
+
const intended = byID(intendedJobs)
|
|
162
|
+
const current = byID(currentJobs)
|
|
163
|
+
const merged = []
|
|
164
|
+
|
|
165
|
+
for (const currentJob of currentJobs || []) {
|
|
166
|
+
const id = currentJob?.id
|
|
167
|
+
if (!id || !base.has(id)) {
|
|
168
|
+
merged.push(structuredClone(currentJob))
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const baseJob = base.get(id)
|
|
172
|
+
const intendedJob = intended.get(id)
|
|
173
|
+
if (!intendedJob) {
|
|
174
|
+
// A deletion based on an old snapshot must not erase a job that changed
|
|
175
|
+
// after that snapshot was read.
|
|
176
|
+
if (!stateValuesEqual(baseJob, currentJob)) merged.push(structuredClone(currentJob))
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
merged.push(mergeStateJob(baseJob, intendedJob, currentJob))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const intendedJob of intendedJobs || []) {
|
|
183
|
+
const id = intendedJob?.id
|
|
184
|
+
if (!id || base.has(id) || current.has(id)) continue
|
|
185
|
+
merged.push(structuredClone(intendedJob))
|
|
186
|
+
}
|
|
187
|
+
return merged
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function writeState(directory, sessionID, state) {
|
|
191
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
192
|
+
await ensureDir(stateDir(directory))
|
|
193
|
+
const target = statePath(directory, sessionID)
|
|
194
|
+
const baseline = state?.[STATE_BASELINE]
|
|
195
|
+
let jobs = structuredClone(state.jobs || [])
|
|
196
|
+
if (Array.isArray(baseline)) {
|
|
197
|
+
const current = await readStateFile(directory, sessionID)
|
|
198
|
+
jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
|
|
199
|
+
state.jobs = structuredClone(jobs)
|
|
200
|
+
state[STATE_BASELINE] = structuredClone(jobs)
|
|
201
|
+
}
|
|
202
|
+
const payload = JSON.stringify({ version: 4, jobs }, null, 2)
|
|
203
|
+
await writeFileAtomically(target, payload)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function removeState(directory, sessionID) {
|
|
208
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
209
|
+
try { await fs.unlink(statePath(directory, sessionID)) } catch {}
|
|
210
|
+
})
|
|
211
|
+
}
|