@bybrawe/opencode-loop 0.5.0 → 0.5.4
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 +8 -0
- package/README.md +816 -756
- package/package.json +3 -3
- package/src/index.js +163 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@opencode-ai/plugin": ">=1.4.0"
|
|
56
56
|
},
|
|
57
57
|
"bin": {
|
|
58
|
-
"opencode-loop": "
|
|
59
|
-
"opencode-loopd": "
|
|
58
|
+
"opencode-loop": "scripts/install-node.mjs",
|
|
59
|
+
"opencode-loopd": "scripts/loopd.mjs"
|
|
60
60
|
}
|
|
61
61
|
}
|
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
|
|
|
@@ -273,11 +276,80 @@ async function writeState(directory, sessionID, state) {
|
|
|
273
276
|
|
|
274
277
|
async function removeState(directory, sessionID) { try { await fs.unlink(statePath(directory, sessionID)) } catch {} }
|
|
275
278
|
|
|
279
|
+
function sdkError(result) {
|
|
280
|
+
if (!result || typeof result !== "object") return undefined
|
|
281
|
+
return result.error || result.error === null ? result.error : undefined
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sdkData(result) {
|
|
285
|
+
if (!result || typeof result !== "object") return result
|
|
286
|
+
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sdkErrorMessage(error) {
|
|
290
|
+
if (!error) return "unknown SDK error"
|
|
291
|
+
if (error instanceof Error) return error.message
|
|
292
|
+
if (typeof error === "string") return error
|
|
293
|
+
if (typeof error === "object") {
|
|
294
|
+
if (typeof error.message === "string") return error.message
|
|
295
|
+
if (typeof error.name === "string") return error.name
|
|
296
|
+
try { return JSON.stringify(error).slice(0, 400) } catch {}
|
|
297
|
+
}
|
|
298
|
+
return String(error)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function sdkCall(method, modernArgs, legacyArgs) {
|
|
302
|
+
let firstError
|
|
303
|
+
try {
|
|
304
|
+
const result = await method(modernArgs)
|
|
305
|
+
const error = sdkError(result)
|
|
306
|
+
if (!error) return sdkData(result)
|
|
307
|
+
firstError = new Error(sdkErrorMessage(error))
|
|
308
|
+
} catch (error) {
|
|
309
|
+
firstError = error
|
|
310
|
+
}
|
|
311
|
+
if (legacyArgs !== undefined) {
|
|
312
|
+
try {
|
|
313
|
+
const result = await method(legacyArgs)
|
|
314
|
+
const error = sdkError(result)
|
|
315
|
+
if (!error) return sdkData(result)
|
|
316
|
+
throw new Error(sdkErrorMessage(error))
|
|
317
|
+
} catch (error) {
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
throw firstError
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function fireSdk(client, label, method, modernArgs, legacyArgs) {
|
|
325
|
+
Promise.resolve()
|
|
326
|
+
.then(() => sdkCall(method, modernArgs, legacyArgs))
|
|
327
|
+
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
328
|
+
}
|
|
329
|
+
|
|
276
330
|
async function log(client, level, message, extra) {
|
|
277
|
-
try {
|
|
331
|
+
try {
|
|
332
|
+
await sdkCall(
|
|
333
|
+
client.app.log.bind(client.app),
|
|
334
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
335
|
+
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
336
|
+
)
|
|
337
|
+
} catch {}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function toast(client, message, variant = "info") {
|
|
341
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { message, variant }, { body: { message, variant } }) } catch {}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function say(client, sessionID, text) {
|
|
345
|
+
try {
|
|
346
|
+
await sdkCall(
|
|
347
|
+
client.session.prompt.bind(client.session),
|
|
348
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
349
|
+
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
350
|
+
)
|
|
351
|
+
} catch {}
|
|
278
352
|
}
|
|
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
353
|
|
|
282
354
|
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args || ""}` }
|
|
283
355
|
function markHandled(sessionID, name, args) {
|
|
@@ -431,7 +503,7 @@ async function maybeCompact(client, sessionID, job) {
|
|
|
431
503
|
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
432
504
|
if (!dueRuns && !dueTime) return job
|
|
433
505
|
try {
|
|
434
|
-
await client.session.summarize({ path: { id: sessionID }, body: {} })
|
|
506
|
+
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
435
507
|
job.lastCompactAt = now()
|
|
436
508
|
job.lastCompactRunCount = job.runCount || 0
|
|
437
509
|
} catch {}
|
|
@@ -508,14 +580,62 @@ async function createCheckpoint(directory, sessionID, job, client) {
|
|
|
508
580
|
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
509
581
|
}
|
|
510
582
|
|
|
511
|
-
function
|
|
583
|
+
function updateSessionStatusFromEvent(event) {
|
|
512
584
|
const sessionID = event?.properties?.sessionID
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
585
|
+
if (typeof sessionID !== "string") return undefined
|
|
586
|
+
if (event?.type === "session.idle") {
|
|
587
|
+
sessionStatuses.set(sessionID, "idle")
|
|
588
|
+
return { sessionID, idle: true }
|
|
589
|
+
}
|
|
590
|
+
if (event?.type === "session.status") {
|
|
591
|
+
const status = event?.properties?.status
|
|
592
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
593
|
+
if (typeof type === "string") sessionStatuses.set(sessionID, type)
|
|
594
|
+
return { sessionID, idle: type === "idle" }
|
|
595
|
+
}
|
|
516
596
|
return undefined
|
|
517
597
|
}
|
|
518
598
|
|
|
599
|
+
async function sessionStatusType(client, sessionID) {
|
|
600
|
+
const cached = sessionStatuses.get(sessionID)
|
|
601
|
+
if (cached && cached !== "idle") return cached
|
|
602
|
+
try {
|
|
603
|
+
const data = await sdkCall(client.session.status.bind(client.session), {}, undefined)
|
|
604
|
+
const status = data?.[sessionID]
|
|
605
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
606
|
+
if (typeof type === "string") {
|
|
607
|
+
sessionStatuses.set(sessionID, type)
|
|
608
|
+
return type
|
|
609
|
+
}
|
|
610
|
+
} catch {}
|
|
611
|
+
sessionStatuses.set(sessionID, "idle")
|
|
612
|
+
return "idle"
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function sessionIsIdle(client, sessionID) {
|
|
616
|
+
return await sessionStatusType(client, sessionID) === "idle"
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function scheduleIdleWork(directory, client, sessionID) {
|
|
620
|
+
const previous = idleTimers.get(sessionID)
|
|
621
|
+
if (previous) clearTimeout(previous)
|
|
622
|
+
const timer = setTimeout(() => {
|
|
623
|
+
idleTimers.delete(sessionID)
|
|
624
|
+
Promise.resolve()
|
|
625
|
+
.then(async () => {
|
|
626
|
+
if (!await sessionIsIdle(client, sessionID)) return
|
|
627
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
628
|
+
if (!await sessionIsIdle(client, sessionID)) return
|
|
629
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
630
|
+
})
|
|
631
|
+
.catch((error) => {
|
|
632
|
+
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
633
|
+
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
634
|
+
})
|
|
635
|
+
}, IDLE_DEBOUNCE_MS)
|
|
636
|
+
idleTimers.set(sessionID, timer)
|
|
637
|
+
}
|
|
638
|
+
|
|
519
639
|
function dueJobs(state, force = false) {
|
|
520
640
|
const current = now()
|
|
521
641
|
return (state.jobs || []).filter((job) => {
|
|
@@ -590,12 +710,12 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
590
710
|
const action = String(job.action || "").trim()
|
|
591
711
|
const kind = actionKind(action)
|
|
592
712
|
if (kind === "compact") {
|
|
593
|
-
await client.session.summarize({ path: { id: sessionID }, body: {} })
|
|
713
|
+
await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
|
|
594
714
|
return { startsAssistantTurn: false }
|
|
595
715
|
}
|
|
596
716
|
if (kind === "command") {
|
|
597
717
|
const [command, argumentsText] = splitFirst(action.slice(1))
|
|
598
|
-
client.session.command({ path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
718
|
+
fireSdk(client, "session.command", client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
|
|
599
719
|
return { startsAssistantTurn: true }
|
|
600
720
|
}
|
|
601
721
|
if (kind === "shell") {
|
|
@@ -605,15 +725,19 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
605
725
|
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
606
726
|
return { startsAssistantTurn: false }
|
|
607
727
|
}
|
|
608
|
-
client.session.shell({ path: { id: sessionID }, body: { command } })
|
|
728
|
+
fireSdk(client, "session.shell", client.session.shell.bind(client.session), { sessionID, command }, { path: { id: sessionID }, body: { command } })
|
|
609
729
|
return { startsAssistantTurn: true }
|
|
610
730
|
}
|
|
611
731
|
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}` }] } })
|
|
732
|
+
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
733
|
return { startsAssistantTurn: true }
|
|
614
734
|
}
|
|
615
735
|
|
|
616
736
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
737
|
+
if (!await sessionIsIdle(client, sessionID)) {
|
|
738
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle event.", "info")
|
|
739
|
+
return
|
|
740
|
+
}
|
|
617
741
|
const active = activeRuns.get(sessionID)
|
|
618
742
|
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) return
|
|
619
743
|
|
|
@@ -692,7 +816,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
692
816
|
}
|
|
693
817
|
if (result.startsAssistantTurn) {
|
|
694
818
|
let timer
|
|
695
|
-
if (job.timeoutMs > 0) timer = setTimeout(() => { client.session.abort
|
|
819
|
+
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
820
|
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
697
821
|
}
|
|
698
822
|
} catch (error) {
|
|
@@ -741,6 +865,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
741
865
|
|
|
742
866
|
state.jobs.push(parsed.job)
|
|
743
867
|
await writeState(directory, sessionID, state)
|
|
868
|
+
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
744
869
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
745
870
|
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
746
871
|
}
|
|
@@ -834,7 +959,7 @@ async function exportLoop(directory, client, sessionID) {
|
|
|
834
959
|
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
835
960
|
}
|
|
836
961
|
|
|
837
|
-
async function handleCommand(directory, client, input, fallbackName, fallbackArgs) {
|
|
962
|
+
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
|
|
838
963
|
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
839
964
|
const sessionID = input?.sessionID
|
|
840
965
|
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
@@ -842,19 +967,27 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
842
967
|
if (wasHandled(sessionID, name, args)) return true
|
|
843
968
|
markHandled(sessionID, name, args)
|
|
844
969
|
|
|
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-
|
|
970
|
+
const handled = () => {
|
|
971
|
+
if (output && Array.isArray(output.parts)) {
|
|
972
|
+
output.parts.length = 0
|
|
973
|
+
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." })
|
|
974
|
+
}
|
|
975
|
+
return true
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
979
|
+
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
980
|
+
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
981
|
+
if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), handled()
|
|
982
|
+
if (name === "loop-status") return await statusLoop(directory, client, sessionID), handled()
|
|
983
|
+
if (name === "loop-logs") return await logsLoop(directory, client, sessionID), handled()
|
|
984
|
+
if (name === "loop-help") return await helpLoop(client, sessionID), handled()
|
|
985
|
+
if (name === "loop-now") return await runNow(directory, client, sessionID, args), handled()
|
|
986
|
+
if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
|
|
987
|
+
if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
|
|
988
|
+
if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
|
|
989
|
+
if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
|
|
990
|
+
if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
|
|
858
991
|
handledCommands.delete(commandKey(sessionID, name, args))
|
|
859
992
|
return false
|
|
860
993
|
}
|
|
@@ -862,17 +995,14 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
862
995
|
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
863
996
|
await log(client, "info", "Plugin initialized", { directory })
|
|
864
997
|
return {
|
|
865
|
-
"command.execute.before": async (input) => { await handleCommand(directory, client, input) },
|
|
998
|
+
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
866
999
|
event: async ({ event }) => {
|
|
867
1000
|
if (event.type === "command.executed") {
|
|
868
1001
|
const props = event.properties || {}
|
|
869
1002
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
870
1003
|
}
|
|
871
|
-
const
|
|
872
|
-
if (
|
|
873
|
-
await finalizeActiveRun(directory, client, idleSessionID)
|
|
874
|
-
await maybeRunDueJobs(directory, client, idleSessionID)
|
|
875
|
-
}
|
|
1004
|
+
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1005
|
+
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
876
1006
|
},
|
|
877
1007
|
}
|
|
878
1008
|
}
|