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