@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.
- package/CHANGELOG.md +17 -0
- package/README.md +34 -13
- 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 +45 -2
- 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
|
-
|
|
535
|
-
|
|
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
|
|
538
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (name === "loop-
|
|
626
|
-
if (name === "loop-
|
|
627
|
-
if (name === "loop-
|
|
628
|
-
if (name === "loop-
|
|
629
|
-
if (name === "loop-
|
|
630
|
-
if (name === "loop-
|
|
631
|
-
if (name === "loop-
|
|
632
|
-
|
|
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 [
|
|
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
|
|
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:
|
|
1491
|
-
{ path: { sessionID }, body:
|
|
1492
|
-
{ sessionID,
|
|
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:
|
|
1509
|
-
{ path: { sessionID }, body:
|
|
1510
|
-
{ sessionID,
|
|
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:
|
|
1527
|
-
{ path: { sessionID }, body:
|
|
1528
|
-
{ sessionID,
|
|
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.
|
|
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 (
|
|
1899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|