@bybrawe/opencode-loop 0.5.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.1",
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": "./scripts/install-node.mjs",
59
- "opencode-loopd": "./scripts/loopd.mjs"
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 { await client.app.log({ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } }) } catch {}
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 getIdleSessionID(event) {
583
+ function updateSessionStatusFromEvent(event) {
512
584
  const sessionID = event?.properties?.sessionID
513
- if (event?.type === "session.idle" && typeof sessionID === "string") return sessionID
514
- const status = event?.properties?.status
515
- if (event?.type === "session.status" && typeof sessionID === "string" && status && typeof status === "object" && status.type === "idle") return sessionID
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 } }).catch(() => {})
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 } }).catch(() => {})
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}` }] } }).catch(() => {})
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?.({ path: { id: sessionID }, body: {} }).catch(() => {}); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
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
- if (name === "loop") return await addLoop(directory, client, sessionID, args), true
846
- if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), true
847
- if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), true
848
- if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), true
849
- if (name === "loop-status") return await statusLoop(directory, client, sessionID), true
850
- if (name === "loop-logs") return await logsLoop(directory, client, sessionID), true
851
- if (name === "loop-help") return await helpLoop(client, sessionID), true
852
- if (name === "loop-now") return await runNow(directory, client, sessionID, args), true
853
- if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), true
854
- if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), true
855
- if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), true
856
- if (name === "loop-init") return await initLoop(directory, client, sessionID, args), true
857
- if (name === "loop-export") return await exportLoop(directory, client, sessionID), true
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 idleSessionID = getIdleSessionID(event)
872
- if (idleSessionID) {
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
  }