@bybrawe/opencode-loop 0.5.15 → 0.5.17

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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +34 -13
  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 +455 -0
  36. package/scripts/install-node.mjs +100 -4
  37. package/scripts/install-test.mjs +86 -0
  38. package/scripts/install.ps1 +6 -32
  39. package/scripts/install.sh +6 -34
  40. package/scripts/smoke-test.mjs +45 -2
  41. package/src/index.js +297 -41
package/src/index.js CHANGED
@@ -20,9 +20,12 @@ 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 LOCAL_COMMAND_AGENT = "opencode-loop-local"
23
24
 
24
25
  const activeRuns = new Map()
25
26
  const handledCommands = new Map()
27
+ const handledCommandEvents = new Map()
28
+ const sessionExecutionContexts = new Map()
26
29
  const loopOwnedUserMessageGuards = new Map()
27
30
  const idleTimers = new Map()
28
31
  const dueTimers = new Map()
@@ -30,6 +33,8 @@ const watchdogTimers = new Map()
30
33
  const runLocks = new Map()
31
34
  const knownSessions = new Map()
32
35
  const stateWriteLocks = new Map()
36
+ const activeToolCalls = new Map()
37
+ const sessionParents = new Map()
33
38
  let heartbeatTimer
34
39
  const sessionStatuses = new Map()
35
40
  const sessionStatusSeenAt = new Map()
@@ -238,6 +243,7 @@ function parseLoopArgs(raw, defaults = {}) {
238
243
  lastCompactAt: 0,
239
244
  lastCompactRunCount: 0,
240
245
  watchSnapshot: {},
246
+ watchTriggered: false,
241
247
  createdAt: new Date().toISOString(),
242
248
  enabled: true,
243
249
  paused: false,
@@ -531,12 +537,26 @@ async function say(client, sessionID, text) {
531
537
 
532
538
  function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
533
539
  function markHandled(sessionID, name, args) {
534
- handledCommands.set(commandKey(sessionID, name, args), now())
535
- for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
540
+ const key = commandKey(sessionID, name, args)
541
+ const previous = handledCommands.get(key)
542
+ const pending = previous && now() - previous.time < 30_000 ? previous.pending + 1 : 1
543
+ handledCommands.set(key, { time: now(), pending })
544
+ for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > 30_000) handledCommands.delete(entryKey)
545
+ for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > 30_000) handledCommandEvents.delete(entryKey)
546
+ }
547
+ function consumeHandled(sessionID, name, args) {
548
+ const key = commandKey(sessionID, name, args)
549
+ const entry = handledCommands.get(key)
550
+ if (!entry || now() - entry.time >= 30_000) {
551
+ handledCommands.delete(key)
552
+ return false
553
+ }
554
+ if (entry.pending <= 1) handledCommands.delete(key)
555
+ else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
556
+ return true
536
557
  }
