@bybrawe/opencode-loop 0.5.16 → 0.5.18

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +36 -11
  3. package/agents/opencode-loop-local.md +10 -0
  4. package/commands/loop-ask.md +1 -0
  5. package/commands/loop-clear.md +1 -0
  6. package/commands/loop-cmd.md +1 -0
  7. package/commands/loop-command.md +1 -0
  8. package/commands/loop-compact.md +1 -0
  9. package/commands/loop-dev.md +1 -0
  10. package/commands/loop-doctor.md +1 -0
  11. package/commands/loop-export.md +1 -0
  12. package/commands/loop-goal-blocked.md +1 -0
  13. package/commands/loop-goal-clear.md +1 -0
  14. package/commands/loop-goal-done.md +1 -0
  15. package/commands/loop-goal-pause.md +1 -0
  16. package/commands/loop-goal-resume.md +1 -0
  17. package/commands/loop-goal-status.md +1 -0
  18. package/commands/loop-goal.md +1 -0
  19. package/commands/loop-help.md +1 -0
  20. package/commands/loop-init.md +1 -0
  21. package/commands/loop-logs.md +1 -0
  22. package/commands/loop-now.md +1 -0
  23. package/commands/loop-pause.md +1 -0
  24. package/commands/loop-progress.md +1 -0
  25. package/commands/loop-prompt.md +1 -0
  26. package/commands/loop-remove.md +1 -0
  27. package/commands/loop-resume.md +1 -0
  28. package/commands/loop-safe-dev.md +1 -0
  29. package/commands/loop-shell.md +1 -0
  30. package/commands/loop-status.md +1 -0
  31. package/commands/loop-stop.md +1 -0
  32. package/commands/loop-testfix.md +1 -0
  33. package/commands/loop.md +1 -0
  34. package/package.json +6 -5
  35. package/scripts/comprehensive-test.mjs +486 -0
  36. package/scripts/install-node.mjs +146 -4
  37. package/scripts/install-test.mjs +104 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/loopd-test.mjs +208 -0
  41. package/scripts/loopd.mjs +125 -41
  42. package/scripts/smoke-test.mjs +1 -1
  43. package/src/index.js +193 -52
package/src/index.js CHANGED
@@ -20,9 +20,13 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
20
20
  const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
21
21
  const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
22
22
  const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
23
+ const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
24
+ const LOCAL_COMMAND_AGENT = "opencode-loop-local"
23
25
 
24
26
  const activeRuns = new Map()
25
27
  const handledCommands = new Map()
28
+ const handledCommandEvents = new Map()
29
+ const sessionExecutionContexts = new Map()
26
30
  const loopOwnedUserMessageGuards = new Map()
27
31
  const idleTimers = new Map()
28
32
  const dueTimers = new Map()
