@bybrawe/opencode-loop 0.5.28 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS, now, safeID } from "../core/args.js"
|
|
4
|
+
import { matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
|
+
import { stateDir, ensureDir, readState, writeState } from "../core/state.js"
|
|
6
|
+
import { appendLoopLog, readSmallTextFile } from "../core/process.js"
|
|
7
|
+
|
|
8
|
+
const GOAL_REPORT_DIR = "goals"
|
|
9
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
10
|
+
|
|
11
|
+
export async function buildGoalPrompt(directory, job) {
|
|
12
|
+
const sections = []
|
|
13
|
+
sections.push(`Working directory:\n${path.resolve(directory)}\nKeep every file operation inside this directory. Prefer workspace-relative paths such as \"src/index.js\"; never turn a relative path into a root path such as \"/src/index.js\".`)
|
|
14
|
+
const objective = String(job.action || "").trim()
|
|
15
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
16
|
+
if (job.goalFile) {
|
|
17
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
18
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
19
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
20
|
+
}
|
|
21
|
+
if (job.promptFile) {
|
|
22
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
23
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
24
|
+
}
|
|
25
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
26
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
27
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
28
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
29
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
30
|
+
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
31
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) sections.push(`No-progress guard:\n${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`)
|
|
32
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
33
|
+
for (const file of job.includeFiles || []) {
|
|
34
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
35
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
39
|
+
|
|
40
|
+
You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
- Work on the next smallest useful step toward the goal.
|
|
44
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
45
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
46
|
+
- If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
|
|
47
|
+
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
48
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
49
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
50
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
51
|
+
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
52
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
53
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
54
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
55
|
+
|
|
56
|
+
${sections.join("\n\n---\n\n")}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function goalReportPath(directory, sessionID, job) {
|
|
60
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function goalReportText(job) {
|
|
64
|
+
const lines = []
|
|
65
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
66
|
+
lines.push("")
|
|
67
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
68
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
69
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
70
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
71
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
72
|
+
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
73
|
+
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
74
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
75
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
76
|
+
lines.push("")
|
|
77
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
78
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
79
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
80
|
+
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
81
|
+
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
82
|
+
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
83
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
84
|
+
if (job.lastGoalChecks?.length) {
|
|
85
|
+
lines.push("## Latest checks", "")
|
|
86
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
87
|
+
lines.push("")
|
|
88
|
+
}
|
|
89
|
+
if (job.goalProgress?.length) {
|
|
90
|
+
lines.push("## Progress", "")
|
|
91
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
92
|
+
lines.push("")
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function writeGoalReport(directory, sessionID, job) {
|
|
98
|
+
if (!isGoalJob(job)) return
|
|
99
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
100
|
+
await ensureDir(path.dirname(target))
|
|
101
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function pickGoalJob(state, target = "") {
|
|
105
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
106
|
+
if (!goals.length) return undefined
|
|
107
|
+
const text = String(target || "").trim()
|
|
108
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
109
|
+
return goals.find((job, index) => matchJob(job, text, index))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseGoalToolText(args, fields) {
|
|
113
|
+
const result = {}
|
|
114
|
+
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function hasConcreteGoalEvidence(value) {
|
|
119
|
+
const text = String(value || "").trim()
|
|
120
|
+
if (text.length < 24) return false
|
|
121
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
122
|
+
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
123
|
+
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
124
|
+
return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function goalChecksPassed(job) {
|
|
128
|
+
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function goalRequiresPassingChecks(job) {
|
|
132
|
+
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function goalProgressSnapshot(job) {
|
|
136
|
+
return {
|
|
137
|
+
status: job?.goalStatus || "",
|
|
138
|
+
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
139
|
+
evidence: String(job?.goalEvidence || ""),
|
|
140
|
+
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
141
|
+
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
142
|
+
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
143
|
+
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
148
|
+
const before = goalProgressSnapshot(beforeJob || {})
|
|
149
|
+
const after = goalProgressSnapshot(afterJob || {})
|
|
150
|
+
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
151
|
+
if (after.progressCount > before.progressCount) return true
|
|
152
|
+
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
153
|
+
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
154
|
+
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
159
|
+
job.goalCompletionRejectedAt = now()
|
|
160
|
+
job.goalCompletionRejectedReason = reason
|
|
161
|
+
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
162
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
163
|
+
await writeState(directory, sessionID, state)
|
|
164
|
+
await writeGoalReport(directory, sessionID, job)
|
|
165
|
+
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
166
|
+
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function setGoalComplete(directory, sessionID, args = {}) {
|
|
170
|
+
const state = await readState(directory, sessionID)
|
|
171
|
+
const job = pickGoalJob(state, args.target)
|
|
172
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
173
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
174
|
+
const manualOverride = args.manual === true || args.manualOverride === true
|
|
175
|
+
const completionEvidence = parsed.evidence || job.goalEvidence || ""
|
|
176
|
+
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
|
|
177
|
+
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
|
|
178
|
+
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
179
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
180
|
+
}
|
|
181
|
+
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
182
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
183
|
+
}
|
|
184
|
+
job.goalStatus = "completed"
|
|
185
|
+
job.enabled = false
|
|
186
|
+
job.paused = true
|
|
187
|
+
job.goalCompletedAt = now()
|
|
188
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
189
|
+
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
190
|
+
job.noProgressCount = 0
|
|
191
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
192
|
+
await writeState(directory, sessionID, state)
|
|
193
|
+
await writeGoalReport(directory, sessionID, job)
|
|
194
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
195
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
199
|
+
const state = await readState(directory, sessionID)
|
|
200
|
+
const job = pickGoalJob(state, args.target)
|
|
201
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
202
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
203
|
+
job.goalStatus = "blocked"
|
|
204
|
+
job.enabled = false
|
|
205
|
+
job.paused = true
|
|
206
|
+
job.goalBlockedAt = now()
|
|
207
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
208
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
209
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
210
|
+
await writeState(directory, sessionID, state)
|
|
211
|
+
await writeGoalReport(directory, sessionID, job)
|
|
212
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
213
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function setGoalProgress(directory, sessionID, args = {}) {
|
|
217
|
+
const state = await readState(directory, sessionID)
|
|
218
|
+
const job = pickGoalJob(state, args.target)
|
|
219
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
220
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
221
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
222
|
+
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
223
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
224
|
+
job.noProgressCount = 0
|
|
225
|
+
job.lastProgressAt = now()
|
|
226
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
227
|
+
await writeState(directory, sessionID, state)
|
|
228
|
+
await writeGoalReport(directory, sessionID, job)
|
|
229
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
230
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
231
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { now as defaultNow } from "../core/args.js"
|
|
2
|
+
import { isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { readState as defaultReadState } from "../core/state.js"
|
|
4
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
5
|
+
import { fireSdk as defaultFireSdk } from "../opencode/host.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_STEERING_SUPPRESSION_MS = 5 * 60_000
|
|
8
|
+
const DEFAULT_SEEN_USER_MESSAGE_MS = 10 * 60_000
|
|
9
|
+
|
|
10
|
+
function requireFunction(value, label) {
|
|
11
|
+
if (typeof value !== "function") throw new TypeError(`createGoalSteeringRuntime requires ${label}`)
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function messageInfo(event) {
|
|
16
|
+
if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
|
|
17
|
+
const props = event?.properties || {}
|
|
18
|
+
return props.info || props.message || props
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function userMessageFromEvent(event) {
|
|
22
|
+
const info = messageInfo(event)
|
|
23
|
+
if (!info || info.role !== "user") return undefined
|
|
24
|
+
const props = event?.properties || {}
|
|
25
|
+
const sessionID = info.sessionID || props.sessionID
|
|
26
|
+
if (typeof sessionID !== "string" || !sessionID) return undefined
|
|
27
|
+
const messageID = typeof (info.id || props.messageID) === "string" ? (info.id || props.messageID) : ""
|
|
28
|
+
return { sessionID, messageID }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assistantMessageFromEvent(event) {
|
|
32
|
+
const info = messageInfo(event)
|
|
33
|
+
if (!info || info.role !== "assistant") return undefined
|
|
34
|
+
const props = event?.properties || {}
|
|
35
|
+
const sessionID = info.sessionID || props.sessionID
|
|
36
|
+
if (typeof sessionID !== "string" || !sessionID) return undefined
|
|
37
|
+
const parentID = typeof (info.parentID || props.parentID) === "string" ? (info.parentID || props.parentID) : ""
|
|
38
|
+
const createdAt = Number(info?.time?.created || 0)
|
|
39
|
+
return { sessionID, parentID, createdAt: Number.isFinite(createdAt) ? createdAt : 0 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function activeGoalJobs(state) {
|
|
43
|
+
return (state?.jobs || []).filter((job) => {
|
|
44
|
+
if (!isGoalJob(job)) return false
|
|
45
|
+
if (job.paused || job.enabled === false) return false
|
|
46
|
+
return !["completed", "blocked", "cleared"].includes(job.goalStatus)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createGoalSteeringRuntime(options = {}) {
|
|
51
|
+
const getActiveRun = requireFunction(options.getActiveRun, "getActiveRun")
|
|
52
|
+
const clearActiveRun = requireFunction(options.clearActiveRun, "clearActiveRun")
|
|
53
|
+
const isLoopOwnedUserMessage = typeof options.isLoopOwnedUserMessage === "function" ? options.isLoopOwnedUserMessage : () => false
|
|
54
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
55
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
56
|
+
const fireSdk = typeof options.fireSdk === "function" ? options.fireSdk : defaultFireSdk
|
|
57
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
58
|
+
const suppressionMs = Number.isFinite(Number(options.suppressionMs)) && Number(options.suppressionMs) > 0
|
|
59
|
+
? Number(options.suppressionMs)
|
|
60
|
+
: DEFAULT_STEERING_SUPPRESSION_MS
|
|
61
|
+
const seenUserMessageMs = Number.isFinite(Number(options.seenUserMessageMs)) && Number(options.seenUserMessageMs) > 0
|
|
62
|
+
? Number(options.seenUserMessageMs)
|
|
63
|
+
: DEFAULT_SEEN_USER_MESSAGE_MS
|
|
64
|
+
|
|
65
|
+
const pendingSteering = new Map()
|
|
66
|
+
const seenUserMessages = new Map()
|
|
67
|
+
|
|
68
|
+
function pendingForSession(sessionID) {
|
|
69
|
+
const entry = pendingSteering.get(sessionID)
|
|
70
|
+
if (!entry) return undefined
|
|
71
|
+
if (entry.expiresAt <= now()) {
|
|
72
|
+
pendingSteering.delete(sessionID)
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
return entry
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shouldSuppressIdle(sessionID) {
|
|
79
|
+
return Boolean(pendingForSession(sessionID))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function seenKey(sessionID, messageID) {
|
|
83
|
+
return messageID ? `${sessionID}\u0000${messageID}` : ""
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function alreadyHandled(sessionID, messageID) {
|
|
87
|
+
const key = seenKey(sessionID, messageID)
|
|
88
|
+
if (!key) return false
|
|
89
|
+
const expiresAt = seenUserMessages.get(key)
|
|
90
|
+
if (!expiresAt) return false
|
|
91
|
+
if (expiresAt <= now()) {
|
|
92
|
+
seenUserMessages.delete(key)
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rememberHandled(sessionID, messageID) {
|
|
99
|
+
const key = seenKey(sessionID, messageID)
|
|
100
|
+
if (!key) return
|
|
101
|
+
const current = now()
|
|
102
|
+
seenUserMessages.set(key, current + seenUserMessageMs)
|
|
103
|
+
for (const [candidate, expiresAt] of seenUserMessages.entries()) {
|
|
104
|
+
if (expiresAt <= current) seenUserMessages.delete(candidate)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function observeAssistantMessage(event) {
|
|
109
|
+
const assistant = assistantMessageFromEvent(event)
|
|
110
|
+
if (!assistant) return false
|
|
111
|
+
const entry = pendingForSession(assistant.sessionID)
|
|
112
|
+
if (!entry) return false
|
|
113
|
+
const matchesMessage = entry.messageID && assistant.parentID === entry.messageID
|
|
114
|
+
const matchesFallback = !entry.messageID && (!assistant.createdAt || assistant.createdAt >= entry.armedAt)
|
|
115
|
+
if (!matchesMessage && !matchesFallback) return false
|
|
116
|
+
pendingSteering.delete(assistant.sessionID)
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleUserMessage(directory, client, user) {
|
|
121
|
+
const sessionID = typeof user?.sessionID === "string" ? user.sessionID : ""
|
|
122
|
+
const messageID = typeof user?.messageID === "string" ? user.messageID : ""
|
|
123
|
+
if (!sessionID) return undefined
|
|
124
|
+
if (alreadyHandled(sessionID, messageID)) return { handled: false, duplicate: true, sessionID, messageID }
|
|
125
|
+
rememberHandled(sessionID, messageID)
|
|
126
|
+
|
|
127
|
+
if (isLoopOwnedUserMessage(sessionID, messageID)) {
|
|
128
|
+
return { handled: false, loopOwned: true, sessionID, messageID }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = await readState(directory, sessionID)
|
|
132
|
+
const goals = activeGoalJobs(state)
|
|
133
|
+
if (!goals.length) return { handled: false, sessionID, messageID }
|
|
134
|
+
|
|
135
|
+
const active = getActiveRun(sessionID)
|
|
136
|
+
const activeGoalIDs = new Set(goals.map((goal) => goal.id))
|
|
137
|
+
const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
|
|
138
|
+
let preempted = false
|
|
139
|
+
let abortError = ""
|
|
140
|
+
|
|
141
|
+
if (canPreempt) {
|
|
142
|
+
pendingSteering.set(sessionID, {
|
|
143
|
+
messageID,
|
|
144
|
+
goalID: active.jobId,
|
|
145
|
+
armedAt: now(),
|
|
146
|
+
expiresAt: now() + suppressionMs,
|
|
147
|
+
})
|
|
148
|
+
try {
|
|
149
|
+
await fireSdk(
|
|
150
|
+
client,
|
|
151
|
+
"session.abort",
|
|
152
|
+
client.session.abort.bind(client.session),
|
|
153
|
+
{ path: { id: sessionID }, body: {} },
|
|
154
|
+
{ path: { sessionID }, body: {} },
|
|
155
|
+
{ sessionID },
|
|
156
|
+
)
|
|
157
|
+
clearActiveRun(sessionID)
|
|
158
|
+
preempted = true
|
|
159
|
+
} catch (error) {
|
|
160
|
+
pendingSteering.delete(sessionID)
|
|
161
|
+
abortError = error instanceof Error ? error.message : String(error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await appendLoopLog(directory, "goal-user-steering", {
|
|
166
|
+
sessionID,
|
|
167
|
+
messageID,
|
|
168
|
+
goals: goals.length,
|
|
169
|
+
preempted,
|
|
170
|
+
...(abortError ? { abortError } : {}),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return { handled: true, preempted, sessionID, messageID }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function handleEvent(directory, client, event) {
|
|
177
|
+
observeAssistantMessage(event)
|
|
178
|
+
const user = userMessageFromEvent(event)
|
|
179
|
+
if (!user) return undefined
|
|
180
|
+
return await handleUserMessage(directory, client, user)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function clearSession(sessionID) {
|
|
184
|
+
pendingSteering.delete(sessionID)
|
|
185
|
+
const prefix = `${sessionID}\u0000`
|
|
186
|
+
for (const key of seenUserMessages.keys()) if (key.startsWith(prefix)) seenUserMessages.delete(key)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
handleUserMessage,
|
|
191
|
+
handleEvent,
|
|
192
|
+
observeAssistantMessage,
|
|
193
|
+
shouldSuppressIdle,
|
|
194
|
+
hasPendingSteering: shouldSuppressIdle,
|
|
195
|
+
pendingForSession,
|
|
196
|
+
clearSession,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { safeID } from "../core/args.js"
|
|
4
|
+
import { decoratePrompt, isGoalJob } from "../core/jobs.js"
|
|
5
|
+
import { stateDir, ensureDir } from "../core/state.js"
|
|
6
|
+
import { appendLoopLog as defaultAppendLoopLog, readSmallTextFile as defaultReadSmallTextFile, runProcess as defaultRunProcess } from "../core/process.js"
|
|
7
|
+
import { buildGoalPrompt as defaultBuildGoalPrompt } from "./goal-runtime.js"
|
|
8
|
+
|
|
9
|
+
const MAX_SCAN_FILES = 200
|
|
10
|
+
const MAX_SCAN_BYTES = 2_000_000
|
|
11
|
+
|
|
12
|
+
function requireFunction(value, name) {
|
|
13
|
+
if (typeof value !== "function") throw new TypeError(`createJobWorkspaceRuntime requires ${name}`)
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function dangerousShell(command) {
|
|
18
|
+
const text = String(command || "").toLowerCase()
|
|
19
|
+
return [
|
|
20
|
+
/\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)/,
|
|
21
|
+
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
22
|
+
/\bgit\s+reset\b/,
|
|
23
|
+
/\bgit\s+clean\b/,
|
|
24
|
+
/\bgit\s+push\b/,
|
|
25
|
+
/\bdel\b[^\r\n]*\s\/s\b/,
|
|
26
|
+
/\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
|
|
27
|
+
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
28
|
+
/\bterraform\s+destroy\b/,
|
|
29
|
+
/\bkubectl\s+delete\b/,
|
|
30
|
+
/\bdeploy\b.*\bproduction\b/,
|
|
31
|
+
].some((pattern) => pattern.test(text))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createJobWorkspaceRuntime(options = {}) {
|
|
35
|
+
const toast = requireFunction(options.toast, "toast")
|
|
36
|
+
const runProcess = typeof options.runProcess === "function" ? options.runProcess : defaultRunProcess
|
|
37
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
38
|
+
const readSmallTextFile = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : defaultReadSmallTextFile
|
|
39
|
+
const buildGoalPrompt = typeof options.buildGoalPrompt === "function" ? options.buildGoalPrompt : defaultBuildGoalPrompt
|
|
40
|
+
|
|
41
|
+
async function buildPrompt(directory, job) {
|
|
42
|
+
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
43
|
+
const sections = []
|
|
44
|
+
if (job.promptFile) {
|
|
45
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
46
|
+
if (text.trim()) sections.push(`Instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
47
|
+
else sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`)
|
|
48
|
+
}
|
|
49
|
+
if (job.action) sections.push(decoratePrompt(job))
|
|
50
|
+
for (const file of job.includeFiles || []) {
|
|
51
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
52
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
53
|
+
}
|
|
54
|
+
return sections.join("\n\n---\n\n") || decoratePrompt(job)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function ensureBranch(directory, job, client, sessionID) {
|
|
58
|
+
if (!job.branch || job.branchDone) return job
|
|
59
|
+
const branch = safeID(job.branch)
|
|
60
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
61
|
+
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
62
|
+
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
63
|
+
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
64
|
+
job.branchDone = true
|
|
65
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
66
|
+
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
67
|
+
return job
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function snapshotPaths(directory, files) {
|
|
71
|
+
const snapshot = {}
|
|
72
|
+
for (const file of files || []) {
|
|
73
|
+
try {
|
|
74
|
+
const stat = await fs.stat(path.resolve(directory, file))
|
|
75
|
+
snapshot[file] = `${stat.mtimeMs}:${stat.size}`
|
|
76
|
+
} catch { snapshot[file] = "missing" }
|
|
77
|
+
}
|
|
78
|
+
return snapshot
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function watchChanged(directory, job) {
|
|
82
|
+
if (!job.watchPaths?.length) return false
|
|
83
|
+
const next = await snapshotPaths(directory, job.watchPaths)
|
|
84
|
+
const previous = job.watchSnapshot || {}
|
|
85
|
+
const changed = job.watchPaths.some((file) => previous[file] !== next[file])
|
|
86
|
+
if (changed) job.watchSnapshot = next
|
|
87
|
+
return changed
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fileContains(filePath, needle) {
|
|
91
|
+
try {
|
|
92
|
+
const stat = await fs.stat(filePath)
|
|
93
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false
|
|
94
|
+
return (await fs.readFile(filePath, "utf8")).includes(needle)
|
|
95
|
+
} catch { return false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function untilReached(directory, job) {
|
|
99
|
+
if (!job.until) return false
|
|
100
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
101
|
+
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
102
|
+
let scanned = 0
|
|
103
|
+
async function walk(current) {
|
|
104
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
105
|
+
let entries
|
|
106
|
+
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return false }
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
109
|
+
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
110
|
+
const full = path.join(current, entry.name)
|
|
111
|
+
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
112
|
+
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
return await walk(directory)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function createCheckpoint(directory, sessionID, job, client) {
|
|
120
|
+
if (!job.checkpointOnly && !job.gitCheckpoint) return
|
|
121
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
122
|
+
if (inRepo.code !== 0) return
|
|
123
|
+
const status = await runProcess("git", ["status", "--short"], directory, 30_000)
|
|
124
|
+
if (!status.stdout.trim()) return
|
|
125
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
126
|
+
const checkpointDir = path.join(stateDir(directory), "checkpoints", safeID(sessionID))
|
|
127
|
+
await ensureDir(checkpointDir)
|
|
128
|
+
const diff = await runProcess("git", ["diff", "--binary"], directory, 120_000)
|
|
129
|
+
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120_000)
|
|
130
|
+
const prefix = `${timestamp}-${safeID(job.name || job.id)}`
|
|
131
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr)
|
|
132
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}\n${staged.stdout}`)
|
|
133
|
+
if (job.gitCheckpoint) {
|
|
134
|
+
await runProcess("git", ["add", "-A"], directory, 120_000)
|
|
135
|
+
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120_000)
|
|
136
|
+
}
|
|
137
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
buildPrompt,
|
|
142
|
+
ensureBranch,
|
|
143
|
+
snapshotPaths,
|
|
144
|
+
watchChanged,
|
|
145
|
+
untilReached,
|
|
146
|
+
createCheckpoint,
|
|
147
|
+
}
|
|
148
|
+
}
|