537
- function wasHandled(sessionID, name, args) {
538
- const time = handledCommands.get(commandKey(sessionID, name, args))
539
- return typeof time === "number" && now() - time < 30_000
558
+ function commandEventKey(sessionID, messageID) {
559
+ return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
540
560
  }
541
561
 
542
562
  function commandName(name) { return String(name || "") }
@@ -570,6 +590,144 @@ function rememberSession(directory, client, sessionID) {
570
590
  startHeartbeat()
571
591
  }
572
592
 
593
+ function normalizedModelRef(model) {
594
+ if (typeof model === "string") {
595
+ const separator = model.indexOf("/")
596
+ if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
597
+ return undefined
598
+ }
599
+ const providerID = model?.providerID
600
+ const modelID = model?.modelID || model?.id
601
+ if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
602
+ return { providerID, modelID }
603
+ }
604
+
605
+ function updateSessionExecutionContext(info) {
606
+ const sessionID = info?.sessionID || info?.id
607
+ if (typeof sessionID !== "string") return
608
+ const previous = sessionExecutionContexts.get(sessionID) || {}
609
+ const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
610
+ const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
611
+ ? candidateAgent
612
+ : previous.agent
613
+ const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
614
+ sessionExecutionContexts.set(sessionID, { agent, model })
615
+ }
616
+
617
+ async function captureSessionExecutionContext(client, sessionID) {
618
+ if (client?.session?.get) {
619
+ try {
620
+ const info = await sdkCall(
621
+ client.session.get.bind(client.session),
622
+ { path: { id: sessionID } },
623
+ { path: { sessionID } },
624
+ { sessionID },
625
+ )
626
+ updateSessionExecutionContext(info)
627
+ } catch {}
628
+ }
629
+ const context = sessionExecutionContexts.get(sessionID) || {}
630
+ const normalized = { agent: context.agent || "build", model: context.model }
631
+ sessionExecutionContexts.set(sessionID, normalized)
632
+ return normalized
633
+ }
634
+
635
+ function hasActiveToolCalls(sessionID) {
636
+ return (activeToolCalls.get(sessionID)?.size || 0) > 0
637
+ }
638
+
639
+ function markToolCallActive(input) {
640
+ const sessionID = input?.sessionID
641
+ const callID = input?.callID
642
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
643
+ const calls = activeToolCalls.get(sessionID) || new Set()
644
+ calls.add(callID)
645
+ activeToolCalls.set(sessionID, calls)
646
+ sessionStatuses.set(sessionID, "busy")
647
+ sessionStatusSeenAt.set(sessionID, now())
648
+ }
649
+
650
+ function markToolCallFinished(input) {
651
+ const sessionID = input?.sessionID
652
+ const callID = input?.callID
653
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
654
+ const calls = activeToolCalls.get(sessionID)
655
+ if (!calls) return
656
+ calls.delete(callID)
657
+ if (!calls.size) activeToolCalls.delete(sessionID)
658
+ }
659
+
660
+ function updateSessionRelationship(info, removed = false) {
661
+ const sessionID = info?.id
662
+ if (typeof sessionID !== "string") return
663
+ if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
664
+ else sessionParents.set(sessionID, info.parentID)
665
+ if (!removed) updateSessionExecutionContext(info)
666
+ else sessionExecutionContexts.delete(sessionID)
667
+ }
668
+
669
+ function updateSessionRelationshipFromEvent(event) {
670
+ if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
671
+ updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
672
+ }
673
+
674
+ function isDescendantSession(sessionID, ancestorID) {
675
+ const visited = new Set()
676
+ let current = sessionID
677
+ while (sessionParents.has(current) && !visited.has(current)) {
678
+ visited.add(current)
679
+ current = sessionParents.get(current)
680
+ if (current === ancestorID) return true
681
+ }
682
+ return false
683
+ }
684
+
685
+ function hasBusyDescendant(sessionID) {
686
+ for (const childID of sessionParents.keys()) {
687
+ if (!isDescendantSession(childID, sessionID)) continue
688
+ const status = sessionStatuses.get(childID)
689
+ if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
690
+ }
691
+ return false
692
+ }
693
+
694
+ async function refreshSessionRelationships(client, directory) {
695
+ if (!client?.session?.list) return
696
+ try {
697
+ const sessions = await sdkCall(
698
+ client.session.list.bind(client.session),
699
+ { query: { directory } },
700
+ { directory },
701
+ {},
702
+ )
703
+ if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
704
+ } catch {}
705
+ }
706
+
707
+ function updateToolActivityFromEvent(event) {
708
+ const props = event?.properties || {}
709
+ if (event?.type === "message.part.updated") {
710
+ const part = props.part
711
+ if (part?.type !== "tool") return
712
+ const sessionID = part.sessionID || props.sessionID
713
+ if (["pending", "running"].includes(part.state?.status)) {
714
+ markToolCallActive({ sessionID, callID: part.callID })
715
+ }
716
+ if (["completed", "error"].includes(part.state?.status)) {
717
+ // Task/subagent hooks have used the part id as their hook call id in some
718
+ // OpenCode versions, while normal tools use part.callID. Clear either.
719
+ const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
720
+ for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
721
+ }
722
+ return
723
+ }
724
+
725
+ const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
726
+ const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
727
+ if (started) markToolCallActive(props)
728
+ if (finished) markToolCallFinished(props)
729
+ }
730
+
573
731
  function startHeartbeat() {
574
732
  if (heartbeatTimer) return
575
733
  heartbeatTimer = setInterval(() => {
@@ -607,9 +765,13 @@ function disposeRuntime(directory, client) {
607
765
  runLocks.delete(sessionID)
608
766
  knownSessions.delete(sessionID)
609
767
  loopOwnedUserMessageGuards.delete(sessionID)
768
+ activeToolCalls.delete(sessionID)
769
+ sessionParents.delete(sessionID)
610
770
  sessionStatuses.delete(sessionID)
611
771
  sessionStatusSeenAt.delete(sessionID)
772
+ sessionExecutionContexts.delete(sessionID)
612
773
  for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
774
+ for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
613
775
  }
614
776
  if (!knownSessions.size && heartbeatTimer) {
615
777
  clearInterval(heartbeatTimer)
@@ -617,20 +779,19 @@ function disposeRuntime(directory, client) {
617
779
  }
618
780
  }
619
781
 
620
- function presetDefaults(name, args) {
621
- const [maybeDuration, rest] = splitFirst(args)
622
- const parsed = parseDuration(maybeDuration)
623
- const intervalMs = parsed === null ? 0 : parsed
624
- const extra = parsed === null ? String(args || "").trim() : rest
625
- if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", kind: "compact", name: "compact", immediate: false }
626
- if (name === "loop-command" || name === "loop-cmd") return { intervalMs, action: extra, kind: "command", name: "command", immediate: false }
627
- if (name === "loop-prompt") return { intervalMs, action: extra, kind: "prompt", name: "prompt", immediate: true }
628
- if (name === "loop-ask") return { intervalMs, action: extra, kind: "prompt", name: "ask", immediate: false }
629
- if (name === "loop-shell") return { intervalMs, action: extra, kind: "shell", name: "shell", immediate: false }
630
- 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"}` }
631
- 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." }
632
- 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." }
633
- 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." }
782
+ function presetDefaults(name) {
783
+ // parseLoopArgs owns duration/flag/action parsing. Presets only provide real
784
+ // defaults; deriving an action from the raw string made flag-only invocations
785
+ // such as `/loop-compact --dry-run` use "--dry-run" as the action.
786
+ if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
787
+ if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
788
+ if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
789
+ if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
790
+ if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
791
+ 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" }
792
+ 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." }
793
+ 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." }
794
+ 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." }
634
795
  }
635
796
 
636
797
  function jobLabel(job) {
@@ -705,7 +866,19 @@ async function notifyJob(directory, job, reason) {
705
866
 
706
867
  function dangerousShell(command) {
707
868
  const text = String(command || "").toLowerCase()
708
- 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))
869
+ return [
870
+ /\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)/,
871
+ /\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
872
+ /\bgit\s+reset\b/,
873
+ /\bgit\s+clean\b/,
874
+ /\bgit\s+push\b/,
875
+ /\bdel\b[^\r\n]*\s\/s\b/,
876
+ /\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
877
+ /(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
878
+ /\bterraform\s+destroy\b/,
879
+ /\bkubectl\s+delete\b/,
880
+ /\bdeploy\b.*\bproduction\b/,
881
+ ].some((pattern) => pattern.test(text))
709
882
  }
710
883
 
711
884
  function actionKind(action, job = {}) {
@@ -970,6 +1143,7 @@ function staleActiveRun(sessionID) {
970
1143
  }
971
1144
 
972
1145
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
1146
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
973
1147
  if (!options.requireIdle && !options.forceStale) return true
974
1148
  if (options.forceStale && staleActiveRun(sessionID)) return true
975
1149
  if (!options.requireIdle) return false
@@ -992,15 +1166,41 @@ async function readLiveSessionStatus(client, sessionID, directory) {
992
1166
  const error = sdkError(result)
993
1167
  if (error) continue
994
1168
  const data = sdkData(result)
1169
+ if (!data || typeof data !== "object" || Array.isArray(data)) continue
1170
+ const observedAt = now()
1171
+ for (const [observedSessionID, observedStatus] of Object.entries(data)) {
1172
+ const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
1173
+ if (typeof observedType !== "string") continue
1174
+ sessionStatuses.set(observedSessionID, observedType)
1175
+ sessionStatusSeenAt.set(observedSessionID, observedAt)
1176
+ }
1177
+ // OpenCode's status list contains active sessions; idle sessions are
1178
+ // normally omitted. Clear a completed descendant that was previously busy.
1179
+ for (const childID of sessionParents.keys()) {
1180
+ if (!isDescendantSession(childID, sessionID) || data[childID]) continue
1181
+ sessionStatuses.set(childID, "idle")
1182
+ sessionStatusSeenAt.set(childID, observedAt)
1183
+ }
1184
+ if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
995
1185
  const status = data?.[sessionID]
996
1186
  const type = status && typeof status === "object" ? status.type : undefined
997
1187
  if (typeof type === "string") return { type, source: "sdk" }
1188
+ return { type: "idle", source: "sdk" }
998
1189
  } catch {}
999
1190
  }
1000
1191
  return undefined
1001
1192
  }
1002
1193
 
1003
1194
  async function sessionStatusType(client, sessionID, directory, options = {}) {
1195
+ // OpenCode can briefly report an idle session while a long-running tool or
1196
+ // subtask is still executing. Tool lifecycle hooks are the more specific
1197
+ // signal here, so never enqueue another turn until every active call ends.
1198
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
1199
+ sessionStatuses.set(sessionID, "busy")
1200
+ sessionStatusSeenAt.set(sessionID, now())
1201
+ return "busy"
1202
+ }
1203
+
1004
1204
  const cached = sessionStatuses.get(sessionID)
1005
1205
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
1006
1206
 
@@ -1159,7 +1359,7 @@ function dueJobs(state, force = false) {
1159
1359
  if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
1160
1360
  if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
1161
1361
  if (force) return true
1162
- if (job.watchPaths?.length) return false
1362
+ if (job.watchPaths?.length) return job.watchTriggered === true
1163
1363
  return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
1164
1364
  })
1165
1365
  }
@@ -1467,16 +1667,18 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1467
1667
  async function fireAction(directory, client, sessionID, job) {
1468
1668
  const action = String(job.action || "").trim()
1469
1669
  const kind = actionKind(action, job)
1670
+ const agent = job.agent || "build"
1671
+ const model = normalizedModelRef(job.model)
1470
1672
  if (kind === "compact") {
1471
1673
  const ok = await compactSession(client, sessionID)
1472
- return { startsAssistantTurn: ok }
1674
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
1473
1675
  }
1474
1676
  if (kind === "command") {
1475
1677
  const normalized = action.startsWith("/") ? action.slice(1) : action
1476
1678
  const [command, argumentsText] = splitFirst(normalized)
1477
1679
  if (!command) {
1478
1680
  await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
1479
- return { startsAssistantTurn: false }
1681
+ return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
1480
1682
  }
1481
1683
  const tuiCommand = compactTuiCommandName(command)
1482
1684
  if (tuiCommand) {
@@ -1485,11 +1687,13 @@ async function fireAction(directory, client, sessionID, job) {
1485
1687
  return { startsAssistantTurn: true }
1486
1688
  }
1487
1689
  guardLoopOwnedUserMessage(sessionID)
1690
+ const commandBody = { command, arguments: argumentsText, agent }
1691
+ if (model) commandBody.model = `${model.providerID}/${model.modelID}`
1488
1692
  await sdkCall(
1489
1693
  client.session.command.bind(client.session),
1490
- { path: { id: sessionID }, body: { command, arguments: argumentsText } },
1491
- { path: { sessionID }, body: { command, arguments: argumentsText } },
1492
- { sessionID, command, arguments: argumentsText },
1694
+ { path: { id: sessionID }, body: commandBody },
1695
+ { path: { sessionID }, body: commandBody },
1696
+ { sessionID, ...commandBody },
1493
1697
  )
1494
1698
  return { startsAssistantTurn: true }
1495
1699
  }
@@ -1498,16 +1702,18 @@ async function fireAction(directory, client, sessionID, job) {
1498
1702
  if (job.safe && dangerousShell(command)) {
1499
1703
  await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
1500
1704
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1501
- return { startsAssistantTurn: false }
1705
+ return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
1502
1706
  }
1503
1707
  guardLoopOwnedUserMessage(sessionID)
1708
+ const shellBody = { command, agent }
1709
+ if (model) shellBody.model = model
1504
1710
  fireSdk(
1505
1711
  client,
1506
1712
  "session.shell",
1507
1713
  client.session.shell.bind(client.session),
1508
- { path: { id: sessionID }, body: { command } },
1509
- { path: { sessionID }, body: { command } },
1510
- { sessionID, command },
1714
+ { path: { id: sessionID }, body: shellBody },
1715
+ { path: { sessionID }, body: shellBody },
1716
+ { sessionID, ...shellBody },
1511
1717
  )
1512
1718
  return { startsAssistantTurn: true }
1513
1719
  }
@@ -1519,13 +1725,15 @@ async function fireAction(directory, client, sessionID, job) {
1519
1725
 
1520
1726
  ${prompt}`
1521
1727
  guardLoopOwnedUserMessage(sessionID)
1728
+ const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
1729
+ if (model) promptBody.model = model
1522
1730
  fireSdk(
1523
1731
  client,
1524
1732
  "session.prompt",
1525
1733
  client.session.prompt.bind(client.session),
1526
- { path: { id: sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1527
- { path: { sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1528
- { sessionID, parts: [{ type: "text", text: promptText }] },
1734
+ { path: { id: sessionID }, body: promptBody },
1735
+ { path: { sessionID }, body: promptBody },
1736
+ { sessionID, ...promptBody },
1529
1737
  )
1530
1738
  return { startsAssistantTurn: true }
1531
1739
  }
@@ -1559,7 +1767,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1559
1767
 
1560
1768
  const state = await readState(directory, sessionID)
1561
1769
  for (const candidate of state.jobs || []) {
1562
- if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
1770
+ if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.watchTriggered = true
1563
1771
  }
1564
1772
  const due = dueJobs(state, options.force)
1565
1773
  if (!due.length) {
@@ -1621,6 +1829,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1621
1829
 
1622
1830
  job = await ensureBranch(directory, job, client, sessionID)
1623
1831
  job = await maybeCompact(client, sessionID, job)
1832
+ job.watchTriggered = false
1624
1833
  job.lastRunAt = now()
1625
1834
  job.runCount = (job.runCount || 0) + 1
1626
1835
  if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
@@ -1636,6 +1845,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1636
1845
  const result = await fireAction(directory, client, sessionID, job)
1637
1846
  if (!result.startsAssistantTurn) {
1638
1847
  const fresh = await readState(directory, sessionID)
1848
+ if (result.pause) {
1849
+ fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
1850
+ ...candidate,
1851
+ paused: true,
1852
+ failureCount: (candidate.failureCount || 0) + 1,
1853
+ lastFailureReason: result.reason || "action_did_not_start",
1854
+ } : candidate)
1855
+ }
1639
1856
  fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1640
1857
  await writeState(directory, sessionID, fresh)
1641
1858
  await reschedule()
@@ -1674,6 +1891,20 @@ function sameLoopDefinition(a, b) {
1674
1891
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
1675
1892
  const parsed = parseLoopArgs(args, defaults)
1676
1893
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1894
+ const executionContext = sessionExecutionContexts.get(sessionID) || { agent: "build" }
1895
+ parsed.job.agent = defaults.agent || executionContext.agent || "build"
1896
+ parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
1897
+ if (defaults.testfixPreset) {
1898
+ const defaultCommand = String(defaults.verifyCommand || "npm test")
1899
+ const parsedAction = String(parsed.job.action || "").trim()
1900
+ const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
1901
+ if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
1902
+ parsed.job.verifyCommand = parsedAction
1903
+ parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
1904
+ } else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
1905
+ parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
1906
+ }
1907
+ }
1677
1908
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1678
1909
  if (!parsed.job.activeRecoveryMs) {
1679
1910
  parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
@@ -1889,20 +2120,32 @@ async function exportLoop(directory, client, sessionID) {
1889
2120
  await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
1890
2121
  }
1891
2122
 
1892
- async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
2123
+ async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
1893
2124
  const name = commandName(input?.command ?? input?.name ?? fallbackName)
1894
2125
  const sessionID = input?.sessionID
1895
2126
  const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
1896
2127
  if (!sessionID || !name) return false
1897
2128
  rememberSession(directory, client, sessionID)
1898
- if (wasHandled(sessionID, name, args)) return true
1899
- markHandled(sessionID, name, args)
2129
+ if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
2130
+ if (source === "event") {
2131
+ if (consumeHandled(sessionID, name, args)) return true
2132
+ const eventKey = commandEventKey(sessionID, input?.messageID)
2133
+ if (handledCommandEvents.has(eventKey)) return true
2134
+ handledCommandEvents.set(eventKey, now())
2135
+ } else {
2136
+ // Every before-hook invocation is an intentional command. Keep a pending
2137
+ // count only so the matching command.executed compatibility event can be
2138
+ // consumed without suppressing a genuine repeated command.
2139
+ markHandled(sessionID, name, args)
2140
+ }
1900
2141
  if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
1901
2142
 
1902
2143
  const handled = () => {
1903
2144
  if (output && Array.isArray(output.parts)) {
2145
+ // The command has already been completed through toasts/noReply prompts
2146
+ // and local state. Leaving a placeholder prompt starts an unnecessary
2147
+ // model turn; weaker agents may even call tools or spawn subagents.
1904
2148
  output.parts.length = 0
1905
- 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." })
1906
2149
  }
1907
2150
  return true
1908
2151
  }
@@ -1927,7 +2170,8 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1927
2170
  if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
1928
2171
  if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
1929
2172
  if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
1930
- handledCommands.delete(commandKey(sessionID, name, args))
2173
+ if (source === "event") handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID))
2174
+ else consumeHandled(sessionID, name, args)
1931
2175
  return false
1932
2176
  }
1933
2177
 
@@ -1970,15 +2214,27 @@ function goalTools(defaultDirectory) {
1970
2214
  }
1971
2215
 
1972
2216
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1973
- await log(client, "info", "Plugin initialized", { directory })
2217
+ // OpenCode's local SDK can be slow or unavailable while a project instance
2218
+ // is still waiting for its plugins to return their hooks. Defer bootstrap
2219
+ // calls so headless/server sessions cannot deadlock during plugin loading.
2220
+ const bootstrap = setTimeout(() => {
2221
+ log(client, "info", "Plugin initialized", { directory }).catch(() => {})
2222
+ refreshSessionRelationships(client, directory).catch(() => {})
2223
+ }, 0)
2224
+ bootstrap.unref?.()
1974
2225
  return {
1975
2226
  dispose: async () => { disposeRuntime(directory, client) },
1976
2227
  tool: goalTools(directory),
1977
2228
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
2229
+ "tool.execute.before": async (input) => { markToolCallActive(input) },
2230
+ "tool.execute.after": async (input) => { markToolCallFinished(input) },
1978
2231
  event: async ({ event }) => {
2232
+ updateSessionRelationshipFromEvent(event)
2233
+ if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
2234
+ updateToolActivityFromEvent(event)
1979
2235
  if (event.type === "command.executed") {
1980
2236
  const props = event.properties || {}
1981
- await handleCommand(directory, client, props, props.name, props.arguments)
2237
+ await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
1982
2238
  }
1983
2239
  const interruptedSessionID = userInterruptSessionFromEvent(event)
1984
2240
  if (interruptedSessionID) {