@bybrawe/opencode-loop 0.5.26 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +9 -3
  3. package/package.json +8 -4
  4. package/scripts/comprehensive-test.mjs +49 -4
  5. package/scripts/comprehensive-watchdog.mjs +32 -0
  6. package/scripts/host-adapter-contract-test.mjs +147 -0
  7. package/scripts/host-bridge-parity-test.mjs +22 -0
  8. package/scripts/host-loop-canary.mjs +357 -0
  9. package/scripts/loopd-test.mjs +45 -18
  10. package/scripts/loopd.mjs +92 -22
  11. package/scripts/opencode2-host-canary.mjs +194 -0
  12. package/scripts/runtime-contract-test.mjs +59 -0
  13. package/scripts/runtime-event-test.mjs +78 -0
  14. package/scripts/runtime-scope-test.mjs +46 -0
  15. package/scripts/session-manager-test.mjs +48 -0
  16. package/scripts/v2-event-bridge-test.mjs +72 -0
  17. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  18. package/scripts/v2-host-contract-test.mjs +55 -0
  19. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  20. package/src/index.js +2304 -1864
  21. package/src/opencode/v1.js +1 -0
  22. package/src/source/core/args.js +281 -0
  23. package/src/source/core/jobs.js +80 -0
  24. package/src/source/core/process.js +51 -0
  25. package/src/source/core/state.js +211 -0
  26. package/src/source/legacy-v1.js +1944 -0
  27. package/src/source/opencode/adapter.js +21 -0
  28. package/src/source/opencode/host.js +134 -0
  29. package/src/source/opencode/sdk.js +38 -0
  30. package/src/source/opencode/session-context.js +50 -0
  31. package/src/source/opencode2/capabilities.js +69 -0
  32. package/src/source/opencode2/event-bridge.js +156 -0
  33. package/src/source/opencode2/experimental.js +16 -0
  34. package/src/source/opencode2/host-contract.js +68 -0
  35. package/src/source/runtime/events.js +113 -0
  36. package/src/source/runtime/observer.js +23 -0
  37. package/src/source/runtime/scope.js +56 -0
  38. package/src/source/runtime/session-manager.js +100 -0
  39. package/src/source/runtime/session-registry.js +58 -0
  40. package/src/source/runtime/timers.js +45 -0
  41. 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
+ }