@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.
- package/CHANGELOG.md +11 -0
- package/README.md +26 -11
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +455 -0
- package/scripts/install-node.mjs +100 -4
- package/scripts/install-test.mjs +86 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/smoke-test.mjs +1 -1
- package/src/index.js +168 -42
package/scripts/install.ps1
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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 }
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 "
|
|
40
|
-
|
|
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
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
537
|
-
|
|
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
|
|
540
|
-
|
|
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
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (name === "loop-
|
|
724
|
-
if (name === "loop-
|
|
725
|
-
if (name === "loop-
|
|
726
|
-
if (name === "loop-
|
|
727
|
-
if (name === "loop-
|
|
728
|
-
if (name === "loop-
|
|
729
|
-
if (name === "loop-
|
|
730
|
-
|
|
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 [
|
|
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
|
|
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:
|
|
1616
|
-
{ path: { sessionID }, body:
|
|
1617
|
-
{ sessionID,
|
|
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:
|
|
1634
|
-
{ path: { sessionID }, body:
|
|
1635
|
-
{ sessionID,
|
|
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:
|
|
1652
|
-
{ path: { sessionID }, body:
|
|
1653
|
-
{ sessionID,
|
|
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.
|
|
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 (
|
|
2024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2099
|
-
|
|
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) {
|