@bybrawe/opencode-loop 0.5.16 → 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 +11 -0
  2. package/README.md +26 -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 +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 +1 -1
  41. package/src/index.js +168 -42
@@ -1,36 +1,10 @@
1
1
  $ErrorActionPreference = "Stop"
2
2
 
3
- $ConfigDir = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".config\opencode" }
4
- $PluginDir = Join-Path $ConfigDir "plugins"
5
- $CommandDir = Join-Path $ConfigDir "commands"
6
3
  $Root = Split-Path -Parent $PSScriptRoot
4
+ $Installer = Join-Path $Root "scripts\install-node.mjs"
5
+ $Runtime = Get-Command node -ErrorAction SilentlyContinue
6
+ if (-not $Runtime) { $Runtime = Get-Command bun -ErrorAction SilentlyContinue }
7
+ if (-not $Runtime) { throw "OpenCode Loop source installation requires Node.js or Bun." }
7
8
 
8
- New-Item -ItemType Directory -Force -Path $PluginDir | Out-Null
9
- New-Item -ItemType Directory -Force -Path $CommandDir | Out-Null
10
-
11
- $PackageFile = Join-Path $ConfigDir "package.json"
12
- if (Test-Path $PackageFile) {
13
- try {
14
- $PackageJson = Get-Content -Raw -Path $PackageFile | ConvertFrom-Json
15
- if (-not $PackageJson.dependencies) {
16
- $PackageJson | Add-Member -MemberType NoteProperty -Name dependencies -Value ([pscustomobject]@{})
17
- }
18
- if (-not $PackageJson.dependencies.PSObject.Properties["@opencode-ai/plugin"]) {
19
- $PackageJson.dependencies | Add-Member -MemberType NoteProperty -Name "@opencode-ai/plugin" -Value ">=1.4.0"
20
- $PackageJson | ConvertTo-Json -Depth 20 | Set-Content -Path $PackageFile -Encoding UTF8
21
- }
22
- } catch {
23
- Write-Warning "Could not update $PackageFile. Add '@opencode-ai/plugin': '>=1.4.0' if OpenCode cannot load the local plugin."
24
- }
25
- } else {
26
- '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' | Set-Content -Path $PackageFile -Encoding UTF8
27
- }
28
-
29
- Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
30
- Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
31
- Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
32
-
33
- Write-Host "Installed OpenCode Loop plugin." -ForegroundColor Green
34
- Write-Host "Plugin: $PluginDir\opencode-loop.ts"
35
- Write-Host "Commands: $CommandDir\loop*.md"
36
- Write-Host "Restart OpenCode, then run: /loop-help"
9
+ & $Runtime.Source $Installer
10
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -1,42 +1,14 @@
1
1
  #!/usr/bin/env sh
2
2
  set -eu
3
3
 
4
- CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
5
- PLUGIN_DIR="$CONFIG_DIR/plugins"
6
- COMMAND_DIR="$CONFIG_DIR/commands"
7
4
  ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
8
5
 
9
- mkdir -p "$PLUGIN_DIR" "$COMMAND_DIR"
10
- PACKAGE_FILE="$CONFIG_DIR/package.json"
11
6
  if command -v node >/dev/null 2>&1; then
12
- PACKAGE_FILE="$PACKAGE_FILE" node --input-type=module - <<'NODE'
13
- import fs from "node:fs"
14
-
15
- const file = process.env.PACKAGE_FILE
16
- let pkg = {}
17
- try {
18
- pkg = JSON.parse(fs.readFileSync(file, "utf8"))
19
- } catch (error) {
20
- if (error.code !== "ENOENT") {
21
- console.warn(`Could not update ${file}: ${error.message}`)
22
- process.exit(0)
23
- }
24
- }
25
- if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
26
- if (!pkg.dependencies || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) pkg.dependencies = {}
27
- if (!pkg.dependencies["@opencode-ai/plugin"]) {
28
- pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
29
- fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n")
30
- }
31
- NODE
32
- elif [ ! -f "$PACKAGE_FILE" ]; then
33
- printf '%s\n' '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' > "$PACKAGE_FILE"
7
+ exec node "$ROOT_DIR/scripts/install-node.mjs"
8
+ fi
9
+ if command -v bun >/dev/null 2>&1; then
10
+ exec bun "$ROOT_DIR/scripts/install-node.mjs"
34
11
  fi
35
- cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
36
- rm -f "$PLUGIN_DIR/opencode-loop.js"
37
- cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
38
12
 
39
- echo "Installed OpenCode Loop plugin."
40
- echo "Plugin: $PLUGIN_DIR/opencode-loop.ts"
41
- echo "Commands: $COMMAND_DIR/loop*.md"
42
- echo "Restart OpenCode, then run: /loop-help"
13
+ echo "OpenCode Loop source installation requires Node.js or Bun." >&2
14
+ exit 1
@@ -83,7 +83,7 @@ try {
83
83
  sessionID,
84
84
  arguments: "Create proof.txt and verify it --max-turns 3",
85
85
  }, output)
