@bybrawe/opencode-loop 0.5.1 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +1016 -847
- package/commands/loop-ask.md +5 -0
- package/commands/loop-cmd.md +5 -0
- package/commands/loop-command.md +5 -0
- package/commands/loop-prompt.md +5 -0
- package/commands/loop-shell.md +5 -0
- package/package.json +4 -1
- package/src/index.js +239 -47
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop ask scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring prompt loop. Usage: /loop-prompt 1h continue the project
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop prompt scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Loop shell scheduler received. The OpenCode Loop plugin handles this shell schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Claude Code style /loop command and background daemon for OpenCode: auto-continue, progress.md TODO automation, compact scheduling, verification, checkpoints, and safe autonomous coding loops.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -57,5 +57,8 @@
|
|
|
57
57
|
"bin": {
|
|
58
58
|
"opencode-loop": "./scripts/install-node.mjs",
|
|
59
59
|
"opencode-loopd": "./scripts/loopd.mjs"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/src/index.js
CHANGED
|
@@ -5,11 +5,14 @@ import { spawn } from "node:child_process"
|
|
|
5
5
|
const SERVICE = "opencode-loop"
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
7
|
const DEFAULT_ACTIVE_GUARD_MS = 60_000
|
|
8
|
+
const IDLE_DEBOUNCE_MS = 1_200
|
|
8
9
|
const MAX_SCAN_FILES = 200
|
|
9
10
|
const MAX_SCAN_BYTES = 2_000_000
|
|
10
11
|
|
|
11
12
|
const activeRuns = new Map()
|
|
12
13
|
const handledCommands = new Map()
|
|
14
|
+
const idleTimers = new Map()
|
|
15
|
+
const sessionStatuses = new Map()
|
|
13
16
|
|
|
14
17
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
15
18
|
|
|
@@ -146,7 +149,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
146
149
|
} else if (first) {
|
|
147
150
|
const parsedDuration = parseDuration(first)
|
|
148
151
|
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
|
|
152
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
150
153
|
else rest = input
|
|
151
154
|
}
|
|
152
155
|
}
|
|
@@ -157,6 +160,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
157
160
|
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
158
161
|
name: defaults.name,
|
|
159
162
|
action: defaults.action || "",
|
|
163
|
+
kind: defaults.kind || undefined,
|
|
160
164
|
intervalMs,
|
|
161
165
|
immediate: defaults.immediate ?? true,
|
|
162
166
|
maxRuns: defaults.maxRuns ?? 0,
|
|
@@ -215,6 +219,13 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
215
219
|
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
216
220
|
;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
|
|
217
221
|
;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
|
|
222
|
+
;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
|
|
223
|
+
;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
|
|
224
|
+
;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
|
|
225
|
+
;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
|
|
226
|
+
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
227
|
+
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
228
|
+
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
218
229
|
|
|
219
230
|
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
220
231
|
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
@@ -273,11 +284,112 @@ async function writeState(directory, sessionID, state) {
|
|
|
273
284
|
|
|
274
285
|
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
275
286
|
|
|
287
|
+
function sdkError(result) {
|
|
288
|
+
if (!result || typeof result !== "object") return undefined
|
|
289
|
+
return result.error || result.error === null ? result.error : undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sdkData(result) {
|
|
293
|
+
if (!result || typeof result !== "object") return result
|
|
294
|
+
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sdkErrorMessage(error) {
|
|
298
|
+
if (!error) return "unknown SDK error"
|
|
299
|
+
if (error instanceof Error) return error.message
|
|
300
|
+
if (typeof error === "string") return error
|
|
301
|
+
if (typeof error === "object") {
|
|
302
|
+
if (typeof error.message === "string") return error.message
|
|
303
|
+
if (typeof error.name === "string") return error.name
|
|
304
|
+
try { return JSON.stringify(error).slice(0, 400) } catch {}
|
|
305
|
+
}
|
|
306
|
+
return String(error)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function sdkCall(method, modernArgs, legacyArgs) {
|
|
310
|
+
let firstError
|
|
311
|
+
try {
|
|
312
|
+
const result = await method(modernArgs)
|
|
313
|
+
const error = sdkError(result)
|
|
314
|
+
if (!error) return sdkData(result)
|
|
315
|
+
firstError = new Error(sdkErrorMessage(error))
|
|
316
|
+
} catch (error) {
|
|
317
|
+
firstError = error
|
|
318
|
+
}
|
|
319
|
+
if (legacyArgs !== undefined) {
|
|
320
|
+
try {
|
|
321
|
+
const result = await method(legacyArgs)
|
|
322
|
+
const error = sdkError(result)
|
|
323
|
+
if (!error) return sdkData(result)
|
|
324
|
+
throw new Error(sdkErrorMessage(error))
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw firstError
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function fireSdk(client, label, method, modernArgs, legacyArgs) {
|
|
333
|
+
Promise.resolve()
|
|
334
|
+
.then(() => sdkCall(method, modernArgs, legacyArgs))
|
|
335
|
+
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function executeTuiCommand(client, command) {
|
|
339
|
+
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
340
|
+
return await sdkCall(
|
|
341
|
+
client.tui.executeCommand.bind(client.tui),
|
|
342
|
+
{ command },
|
|
343
|
+
{ body: { command } },
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function compactTuiCommandName(command = "compact") {
|
|
348
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
349
|
+
if (normalized === "compact" || normalized === "summarize") return "session.compact"
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function compactSession(client, sessionID) {
|
|
354
|
+
try {
|
|
355
|
+
await executeTuiCommand(client, "session.compact")
|
|
356
|
+
return true
|
|
357
|
+
} catch (error) {
|
|
358
|
+
await log(client, "warn", "tui session.compact failed", { error: sdkErrorMessage(error) })
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
362
|
+
return true
|
|
363
|
+
} catch (error) {
|
|
364
|
+
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
365
|
+
}
|
|
366
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
|
|
276
370
|
async function log(client, level, message, extra) {
|
|
277
|
-
try {
|
|
371
|
+
try {
|
|
372
|
+
await sdkCall(
|
|
373
|
+
client.app.log.bind(client.app),
|
|
374
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
375
|
+
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
376
|
+
)
|
|
377
|
+
} catch {}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function toast(client, message, variant = "info") {
|
|
381
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { message, variant }, { body: { message, variant } }) } catch {}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function say(client, sessionID, text) {
|
|
385
|
+
try {
|
|
386
|
+
await sdkCall(
|
|
387
|
+
client.session.prompt.bind(client.session),
|
|
388
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
389
|
+
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
390
|
+
)
|
|
391
|
+
} catch {}
|
|
278
392
|
}
|
|
279
|
-
async function toast(client, message, variant = "info") { try { await client.tui.showToast({ body: { message, variant } }) } catch {} }
|
|
280
|
-
async function say(client, sessionID, text) { try { await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } }) } catch {} }
|
|
281
393
|
|
|
282
394
|
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args || ""}` }
|
|
283
395
|
function markHandled(sessionID, name, args) {
|
|
@@ -290,13 +402,17 @@ function wasHandled(sessionID, name, args) {
|
|
|
290
402
|
}
|
|
291
403
|
|
|
292
404
|
function commandName(name) { return String(name || "") }
|
|
293
|
-
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev"].includes(name) }
|
|
405
|
+
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
|
|
294
406
|
function presetDefaults(name, args) {
|
|
295
407
|
const [maybeDuration, rest] = splitFirst(args)
|
|
296
408
|
const parsed = parseDuration(maybeDuration)
|
|
297
409
|
const intervalMs = parsed === null ? 0 : parsed
|
|
298
410
|
const extra = parsed === null ? String(args || "").trim() : rest
|
|
299
|
-
if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", name: "compact", immediate: false }
|
|
411
|
+
if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", kind: "compact", name: "compact", immediate: false }
|
|
412
|
+
if (name === "loop-command" || name === "loop-cmd") return { intervalMs, action: extra, kind: "command", name: "command", immediate: false }
|
|
413
|
+
if (name === "loop-prompt") return { intervalMs, action: extra, kind: "prompt", name: "prompt", immediate: true }
|
|
414
|
+
if (name === "loop-ask") return { intervalMs, action: extra, kind: "prompt", name: "ask", immediate: false }
|
|
415
|
+
if (name === "loop-shell") return { intervalMs, action: extra, kind: "shell", name: "shell", immediate: false }
|
|
300
416
|
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"}` }
|
|
301
417
|
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." }
|
|
302
418
|
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." }
|
|
@@ -305,6 +421,7 @@ function presetDefaults(name, args) {
|
|
|
305
421
|
|
|
306
422
|
function jobLabel(job) {
|
|
307
423
|
const title = job.name ? `${job.name}: ` : ""
|
|
424
|
+
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
308
425
|
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
309
426
|
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
310
427
|
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
@@ -315,7 +432,7 @@ function jobLabel(job) {
|
|
|
315
432
|
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
316
433
|
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
317
434
|
const paused = job.paused ? ", paused" : ""
|
|
318
|
-
return `${title}${durationToText(job.intervalMs)} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
|
|
435
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
|
|
319
436
|
}
|
|
320
437
|
|
|
321
438
|
function matchJob(job, target, index) {
|
|
@@ -376,9 +493,14 @@ function dangerousShell(command) {
|
|
|
376
493
|
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))
|
|
377
494
|
}
|
|
378
495
|
|
|
379
|
-
function actionKind(action) {
|
|
496
|
+
function actionKind(action, job = {}) {
|
|
380
497
|
const text = String(action || "").trim()
|
|
498
|
+
const forced = String(job.kind || "").trim().toLowerCase()
|
|
499
|
+
if (forced === "compact") return "compact"
|
|
381
500
|
if (text === "/compact" || text === "/summarize") return "compact"
|
|
501
|
+
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
502
|
+
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
503
|
+
if (forced === "shell") return "shell"
|
|
382
504
|
if (text.startsWith("/")) return "command"
|
|
383
505
|
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
384
506
|
return "prompt"
|
|
@@ -430,11 +552,10 @@ async function maybeCompact(client, sessionID, job) {
|
|
|
430
552
|
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
431
553
|
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
432
554
|
if (!dueRuns && !dueTime) return job
|
|
433
|
-
|
|
434
|
-
await client.session.summarize({ path: { id: sessionID }, body: {} })
|
|
555
|
+
if (await compactSession(client, sessionID)) {
|
|
435
556
|
job.lastCompactAt = now()
|
|
436
557
|
job.lastCompactRunCount = job.runCount || 0
|
|
437
|
-
}
|
|
558
|
+
}
|
|
438
559
|
return job
|
|
439
560
|
}
|
|
440
561
|
|
|
@@ -508,14 +629,62 @@ async function createCheckpoint(directory, sessionID, job, client) {
|
|
|
508
629
|
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
509
630
|
}
|
|
510
631
|
|
|
511
|
-
function
|
|
632
|
+
function updateSessionStatusFromEvent(event) {
|
|
512
633
|
const sessionID = event?.properties?.sessionID
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
634
|
+
if (typeof sessionID !== "string") return undefined
|
|
635
|
+
if (event?.type === "session.idle") {
|
|
636
|
+
sessionStatuses.set(sessionID, "idle")
|
|
637
|
+
return { sessionID, idle: true }
|
|
638
|
+
}
|
|
639
|
+
if (event?.type === "session.status") {
|
|
640
|
+
const status = event?.properties?.status
|
|
641
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
642
|
+
if (typeof type === "string") sessionStatuses.set(sessionID, type)
|
|
643
|
+
return { sessionID, idle: type === "idle" }
|
|
644
|
+
}
|
|
516
645
|
return undefined
|
|
517
646
|
}
|
|
518
647
|
|
|
648
|
+
async function sessionStatusType(client, sessionID) {
|
|
649
|
+
const cached = sessionStatuses.get(sessionID)
|
|
650
|
+
if (cached && cached !== "idle") return cached
|
|
651
|
+
try {
|
|
652
|
+
const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
|
|
653
|
+
const status = data?.[sessionID]
|
|
654
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
655
|
+
if (typeof type === "string") {
|
|
656
|
+
sessionStatuses.set(sessionID, type)
|
|
657
|
+
return type
|
|
658
|
+
}
|
|
659
|
+
} catch {}
|
|
660
|
+
sessionStatuses.set(sessionID, "idle")
|
|
661
|
+
return "idle"
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function sessionIsIdle(client, sessionID) {
|
|
665
|
+
return await sessionStatusType(client, sessionID) === "idle"
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function scheduleIdleWork(directory, client, sessionID) {
|
|
669
|
+
const previous = idleTimers.get(sessionID)
|
|
670
|
+
if (previous) clearTimeout(previous)
|
|
671
|
+
const timer = setTimeout(() => {
|
|
672
|
+
idleTimers.delete(sessionID)
|
|
673
|
+
Promise.resolve()
|
|
674
|
+
.then(async () => {
|
|
675
|
+
if (!await sessionIsIdle(client, sessionID)) return
|
|
676
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
677
|
+
if (!await sessionIsIdle(client, sessionID)) return
|
|
678
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
679
|
+
})
|
|
680
|
+
.catch((error) => {
|
|
681
|
+
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
682
|
+
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
683
|
+
})
|
|
684
|
+
}, IDLE_DEBOUNCE_MS)
|
|
685
|
+
idleTimers.set(sessionID, timer)
|
|
686
|
+
}
|
|
687
|
+
|
|
519
688
|
function dueJobs(state, force = false) {
|
|
520
689
|
const current = now()
|
|
521
690
|
return (state.jobs || []).filter((job) => {
|
|
@@ -588,32 +757,46 @@ async function finalizeActiveRun(directory, client, sessionID) {
|
|
|
588
757
|
|
|
589
758
|
async function fireAction(directory, client, sessionID, job) {
|
|
590
759
|
const action = String(job.action || "").trim()
|
|
591
|
-
const kind = actionKind(action)
|
|
760
|
+
const kind = actionKind(action, job)
|
|
592
761
|
if (kind === "compact") {
|
|
593
|
-
|
|
594
|
-
return { startsAssistantTurn:
|
|
762
|
+
const ok = await compactSession(client, sessionID)
|
|
763
|
+
return { startsAssistantTurn: ok }
|
|
595
764
|
}
|
|
596
765
|
if (kind === "command") {
|
|
597
|
-
const
|
|
598
|
-
|
|
766
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
767
|
+
const [command, argumentsText] = splitFirst(normalized)
|
|
768
|
+
if (!command) {
|
|
769
|
+
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
770
|
+
return { startsAssistantTurn: false }
|
|
771
|
+
}
|
|
772
|
+
const tuiCommand = compactTuiCommandName(command)
|
|
773
|
+
if (tuiCommand) {
|
|
774
|
+
await executeTuiCommand(client, tuiCommand)
|
|
775
|
+
return { startsAssistantTurn: true }
|
|
776
|
+
}
|
|
777
|
+
await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
599
778
|
return { startsAssistantTurn: true }
|
|
600
779
|
}
|
|
601
780
|
if (kind === "shell") {
|
|
602
|
-
const command = action.
|
|
781
|
+
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
603
782
|
if (job.safe && dangerousShell(command)) {
|
|
604
783
|
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
605
784
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
606
785
|
return { startsAssistantTurn: false }
|
|
607
786
|
}
|
|
608
|
-
client.session.shell({ path: { id: sessionID }, body: { command } })
|
|
787
|
+
fireSdk(client, "session.shell", client.session.shell.bind(client.session), { sessionID, command }, { path: { id: sessionID }, body: { command } })
|
|
609
788
|
return { startsAssistantTurn: true }
|
|
610
789
|
}
|
|
611
790
|
const prompt = await buildPrompt(directory, job)
|
|
612
|
-
client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: `AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.\n\n${prompt}` }] } })
|
|
791
|
+
fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { sessionID, parts: [{ type: "text", text: `AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.\n\n${prompt}` }] }, { path: { id: sessionID }, body: { parts: [{ type: "text", text: `AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.\n\n${prompt}` }] } })
|
|
613
792
|
return { startsAssistantTurn: true }
|
|
614
793
|
}
|
|
615
794
|
|
|
616
795
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
796
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
797
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle event.", "info")
|
|
798
|
+
return
|
|
799
|
+
}
|
|
617
800
|
const active = activeRuns.get(sessionID)
|
|
618
801
|
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) return
|
|
619
802
|
|
|
@@ -692,7 +875,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
692
875
|
}
|
|
693
876
|
if (result.startsAssistantTurn) {
|
|
694
877
|
let timer
|
|
695
|
-
if (job.timeoutMs > 0) timer = setTimeout(() => { client.session.abort
|
|
878
|
+
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
696
879
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
697
880
|
}
|
|
698
881
|
} catch (error) {
|
|
@@ -711,6 +894,7 @@ function sameLoopDefinition(a, b) {
|
|
|
711
894
|
return (a.name || "") === (b.name || "") &&
|
|
712
895
|
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
713
896
|
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
897
|
+
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
714
898
|
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
715
899
|
}
|
|
716
900
|
|
|
@@ -741,6 +925,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
741
925
|
|
|
742
926
|
state.jobs.push(parsed.job)
|
|
743
927
|
await writeState(directory, sessionID, state)
|
|
928
|
+
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
744
929
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
745
930
|
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
746
931
|
}
|
|
@@ -786,9 +971,11 @@ async function helpLoop(client, sessionID) {
|
|
|
786
971
|
await say(client, sessionID, [
|
|
787
972
|
"OpenCode Loop help:",
|
|
788
973
|
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
789
|
-
"/loop 5m --ask-never --safe <prompt> interval autonomous loop",
|
|
790
|
-
"/loop 200m
|
|
791
|
-
"/loop
|
|
974
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
975
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
976
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
977
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
978
|
+
"/loop 200m --command /compact same as command loop",
|
|
792
979
|
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
793
980
|
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
794
981
|
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
@@ -834,7 +1021,7 @@ async function exportLoop(directory, client, sessionID) {
|
|
|
834
1021
|
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
835
1022
|
}
|
|
836
1023
|
|
|
837
|
-
async function handleCommand(directory, client, input, fallbackName, fallbackArgs) {
|
|
1024
|
+
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
|
|
838
1025
|
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
839
1026
|
const sessionID = input?.sessionID
|
|
840
1027
|
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
@@ -842,19 +1029,27 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
842
1029
|
if (wasHandled(sessionID, name, args)) return true
|
|
843
1030
|
markHandled(sessionID, name, args)
|
|
844
1031
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (name === "loop
|
|
854
|
-
if (name
|
|
855
|
-
if (name === "loop-
|
|
856
|
-
if (name === "loop-
|
|
857
|
-
if (name === "loop-
|
|
1032
|
+
const handled = () => {
|
|
1033
|
+
if (output && Array.isArray(output.parts)) {
|
|
1034
|
+
output.parts.length = 0
|
|
1035
|
+
output.parts.push({ type: "text", text: "OpenCode Loop command was handled by the local plugin. Do not explain this command; continue only when the loop injects its next task." })
|
|
1036
|
+
}
|
|
1037
|
+
return true
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
1041
|
+
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
1042
|
+
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
1043
|
+
if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), handled()
|
|
1044
|
+
if (name === "loop-status") return await statusLoop(directory, client, sessionID), handled()
|
|
1045
|
+
if (name === "loop-logs") return await logsLoop(directory, client, sessionID), handled()
|
|
1046
|
+
if (name === "loop-help") return await helpLoop(client, sessionID), handled()
|
|
1047
|
+
if (name === "loop-now") return await runNow(directory, client, sessionID, args), handled()
|
|
1048
|
+
if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
|
|
1049
|
+
if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
|
|
1050
|
+
if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
|
|
1051
|
+
if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
|
|
1052
|
+
if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
|
|
858
1053
|
handledCommands.delete(commandKey(sessionID, name, args))
|
|
859
1054
|
return false
|
|
860
1055
|
}
|
|
@@ -862,17 +1057,14 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
862
1057
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
863
1058
|
await log(client, "info", "Plugin initialized", { directory })
|
|
864
1059
|
return {
|
|
865
|
-
"command.execute.before": async (input) => { await handleCommand(directory, client, input) },
|
|
1060
|
+
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
866
1061
|
event: async ({ event }) => {
|
|
867
1062
|
if (event.type === "command.executed") {
|
|
868
1063
|
const props = event.properties || {}
|
|
869
1064
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
870
1065
|
}
|
|
871
|
-
const
|
|
872
|
-
if (
|
|
873
|
-
await finalizeActiveRun(directory, client, idleSessionID)
|
|
874
|
-
await maybeRunDueJobs(directory, client, idleSessionID)
|
|
875
|
-
}
|
|
1066
|
+
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1067
|
+
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
876
1068
|
},
|
|
877
1069
|
}
|
|
878
1070
|
}
|