@@ -240,6 +244,7 @@ function parseLoopArgs(raw, defaults = {}) {
240
244
  lastCompactAt: 0,
241
245
  lastCompactRunCount: 0,
242
246
  watchSnapshot: {},
247
+ watchTriggered: false,
243
248
  createdAt: new Date().toISOString(),
244
249
  enabled: true,
245
250
  paused: false,
@@ -505,18 +510,31 @@ async function toast(client, message, variant = "info") {
505
510
 
506
511
  function guardLoopOwnedUserMessage(sessionID) {
507
512
  if (!sessionID) return
508
- loopOwnedUserMessageGuards.set(sessionID, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
509
- for (const [key, time] of loopOwnedUserMessageGuards.entries()) if (time < now()) loopOwnedUserMessageGuards.delete(key)
513
+ const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
514
+ current.pending += 1
515
+ current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
516
+ for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
517
+ loopOwnedUserMessageGuards.set(sessionID, current)
518
+ for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
519
+ for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
520
+ if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
521
+ }
510
522
  }
511
523
 
512
- function loopOwnedUserMessageGuardActive(sessionID) {
513
- const until = loopOwnedUserMessageGuards.get(sessionID)
514
- if (typeof until !== "number") return false
515
- if (until < now()) {
516
- loopOwnedUserMessageGuards.delete(sessionID)
517
- return false
524
+ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
525
+ const entry = loopOwnedUserMessageGuards.get(sessionID)
526
+ if (!entry || typeof entry !== "object") return false
527
+ for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
528
+ const id = typeof messageID === "string" ? messageID : ""
529
+ if (id && entry.messageIDs.has(id)) return true
530
+ if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
531
+ entry.pending -= 1
532
+ if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
533
+ loopOwnedUserMessageGuards.set(sessionID, entry)
534
+ return true
518
535
  }
519
- return true
536
+ if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
537
+ return false
520
538
  }
521
539
 
522
540
  async function say(client, sessionID, text) {
@@ -533,12 +551,26 @@ async function say(client, sessionID, text) {
533
551
 
534
552
  function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
535
553
  function markHandled(sessionID, name, args) {
536
- handledCommands.set(commandKey(sessionID, name, args), now())
537
- for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
554
+ const key = commandKey(sessionID, name, args)
555
+ const previous = handledCommands.get(key)
556
+ const pending = previous && now() - previous.time < 30_000 ? previous.pending + 1 : 1
557
+ handledCommands.set(key, { time: now(), pending })
558
+ for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > 30_000) handledCommands.delete(entryKey)
559
+ for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > 30_000) handledCommandEvents.delete(entryKey)
560
+ }
561
+ function consumeHandled(sessionID, name, args) {
562
+ const key = commandKey(sessionID, name, args)
563
+ const entry = handledCommands.get(key)
564
+ if (!entry || now() - entry.time >= 30_000) {
565
+ handledCommands.delete(key)
566
+ return false
567
+ }
568
+ if (entry.pending <= 1) handledCommands.delete(key)
569
+ else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
570
+ return true
538
571
  }
539
- function wasHandled(sessionID, name, args) {
540
- const time = handledCommands.get(commandKey(sessionID, name, args))
541
- return typeof time === "number" && now() - time < 30_000
572
+ function commandEventKey(sessionID, messageID) {
573
+ return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
542
574
  }
543
575
 
544
576
  function commandName(name) { return String(name || "") }
@@ -572,6 +604,48 @@ function rememberSession(directory, client, sessionID) {
572
604
  startHeartbeat()
573
605
  }
574
606
 
607
+ function normalizedModelRef(model) {
608
+ if (typeof model === "string") {
609
+ const separator = model.indexOf("/")
610
+ if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
611
+ return undefined
612
+ }
613
+ const providerID = model?.providerID
614
+ const modelID = model?.modelID || model?.id
615
+ if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
616
+ return { providerID, modelID }
617
+ }
618
+
619
+ function updateSessionExecutionContext(info) {
620
+ const sessionID = info?.sessionID || info?.id
621
+ if (typeof sessionID !== "string") return
622
+ const previous = sessionExecutionContexts.get(sessionID) || {}
623
+ const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
624
+ const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
625
+ ? candidateAgent
626
+ : previous.agent
627
+ const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
628
+ sessionExecutionContexts.set(sessionID, { agent, model })
629
+ }
630
+
631
+ async function captureSessionExecutionContext(client, sessionID) {
632
+ if (client?.session?.get) {
633
+ try {
634
+ const info = await sdkCall(
635
+ client.session.get.bind(client.session),
636
+ { path: { id: sessionID } },
637
+ { path: { sessionID } },
638
+ { sessionID },
639
+ )
640
+ updateSessionExecutionContext(info)
641
+ } catch {}
642
+ }
643
+ const context = sessionExecutionContexts.get(sessionID) || {}
644
+ const normalized = { agent: context.agent || "build", model: context.model }
645
+ sessionExecutionContexts.set(sessionID, normalized)
646
+ return normalized
647
+ }
648
+
575
649
  function hasActiveToolCalls(sessionID) {
576
650
  return (activeToolCalls.get(sessionID)?.size || 0) > 0
577
651
  }
@@ -602,6 +676,8 @@ function updateSessionRelationship(info, removed = false) {
602
676
  if (typeof sessionID !== "string") return
603
677
  if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
604
678
  else sessionParents.set(sessionID, info.parentID)
679
+ if (!removed) updateSessionExecutionContext(info)
680
+ else sessionExecutionContexts.delete(sessionID)
605
681
  }
606
682
 
607
683
  function updateSessionRelationshipFromEvent(event) {
@@ -707,7 +783,9 @@ function disposeRuntime(directory, client) {
707
783
  sessionParents.delete(sessionID)
708
784
  sessionStatuses.delete(sessionID)
709
785
  sessionStatusSeenAt.delete(sessionID)
786
+ sessionExecutionContexts.delete(sessionID)
710
787
  for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
788
+ for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
711
789
  }
712
790
  if (!knownSessions.size && heartbeatTimer) {
713
791
  clearInterval(heartbeatTimer)
@@ -715,20 +793,19 @@ function disposeRuntime(directory, client) {
715
793
  }
716
794
  }
717
795
 
718
- function presetDefaults(name, args) {
719
- const [maybeDuration, rest] = splitFirst(args)
720
- const parsed = parseDuration(maybeDuration)
721
- const intervalMs = parsed === null ? 0 : parsed
722
- const extra = parsed === null ? String(args || "").trim() : rest
723
- if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", kind: "compact", name: "compact", immediate: false }
724
- if (name === "loop-command" || name === "loop-cmd") return { intervalMs, action: extra, kind: "command", name: "command", immediate: false }
725
- if (name === "loop-prompt") return { intervalMs, action: extra, kind: "prompt", name: "prompt", immediate: true }
726
- if (name === "loop-ask") return { intervalMs, action: extra, kind: "prompt", name: "ask", immediate: false }
727
- if (name === "loop-shell") return { intervalMs, action: extra, kind: "shell", name: "shell", immediate: false }
728
- 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"}` }
729
- 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." }
730
- 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." }
731
- return { intervalMs, name: "dev", askNever: true, progressFile: "progress.md", action: extra || "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
796
+ function presetDefaults(name) {
797
+ // parseLoopArgs owns duration/flag/action parsing. Presets only provide real
798
+ // defaults; deriving an action from the raw string made flag-only invocations
799
+ // such as `/loop-compact --dry-run` use "--dry-run" as the action.
800
+ if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
801
+ if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
802
+ if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
803
+ if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
804
+ if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
805
+ if (name === "loop-testfix") return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" }
806
+ if (name === "loop-progress") return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
807
+ if (name === "loop-safe-dev") return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "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." }
808
+ return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
732
809
  }
733
810
 
734
811
  function jobLabel(job) {
@@ -803,7 +880,19 @@ async function notifyJob(directory, job, reason) {
803
880
 
804
881
  function dangerousShell(command) {
805
882
  const text = String(command || "").toLowerCase()
806
- 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))
883
+ return [
884
+ /\brm\b(?=[^\r\n]*\s-{1,2}(?:[a-z]*r[a-z]*|recursive)\b)(?=[^\r\n]*\s-{1,2}(?:[a-z]*f[a-z]*|force)\b)/,
885
+ /\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
886
+ /\bgit\s+reset\b/,
887
+ /\bgit\s+clean\b/,
888
+ /\bgit\s+push\b/,
889
+ /\bdel\b[^\r\n]*\s\/s\b/,
890
+ /\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
891
+ /(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
892
+ /\bterraform\s+destroy\b/,
893
+ /\bkubectl\s+delete\b/,
894
+ /\bdeploy\b.*\bproduction\b/,
895
+ ].some((pattern) => pattern.test(text))
807
896
  }
808
897
 
809
898
  function actionKind(action, job = {}) {
@@ -1031,8 +1120,9 @@ function userInterruptSessionFromEvent(event) {
1031
1120
  const info = props.info || props.message || props
1032
1121
  const role = info?.role
1033
1122
  const sessionID = info?.sessionID || props.sessionID
1123
+ const messageID = info?.id || props.messageID
1034
1124
  if (role !== "user" || typeof sessionID !== "string") return undefined
1035
- if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
1125
+ if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
1036
1126
  return sessionID
1037
1127
  }
1038
1128
 
@@ -1284,7 +1374,7 @@ function dueJobs(state, force = false) {
1284
1374
  if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
1285
1375
  if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
1286
1376
  if (force) return true
1287
- if (job.watchPaths?.length) return false
1377
+ if (job.watchPaths?.length) return job.watchTriggered === true
1288
1378
  return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
1289
1379
  })
1290
1380
  }
@@ -1592,16 +1682,18 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1592
1682
  async function fireAction(directory, client, sessionID, job) {
1593
1683
  const action = String(job.action || "").trim()
1594
1684
  const kind = actionKind(action, job)
1685
+ const agent = job.agent || "build"
1686
+ const model = normalizedModelRef(job.model)
1595
1687
  if (kind === "compact") {
1596
1688
  const ok = await compactSession(client, sessionID)
1597
- return { startsAssistantTurn: ok }
1689
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
1598
1690
  }
1599
1691
  if (kind === "command") {
1600
1692
  const normalized = action.startsWith("/") ? action.slice(1) : action
1601
1693
  const [command, argumentsText] = splitFirst(normalized)
1602
1694
  if (!command) {
1603
1695
  await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
1604
- return { startsAssistantTurn: false }
1696
+ return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
1605
1697
  }
1606
1698
  const tuiCommand = compactTuiCommandName(command)
1607
1699
  if (tuiCommand) {
@@ -1610,11 +1702,13 @@ async function fireAction(directory, client, sessionID, job) {
1610
1702
  return { startsAssistantTurn: true }
1611
1703
  }
1612
1704
  guardLoopOwnedUserMessage(sessionID)
1705
+ const commandBody = { command, arguments: argumentsText, agent }
1706
+ if (model) commandBody.model = `${model.providerID}/${model.modelID}`
1613
1707
  await sdkCall(
1614
1708
  client.session.command.bind(client.session),
1615
- { path: { id: sessionID }, body: { command, arguments: argumentsText } },
1616
- { path: { sessionID }, body: { command, arguments: argumentsText } },
1617
- { sessionID, command, arguments: argumentsText },
1709
+ { path: { id: sessionID }, body: commandBody },
1710
+ { path: { sessionID }, body: commandBody },
1711
+ { sessionID, ...commandBody },
1618
1712
  )
1619
1713
  return { startsAssistantTurn: true }
1620
1714
  }
@@ -1623,16 +1717,18 @@ async function fireAction(directory, client, sessionID, job) {
1623
1717
  if (job.safe && dangerousShell(command)) {
1624
1718
  await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
1625
1719
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1626
- return { startsAssistantTurn: false }
1720
+ return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
1627
1721
  }
1628
1722
  guardLoopOwnedUserMessage(sessionID)
1723
+ const shellBody = { command, agent }
1724
+ if (model) shellBody.model = model
1629
1725
  fireSdk(
1630
1726
  client,
1631
1727
  "session.shell",
1632
1728
  client.session.shell.bind(client.session),
1633
- { path: { id: sessionID }, body: { command } },
1634
- { path: { sessionID }, body: { command } },
1635
- { sessionID, command },
1729
+ { path: { id: sessionID }, body: shellBody },
1730
+ { path: { sessionID }, body: shellBody },
1731
+ { sessionID, ...shellBody },
1636
1732
  )
1637
1733
  return { startsAssistantTurn: true }
1638
1734
  }
@@ -1644,13 +1740,15 @@ async function fireAction(directory, client, sessionID, job) {
1644
1740
 
1645
1741
  ${prompt}`
1646
1742
  guardLoopOwnedUserMessage(sessionID)
1743
+ const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
1744
+ if (model) promptBody.model = model
1647
1745
  fireSdk(
1648
1746
  client,
1649
1747
  "session.prompt",
1650
1748
  client.session.prompt.bind(client.session),
1651
- { path: { id: sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1652
- { path: { sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1653
- { sessionID, parts: [{ type: "text", text: promptText }] },
1749
+ { path: { id: sessionID }, body: promptBody },
1750
+ { path: { sessionID }, body: promptBody },
1751
+ { sessionID, ...promptBody },
1654
1752
  )
1655
1753
  return { startsAssistantTurn: true }
1656
1754
  }
@@ -1684,7 +1782,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1684
1782
 
1685
1783
  const state = await readState(directory, sessionID)
1686
1784
  for (const candidate of state.jobs || []) {
1687
- if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
1785
+ if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.watchTriggered = true
1688
1786
  }
1689
1787
  const due = dueJobs(state, options.force)
1690
1788
  if (!due.length) {
@@ -1746,6 +1844,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1746
1844
 
1747
1845
  job = await ensureBranch(directory, job, client, sessionID)
1748
1846
  job = await maybeCompact(client, sessionID, job)
1847
+ job.watchTriggered = false
1749
1848
  job.lastRunAt = now()
1750
1849
  job.runCount = (job.runCount || 0) + 1
1751
1850
  if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
@@ -1761,6 +1860,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1761
1860
  const result = await fireAction(directory, client, sessionID, job)
1762
1861
  if (!result.startsAssistantTurn) {
1763
1862
  const fresh = await readState(directory, sessionID)
1863
+ if (result.pause) {
1864
+ fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
1865
+ ...candidate,
1866
+ paused: true,
1867
+ failureCount: (candidate.failureCount || 0) + 1,
1868
+ lastFailureReason: result.reason || "action_did_not_start",
1869
+ } : candidate)
1870
+ }
1764
1871
  fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1765
1872
  await writeState(directory, sessionID, fresh)
1766
1873
  await reschedule()
@@ -1799,6 +1906,20 @@ function sameLoopDefinition(a, b) {
1799
1906
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
1800
1907
  const parsed = parseLoopArgs(args, defaults)
1801
1908
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1909
+ const executionContext = sessionExecutionContexts.get(sessionID) || { agent: "build" }
1910
+ parsed.job.agent = defaults.agent || executionContext.agent || "build"
1911
+ parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
1912
+ if (defaults.testfixPreset) {
1913
+ const defaultCommand = String(defaults.verifyCommand || "npm test")
1914
+ const parsedAction = String(parsed.job.action || "").trim()
1915
+ const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
1916
+ if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
1917
+ parsed.job.verifyCommand = parsedAction
1918
+ parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
1919
+ } else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
1920
+ parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
1921
+ }
1922
+ }
1802
1923
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1803
1924
  if (!parsed.job.activeRecoveryMs) {
1804
1925
  parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
@@ -2014,20 +2135,32 @@ async function exportLoop(directory, client, sessionID) {
2014
2135
  await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
2015
2136
  }
2016
2137
 
2017
- async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
2138
+ async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
2018
2139
  const name = commandName(input?.command ?? input?.name ?? fallbackName)
2019
2140
  const sessionID = input?.sessionID
2020
2141
  const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
2021
2142
  if (!sessionID || !name) return false
2022
2143
  rememberSession(directory, client, sessionID)
2023
- if (wasHandled(sessionID, name, args)) return true
2024
- markHandled(sessionID, name, args)
2144
+ if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
2145
+ if (source === "event") {
2146
+ if (consumeHandled(sessionID, name, args)) return true
2147
+ const eventKey = commandEventKey(sessionID, input?.messageID)
2148
+ if (handledCommandEvents.has(eventKey)) return true
2149
+ handledCommandEvents.set(eventKey, now())
2150
+ } else {
2151
+ // Every before-hook invocation is an intentional command. Keep a pending
2152
+ // count only so the matching command.executed compatibility event can be
2153
+ // consumed without suppressing a genuine repeated command.
2154
+ markHandled(sessionID, name, args)
2155
+ }
2025
2156
  if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
2026
2157
 
2027
2158
  const handled = () => {
2028
2159
  if (output && Array.isArray(output.parts)) {
2160
+ // The command has already been completed through toasts/noReply prompts
2161
+ // and local state. Leaving a placeholder prompt starts an unnecessary
2162
+ // model turn; weaker agents may even call tools or spawn subagents.
2029
2163
  output.parts.length = 0
2030
- 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." })
2031
2164
  }
2032
2165
  return true
2033
2166
  }
@@ -2052,7 +2185,8 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
2052
2185
  if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
2053
2186
  if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
2054
2187
  if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
2055
- handledCommands.delete(commandKey(sessionID, name, args))
2188
+ if (source === "event") handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID))
2189
+ else consumeHandled(sessionID, name, args)
2056
2190
  return false
2057
2191
  }
2058
2192
 
@@ -2095,8 +2229,14 @@ function goalTools(defaultDirectory) {
2095
2229
  }
2096
2230
 
2097
2231
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2098
- await log(client, "info", "Plugin initialized", { directory })
2099
- await refreshSessionRelationships(client, directory)
2232
+ // OpenCode's local SDK can be slow or unavailable while a project instance
2233
+ // is still waiting for its plugins to return their hooks. Defer bootstrap
2234
+ // calls so headless/server sessions cannot deadlock during plugin loading.
2235
+ const bootstrap = setTimeout(() => {
2236
+ log(client, "info", "Plugin initialized", { directory }).catch(() => {})
2237
+ refreshSessionRelationships(client, directory).catch(() => {})
2238
+ }, 0)
2239
+ bootstrap.unref?.()
2100
2240
  return {
2101
2241
  dispose: async () => { disposeRuntime(directory, client) },
2102
2242
  tool: goalTools(directory),
@@ -2105,10 +2245,11 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2105
2245
  "tool.execute.after": async (input) => { markToolCallFinished(input) },
2106
2246
  event: async ({ event }) => {
2107
2247
  updateSessionRelationshipFromEvent(event)
2248
+ if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
2108
2249
  updateToolActivityFromEvent(event)
2109
2250
  if (event.type === "command.executed") {
2110
2251
  const props = event.properties || {}
2111
- await handleCommand(directory, client, props, props.name, props.arguments)
2252
+ await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
2112
2253
  }
2113
2254
  const interruptedSessionID = userInterruptSessionFromEvent(event)
2114
2255
  if (interruptedSessionID) {