86
- assert.match(output.parts[0].text, /handled by the local plugin/i)
86
+ assert.equal(output.parts.length, 0, "a locally handled slash command must not start a placeholder model turn")
87
87
 
88
88
  await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
89
89
  await new Promise((resolve) => setTimeout(resolve, 0))
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()
@@ -240,6 +243,7 @@ function parseLoopArgs(raw, defaults = {}) {
240
243
  lastCompactAt: 0,
241
244
  lastCompactRunCount: 0,
242
245
  watchSnapshot: {},
246
+ watchTriggered: false,
243
247
  createdAt: new Date().toISOString(),
244
248
  enabled: true,
245
249
  paused: false,
@@ -533,12 +537,26 @@ async function say(client, sessionID, text) {
533
537
 
534
538
  function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
535
539
  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)
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
538
557
  }
539
- function wasHandled(sessionID, name, args) {
540
- const time = handledCommands.get(commandKey(sessionID, name, args))
541
- return typeof time === "number" && now() - time < 30_000
558
+ function commandEventKey(sessionID, messageID) {
559
+ return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
542
560
  }
543
561
 
544
562
  function commandName(name) { return String(name || "") }
@@ -572,6 +590,48 @@ function rememberSession(directory, client, sessionID) {
572
590
  startHeartbeat()
573
591
  }
574
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
+
575
635
  function hasActiveToolCalls(sessionID) {
576
636
  return (activeToolCalls.get(sessionID)?.size || 0) > 0
577
637
  }
@@ -602,6 +662,8 @@ function updateSessionRelationship(info, removed = false) {
602
662
  if (typeof sessionID !== "string") return
603
663
  if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
604
664
  else sessionParents.set(sessionID, info.parentID)
665
+ if (!removed) updateSessionExecutionContext(info)
666
+ else sessionExecutionContexts.delete(sessionID)
605
667
  }
606
668
 
607
669
  function updateSessionRelationshipFromEvent(event) {
@@ -707,7 +769,9 @@ function disposeRuntime(directory, client) {
707
769
  sessionParents.delete(sessionID)
708
770
  sessionStatuses.delete(sessionID)
709
771
  sessionStatusSeenAt.delete(sessionID)
772
+ sessionExecutionContexts.delete(sessionID)
710
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)
711
775
  }
712
776
  if (!knownSessions.size && heartbeatTimer) {
713
777
  clearInterval(heartbeatTimer)
@@ -715,20 +779,19 @@ function disposeRuntime(directory, client) {
715
779
  }
716
780
  }
717
781
 
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." }
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." }
732
795
  }
733
796
 
734
797
  function jobLabel(job) {
@@ -803,7 +866,19 @@ async function notifyJob(directory, job, reason) {
803
866
 
804
867
  function dangerousShell(command) {
805
868
  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))
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))
807
882
  }
808
883
 
809
884
  function actionKind(action, job = {}) {
@@ -1284,7 +1359,7 @@ function dueJobs(state, force = false) {
1284
1359
  if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
1285
1360
  if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
1286
1361
  if (force) return true
1287
- if (job.watchPaths?.length) return false
1362
+ if (job.watchPaths?.length) return job.watchTriggered === true
1288
1363
  return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
1289
1364
  })
1290
1365
  }
