@bybrawe/opencode-loop 0.5.37 → 0.6.0
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 +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- 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 +105 -58
- package/scripts/install-test.mjs +6 -1
- 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 +107 -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/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- 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 +14 -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/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- 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 +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -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
|
+
}
|
|
@@ -90,9 +90,11 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
90
90
|
const setTimer = typeof options.setTimer === "function" ? options.setTimer : setTimeout
|
|
91
91
|
const clearTimer = typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout
|
|
92
92
|
const onError = typeof options.onError === "function" ? options.onError : () => {}
|
|
93
|
+
const requireBusyBeforeIdle = options.requireBusyBeforeIdle === true
|
|
93
94
|
const timers = new Map()
|
|
94
95
|
const idle = new Map()
|
|
95
96
|
const queues = new Map()
|
|
97
|
+
const awaitingBusy = new Set()
|
|
96
98
|
let disposed = false
|
|
97
99
|
|
|
98
100
|
function report(error) {
|
|
@@ -116,6 +118,7 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
116
118
|
function clearScope(scope) {
|
|
117
119
|
clearScopeTimer(scope.key)
|
|
118
120
|
idle.delete(scope.key)
|
|
121
|
+
awaitingBusy.delete(scope.key)
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
function enqueueScope(scope, task) {
|
|
@@ -183,7 +186,13 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
183
186
|
|
|
184
187
|
const text = promptText(job)
|
|
185
188
|
const request = { sessionID: scope.sessionID, text }
|
|
186
|
-
|
|
189
|
+
if (requireBusyBeforeIdle) awaitingBusy.add(scope.key)
|
|
190
|
+
try {
|
|
191
|
+
await options.prompt(request)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
awaitingBusy.delete(scope.key)
|
|
194
|
+
throw error
|
|
195
|
+
}
|
|
187
196
|
return { kind: "prompt", request, text }
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -221,6 +230,8 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
221
230
|
const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
|
|
222
231
|
if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
|
|
223
232
|
|
|
233
|
+
parsed.job.createdAt = new Date(now()).toISOString()
|
|
234
|
+
parsed.job.lastRunAt = parsed.job.immediate === false ? now() : 0
|
|
224
235
|
parsed.job.name = jobName(parsed.job)
|
|
225
236
|
const state = await readState(scope.directory, scope.sessionID)
|
|
226
237
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -329,14 +340,34 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
329
340
|
}
|
|
330
341
|
|
|
331
342
|
if (event?.kind === "session" && event?.action === "idle") {
|
|
343
|
+
if (requireBusyBeforeIdle && scope && awaitingBusy.has(scope.key)) {
|
|
344
|
+
return { handled: true, dispatched: false, reason: "awaiting-busy" }
|
|
345
|
+
}
|
|
332
346
|
return runIdlePrompt(event)
|
|
333
347
|
}
|
|
334
348
|
if (event?.kind === "session" && event?.action === "status" && scope) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return
|
|
339
|
-
|
|
349
|
+
if (event.status === "busy" || event.status === "retry") {
|
|
350
|
+
awaitingBusy.delete(scope.key)
|
|
351
|
+
idle.set(scope.key, false)
|
|
352
|
+
return await enqueueScope(scope, async () => {
|
|
353
|
+
await scheduleScope(scope)
|
|
354
|
+
return { handled: true, dispatched: false }
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
if (event.status === "idle") {
|
|
358
|
+
if (requireBusyBeforeIdle) {
|
|
359
|
+
if (awaitingBusy.has(scope.key)) {
|
|
360
|
+
return { handled: true, dispatched: false, reason: "awaiting-busy" }
|
|
361
|
+
}
|
|
362
|
+
return runIdlePrompt(event)
|
|
363
|
+
}
|
|
364
|
+
idle.set(scope.key, true)
|
|
365
|
+
return await enqueueScope(scope, async () => {
|
|
366
|
+
await scheduleScope(scope)
|
|
367
|
+
return { handled: true, dispatched: false }
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
return { handled: false }
|
|
340
371
|
}
|
|
341
372
|
if (event?.kind === "session" && event?.action === "deleted" && scope) {
|
|
342
373
|
clearScope(scope)
|
|
@@ -361,6 +392,7 @@ export function createOpenCode2PromptRuntime(options = {}) {
|
|
|
361
392
|
}
|
|
362
393
|
timers.clear()
|
|
363
394
|
idle.clear()
|
|
395
|
+
awaitingBusy.clear()
|
|
364
396
|
const pending = [...queues.values()]
|
|
365
397
|
if (pending.length) await Promise.allSettled(pending)
|
|
366
398
|
queues.clear()
|