@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/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