@@ -1592,16 +1667,18 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1592
1667
  async function fireAction(directory, client, sessionID, job) {
1593
1668
  const action = String(job.action || "").trim()
1594
1669
  const kind = actionKind(action, job)
1670
+ const agent = job.agent || "build"
1671
+ const model = normalizedModelRef(job.model)
1595
1672
  if (kind === "compact") {
1596
1673
  const ok = await compactSession(client, sessionID)
1597
- return { startsAssistantTurn: ok }
1674
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
1598
1675
  }
1599
1676
  if (kind === "command") {
1600
1677
  const normalized = action.startsWith("/") ? action.slice(1) : action
1601
1678
  const [command, argumentsText] = splitFirst(normalized)
1602
1679
  if (!command) {
1603
1680
  await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
1604
- return { startsAssistantTurn: false }
1681
+ return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
1605
1682
  }
1606
1683
  const tuiCommand = compactTuiCommandName(command)
1607
1684
  if (tuiCommand) {
@@ -1610,11 +1687,13 @@ async function fireAction(directory, client, sessionID, job) {
1610
1687
  return { startsAssistantTurn: true }
1611
1688
  }
1612
1689
  guardLoopOwnedUserMessage(sessionID)
1690
+ const commandBody = { command, arguments: argumentsText, agent }
1691
+ if (model) commandBody.model = `${model.providerID}/${model.modelID}`
1613
1692
  await sdkCall(
1614
1693
  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 },
1694
+ { path: { id: sessionID }, body: commandBody },
1695
+ { path: { sessionID }, body: commandBody },
1696
+ { sessionID, ...commandBody },
1618
1697
  )
1619
1698
  return { startsAssistantTurn: true }
1620
1699
  }
@@ -1623,16 +1702,18 @@ async function fireAction(directory, client, sessionID, job) {
1623
1702
  if (job.safe && dangerousShell(command)) {
1624
1703
  await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
1625
1704
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1626
- return { startsAssistantTurn: false }
1705
+ return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
1627
1706
  }
1628
1707
  guardLoopOwnedUserMessage(sessionID)
1708
+ const shellBody = { command, agent }
1709
+ if (model) shellBody.model = model
1629
1710
  fireSdk(
1630
1711
  client,
1631
1712
  "session.shell",
1632
1713
  client.session.shell.bind(client.session),
1633
- { path: { id: sessionID }, body: { command } },
1634
- { path: { sessionID }, body: { command } },
1635
- { sessionID, command },
1714
+ { path: { id: sessionID }, body: shellBody },
1715
+ { path: { sessionID }, body: shellBody },
1716
+ { sessionID, ...shellBody },
1636
1717
  )
1637
1718
  return { startsAssistantTurn: true }
1638
1719
  }
@@ -1644,13 +1725,15 @@ async function fireAction(directory, client, sessionID, job) {
1644
1725
 
1645
1726
  ${prompt}`
1646
1727
  guardLoopOwnedUserMessage(sessionID)
1728
+ const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
1729
+ if (model) promptBody.model = model
1647
1730
  fireSdk(
1648
1731
  client,
1649
1732
  "session.prompt",
1650
1733
  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 }] },
1734
+ { path: { id: sessionID }, body: promptBody },
1735
+ { path: { sessionID }, body: promptBody },
1736
+ { sessionID, ...promptBody },
1654
1737
  )
1655
1738
  return { startsAssistantTurn: true }
1656
1739
  }
@@ -1684,7 +1767,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1684
1767
 
1685
1768
  const state = await readState(directory, sessionID)
1686
1769
  for (const candidate of state.jobs || []) {
1687
- 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
1688
1771
  }
1689
1772
  const due = dueJobs(state, options.force)
1690
1773
  if (!due.length) {
@@ -1746,6 +1829,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1746
1829
 
1747
1830
  job = await ensureBranch(directory, job, client, sessionID)
1748
1831
  job = await maybeCompact(client, sessionID, job)
1832
+ job.watchTriggered = false
1749
1833
  job.lastRunAt = now()
1750
1834
  job.runCount = (job.runCount || 0) + 1
1751
1835
  if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
@@ -1761,6 +1845,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1761
1845
  const result = await fireAction(directory, client, sessionID, job)
1762
1846
  if (!result.startsAssistantTurn) {
1763
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
+ }
1764
1856
  fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1765
1857
  await writeState(directory, sessionID, fresh)
1766
1858
  await reschedule()
@@ -1799,6 +1891,20 @@ function sameLoopDefinition(a, b) {
1799
1891
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
1800
1892
  const parsed = parseLoopArgs(args, defaults)
1801
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
+ }
1802
1908
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1803
1909
  if (!parsed.job.activeRecoveryMs) {
1804
1910
  parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
@@ -2014,20 +2120,32 @@ async function exportLoop(directory, client, sessionID) {
2014
2120
  await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
2015
2121
  }
2016
2122
 
2017
- async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output) {
2123
+ async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
2018
2124
  const name = commandName(input?.command ?? input?.name ?? fallbackName)
2019
2125
  const sessionID = input?.sessionID
2020
2126
  const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
2021
2127
  if (!sessionID || !name) return false
2022
2128
  rememberSession(directory, client, sessionID)
2023
- if (wasHandled(sessionID, name, args)) return true
2024
- 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
+ }
2025
2141
  if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
2026
2142
 
2027
2143
  const handled = () => {
2028
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.
2029
2148
  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
2149
  }
2032
2150
  return true
2033
2151
  }
@@ -2052,7 +2170,8 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
2052
2170
  if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
2053
2171
  if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
2054
2172
  if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
2055
- handledCommands.delete(commandKey(sessionID, name, args))
2173
+ if (source === "event") handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID))
2174
+ else consumeHandled(sessionID, name, args)
2056
2175
  return false
2057
2176
  }
2058
2177
 
@@ -2095,8 +2214,14 @@ function goalTools(defaultDirectory) {
2095
2214
  }
2096
2215
 
2097
2216
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2098
- await log(client, "info", "Plugin initialized", { directory })
2099
- await refreshSessionRelationships(client, 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?.()
2100
2225
  return {
2101
2226
  dispose: async () => { disposeRuntime(directory, client) },
2102
2227
  tool: goalTools(directory),
@@ -2105,10 +2230,11 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2105
2230
  "tool.execute.after": async (input) => { markToolCallFinished(input) },
2106
2231
  event: async ({ event }) => {
2107
2232
  updateSessionRelationshipFromEvent(event)
2233
+ if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
2108
2234
  updateToolActivityFromEvent(event)
2109
2235
  if (event.type === "command.executed") {
2110
2236
  const props = event.properties || {}
2111
- await handleCommand(directory, client, props, props.name, props.arguments)
2237
+ await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
2112
2238
  }
2113
2239
  const interruptedSessionID = userInterruptSessionFromEvent(event)
2114
2240
  if (interruptedSessionID) {