@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.
@@ -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.1",
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 !npm test | /loop --watch progress.md <prompt>" }
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 { await client.app.log({ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } }) } catch {}
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
- try {
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
- } catch {}
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 getIdleSessionID(event) {
632
+ function updateSessionStatusFromEvent(event) {
512
633
  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
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
- await client.session.summarize({ path: { id: sessionID }, body: {} })
594
- return { startsAssistantTurn: false }
762
+ const ok = await compactSession(client, sessionID)
763
+ return { startsAssistantTurn: ok }
595
764
  }
596
765
  if (kind === "command") {
597
- const [command, argumentsText] = splitFirst(action.slice(1))
598
- client.session.command({ path: { id: sessionID }, body: { command, arguments: argumentsText } }).catch(() => {})
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.slice(1).trim()
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 } }).catch(() => {})
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}` }] } }).catch(() => {})
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?.({ path: { id: sessionID }, body: {} }).catch(() => {}); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
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 --no-now /compact compact/summarize loop",
791
- "/loop 10m !npm test shell 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
- 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
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 idleSessionID = getIdleSessionID(event)
872
- if (idleSessionID) {
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
  }