@bybrawe/opencode-loop 0.5.38 → 0.6.1
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 +19 -0
- package/README.md +33 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/examples/OPENCODE2-0.6.1.md +60 -0
- package/package.json +15 -9
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +12 -6
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +118 -60
- package/scripts/install-test.mjs +3 -3
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +114 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +72 -4
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -19
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/session-status-test.mjs +12 -0
- package/src/index.js +87 -48
- package/src/native.js +3135 -0
- package/src/plugin.js +19 -0
- package/src/server.js +2164 -4560
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +9 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/commands.js +9 -4
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +20 -0
- package/src/source/opencode2/experimental.js +2 -0
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +2 -0
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/session-status.js +11 -1
- package/src/source/server.js +9 -1
- package/src/v1.js +1 -0
- package/src/v2.js +10 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { dedicatedGoalOwnsContinuation } from "../src/source/runtime/companion-goal.js"
|
|
4
|
+
|
|
5
|
+
const LOCK_STALE_MS = 30_000
|
|
6
|
+
const LOCK_RETRY_MS = 25
|
|
7
|
+
const LOCK_RETRIES = 80
|
|
8
|
+
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parsePulseDurationMs(value) {
|
|
14
|
+
const text = String(value ?? "").trim().toLowerCase()
|
|
15
|
+
if (text === "0" || text === "0s" || text === "now") return 0
|
|
16
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
|
|
17
|
+
if (!match) throw new Error(`Invalid duration: ${value}`)
|
|
18
|
+
const amount = Number(match[1])
|
|
19
|
+
const unit = match[2]
|
|
20
|
+
if (unit === "ms") return amount
|
|
21
|
+
if (unit.startsWith("s")) return amount * 1000
|
|
22
|
+
if (unit.startsWith("m")) return amount * 60_000
|
|
23
|
+
if (unit.startsWith("h")) return amount * 3_600_000
|
|
24
|
+
if (unit.startsWith("d")) return amount * 86_400_000
|
|
25
|
+
return amount
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pulseDirectory(project) {
|
|
29
|
+
return path.join(project, ".opencode", "opencode-loop")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pulseStateFile(project) {
|
|
33
|
+
return path.join(pulseDirectory(project), "pulse-check.json")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pulseLockDirectory(project) {
|
|
37
|
+
return path.join(pulseDirectory(project), "pulse-check.lock")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function goalDirectory(project) {
|
|
41
|
+
return path.join(project, ".opencode", "goals")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timestamp(value) {
|
|
45
|
+
const parsed = Number(value)
|
|
46
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function revision(value) {
|
|
50
|
+
const parsed = Number(value)
|
|
51
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function goalFingerprint(goal) {
|
|
55
|
+
return [
|
|
56
|
+
String(goal?.status || "unknown"),
|
|
57
|
+
timestamp(goal?.updatedAt) ?? 0,
|
|
58
|
+
revision(goal?.progressRevision),
|
|
59
|
+
revision(goal?.revision),
|
|
60
|
+
revision(goal?.storageGeneration),
|
|
61
|
+
].join(":")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatAge(ms) {
|
|
65
|
+
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`
|
|
66
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
|
|
67
|
+
if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(ms < 36_000_000 ? 1 : 0)}h`
|
|
68
|
+
return `${(ms / 86_400_000).toFixed(ms < 864_000_000 ? 1 : 0)}d`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function iso(value) {
|
|
72
|
+
const parsed = timestamp(value)
|
|
73
|
+
if (parsed === undefined) return "unknown"
|
|
74
|
+
try { return new Date(parsed).toISOString() } catch { return "unknown" }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readJson(file, fallback) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(await fs.readFile(file, "utf8"))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code === "ENOENT") return fallback
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeJsonAtomic(file, value) {
|
|
87
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
88
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
89
|
+
await fs.writeFile(temporary, JSON.stringify(value, null, 2) + "\n", "utf8")
|
|
90
|
+
try {
|
|
91
|
+
await fs.rename(temporary, file)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try { await fs.rm(temporary, { force: true }) } catch {}
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function acquireLock(project) {
|
|
99
|
+
const directory = pulseDirectory(project)
|
|
100
|
+
const lock = pulseLockDirectory(project)
|
|
101
|
+
await fs.mkdir(directory, { recursive: true })
|
|
102
|
+
|
|
103
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.mkdir(lock)
|
|
106
|
+
return async () => {
|
|
107
|
+
try { await fs.rm(lock, { recursive: true, force: true }) } catch {}
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code !== "EEXIST") throw error
|
|
111
|
+
try {
|
|
112
|
+
const stat = await fs.stat(lock)
|
|
113
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
114
|
+
await fs.rm(lock, { recursive: true, force: true })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
} catch (statError) {
|
|
118
|
+
if (statError?.code !== "ENOENT") throw statError
|
|
119
|
+
}
|
|
120
|
+
await sleep(LOCK_RETRY_MS)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error("Goal pulse-check state is busy; another checker still holds the lock")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readGoalSnapshots(project, target) {
|
|
128
|
+
let names
|
|
129
|
+
try {
|
|
130
|
+
names = await fs.readdir(goalDirectory(project))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code === "ENOENT") return []
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const goals = []
|
|
137
|
+
for (const name of names.sort()) {
|
|
138
|
+
if (!name.endsWith(".json")) continue
|
|
139
|
+
try {
|
|
140
|
+
const goal = JSON.parse(await fs.readFile(path.join(goalDirectory(project), name), "utf8"))
|
|
141
|
+
if (!goal?.id || !goal?.sessionID) continue
|
|
142
|
+
if (target && goal.id !== target && goal.sessionID !== target) continue
|
|
143
|
+
goals.push(goal)
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
return goals
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function checkGoalPulse(project, options = {}) {
|
|
150
|
+
const staleAfterMs = Number(options.staleAfterMs)
|
|
151
|
+
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new Error("staleAfterMs must be a non-negative number")
|
|
152
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
|
|
153
|
+
const target = options.target ? String(options.target) : undefined
|
|
154
|
+
const sessionActivity = options.sessionActivity && typeof options.sessionActivity === "object" ? options.sessionActivity : {}
|
|
155
|
+
const release = await acquireLock(project)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const goals = await readGoalSnapshots(project, target)
|
|
159
|
+
const stateFile = pulseStateFile(project)
|
|
160
|
+
const state = await readJson(stateFile, { schemaVersion: 1, goals: {} })
|
|
161
|
+
if (!state.goals || typeof state.goals !== "object") state.goals = {}
|
|
162
|
+
|
|
163
|
+
const alerts = []
|
|
164
|
+
for (const goal of goals) {
|
|
165
|
+
const id = String(goal.id)
|
|
166
|
+
const status = String(goal.status || "unknown")
|
|
167
|
+
const progressRevision = revision(goal.progressRevision)
|
|
168
|
+
const goalUpdatedAt = timestamp(goal.updatedAt) ?? now
|
|
169
|
+
const fingerprint = goalFingerprint(goal)
|
|
170
|
+
const previous = state.goals[id]
|
|
171
|
+
const changed = !previous || previous.fingerprint !== fingerprint
|
|
172
|
+
const statusChanged = Boolean(previous && previous.status !== status)
|
|
173
|
+
const progressChanged = Boolean(previous && Number(previous.progressRevision || 0) !== progressRevision)
|
|
174
|
+
const updatedAdvanced = Boolean(previous && goalUpdatedAt > Number(previous.goalUpdatedAt || 0))
|
|
175
|
+
|
|
176
|
+
let lastActivityAt
|
|
177
|
+
if (!previous) {
|
|
178
|
+
lastActivityAt = goalUpdatedAt
|
|
179
|
+
} else if (!changed) {
|
|
180
|
+
lastActivityAt = Number(previous.lastActivityAt || goalUpdatedAt)
|
|
181
|
+
} else if (updatedAdvanced) {
|
|
182
|
+
lastActivityAt = goalUpdatedAt
|
|
183
|
+
} else if (statusChanged || progressChanged || Number(previous.revision || 0) !== revision(goal.revision)) {
|
|
184
|
+
lastActivityAt = now
|
|
185
|
+
} else {
|
|
186
|
+
lastActivityAt = Math.max(Number(previous.lastActivityAt || 0), goalUpdatedAt)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let alerted = changed ? false : Boolean(previous?.alerted)
|
|
190
|
+
let newAlert = false
|
|
191
|
+
const active = dedicatedGoalOwnsContinuation(goal)
|
|
192
|
+
const ageMs = Math.max(0, now - lastActivityAt)
|
|
193
|
+
const sessionUpdatedAt = timestamp(sessionActivity[goal.sessionID])
|
|
194
|
+
|
|
195
|
+
if (!active) {
|
|
196
|
+
alerted = false
|
|
197
|
+
} else if (ageMs >= staleAfterMs && !alerted) {
|
|
198
|
+
alerted = true
|
|
199
|
+
newAlert = true
|
|
200
|
+
const message = [
|
|
201
|
+
"[opencode-loopd] GOAL PULSE STALLED",
|
|
202
|
+
`goal=${id}`,
|
|
203
|
+
`session=${goal.sessionID}`,
|
|
204
|
+
`stale=${formatAge(ageMs)}`,
|
|
205
|
+
`goalUpdated=${iso(goalUpdatedAt)}`,
|
|
206
|
+
sessionUpdatedAt === undefined ? undefined : `sessionUpdated=${iso(sessionUpdatedAt)}`,
|
|
207
|
+
'action="/goal resume"',
|
|
208
|
+
].filter(Boolean).join(" ")
|
|
209
|
+
alerts.push({
|
|
210
|
+
goalID: id,
|
|
211
|
+
sessionID: String(goal.sessionID),
|
|
212
|
+
ageMs,
|
|
213
|
+
goalUpdatedAt,
|
|
214
|
+
sessionUpdatedAt,
|
|
215
|
+
progressRevision,
|
|
216
|
+
message,
|
|
217
|
+
})
|
|
218
|
+
} else if (ageMs < staleAfterMs) {
|
|
219
|
+
alerted = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
state.goals[id] = {
|
|
223
|
+
fingerprint,
|
|
224
|
+
status,
|
|
225
|
+
progressRevision,
|
|
226
|
+
revision: revision(goal.revision),
|
|
227
|
+
goalUpdatedAt,
|
|
228
|
+
lastActivityAt,
|
|
229
|
+
alerted,
|
|
230
|
+
...(alerted ? { lastAlertAt: newAlert ? now : previous?.lastAlertAt } : {}),
|
|
231
|
+
sessionID: String(goal.sessionID),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
state.schemaVersion = 1
|
|
236
|
+
state.updatedAt = now
|
|
237
|
+
await writeJsonAtomic(stateFile, state)
|
|
238
|
+
return {
|
|
239
|
+
alerts,
|
|
240
|
+
checked: goals.length,
|
|
241
|
+
stateFile,
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
await release()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -231,6 +231,17 @@ try {
|
|
|
231
231
|
track(sessionID)
|
|
232
232
|
runtime.clearSessionStatus(sessionID)
|
|
233
233
|
const active = { jobId: "job", job: {}, startedAt: clock - 100 }
|
|
234
|
+
completion = "unknown"
|
|
235
|
+
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
|
|
236
|
+
|
|
237
|
+
completion = "incomplete"
|
|
238
|
+
assert.equal(
|
|
239
|
+
await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active),
|
|
240
|
+
false,
|
|
241
|
+
"a transient idle boundary must not finalize a Loop-owned run while the assistant turn is still incomplete",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
completion = "completed"
|
|
234
245
|
assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
|
|
235
246
|
|
|
236
247
|
markToolCallActive({ sessionID, callID: "tool-finalize" })
|
|
@@ -240,6 +251,7 @@ try {
|
|
|
240
251
|
runtime.markSessionStatus(sessionID, "idle", clock + 1)
|
|
241
252
|
const client = { session: { status: async () => { throw new Error("status unavailable") } } }
|
|
242
253
|
assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
|
|
254
|
+
completion = "unknown"
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
{
|
package/src/index.js
CHANGED
|
@@ -619,7 +619,9 @@ async function runProcess(command, args, cwd, timeoutMs = 60000) {
|
|
|
619
619
|
const child = spawn(command, args, { cwd, shell: false, windowsHide: true });
|
|
620
620
|
const stdout = [];
|
|
621
621
|
const stderr = [];
|
|
622
|
+
let timedOut = false;
|
|
622
623
|
const timer = setTimeout(() => {
|
|
624
|
+
timedOut = true;
|
|
623
625
|
try {
|
|
624
626
|
child.kill("SIGTERM");
|
|
625
627
|
} catch {}
|
|
@@ -632,7 +634,7 @@ async function runProcess(command, args, cwd, timeoutMs = 60000) {
|
|
|
632
634
|
});
|
|
633
635
|
child.on("close", (code) => {
|
|
634
636
|
clearTimeout(timer);
|
|
635
|
-
resolve({ code: code ??
|
|
637
|
+
resolve({ code: timedOut ? 124 : code ?? -1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
636
638
|
});
|
|
637
639
|
});
|
|
638
640
|
}
|
|
@@ -641,7 +643,9 @@ async function runShellCommand(command, cwd, timeoutMs = 120000) {
|
|
|
641
643
|
const child = spawn(command, [], { cwd, shell: true, windowsHide: true });
|
|
642
644
|
const stdout = [];
|
|
643
645
|
const stderr = [];
|
|
646
|
+
let timedOut = false;
|
|
644
647
|
const timer = setTimeout(() => {
|
|
648
|
+
timedOut = true;
|
|
645
649
|
try {
|
|
646
650
|
child.kill("SIGTERM");
|
|
647
651
|
} catch {}
|
|
@@ -654,7 +658,7 @@ async function runShellCommand(command, cwd, timeoutMs = 120000) {
|
|
|
654
658
|
});
|
|
655
659
|
child.on("close", (code) => {
|
|
656
660
|
clearTimeout(timer);
|
|
657
|
-
resolve({ code: code ??
|
|
661
|
+
resolve({ code: timedOut ? 124 : code ?? -1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
658
662
|
});
|
|
659
663
|
});
|
|
660
664
|
}
|
|
@@ -819,6 +823,8 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
|
|
|
819
823
|
if (!messages)
|
|
820
824
|
return "unknown";
|
|
821
825
|
const ordered = orderedSessionMessages(messages);
|
|
826
|
+
if (ordered.length === 0)
|
|
827
|
+
return "unknown";
|
|
822
828
|
const tail = ordered.at(-1);
|
|
823
829
|
const info = tail?.info || tail;
|
|
824
830
|
if (!info || info.role !== "assistant")
|
|
@@ -1238,6 +1244,20 @@ function goalStatusText(job) {
|
|
|
1238
1244
|
return "paused";
|
|
1239
1245
|
return status;
|
|
1240
1246
|
}
|
|
1247
|
+
function applyTestfixPreset(job, defaults = {}) {
|
|
1248
|
+
if (defaults.testfixPreset) {
|
|
1249
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test");
|
|
1250
|
+
const parsedAction = String(job.action || "").trim();
|
|
1251
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
|
|
1252
|
+
if (!usedDefaultAction && job.verifyCommand === defaults.verifyCommand) {
|
|
1253
|
+
job.verifyCommand = parsedAction;
|
|
1254
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
|
|
1255
|
+
} else if (usedDefaultAction && job.verifyCommand !== defaults.verifyCommand) {
|
|
1256
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${job.verifyCommand || defaultCommand}`;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return job;
|
|
1260
|
+
}
|
|
1241
1261
|
|
|
1242
1262
|
// src/source/opencode/command-router.js
|
|
1243
1263
|
var HANDLER_NAMES = [
|
|
@@ -1739,6 +1759,34 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1739
1759
|
import { promises as fs6 } from "fs";
|
|
1740
1760
|
import path7 from "path";
|
|
1741
1761
|
|
|
1762
|
+
// src/source/core/progress.js
|
|
1763
|
+
var DEFAULT_PROGRESS_MD = `# Progress
|
|
1764
|
+
|
|
1765
|
+
## Current Goal
|
|
1766
|
+
Describe the current project goal here.
|
|
1767
|
+
|
|
1768
|
+
## Agent Rules
|
|
1769
|
+
- Do not ask questions unless truly blocked.
|
|
1770
|
+
- Make reasonable assumptions and continue.
|
|
1771
|
+
- Work on unfinished TODOs in order.
|
|
1772
|
+
- Mark completed TODOs with [x].
|
|
1773
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
1774
|
+
- Run tests, lint, or build when available.
|
|
1775
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
1776
|
+
|
|
1777
|
+
## Active TODO
|
|
1778
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
1779
|
+
|
|
1780
|
+
## Completed
|
|
1781
|
+
- [x] Created progress.md.
|
|
1782
|
+
|
|
1783
|
+
## Backlog Ideas
|
|
1784
|
+
- [ ] Add more project-specific tasks here.
|
|
1785
|
+
|
|
1786
|
+
## Blocked
|
|
1787
|
+
- None.
|
|
1788
|
+
`;
|
|
1789
|
+
|
|
1742
1790
|
// src/source/runtime/companion-goal.js
|
|
1743
1791
|
import { promises as fs4 } from "fs";
|
|
1744
1792
|
import path5 from "path";
|
|
@@ -1916,32 +1964,6 @@ async function listPersistedLoopSessions(directory, currentSessionID) {
|
|
|
1916
1964
|
|
|
1917
1965
|
// src/source/opencode/loop-commands.js
|
|
1918
1966
|
var SERVICE2 = "opencode-loop";
|
|
1919
|
-
var DEFAULT_PROGRESS_MD = `# Progress
|
|
1920
|
-
|
|
1921
|
-
## Current Goal
|
|
1922
|
-
Describe the current project goal here.
|
|
1923
|
-
|
|
1924
|
-
## Agent Rules
|
|
1925
|
-
- Do not ask questions unless truly blocked.
|
|
1926
|
-
- Make reasonable assumptions and continue.
|
|
1927
|
-
- Work on unfinished TODOs in order.
|
|
1928
|
-
- Mark completed TODOs with [x].
|
|
1929
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
1930
|
-
- Run tests, lint, or build when available.
|
|
1931
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
1932
|
-
|
|
1933
|
-
## Active TODO
|
|
1934
|
-
- [ ] Review the project structure and pick the next safe improvement.
|
|
1935
|
-
|
|
1936
|
-
## Completed
|
|
1937
|
-
- [x] Created progress.md.
|
|
1938
|
-
|
|
1939
|
-
## Backlog Ideas
|
|
1940
|
-
- [ ] Add more project-specific tasks here.
|
|
1941
|
-
|
|
1942
|
-
## Blocked
|
|
1943
|
-
- None.
|
|
1944
|
-
`;
|
|
1945
1967
|
function requireFunction3(value, name) {
|
|
1946
1968
|
if (typeof value !== "function")
|
|
1947
1969
|
throw new TypeError(`createLoopCommandHandlers requires ${name}`);
|
|
@@ -2241,17 +2263,7 @@ function createLoopRegistration(options = {}) {
|
|
|
2241
2263
|
const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
|
|
2242
2264
|
parsed.job.agent = defaults.agent || executionContext.agent || "build";
|
|
2243
2265
|
parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
|
|
2244
|
-
|
|
2245
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test");
|
|
2246
|
-
const parsedAction = String(parsed.job.action || "").trim();
|
|
2247
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
|
|
2248
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
2249
|
-
parsed.job.verifyCommand = parsedAction;
|
|
2250
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
|
|
2251
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
2252
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2266
|
+
applyTestfixPreset(parsed.job, defaults);
|
|
2255
2267
|
if (parsed.job.watchPaths.length)
|
|
2256
2268
|
parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
|
|
2257
2269
|
if (!parsed.job.activeRecoveryMs) {
|
|
@@ -2776,13 +2788,14 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2776
2788
|
const branch = safeID(job.branch);
|
|
2777
2789
|
const inRepo = await runProcess2("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
|
|
2778
2790
|
if (inRepo.code !== 0) {
|
|
2779
|
-
job.branchDone =
|
|
2791
|
+
job.branchDone = false;
|
|
2792
|
+
job.branchUnavailable = true;
|
|
2780
2793
|
return job;
|
|
2781
2794
|
}
|
|
2782
2795
|
let result = await runProcess2("git", ["switch", branch], directory, 30000);
|
|
2783
2796
|
if (result.code !== 0)
|
|
2784
2797
|
result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
|
|
2785
|
-
job.branchDone =
|
|
2798
|
+
job.branchDone = result.code === 0;
|
|
2786
2799
|
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
2787
2800
|
await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
|
|
2788
2801
|
return job;
|
|
@@ -2822,6 +2835,10 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2822
2835
|
async function untilReached(directory, job) {
|
|
2823
2836
|
if (!job.until)
|
|
2824
2837
|
return false;
|
|
2838
|
+
const controlRoots = new Set([
|
|
2839
|
+
stateDir(directory),
|
|
2840
|
+
...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"].map((name) => path8.join(directory, ".opencode", name))
|
|
2841
|
+
].map((root) => path8.resolve(root).toLowerCase()));
|
|
2825
2842
|
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
|
|
2826
2843
|
for (const file of files)
|
|
2827
2844
|
if (await fileContains(path8.resolve(directory, file), job.until))
|
|
@@ -2842,6 +2859,8 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2842
2859
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
|
|
2843
2860
|
continue;
|
|
2844
2861
|
const full = path8.join(current, entry.name);
|
|
2862
|
+
if (controlRoots.has(path8.resolve(full).toLowerCase()))
|
|
2863
|
+
continue;
|
|
2845
2864
|
if (entry.isDirectory()) {
|
|
2846
2865
|
if (await walk(full))
|
|
2847
2866
|
return true;
|
|
@@ -2994,8 +3013,10 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2994
3013
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
2995
3014
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
2996
3015
|
return false;
|
|
2997
|
-
if (!options.requireIdle && !options.forceStale)
|
|
2998
|
-
|
|
3016
|
+
if (!options.requireIdle && !options.forceStale) {
|
|
3017
|
+
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
3018
|
+
return completion !== "incomplete";
|
|
3019
|
+
}
|
|
2999
3020
|
const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
3000
3021
|
if (settledAssistantCompletion(completion))
|
|
3001
3022
|
return true;
|
|
@@ -4268,6 +4289,7 @@ function createGoalSteeringRuntime(options = {}) {
|
|
|
4268
4289
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
4269
4290
|
const suppressionMs = Number.isFinite(Number(options.suppressionMs)) && Number(options.suppressionMs) > 0 ? Number(options.suppressionMs) : DEFAULT_STEERING_SUPPRESSION_MS;
|
|
4270
4291
|
const seenUserMessageMs = Number.isFinite(Number(options.seenUserMessageMs)) && Number(options.seenUserMessageMs) > 0 ? Number(options.seenUserMessageMs) : DEFAULT_SEEN_USER_MESSAGE_MS;
|
|
4292
|
+
const compactingSessions = new Set;
|
|
4271
4293
|
const pendingSteering = new Map;
|
|
4272
4294
|
const seenUserMessages = new Map;
|
|
4273
4295
|
function pendingForSession(sessionID) {
|
|
@@ -4341,7 +4363,7 @@ function createGoalSteeringRuntime(options = {}) {
|
|
|
4341
4363
|
return { handled: false, sessionID, messageID };
|
|
4342
4364
|
const active = getActiveRun(sessionID);
|
|
4343
4365
|
const activeGoalIDs = new Set(goals.map((goal) => goal.id));
|
|
4344
|
-
const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function";
|
|
4366
|
+
const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function";
|
|
4345
4367
|
let preempted = false;
|
|
4346
4368
|
let abortError = "";
|
|
4347
4369
|
if (canPreempt) {
|
|
@@ -4353,7 +4375,8 @@ function createGoalSteeringRuntime(options = {}) {
|
|
|
4353
4375
|
});
|
|
4354
4376
|
try {
|
|
4355
4377
|
await fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
4356
|
-
|
|
4378
|
+
if (getActiveRun(sessionID) === active)
|
|
4379
|
+
clearActiveRun(sessionID);
|
|
4357
4380
|
preempted = true;
|
|
4358
4381
|
} catch (error) {
|
|
4359
4382
|
pendingSteering.delete(sessionID);
|
|
@@ -4374,9 +4397,20 @@ function createGoalSteeringRuntime(options = {}) {
|
|
|
4374
4397
|
const user = userMessageFromEvent(event);
|
|
4375
4398
|
if (!user)
|
|
4376
4399
|
return;
|
|
4377
|
-
|
|
4400
|
+
if (alreadyHandled(user.sessionID, user.messageID))
|
|
4401
|
+
return { handled: false, duplicate: true, ...user };
|
|
4402
|
+
return { handled: false, reason: "untrusted-message-event", ...user };
|
|
4403
|
+
}
|
|
4404
|
+
function setCompacting(sessionID, value = true) {
|
|
4405
|
+
if (typeof sessionID !== "string" || !sessionID)
|
|
4406
|
+
return;
|
|
4407
|
+
if (value)
|
|
4408
|
+
compactingSessions.add(sessionID);
|
|
4409
|
+
else
|
|
4410
|
+
compactingSessions.delete(sessionID);
|
|
4378
4411
|
}
|
|
4379
4412
|
function clearSession(sessionID) {
|
|
4413
|
+
compactingSessions.delete(sessionID);
|
|
4380
4414
|
pendingSteering.delete(sessionID);
|
|
4381
4415
|
const prefix = `${sessionID}\x00`;
|
|
4382
4416
|
for (const key of seenUserMessages.keys())
|
|
@@ -4390,6 +4424,7 @@ function createGoalSteeringRuntime(options = {}) {
|
|
|
4390
4424
|
shouldSuppressIdle,
|
|
4391
4425
|
hasPendingSteering: shouldSuppressIdle,
|
|
4392
4426
|
pendingForSession,
|
|
4427
|
+
setCompacting,
|
|
4393
4428
|
clearSession
|
|
4394
4429
|
};
|
|
4395
4430
|
}
|
|
@@ -4616,10 +4651,10 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
4616
4651
|
"command.execute.before": async (input, output) => {
|
|
4617
4652
|
await handleCommand(directory, client, input, undefined, undefined, output);
|
|
4618
4653
|
},
|
|
4619
|
-
"chat.message": async (input) => {
|
|
4654
|
+
"chat.message": async (input, output) => {
|
|
4620
4655
|
const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
|
|
4621
4656
|
sessionID: input?.sessionID,
|
|
4622
|
-
messageID: input?.messageID
|
|
4657
|
+
messageID: input?.messageID || output?.message?.id
|
|
4623
4658
|
});
|
|
4624
4659
|
if (steering?.handled && steering.sessionID)
|
|
4625
4660
|
rememberSession(directory, client, steering.sessionID);
|
|
@@ -4631,9 +4666,13 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
4631
4666
|
markToolCallFinished(input);
|
|
4632
4667
|
},
|
|
4633
4668
|
"experimental.session.compacting": async (input) => {
|
|
4669
|
+
goalSteeringRuntime.setCompacting(input?.sessionID);
|
|
4634
4670
|
await noteLoopCompactionStarted(directory, input?.sessionID);
|
|
4635
4671
|
},
|
|
4636
4672
|
event: async ({ event }) => {
|
|
4673
|
+
if (["session.compacted", "session.error", "session.deleted"].includes(event?.type)) {
|
|
4674
|
+
goalSteeringRuntime.setCompacting(event?.properties?.sessionID || event?.properties?.info?.id, false);
|
|
4675
|
+
}
|
|
4637
4676
|
if (event.type === "session.compacted")
|
|
4638
4677
|
await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID);
|
|
4639
4678
|
updateSessionRelationshipFromEvent(event);
|