@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,464 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { nativeGoalReservesSession } from "./native-companion.js"
|
|
3
|
+
import { parseLoopArgs, splitFirst } from "../core/args.js"
|
|
4
|
+
import { actionKind, matchJob, presetDefaults, applyTestfixPreset } from "../core/jobs.js"
|
|
5
|
+
import { normalizeLoopScheduleArgs } from "../core/schedule-syntax.js"
|
|
6
|
+
import { OPENCODE_LOOP_V2_PRESET_NAMES } from "./commands.js"
|
|
7
|
+
import { readState, writeState, removeState } from "../core/state.js"
|
|
8
|
+
import { appendLoopLog } from "../core/process.js"
|
|
9
|
+
import { formatOpenCode2LoopStatus } from "./status.js"
|
|
10
|
+
import { createNativeJobPolicy } from "./native-policy.js"
|
|
11
|
+
|
|
12
|
+
const PREFIX = "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Work directly on the configured task."
|
|
13
|
+
const result = (detail = {}) => ({ handled: true, dispatched: false, ...detail })
|
|
14
|
+
const id = () => `msg_loop_${randomUUID().replaceAll("-", "")}`
|
|
15
|
+
|
|
16
|
+
// This runtime uses native durable inbox admission. It never calls session.abort.
|
|
17
|
+
export function createNativeLoopRuntime(options = {}) {
|
|
18
|
+
if (typeof options.prompt !== "function") throw new TypeError("Native Loop requires prompt()")
|
|
19
|
+
const now = options.now || Date.now
|
|
20
|
+
const setTimer = options.setTimer || setTimeout
|
|
21
|
+
const clearTimer = options.clearTimer || clearTimeout
|
|
22
|
+
const policy = createNativeJobPolicy({ ...options, now })
|
|
23
|
+
const scopes = new Map()
|
|
24
|
+
let disposed = false
|
|
25
|
+
|
|
26
|
+
function scopeFor(event) {
|
|
27
|
+
const sessionID = String(event?.sessionID || "").trim()
|
|
28
|
+
const directory = String(event?.directory || options.directory || "").trim()
|
|
29
|
+
if (!sessionID || !directory) return undefined
|
|
30
|
+
const key = `${directory}\u0000${sessionID}`
|
|
31
|
+
if (!scopes.has(key)) scopes.set(key, { key, sessionID, directory, epoch: 0, busy: false, queue: Promise.resolve() })
|
|
32
|
+
return scopes.get(key)
|
|
33
|
+
}
|
|
34
|
+
const current = (scope) => !disposed && !scope.deleted
|
|
35
|
+
const read = (scope) => readState(scope.directory, scope.sessionID)
|
|
36
|
+
const save = (scope, state) => writeState(scope.directory, scope.sessionID, state)
|
|
37
|
+
const report = (error) => { try { options.onError?.(error) } catch {} }
|
|
38
|
+
function enqueue(scope, task) {
|
|
39
|
+
const pending = scope.queue.catch(() => {}).then(async () => {
|
|
40
|
+
if (!current(scope)) return result({ reason: "disposed" })
|
|
41
|
+
if (typeof options.scopeAllowed === "function" && !await options.scopeAllowed(scope)) {
|
|
42
|
+
scope.epoch++
|
|
43
|
+
clear(scope, "timer"); clear(scope, "deadline"); clear(scope, "companionTimer")
|
|
44
|
+
return result({ accepted: false, reason: "session-location-mismatch", error: "Native session location cannot be verified for this project/worktree." })
|
|
45
|
+
}
|
|
46
|
+
return current(scope) ? task() : result({ reason: "disposed" })
|
|
47
|
+
})
|
|
48
|
+
scope.queue = pending.catch(report)
|
|
49
|
+
return pending
|
|
50
|
+
}
|
|
51
|
+
function clear(scope, field) {
|
|
52
|
+
if (scope[field] !== undefined) clearTimer(scope[field])
|
|
53
|
+
delete scope[field]
|
|
54
|
+
}
|
|
55
|
+
function eligible(job) {
|
|
56
|
+
return job.enabled !== false && !job.paused && !(job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
57
|
+
}
|
|
58
|
+
function dueAt(job) {
|
|
59
|
+
if (job.runNowRequestedAt > 0) return now()
|
|
60
|
+
if (job.watchPaths?.length && !job.watchTriggered) return Infinity
|
|
61
|
+
if (job.lastRunAt > 0) return job.lastRunAt + Math.max(0, job.intervalMs || 0)
|
|
62
|
+
return job.immediate === false ? Date.parse(job.createdAt) + Math.max(0, job.intervalMs || 0) : now()
|
|
63
|
+
}
|
|
64
|
+
function blocked(job) {
|
|
65
|
+
const kind = actionKind(job.action, job)
|
|
66
|
+
if (kind === "goal") return ["Use the native @bybrawe/opencode-goal /goal workflow; legacy Loop Goal state is not silently converted."]
|
|
67
|
+
if (!["prompt", "command", "compact", "shell"].includes(kind)) return ["unsupported action kind"]
|
|
68
|
+
if (kind === "command" && (typeof options.command !== "function" || typeof options.wait !== "function")) return ["native command and wait capabilities are required"]
|
|
69
|
+
if ((kind === "compact" || job.compactEveryRuns > 0 || job.compactEveryMs > 0) && typeof options.compact !== "function") return ["native compaction capability is required"]
|
|
70
|
+
if (kind === "shell" && typeof options.shell !== "function") return ["native shell capability is required"]
|
|
71
|
+
if (job.noOverlap === false) return ["Native V2 sessions are serialized; remove --allow-overlap."]
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function schedule(scope) {
|
|
76
|
+
clear(scope, "timer")
|
|
77
|
+
if (!current(scope) || scope.busy || scope.active || scope.compaction) return
|
|
78
|
+
const state = await read(scope)
|
|
79
|
+
if (!current(scope) || scope.busy || scope.active || scope.compaction) return
|
|
80
|
+
let delay = Infinity
|
|
81
|
+
for (const job of state.jobs || []) {
|
|
82
|
+
if (!eligible(job) || job.v2Run || blocked(job).length) continue
|
|
83
|
+
if (job.watchPaths?.length || job.stopFile || job.until) delay = Math.min(delay, 1000)
|
|
84
|
+
const due = dueAt(job)
|
|
85
|
+
if (due > now()) delay = Math.min(delay, due - now())
|
|
86
|
+
// A zero-delay deferred preset still needs a first idle-safe wake after
|
|
87
|
+
// its local command returns; it must not wait for an unrelated model turn.
|
|
88
|
+
else if (job.immediate === false && !(job.lastRunAt > 0)) delay = Math.min(delay, 1)
|
|
89
|
+
if (job.maxRuntimeMs > 0) delay = Math.min(delay, Math.max(1, Date.parse(job.createdAt) + job.maxRuntimeMs - now()))
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(delay)) return
|
|
92
|
+
scope.timer = setTimer(() => {
|
|
93
|
+
delete scope.timer
|
|
94
|
+
return enqueue(scope, () => advance(scope)).catch(report)
|
|
95
|
+
}, Math.max(1, Math.min(delay, 2_147_483_647)))
|
|
96
|
+
scope.timer?.unref?.()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function note(scope, name, detail = {}) {
|
|
100
|
+
await appendLoopLog(scope.directory, `v2-native-${name}`, { sessionID: scope.sessionID, ...detail })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pauseActive(scope, reason, failed = true) {
|
|
104
|
+
clear(scope, "deadline")
|
|
105
|
+
const state = await read(scope)
|
|
106
|
+
for (const job of state.jobs || []) {
|
|
107
|
+
if (scope.active ? job.id !== scope.active.jobID : !eligible(job)) continue
|
|
108
|
+
policy.pause(job, reason, failed)
|
|
109
|
+
}
|
|
110
|
+
await save(scope, state)
|
|
111
|
+
await cancelPending(scope, state)
|
|
112
|
+
await note(scope, "paused", { reason })
|
|
113
|
+
return result({ reason })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cancelPending(scope, state) {
|
|
117
|
+
const run = scope.active
|
|
118
|
+
if (!run || run.delivered || !["prompt", "command"].includes(run.kind) || !run.inboxID || typeof options.cancel !== "function") return false
|
|
119
|
+
let cancelled = false
|
|
120
|
+
try { cancelled = (await options.cancel({ sessionID: scope.sessionID, inboxID: run.inboxID })) === true } catch (error) { report(error) }
|
|
121
|
+
if (!cancelled) return false
|
|
122
|
+
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
123
|
+
if (job?.v2Run?.id === run.id) {
|
|
124
|
+
// Refund only a proven undelivered cancellation, not an interrupted model turn.
|
|
125
|
+
Object.assign(job, run.previous)
|
|
126
|
+
delete job.v2Run
|
|
127
|
+
await save(scope, state)
|
|
128
|
+
}
|
|
129
|
+
if (scope.active === run) delete scope.active
|
|
130
|
+
clear(scope, "deadline")
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function finish(scope) {
|
|
135
|
+
const run = scope.active
|
|
136
|
+
if (!run || scope.compaction || scope.busy) return result({ reason: "no-terminal-boundary" })
|
|
137
|
+
if ((run.kind === "prompt" && !run.delivered) || (run.kind === "command" && !run.commandFinished) || (run.kind === "shell" && !run.shellFinished) || (["compact", "cadence"].includes(run.kind) && !run.compactFinished)) {
|
|
138
|
+
return result({ reason: "awaiting-owned-completion" })
|
|
139
|
+
}
|
|
140
|
+
clear(scope, "deadline")
|
|
141
|
+
const state = await read(scope)
|
|
142
|
+
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
143
|
+
if (job?.v2Run?.id === run.id) {
|
|
144
|
+
if (["compact", "cadence"].includes(run.kind)) {
|
|
145
|
+
job.lastCompactAt = now()
|
|
146
|
+
job.lastCompactRunCount = job.runCount || 0
|
|
147
|
+
} else if (!job.paused && !run.foreground) {
|
|
148
|
+
await policy.finish(scope, job, () => current(scope) && scope.active === run && !run.foreground)
|
|
149
|
+
}
|
|
150
|
+
delete job.v2Run
|
|
151
|
+
job.lastCompletedAt = now()
|
|
152
|
+
await save(scope, state)
|
|
153
|
+
if (job.paused || job.enabled === false) await policy.notify(scope, job, job.pauseReason || "completed")
|
|
154
|
+
}
|
|
155
|
+
if (scope.active === run) delete scope.active
|
|
156
|
+
await note(scope, "completed", { jobID: run.jobID, kind: run.kind, foreground: Boolean(run.foreground) })
|
|
157
|
+
return advance(scope)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function dispatch(scope, state, job, kind) {
|
|
161
|
+
const epoch = scope.epoch
|
|
162
|
+
const isCurrent = () => current(scope) && scope.epoch === epoch && !scope.busy && !scope.compaction
|
|
163
|
+
let request
|
|
164
|
+
if (kind === "prompt") request = { sessionID: scope.sessionID, id: id(), text: `${PREFIX}\n\n${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata: { opencode_loop_v2: true, opencode_loop_job: job.id } }
|
|
165
|
+
else if (kind === "compact" || kind === "cadence") request = { sessionID: scope.sessionID, id: id(), delivery: "queue" }
|
|
166
|
+
else if (kind === "shell") request = { sessionID: scope.sessionID, id: id(), command: String(job.action).replace(/^[!$]\s*/, ""), timeoutMs: job.timeoutMs }
|
|
167
|
+
else {
|
|
168
|
+
const [name, text] = splitFirst(String(job.action).replace(/^\/+/, ""))
|
|
169
|
+
request = { sessionID: scope.sessionID, name, text: text || "", delivery: "queue" }
|
|
170
|
+
}
|
|
171
|
+
if (!isCurrent()) return result({ reason: "foreground-or-compaction" })
|
|
172
|
+
if (job.dryRun) {
|
|
173
|
+
policy.pause(job, "dry-run")
|
|
174
|
+
await save(scope, state)
|
|
175
|
+
return result({ dryRun: true, request })
|
|
176
|
+
}
|
|
177
|
+
if (kind === "shell" && job.safe) {
|
|
178
|
+
const { dangerousShell } = await import("../runtime/job-workspace.js")
|
|
179
|
+
if (dangerousShell(request.command)) {
|
|
180
|
+
policy.pause(job, "unsafe-shell", true)
|
|
181
|
+
await save(scope, state)
|
|
182
|
+
return result({ reason: "unsafe-shell" })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const run = {
|
|
186
|
+
id: request.id || id(), jobID: job.id, kind, createdAt: now(), delivered: false,
|
|
187
|
+
previous: { runCount: job.runCount || 0, lastRunAt: job.lastRunAt || 0, enabled: job.enabled },
|
|
188
|
+
}
|
|
189
|
+
job.v2Run = { id: run.id, kind, status: "admitting", createdAt: run.createdAt }
|
|
190
|
+
await save(scope, state)
|
|
191
|
+
job = state.jobs.find((entry) => entry.id === run.jobID)
|
|
192
|
+
if (!isCurrent() || !job || !eligible(job)) {
|
|
193
|
+
if (job?.v2Run?.id === run.id) { delete job.v2Run; await save(scope, state) }
|
|
194
|
+
return result({ reason: "admission-withdrawn" })
|
|
195
|
+
}
|
|
196
|
+
scope.active = run
|
|
197
|
+
clear(scope, "timer")
|
|
198
|
+
try {
|
|
199
|
+
let response
|
|
200
|
+
if (kind === "prompt") response = await options.prompt(request)
|
|
201
|
+
else if (kind === "compact" || kind === "cadence") response = await options.compact(request)
|
|
202
|
+
else if (kind === "shell") response = await options.shell(request)
|
|
203
|
+
else response = await options.command(request)
|
|
204
|
+
if (response?.error || response?.accepted === false) throw new Error(String(response.error?.message || response.error || "Native admission rejected"))
|
|
205
|
+
run.inboxID = response?.id || response?.data?.id || request.id
|
|
206
|
+
run.shellID = kind === "shell" ? (response?.id || response?.shell?.id || request.id) : undefined
|
|
207
|
+
if (kind !== "cadence") {
|
|
208
|
+
job.runCount = (job.runCount || 0) + 1
|
|
209
|
+
job.lastRunAt = now()
|
|
210
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) job.enabled = false
|
|
211
|
+
}
|
|
212
|
+
delete job.runNowRequestedAt
|
|
213
|
+
job.watchTriggered = false
|
|
214
|
+
job.v2Run = { ...job.v2Run, status: "admitted", inboxID: run.inboxID, shellID: run.shellID }
|
|
215
|
+
await save(scope, state)
|
|
216
|
+
await note(scope, "admitted", { jobID: job.id, kind, inboxID: run.inboxID, runCount: job.runCount })
|
|
217
|
+
if (current(scope) && job.timeoutMs > 0) {
|
|
218
|
+
scope.deadline = setTimer(() => enqueue(scope, () => scope.active === run ? pauseActive(scope, "timeout-waiting-for-host-boundary") : result()).catch(report), Math.min(job.timeoutMs, 2_147_483_647))
|
|
219
|
+
scope.deadline?.unref?.()
|
|
220
|
+
}
|
|
221
|
+
if (kind === "command") {
|
|
222
|
+
Promise.resolve().then(() => options.wait({ sessionID: scope.sessionID })).then(
|
|
223
|
+
() => enqueue(scope, async () => { if (scope.active !== run) return result(); run.commandFinished = true; return finish(scope) }),
|
|
224
|
+
() => enqueue(scope, () => pauseActive(scope, "command-failed")),
|
|
225
|
+
).catch(report)
|
|
226
|
+
}
|
|
227
|
+
if (kind === "shell" && response?.status && response.status !== "running") {
|
|
228
|
+
run.shellFinished = true
|
|
229
|
+
if (response.status !== "exited" || Number(response.exit || 0) !== 0) return pauseActive(scope, "shell-failed")
|
|
230
|
+
return finish(scope)
|
|
231
|
+
}
|
|
232
|
+
return result({ dispatched: true, job, kind, request })
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// A lost response may follow a successful host admission. Preserve its ID
|
|
235
|
+
// and pause instead of charging a run or blindly creating another input.
|
|
236
|
+
policy.pause(job, "admission-uncertain", true)
|
|
237
|
+
job.lastError = error instanceof Error ? error.message : String(error)
|
|
238
|
+
await save(scope, state)
|
|
239
|
+
await note(scope, "admission-failed", { jobID: job.id, message: job.lastError })
|
|
240
|
+
return result({ reason: "admission-uncertain", error: job.lastError })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function advance(scope) {
|
|
245
|
+
if (!current(scope) || scope.busy || scope.compaction || scope.active) return result({ reason: "host-not-idle" })
|
|
246
|
+
const epoch = scope.epoch
|
|
247
|
+
const state = await read(scope)
|
|
248
|
+
clear(scope, "companionTimer")
|
|
249
|
+
if (!(state.jobs || []).some(eligible)) return result()
|
|
250
|
+
if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) {
|
|
251
|
+
if (current(scope)) {
|
|
252
|
+
scope.companionTimer = setTimer(() => {
|
|
253
|
+
delete scope.companionTimer
|
|
254
|
+
return enqueue(scope, () => advance(scope)).catch(report)
|
|
255
|
+
}, 1000)
|
|
256
|
+
scope.companionTimer?.unref?.()
|
|
257
|
+
}
|
|
258
|
+
return result({ reason: "dedicated-goal-owns-session" })
|
|
259
|
+
}
|
|
260
|
+
for (const job of state.jobs || []) {
|
|
261
|
+
if (!eligible(job)) continue
|
|
262
|
+
if (job.v2Run) {
|
|
263
|
+
policy.pause(job, "restart-requires-review")
|
|
264
|
+
await save(scope, state)
|
|
265
|
+
return result({ reason: "restart-requires-review" })
|
|
266
|
+
}
|
|
267
|
+
if (blocked(job).length) continue
|
|
268
|
+
if (job.watchPaths?.length && await policy.watchChanged(scope.directory, job)) job.watchTriggered = true
|
|
269
|
+
const safe = () => current(scope) && !scope.busy && !scope.compaction && !scope.active && scope.epoch === epoch
|
|
270
|
+
if (!safe()) return result({ reason: "foreground-or-compaction" })
|
|
271
|
+
// Stop conditions apply even when a future timer or watch is not due.
|
|
272
|
+
if (!await policy.checkStop(scope, job)) {
|
|
273
|
+
await save(scope, state)
|
|
274
|
+
await policy.notify(scope, job, job.pauseReason)
|
|
275
|
+
return advance(scope)
|
|
276
|
+
}
|
|
277
|
+
if (dueAt(job) > now()) continue
|
|
278
|
+
if (!await policy.prepare(scope, job, safe)) {
|
|
279
|
+
if (job.paused || job.enabled === false) { await save(scope, state); await policy.notify(scope, job, job.pauseReason); return advance(scope) }
|
|
280
|
+
return result({ reason: job.pauseReason || "foreground-or-compaction" })
|
|
281
|
+
}
|
|
282
|
+
const kind = actionKind(job.action, job)
|
|
283
|
+
const cadence = kind !== "compact" && job.runCount > 0 && (
|
|
284
|
+
(job.compactEveryRuns > 0 && job.runCount - (job.lastCompactRunCount || 0) >= job.compactEveryRuns) ||
|
|
285
|
+
(job.compactEveryMs > 0 && now() - (job.lastCompactAt || Date.parse(job.createdAt)) >= job.compactEveryMs)
|
|
286
|
+
)
|
|
287
|
+
return dispatch(scope, state, job, cadence ? "cadence" : kind)
|
|
288
|
+
}
|
|
289
|
+
await schedule(scope)
|
|
290
|
+
return result()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function command(scope, event) {
|
|
294
|
+
const state = await read(scope)
|
|
295
|
+
const target = String(event.arguments || "").trim() || "all"
|
|
296
|
+
if (event.name === "loop" || OPENCODE_LOOP_V2_PRESET_NAMES.includes(event.name)) {
|
|
297
|
+
const defaults = event.name === "loop" ? {} : presetDefaults(event.name)
|
|
298
|
+
const normalized = normalizeLoopScheduleArgs(event.arguments || "", defaults)
|
|
299
|
+
if (!normalized.ok) return result({ accepted: false, error: normalized.error })
|
|
300
|
+
if (normalized.allowGoalOverlap) return result({ accepted: false, error: "Native Goal session reservation cannot be overridden; use another session." })
|
|
301
|
+
const parsed = parseLoopArgs(normalized.args, normalized.defaults)
|
|
302
|
+
if (!parsed.ok) return result({ accepted: false, error: parsed.error })
|
|
303
|
+
parsed.job.scheduleMode = normalized.scheduleMode
|
|
304
|
+
parsed.job.scheduleSyntax = normalized.scheduleSyntax
|
|
305
|
+
if (normalized.scheduleSyntax === "after") {
|
|
306
|
+
parsed.job.immediate = false
|
|
307
|
+
parsed.job.maxRuns = 1
|
|
308
|
+
}
|
|
309
|
+
applyTestfixPreset(parsed.job, defaults)
|
|
310
|
+
const blockers = blocked(parsed.job)
|
|
311
|
+
if (blockers.length) return result({ accepted: false, reason: "unsupported", blockers, error: blockers.join(" ") })
|
|
312
|
+
const job = parsed.job
|
|
313
|
+
job.name = String(job.name || "default")
|
|
314
|
+
job.createdAt = new Date(now()).toISOString()
|
|
315
|
+
job.lastRunAt = 0
|
|
316
|
+
if (job.watchPaths?.length) job.watchSnapshot = await policy.snapshotPaths(scope.directory, job.watchPaths)
|
|
317
|
+
const jobs = state.jobs || []
|
|
318
|
+
if (!job.multi && jobs.some((other) => (other.name || "default") === job.name && other.v2Run)) return result({ accepted: false, error: "Pause/stop the existing in-flight job before replacing it." })
|
|
319
|
+
state.jobs = job.multi ? jobs : jobs.filter((other) => (other.name || "default") !== job.name)
|
|
320
|
+
state.jobs.push(job)
|
|
321
|
+
await save(scope, state)
|
|
322
|
+
await schedule(scope)
|
|
323
|
+
return result({ accepted: true, job })
|
|
324
|
+
}
|
|
325
|
+
if (event.name === "loop-status" || event.name === "loop-export") {
|
|
326
|
+
const status = formatOpenCode2LoopStatus(state, now())
|
|
327
|
+
const text = event.name === "loop-export" ? JSON.stringify(state, null, 2) : status.text
|
|
328
|
+
await options.prompt({ sessionID: scope.sessionID, text, resume: false, metadata: { opencode_loop_v2: true } })
|
|
329
|
+
return result({ accepted: true, status })
|
|
330
|
+
}
|
|
331
|
+
if (!["loop-now", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"].includes(event.name)) return { handled: false }
|
|
332
|
+
const matches = (job, index) => event.name === "loop-clear" || matchJob(job, target, index)
|
|
333
|
+
let count = 0
|
|
334
|
+
for (const [index, job] of (state.jobs || []).entries()) {
|
|
335
|
+
if (!matches(job, index)) continue
|
|
336
|
+
count++
|
|
337
|
+
if (["loop-pause", "loop-stop", "loop-remove", "loop-clear"].includes(event.name)) {
|
|
338
|
+
policy.pause(job, "user-paused")
|
|
339
|
+
} else if (!job.v2Run) {
|
|
340
|
+
job.paused = false
|
|
341
|
+
delete job.pauseReason
|
|
342
|
+
if (event.name === "loop-now" || event.name === "loop-resume") job.runNowRequestedAt = Math.max(1, now())
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await save(scope, state)
|
|
346
|
+
if (state.jobs.some((job) => job.id === scope.active?.jobID && job.paused)) await cancelPending(scope, state)
|
|
347
|
+
if (["loop-stop", "loop-remove", "loop-clear"].includes(event.name)) {
|
|
348
|
+
state.jobs = state.jobs.filter((job, index) => !matches(job, index))
|
|
349
|
+
if (!state.jobs.length) await removeState(scope.directory, scope.sessionID)
|
|
350
|
+
else await save(scope, state)
|
|
351
|
+
}
|
|
352
|
+
await schedule(scope)
|
|
353
|
+
return result({ accepted: true, count, target })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function onEvent(event) {
|
|
357
|
+
if (disposed) return result({ reason: "disposed" })
|
|
358
|
+
if (event?.kind === "server" && event.action === "disposed") { await dispose(); return result({ disposed: true }) }
|
|
359
|
+
const scope = scopeFor(event)
|
|
360
|
+
if (!scope) return { handled: false }
|
|
361
|
+
// Hooks update these gates synchronously, even while file/host work awaits.
|
|
362
|
+
if (event.kind === "foreground") {
|
|
363
|
+
scope.epoch++
|
|
364
|
+
scope.busy = true
|
|
365
|
+
if (scope.active) scope.active.foreground = true
|
|
366
|
+
clear(scope, "timer")
|
|
367
|
+
return result({ reason: "foreground-admission" })
|
|
368
|
+
}
|
|
369
|
+
if (event.kind === "compaction" && event.action === "started") {
|
|
370
|
+
scope.compaction ||= { ended: false, terminal: false }
|
|
371
|
+
scope.busy = true
|
|
372
|
+
clear(scope, "timer")
|
|
373
|
+
return result({ reason: "compacting" })
|
|
374
|
+
}
|
|
375
|
+
if (event.kind === "session" && event.action === "status" && ["busy", "retry"].includes(event.status)) {
|
|
376
|
+
scope.busy = true
|
|
377
|
+
clear(scope, "timer")
|
|
378
|
+
return result()
|
|
379
|
+
}
|
|
380
|
+
if (event.kind === "session" && event.action === "deleted") {
|
|
381
|
+
scope.deleted = true
|
|
382
|
+
clear(scope, "timer"); clear(scope, "deadline"); clear(scope, "companionTimer")
|
|
383
|
+
scopes.delete(scope.key)
|
|
384
|
+
return result({ disposedScope: true })
|
|
385
|
+
}
|
|
386
|
+
return enqueue(scope, async () => {
|
|
387
|
+
if (event.kind === "command" && event.action === "executed") return command(scope, event)
|
|
388
|
+
if (event.kind === "inbox") {
|
|
389
|
+
if (scope.active?.inboxID === event.inboxID || scope.active?.id === event.inboxID) {
|
|
390
|
+
if (event.action === "delivered") scope.active.delivered = true
|
|
391
|
+
if (event.action === "cancelled") {
|
|
392
|
+
const run = scope.active
|
|
393
|
+
const state = await read(scope)
|
|
394
|
+
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
395
|
+
if (job?.v2Run?.id === run.id && !run.delivered) {
|
|
396
|
+
Object.assign(job, run.previous)
|
|
397
|
+
policy.pause(job, "inbox-cancelled")
|
|
398
|
+
delete job.v2Run
|
|
399
|
+
await save(scope, state)
|
|
400
|
+
}
|
|
401
|
+
if (scope.active === run) delete scope.active
|
|
402
|
+
clear(scope, "deadline")
|
|
403
|
+
return result({ reason: "inbox-cancelled" })
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return result()
|
|
407
|
+
}
|
|
408
|
+
if (event.kind === "shell" && event.action === "ended" && scope.active?.shellID === event.shellID) {
|
|
409
|
+
scope.active.shellFinished = true
|
|
410
|
+
// A local shell terminal is not the terminal of a foreground model turn.
|
|
411
|
+
if (event.status !== "exited" || event.code !== 0) {
|
|
412
|
+
const run = scope.active
|
|
413
|
+
const paused = await pauseActive(scope, "shell-failed")
|
|
414
|
+
const state = await read(scope)
|
|
415
|
+
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
416
|
+
if (job?.v2Run?.id === run.id) { delete job.v2Run; await save(scope, state) }
|
|
417
|
+
if (scope.active === run) delete scope.active
|
|
418
|
+
return paused
|
|
419
|
+
}
|
|
420
|
+
return finish(scope)
|
|
421
|
+
}
|
|
422
|
+
if ((event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action)) || (event.kind === "compaction" && event.action === "failed")) {
|
|
423
|
+
delete scope.compaction
|
|
424
|
+
scope.busy = false
|
|
425
|
+
const run = scope.active
|
|
426
|
+
const paused = await pauseActive(scope, event.reason || `${event.kind}-${event.action}`)
|
|
427
|
+
if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
|
|
428
|
+
const state = await read(scope)
|
|
429
|
+
const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
|
|
430
|
+
if (job?.v2Run?.id === run.id) { delete job.v2Run; await save(scope, state) }
|
|
431
|
+
if (scope.active === run) delete scope.active
|
|
432
|
+
}
|
|
433
|
+
return paused
|
|
434
|
+
}
|
|
435
|
+
if (event.kind === "compaction" && event.action === "ended") {
|
|
436
|
+
scope.compaction ||= { ended: false, terminal: false }
|
|
437
|
+
scope.compaction.ended = true
|
|
438
|
+
if (["compact", "cadence"].includes(scope.active?.kind)) scope.active.compactFinished = true
|
|
439
|
+
if (!scope.compaction.terminal) return result({ reason: "awaiting-execution-terminal" })
|
|
440
|
+
} else if (event.kind === "session" && event.action === "idle") {
|
|
441
|
+
if (scope.compaction) { scope.compaction.terminal = true; if (!scope.compaction.ended) return result({ reason: "awaiting-compaction-end" }) }
|
|
442
|
+
} else if (event.kind === "session" && event.action === "status" && event.status === "idle") {
|
|
443
|
+
if (scope.active || scope.compaction) return result({ reason: "awaiting-execution-terminal" })
|
|
444
|
+
} else return { handled: false }
|
|
445
|
+
delete scope.compaction
|
|
446
|
+
scope.busy = false
|
|
447
|
+
return scope.active ? finish(scope) : advance(scope)
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function wake(event) {
|
|
452
|
+
const scope = scopeFor(event)
|
|
453
|
+
return scope ? enqueue(scope, () => advance(scope)) : { handled: false }
|
|
454
|
+
}
|
|
455
|
+
async function dispose() {
|
|
456
|
+
if (disposed) return false
|
|
457
|
+
disposed = true
|
|
458
|
+
for (const scope of scopes.values()) { clear(scope, "timer"); clear(scope, "deadline"); clear(scope, "companionTimer") }
|
|
459
|
+
await Promise.allSettled([...scopes.values()].map((scope) => scope.queue))
|
|
460
|
+
scopes.clear()
|
|
461
|
+
return true
|
|
462
|
+
}
|
|
463
|
+
return Object.freeze({ onEvent, wake, dispose, scheduledCount: () => [...scopes.values()].filter((scope) => scope.timer !== undefined).length })
|
|
464
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
// User-configured host commands, not model tools. No OpenCode session is aborted.
|
|
5
|
+
export function createNativeShellHost({ directory, onTerminal, onError = () => {} } = {}) {
|
|
6
|
+
if (!directory || typeof onTerminal !== "function") throw new TypeError("Native shell requires directory and onTerminal")
|
|
7
|
+
const tasks = new Set()
|
|
8
|
+
let disposed = false
|
|
9
|
+
let disposal
|
|
10
|
+
const report = (error) => { try { onError(error) } catch {} }
|
|
11
|
+
const tail = (text, data) => (text + String(data)).slice(-64_000)
|
|
12
|
+
|
|
13
|
+
function terminate(task) {
|
|
14
|
+
if (task.termination) return task.termination
|
|
15
|
+
if (task.settled || !task.child.pid) return Promise.resolve()
|
|
16
|
+
const pid = task.child.pid
|
|
17
|
+
task.termination = new Promise((resolve) => {
|
|
18
|
+
if (process.platform === "win32") {
|
|
19
|
+
const fallback = () => { try { task.child.kill() } catch {} }
|
|
20
|
+
try {
|
|
21
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { windowsHide: true, stdio: "ignore" })
|
|
22
|
+
killer.once("error", (error) => { report(error); fallback(); resolve() })
|
|
23
|
+
killer.once("close", (code) => { if (code !== 0) fallback(); resolve() })
|
|
24
|
+
} catch (error) { report(error); fallback(); resolve() }
|
|
25
|
+
} else {
|
|
26
|
+
try { process.kill(-pid, "SIGTERM") } catch { try { task.child.kill("SIGTERM") } catch {} }
|
|
27
|
+
// A descendant can ignore SIGTERM and close all inherited pipes. The
|
|
28
|
+
// shell's close event is not proof that its process group has stopped.
|
|
29
|
+
// Keep escalation alive and await it before publishing a terminal.
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
try { process.kill(-pid, "SIGKILL") } catch {}
|
|
32
|
+
resolve()
|
|
33
|
+
}, 1000)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
return task.termination
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function dispatch(request) {
|
|
40
|
+
if (disposed) throw new Error("Native shell is disposed")
|
|
41
|
+
if (!request?.sessionID || typeof request.command !== "string" || !request.command.trim()) throw new TypeError("Native shell requires sessionID and command")
|
|
42
|
+
const shellID = request.id || `shell_loop_${randomUUID().replaceAll("-", "")}`
|
|
43
|
+
const child = spawn(request.command, { cwd: directory, shell: true, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] })
|
|
44
|
+
const task = { child, settled: false, timedOut: false, cancelled: false, stdout: "", stderr: "" }
|
|
45
|
+
task.closed = new Promise((resolve) => { task.resolve = resolve })
|
|
46
|
+
tasks.add(task)
|
|
47
|
+
child.stdout?.on("data", (chunk) => { task.stdout = tail(task.stdout, chunk) })
|
|
48
|
+
child.stderr?.on("data", (chunk) => { task.stderr = tail(task.stderr, chunk) })
|
|
49
|
+
const delay = Number(request.timeoutMs)
|
|
50
|
+
task.timer = setTimeout(() => { task.timedOut = true; terminate(task) }, Number.isFinite(delay) && delay > 0 ? Math.min(delay, 2_147_483_647) : 120_000)
|
|
51
|
+
task.timer.unref?.()
|
|
52
|
+
child.once("close", async (code, signal) => {
|
|
53
|
+
task.settled = true
|
|
54
|
+
clearTimeout(task.timer)
|
|
55
|
+
await task.termination
|
|
56
|
+
tasks.delete(task)
|
|
57
|
+
const event = {
|
|
58
|
+
kind: "shell", action: "ended", directory, sessionID: request.sessionID, shellID,
|
|
59
|
+
status: task.timedOut ? "timeout" : task.cancelled || signal || code === null ? "killed" : "exited",
|
|
60
|
+
code: task.timedOut ? 124 : Number.isInteger(code) ? code : -1,
|
|
61
|
+
stdout: task.stdout, stderr: task.stderr,
|
|
62
|
+
}
|
|
63
|
+
task.resolve(event)
|
|
64
|
+
if (!task.spawnError) Promise.resolve().then(() => onTerminal(event)).catch(report)
|
|
65
|
+
})
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
child.once("spawn", () => resolve({ id: shellID, status: "running" }))
|
|
68
|
+
child.once("error", (error) => { task.spawnError = true; reject(error) })
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dispose() {
|
|
73
|
+
if (disposal) return disposal
|
|
74
|
+
disposed = true
|
|
75
|
+
const active = [...tasks]
|
|
76
|
+
for (const task of active) { task.cancelled = true; terminate(task) }
|
|
77
|
+
disposal = Promise.allSettled(active.map((task) => task.closed)).then(() => undefined)
|
|
78
|
+
return disposal
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({ dispatch, dispose, activeCount: () => tasks.size })
|
|
81
|
+
}
|
|
@@ -230,6 +230,8 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
230
230
|
const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
|
|
231
231
|
if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
|
|
232
232
|
|
|
233
|
+
parsed.job.createdAt = new Date(now()).toISOString()
|
|
234
|
+
parsed.job.lastRunAt = parsed.job.immediate === false ? now() : 0
|
|
233
235
|
parsed.job.name = jobName(parsed.job)
|
|
234
236
|
const state = await readState(scope.directory, scope.sessionID)
|
|
235
237
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
// The plugin location is not evidence about an arbitrary session.
|
|
4
|
+
// Resolve through the host before using the instance's local files.
|
|
5
|
+
export function createNativeSessionLocationGuard({ directory, workspaceID, getSession }) {
|
|
6
|
+
const expected = path.resolve(directory)
|
|
7
|
+
return async (sessionID) => {
|
|
8
|
+
if (typeof sessionID !== 'string' || !sessionID || typeof getSession !== 'function') return false
|
|
9
|
+
try {
|
|
10
|
+
const session = await getSession({ sessionID })
|
|
11
|
+
if (session?.id !== sessionID || typeof session.location?.directory !== 'string' || !path.isAbsolute(session.location.directory)) return false
|
|
12
|
+
if (path.relative(expected, path.resolve(session.location.directory)) !== '') return false
|
|
13
|
+
return (session.location.workspaceID ?? '') === (workspaceID ?? '')
|
|
14
|
+
} catch { return false }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -62,6 +62,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
62
62
|
? Number(options.seenUserMessageMs)
|
|
63
63
|
: DEFAULT_SEEN_USER_MESSAGE_MS
|
|
64
64
|
|
|
65
|
+
const compactingSessions = new Set()
|
|
65
66
|
const pendingSteering = new Map()
|
|
66
67
|
const seenUserMessages = new Map()
|
|
67
68
|
|
|
@@ -134,7 +135,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
134
135
|
|
|
135
136
|
const active = getActiveRun(sessionID)
|
|
136
137
|
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
|
+
const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
|
|
138
139
|
let preempted = false
|
|
139
140
|
let abortError = ""
|
|
140
141
|
|
|
@@ -154,7 +155,8 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
154
155
|
{ path: { sessionID }, body: {} },
|
|
155
156
|
{ sessionID },
|
|
156
157
|
)
|
|
157
|
-
|
|
158
|
+
// A foreground turn can replace the active run while abort settles.
|
|
159
|
+
if (getActiveRun(sessionID) === active) clearActiveRun(sessionID)
|
|
158
160
|
preempted = true
|
|
159
161
|
} catch (error) {
|
|
160
162
|
pendingSteering.delete(sessionID)
|
|
@@ -177,10 +179,21 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
177
179
|
observeAssistantMessage(event)
|
|
178
180
|
const user = userMessageFromEvent(event)
|
|
179
181
|
if (!user) return undefined
|
|
180
|
-
|
|
182
|
+
// Persisted user-role events include core compaction markers, synthetic
|
|
183
|
+
// continuations and replayed history. Parts may not even be persisted yet.
|
|
184
|
+
// Only the pre-dispatch chat.message admission hook is steering authority.
|
|
185
|
+
if (alreadyHandled(user.sessionID, user.messageID)) return { handled: false, duplicate: true, ...user }
|
|
186
|
+
return { handled: false, reason: "untrusted-message-event", ...user }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setCompacting(sessionID, value = true) {
|
|
190
|
+
if (typeof sessionID !== "string" || !sessionID) return
|
|
191
|
+
if (value) compactingSessions.add(sessionID)
|
|
192
|
+
else compactingSessions.delete(sessionID)
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
function clearSession(sessionID) {
|
|
196
|
+
compactingSessions.delete(sessionID)
|
|
184
197
|
pendingSteering.delete(sessionID)
|
|
185
198
|
const prefix = `${sessionID}\u0000`
|
|
186
199
|
for (const key of seenUserMessages.keys()) if (key.startsWith(prefix)) seenUserMessages.delete(key)
|
|
@@ -193,6 +206,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
193
206
|
shouldSuppressIdle,
|
|
194
207
|
hasPendingSteering: shouldSuppressIdle,
|
|
195
208
|
pendingForSession,
|
|
209
|
+
setCompacting,
|
|
196
210
|
clearSession,
|
|
197
211
|
}
|
|
198
212
|
}
|
|
@@ -58,10 +58,10 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
58
58
|
if (!job.branch || job.branchDone) return job
|
|
59
59
|
const branch = safeID(job.branch)
|
|
60
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 }
|
|
61
|
+
if (inRepo.code !== 0) { job.branchDone = false; job.branchUnavailable = true; return job }
|
|
62
62
|
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
63
63
|
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
64
|
-
job.branchDone =
|
|
64
|
+
job.branchDone = result.code === 0
|
|
65
65
|
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
66
66
|
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
67
67
|
return job
|
|
@@ -97,6 +97,13 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
97
97
|
|
|
98
98
|
async function untilReached(directory, job) {
|
|
99
99
|
if (!job.until) return false
|
|
100
|
+
// Goal contracts/archives/leases can contain the configured marker too.
|
|
101
|
+
// None of this control state proves completion of user-project work.
|
|
102
|
+
const controlRoots = new Set([
|
|
103
|
+
stateDir(directory),
|
|
104
|
+
...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"]
|
|
105
|
+
.map((name) => path.join(directory, ".opencode", name)),
|
|
106
|
+
].map((root) => path.resolve(root).toLowerCase()))
|
|
100
107
|
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
101
108
|
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
102
109
|
let scanned = 0
|
|
@@ -108,6 +115,8 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
108
115
|
if (scanned >= MAX_SCAN_FILES) return false
|
|
109
116
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
110
117
|
const full = path.join(current, entry.name)
|
|
118
|
+
// Keep the explicit until.txt marker above, but never scan plugin state.
|
|
119
|
+
if (controlRoots.has(path.resolve(full).toLowerCase())) continue
|
|
111
120
|
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
112
121
|
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
113
122
|
}
|