@bybrawe/opencode-loop 0.5.27 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { parseLoopArgs, splitFirst } from "../core/args.js"
|
|
2
|
+
import { actionKind, decoratePrompt, matchJob } from "../core/jobs.js"
|
|
3
|
+
import { readState, removeState, writeState } from "../core/state.js"
|
|
4
|
+
import { formatOpenCode2LoopStatus } from "./status.js"
|
|
5
|
+
|
|
6
|
+
export const OPENCODE_LOOP_V2_PROMPT_RUNTIME = "prompt-zero-interval"
|
|
7
|
+
export const OPENCODE_LOOP_V2_INTERVAL_RUNTIME = "idle-safe-timer"
|
|
8
|
+
export const OPENCODE_LOOP_V2_PROMPT_PREFIX = "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
|
|
9
|
+
|
|
10
|
+
function directoryFrom(event) {
|
|
11
|
+
return typeof event?.directory === "string" && event.directory.trim() ? event.directory : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commandParts(action) {
|
|
15
|
+
const normalized = String(action || "").trim().replace(/^\/+/, "")
|
|
16
|
+
return splitFirst(normalized)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unsupportedRuntimeJob(job, supportsCommand) {
|
|
20
|
+
const blockers = []
|
|
21
|
+
const kind = actionKind(job?.action, job)
|
|
22
|
+
if (!String(job?.action || "").trim()) blockers.push("action")
|
|
23
|
+
if (kind === "command") {
|
|
24
|
+
if (!supportsCommand) blockers.push("command-capability")
|
|
25
|
+
if (!commandParts(job?.action)[0]) blockers.push("action")
|
|
26
|
+
} else if (kind !== "prompt") {
|
|
27
|
+
blockers.push("kind")
|
|
28
|
+
}
|
|
29
|
+
if (job?.watchPaths?.length) blockers.push("watch")
|
|
30
|
+
if (job?.promptFile) blockers.push("prompt-file")
|
|
31
|
+
if (job?.includeFiles?.length) blockers.push("include-file")
|
|
32
|
+
if (job?.verifyCommand) blockers.push("verify")
|
|
33
|
+
if (job?.preflightCommand) blockers.push("preflight")
|
|
34
|
+
if (job?.postrunCommand) blockers.push("postrun")
|
|
35
|
+
if (job?.notifyCommand) blockers.push("notify")
|
|
36
|
+
if (job?.branch) blockers.push("branch")
|
|
37
|
+
if (job?.compactEveryRuns > 0 || job?.compactEveryMs > 0) blockers.push("compact-every")
|
|
38
|
+
if (job?.gitCheckpoint || job?.checkpointOnly) blockers.push("checkpoint")
|
|
39
|
+
if (job?.timeoutMs > 0) blockers.push("timeout")
|
|
40
|
+
if (job?.maxRuntimeMs > 0) blockers.push("max-runtime")
|
|
41
|
+
if (job?.maxFailures > 0) blockers.push("max-failures")
|
|
42
|
+
if (job?.until) blockers.push("until")
|
|
43
|
+
if (job?.stopFile) blockers.push("stop-file")
|
|
44
|
+
return [...new Set(blockers)]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function jobName(job) {
|
|
48
|
+
return String(job?.name || "default")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function promptText(job) {
|
|
52
|
+
return `${OPENCODE_LOOP_V2_PROMPT_PREFIX}\n\n${decoratePrompt(job)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function commandTarget(event, fallback = "all") {
|
|
56
|
+
return String(event?.arguments || "").trim() || fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scopeFrom(event) {
|
|
60
|
+
const directory = directoryFrom(event)
|
|
61
|
+
const sessionID = String(event?.sessionID || "").trim()
|
|
62
|
+
if (!directory || !sessionID) return undefined
|
|
63
|
+
return { directory, sessionID, key: `${directory}\u0000${sessionID}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runNowRequested(job) {
|
|
67
|
+
return Number(job?.runNowRequestedAt || 0) > 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function dueAt(job, current) {
|
|
71
|
+
if (runNowRequested(job)) return current
|
|
72
|
+
const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
|
|
73
|
+
const lastRunAt = Number(job?.lastRunAt || 0)
|
|
74
|
+
if (intervalMs === 0) return current
|
|
75
|
+
if (lastRunAt <= 0) {
|
|
76
|
+
if (job?.immediate === false) {
|
|
77
|
+
const createdAt = Date.parse(job?.createdAt || "")
|
|
78
|
+
return (Number.isFinite(createdAt) ? createdAt : current) + intervalMs
|
|
79
|
+
}
|
|
80
|
+
return current
|
|
81
|
+
}
|
|
82
|
+
return lastRunAt + intervalMs
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createOpenCode2PromptRuntime(options = {}) {
|
|
86
|
+
if (typeof options.prompt !== "function") throw new TypeError("V2 prompt runtime requires prompt()")
|
|
87
|
+
|
|
88
|
+
const supportsCommand = typeof options.command === "function"
|
|
89
|
+
const now = typeof options.now === "function" ? options.now : Date.now
|
|
90
|
+
const setTimer = typeof options.setTimer === "function" ? options.setTimer : setTimeout
|
|
91
|
+
const clearTimer = typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout
|
|
92
|
+
const onError = typeof options.onError === "function" ? options.onError : () => {}
|
|
93
|
+
const timers = new Map()
|
|
94
|
+
const idle = new Map()
|
|
95
|
+
const queues = new Map()
|
|
96
|
+
let disposed = false
|
|
97
|
+
|
|
98
|
+
function report(error) {
|
|
99
|
+
try { onError(error) } catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function eligibleRuntimeJob(job) {
|
|
103
|
+
if (!job?.enabled || job?.paused) return false
|
|
104
|
+
if (unsupportedRuntimeJob(job, supportsCommand).length) return false
|
|
105
|
+
return !(job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clearScopeTimer(key) {
|
|
109
|
+
const current = timers.get(key)
|
|
110
|
+
if (!current) return false
|
|
111
|
+
timers.delete(key)
|
|
112
|
+
try { clearTimer(current.handle) } catch {}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearScope(scope) {
|
|
117
|
+
clearScopeTimer(scope.key)
|
|
118
|
+
idle.delete(scope.key)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function enqueueScope(scope, task) {
|
|
122
|
+
if (disposed) return Promise.resolve({ handled: false, reason: "disposed" })
|
|
123
|
+
const previous = queues.get(scope.key) || Promise.resolve()
|
|
124
|
+
const result = previous.catch(() => undefined).then(async () => {
|
|
125
|
+
if (disposed) return { handled: false, reason: "disposed" }
|
|
126
|
+
return await task()
|
|
127
|
+
})
|
|
128
|
+
const tail = result.catch(() => undefined)
|
|
129
|
+
queues.set(scope.key, tail)
|
|
130
|
+
tail.then(() => {
|
|
131
|
+
if (queues.get(scope.key) === tail) queues.delete(scope.key)
|
|
132
|
+
})
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function scheduleScope(scope) {
|
|
137
|
+
clearScopeTimer(scope.key)
|
|
138
|
+
if (disposed) return undefined
|
|
139
|
+
|
|
140
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
141
|
+
const current = now()
|
|
142
|
+
let earliest
|
|
143
|
+
for (const job of state.jobs || []) {
|
|
144
|
+
if (!eligibleRuntimeJob(job)) continue
|
|
145
|
+
const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
|
|
146
|
+
if (intervalMs === 0) continue
|
|
147
|
+
const candidate = dueAt(job, current)
|
|
148
|
+
if (candidate <= current) continue
|
|
149
|
+
if (!earliest || candidate < earliest) earliest = candidate
|
|
150
|
+
}
|
|
151
|
+
if (!earliest) return undefined
|
|
152
|
+
|
|
153
|
+
const delay = Math.max(0, earliest - current)
|
|
154
|
+
const token = Symbol(scope.key)
|
|
155
|
+
const handle = setTimer(() => {
|
|
156
|
+
const pending = enqueueScope(scope, async () => {
|
|
157
|
+
const currentTimer = timers.get(scope.key)
|
|
158
|
+
if (!currentTimer || currentTimer.token !== token) return { handled: false, reason: "stale-timer" }
|
|
159
|
+
timers.delete(scope.key)
|
|
160
|
+
if (idle.get(scope.key) !== true) return { handled: true, dispatched: false, reason: "not-idle" }
|
|
161
|
+
return await runDueAction(scope)
|
|
162
|
+
})
|
|
163
|
+
pending.catch(report)
|
|
164
|
+
return pending
|
|
165
|
+
}, delay)
|
|
166
|
+
handle?.unref?.()
|
|
167
|
+
timers.set(scope.key, { handle, token, dueAt: earliest })
|
|
168
|
+
return earliest
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function dispatchJob(scope, job) {
|
|
172
|
+
const kind = actionKind(job?.action, job)
|
|
173
|
+
if (kind === "command") {
|
|
174
|
+
const [command, argumentsText] = commandParts(job.action)
|
|
175
|
+
const request = {
|
|
176
|
+
sessionID: scope.sessionID,
|
|
177
|
+
command,
|
|
178
|
+
arguments: argumentsText || undefined,
|
|
179
|
+
}
|
|
180
|
+
await options.command(request)
|
|
181
|
+
return { kind, request }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const text = promptText(job)
|
|
185
|
+
const request = { sessionID: scope.sessionID, text }
|
|
186
|
+
await options.prompt(request)
|
|
187
|
+
return { kind: "prompt", request, text }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runDueAction(scope) {
|
|
191
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
192
|
+
const current = now()
|
|
193
|
+
const due = (state.jobs || []).filter((candidate) => eligibleRuntimeJob(candidate) && dueAt(candidate, current) <= current)
|
|
194
|
+
const job = due.find(runNowRequested) || due[0]
|
|
195
|
+
if (!job) {
|
|
196
|
+
await scheduleScope(scope)
|
|
197
|
+
return { handled: true, dispatched: false }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
delete job.runNowRequestedAt
|
|
201
|
+
job.lastRunAt = current
|
|
202
|
+
job.runCount = (job.runCount || 0) + 1
|
|
203
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) job.enabled = false
|
|
204
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
205
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
206
|
+
|
|
207
|
+
idle.set(scope.key, false)
|
|
208
|
+
await scheduleScope(scope)
|
|
209
|
+
|
|
210
|
+
const dispatched = await dispatchJob(scope, job)
|
|
211
|
+
return { handled: true, dispatched: true, job, ...dispatched }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function addPromptLoop(event) {
|
|
215
|
+
const scope = scopeFrom(event)
|
|
216
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
217
|
+
return await enqueueScope(scope, async () => {
|
|
218
|
+
const parsed = parseLoopArgs(event.arguments || "")
|
|
219
|
+
if (!parsed.ok) return { handled: true, accepted: false, reason: "parse", error: parsed.error }
|
|
220
|
+
|
|
221
|
+
const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
|
|
222
|
+
if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
|
|
223
|
+
|
|
224
|
+
parsed.job.name = jobName(parsed.job)
|
|
225
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
226
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
227
|
+
if (!parsed.job.multi) {
|
|
228
|
+
state.jobs = jobs.filter((job) => jobName(job) !== parsed.job.name)
|
|
229
|
+
} else {
|
|
230
|
+
state.jobs = jobs
|
|
231
|
+
}
|
|
232
|
+
state.jobs.push(parsed.job)
|
|
233
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
234
|
+
await scheduleScope(scope)
|
|
235
|
+
return { handled: true, accepted: true, job: parsed.job }
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function statusPromptLoops(event) {
|
|
240
|
+
const scope = scopeFrom(event)
|
|
241
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
242
|
+
return await enqueueScope(scope, async () => {
|
|
243
|
+
const status = formatOpenCode2LoopStatus(await readState(scope.directory, scope.sessionID), now())
|
|
244
|
+
const request = { sessionID: scope.sessionID, text: status.text, noReply: true }
|
|
245
|
+
await options.prompt(request)
|
|
246
|
+
return { handled: true, accepted: true, status, request }
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runPromptLoopsNow(event) {
|
|
251
|
+
const scope = scopeFrom(event)
|
|
252
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
253
|
+
return await enqueueScope(scope, async () => {
|
|
254
|
+
const target = commandTarget(event)
|
|
255
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
256
|
+
const requestedAt = Math.max(1, now())
|
|
257
|
+
let count = 0
|
|
258
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
259
|
+
if (!matchJob(job, target, index)) return job
|
|
260
|
+
const resumed = { ...job, paused: false }
|
|
261
|
+
if (!eligibleRuntimeJob(resumed)) return job
|
|
262
|
+
count += 1
|
|
263
|
+
return { ...resumed, runNowRequestedAt: requestedAt }
|
|
264
|
+
})
|
|
265
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
266
|
+
await scheduleScope(scope)
|
|
267
|
+
return { handled: true, accepted: true, count, target, requestedAt: count ? requestedAt : undefined }
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function updatePromptLoops(event, updater) {
|
|
272
|
+
const scope = scopeFrom(event)
|
|
273
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
274
|
+
return await enqueueScope(scope, async () => {
|
|
275
|
+
const target = commandTarget(event)
|
|
276
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
277
|
+
let count = 0
|
|
278
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
279
|
+
if (!matchJob(job, target, index)) return job
|
|
280
|
+
count += 1
|
|
281
|
+
return updater(job)
|
|
282
|
+
})
|
|
283
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
284
|
+
await scheduleScope(scope)
|
|
285
|
+
return { handled: true, accepted: true, count, target }
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function stopPromptLoops(event, forcedTarget) {
|
|
290
|
+
const scope = scopeFrom(event)
|
|
291
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
292
|
+
return await enqueueScope(scope, async () => {
|
|
293
|
+
const target = forcedTarget || commandTarget(event)
|
|
294
|
+
if (target.toLowerCase() === "all") {
|
|
295
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
296
|
+
const count = (state.jobs || []).length
|
|
297
|
+
await removeState(scope.directory, scope.sessionID)
|
|
298
|
+
await scheduleScope(scope)
|
|
299
|
+
return { handled: true, accepted: true, count, target }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
303
|
+
const before = (state.jobs || []).length
|
|
304
|
+
state.jobs = (state.jobs || []).filter((job, index) => !matchJob(job, target, index))
|
|
305
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
306
|
+
await scheduleScope(scope)
|
|
307
|
+
return { handled: true, accepted: true, count: before - state.jobs.length, target }
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function runIdlePrompt(event) {
|
|
312
|
+
const scope = scopeFrom(event)
|
|
313
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
314
|
+
idle.set(scope.key, true)
|
|
315
|
+
return await enqueueScope(scope, () => runDueAction(scope))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function onEvent(event) {
|
|
319
|
+
const scope = scopeFrom(event)
|
|
320
|
+
if (event?.kind === "command" && event?.action === "executed") {
|
|
321
|
+
if (scope) idle.set(scope.key, false)
|
|
322
|
+
if (event?.name === "loop") return addPromptLoop(event)
|
|
323
|
+
if (event?.name === "loop-status") return statusPromptLoops(event)
|
|
324
|
+
if (event?.name === "loop-now") return runPromptLoopsNow(event)
|
|
325
|
+
if (event?.name === "loop-pause") return updatePromptLoops(event, (job) => ({ ...job, paused: true }))
|
|
326
|
+
if (event?.name === "loop-resume") return updatePromptLoops(event, (job) => ({ ...job, paused: false, lastRunAt: 0 }))
|
|
327
|
+
if (["loop-stop", "loop-remove"].includes(event?.name)) return stopPromptLoops(event)
|
|
328
|
+
if (event?.name === "loop-clear") return stopPromptLoops(event, "all")
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (event?.kind === "session" && event?.action === "idle") {
|
|
332
|
+
return runIdlePrompt(event)
|
|
333
|
+
}
|
|
334
|
+
if (event?.kind === "session" && event?.action === "status" && scope) {
|
|
335
|
+
idle.set(scope.key, event.status === "idle")
|
|
336
|
+
return await enqueueScope(scope, async () => {
|
|
337
|
+
await scheduleScope(scope)
|
|
338
|
+
return { handled: true, dispatched: false }
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
if (event?.kind === "session" && event?.action === "deleted" && scope) {
|
|
342
|
+
clearScope(scope)
|
|
343
|
+
return { handled: true, disposedScope: true }
|
|
344
|
+
}
|
|
345
|
+
if (event?.kind === "message" && event?.action === "updated" && scope) {
|
|
346
|
+
if (event.role === "user" || (event.role === "assistant" && !event.completedAt)) idle.set(scope.key, false)
|
|
347
|
+
return { handled: false }
|
|
348
|
+
}
|
|
349
|
+
if (event?.kind === "server" && event?.action === "disposed") {
|
|
350
|
+
await dispose()
|
|
351
|
+
return { handled: true, disposed: true }
|
|
352
|
+
}
|
|
353
|
+
return { handled: false }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function dispose() {
|
|
357
|
+
if (disposed) return false
|
|
358
|
+
disposed = true
|
|
359
|
+
for (const { handle } of timers.values()) {
|
|
360
|
+
try { clearTimer(handle) } catch {}
|
|
361
|
+
}
|
|
362
|
+
timers.clear()
|
|
363
|
+
idle.clear()
|
|
364
|
+
const pending = [...queues.values()]
|
|
365
|
+
if (pending.length) await Promise.allSettled(pending)
|
|
366
|
+
queues.clear()
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return Object.freeze({
|
|
371
|
+
onEvent,
|
|
372
|
+
addPromptLoop,
|
|
373
|
+
statusPromptLoops,
|
|
374
|
+
runPromptLoopsNow,
|
|
375
|
+
updatePromptLoops,
|
|
376
|
+
stopPromptLoops,
|
|
377
|
+
runIdlePrompt,
|
|
378
|
+
dispose,
|
|
379
|
+
scheduledCount: () => timers.size,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { inspectOpenCode2Context } from "./capabilities.js"
|
|
2
|
+
import { createOpenCode2HostContract } from "./host-contract.js"
|
|
3
|
+
|
|
4
|
+
function promptRequest(request) {
|
|
5
|
+
const value = {
|
|
6
|
+
sessionID: request.sessionID,
|
|
7
|
+
}
|
|
8
|
+
if (request.noReply === true) {
|
|
9
|
+
value.noReply = true
|
|
10
|
+
value.parts = [{ type: "text", text: request.text }]
|
|
11
|
+
} else {
|
|
12
|
+
value.text = request.text
|
|
13
|
+
}
|
|
14
|
+
if (request.agent !== undefined) value.agent = request.agent
|
|
15
|
+
if (request.model !== undefined) value.model = request.model
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function commandRequest(request) {
|
|
20
|
+
const value = {
|
|
21
|
+
sessionID: request.sessionID,
|
|
22
|
+
command: request.command,
|
|
23
|
+
}
|
|
24
|
+
if (request.arguments !== undefined) value.arguments = request.arguments
|
|
25
|
+
if (request.agent !== undefined) value.agent = request.agent
|
|
26
|
+
if (request.model !== undefined) value.model = request.model
|
|
27
|
+
if (request.delivery !== undefined) value.delivery = request.delivery
|
|
28
|
+
if (request.resume !== undefined) value.resume = request.resume
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createOpenCode2RuntimeAdapter(ctx, options = {}) {
|
|
33
|
+
const capabilities = inspectOpenCode2Context(ctx)
|
|
34
|
+
if (!capabilities.eventSubscribe) throw new Error("OpenCode 2 event.subscribe capability is unavailable")
|
|
35
|
+
if (!capabilities.sessionPrompt) throw new Error("OpenCode 2 session.prompt capability is unavailable")
|
|
36
|
+
|
|
37
|
+
const host = createOpenCode2HostContract({
|
|
38
|
+
directory: options.directory,
|
|
39
|
+
subscribe: ctx.event.subscribe.bind(ctx.event),
|
|
40
|
+
sendPrompt: (request) => ctx.session.prompt(promptRequest(request)),
|
|
41
|
+
sendCommand: capabilities.sessionCommand ? (request) => ctx.session.command(commandRequest(request)) : undefined,
|
|
42
|
+
onEvent: options.onEvent,
|
|
43
|
+
onError: options.onError,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
start: () => host.start(),
|
|
48
|
+
prompt: (request) => host.prompt(request),
|
|
49
|
+
command: (request) => host.command(request),
|
|
50
|
+
dispose: (reason = "runtime-adapter-disposed") => host.dispose(reason),
|
|
51
|
+
runtimeManager: host.runtimeManager,
|
|
52
|
+
isStarted: host.isStarted,
|
|
53
|
+
isDisposed: host.isDisposed,
|
|
54
|
+
isHostDisposed: host.isHostDisposed,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { durationToText } from "../core/args.js"
|
|
2
|
+
import { goalStatusText, isGoalJob, jobLabel } from "../core/jobs.js"
|
|
3
|
+
|
|
4
|
+
function dueAt(job, current) {
|
|
5
|
+
if (Number(job?.runNowRequestedAt || 0) > 0) return current
|
|
6
|
+
const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
|
|
7
|
+
const lastRunAt = Number(job?.lastRunAt || 0)
|
|
8
|
+
if (intervalMs === 0) return current
|
|
9
|
+
if (lastRunAt > 0) return lastRunAt + intervalMs
|
|
10
|
+
if (job?.immediate === false) {
|
|
11
|
+
const createdAt = Date.parse(job?.createdAt || "")
|
|
12
|
+
return (Number.isFinite(createdAt) ? createdAt : current) + intervalMs
|
|
13
|
+
}
|
|
14
|
+
return current
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatOpenCode2LoopStatus(state, current = Date.now()) {
|
|
18
|
+
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
|
|
19
|
+
const lines = jobs.length
|
|
20
|
+
? jobs.map((job, index) => {
|
|
21
|
+
const dueIn = Math.max(0, dueAt(job, current) - current)
|
|
22
|
+
const flags = [
|
|
23
|
+
isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined,
|
|
24
|
+
job.paused ? "paused" : "active",
|
|
25
|
+
Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined,
|
|
26
|
+
job.safe ? "safe" : undefined,
|
|
27
|
+
job.askNever ? "ask-never" : undefined,
|
|
28
|
+
job.noOverlap ? "no-overlap" : undefined,
|
|
29
|
+
job.checkpointOnly ? "checkpoint-only" : undefined,
|
|
30
|
+
job.gitCheckpoint ? "git-checkpoint" : undefined,
|
|
31
|
+
].filter(Boolean).join(",")
|
|
32
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
33
|
+
})
|
|
34
|
+
: ["No active loop jobs."]
|
|
35
|
+
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
jobs,
|
|
38
|
+
text: `OpenCode loop status:\n${lines.join("\n")}`,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { now as defaultNow } from "../core/args.js"
|
|
2
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
3
|
+
import { compactSession as defaultCompactSession, log as defaultLog } from "../opencode/host.js"
|
|
4
|
+
import { sdkErrorMessage as defaultErrorMessage } from "../opencode/sdk.js"
|
|
5
|
+
|
|
6
|
+
export function createCompactionRuntime(options = {}) {
|
|
7
|
+
const activeRuns = options.activeRuns
|
|
8
|
+
if (!(activeRuns instanceof Map)) throw new TypeError("createCompactionRuntime requires activeRuns Map")
|
|
9
|
+
if (typeof options.finalizeActiveRun !== "function") throw new TypeError("createCompactionRuntime requires finalizeActiveRun")
|
|
10
|
+
|
|
11
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
12
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
13
|
+
const compactSession = typeof options.compactSession === "function" ? options.compactSession : defaultCompactSession
|
|
14
|
+
const log = typeof options.log === "function" ? options.log : defaultLog
|
|
15
|
+
const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : defaultErrorMessage
|
|
16
|
+
const finalizeActiveRun = options.finalizeActiveRun
|
|
17
|
+
const requests = new Map()
|
|
18
|
+
|
|
19
|
+
function begin(sessionID, jobId, resumeAfter = false) {
|
|
20
|
+
if (typeof sessionID !== "string" || typeof jobId !== "string") return undefined
|
|
21
|
+
const request = {
|
|
22
|
+
jobId,
|
|
23
|
+
resumeAfter: Boolean(resumeAfter),
|
|
24
|
+
requestedAt: now(),
|
|
25
|
+
startedAt: 0,
|
|
26
|
+
completedAt: 0,
|
|
27
|
+
}
|
|
28
|
+
requests.set(sessionID, request)
|
|
29
|
+
return request
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getPending(sessionID) {
|
|
33
|
+
return requests.get(sessionID)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clear(sessionID) {
|
|
37
|
+
return requests.delete(sessionID)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clearForActiveRun(sessionID, active) {
|
|
41
|
+
const pending = requests.get(sessionID)
|
|
42
|
+
if (!pending || !active || pending.jobId === active.jobId) requests.delete(sessionID)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isCompleted(sessionID, jobId) {
|
|
46
|
+
const pending = requests.get(sessionID)
|
|
47
|
+
return Boolean(pending && pending.jobId === jobId && pending.completedAt)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function start(directory, client, sessionID, jobId, model, resumeAfter = false) {
|
|
51
|
+
begin(sessionID, jobId, resumeAfter)
|
|
52
|
+
const ok = await compactSession(directory, client, sessionID, model)
|
|
53
|
+
if (!ok) clear(sessionID)
|
|
54
|
+
return ok
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function maybeCompact(directory, client, sessionID, job) {
|
|
58
|
+
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
59
|
+
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
60
|
+
if (!dueRuns && !dueTime) return { job, started: false }
|
|
61
|
+
|
|
62
|
+
if (await start(directory, client, sessionID, job.id, job.model, true)) {
|
|
63
|
+
job.lastCompactAt = now()
|
|
64
|
+
job.lastCompactRunCount = job.runCount || 0
|
|
65
|
+
return { job, started: true }
|
|
66
|
+
}
|
|
67
|
+
return { job, started: false }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function noteStarted(directory, sessionID) {
|
|
71
|
+
const pending = requests.get(sessionID)
|
|
72
|
+
if (!pending) return false
|
|
73
|
+
if (!pending.startedAt) {
|
|
74
|
+
pending.startedAt = now()
|
|
75
|
+
requests.set(sessionID, pending)
|
|
76
|
+
await appendLoopLog(directory, "compact-started", {
|
|
77
|
+
sessionID,
|
|
78
|
+
job: pending.jobId,
|
|
79
|
+
resumeAfter: pending.resumeAfter,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function finalize(directory, client, sessionID) {
|
|
86
|
+
const pending = requests.get(sessionID)
|
|
87
|
+
const active = activeRuns.get(sessionID)
|
|
88
|
+
if (!pending || !active || pending.jobId !== active.jobId) return false
|
|
89
|
+
return await finalizeActiveRun(directory, client, sessionID)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function noteCompleted(directory, client, sessionID) {
|
|
93
|
+
const pending = requests.get(sessionID)
|
|
94
|
+
if (!pending) return false
|
|
95
|
+
pending.completedAt = now()
|
|
96
|
+
requests.set(sessionID, pending)
|
|
97
|
+
await appendLoopLog(directory, "compact-event", {
|
|
98
|
+
sessionID,
|
|
99
|
+
job: pending.jobId,
|
|
100
|
+
resumeAfter: pending.resumeAfter,
|
|
101
|
+
})
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
finalize(directory, client, sessionID)
|
|
104
|
+
.catch((error) => log(client, "error", "compaction finalization failed", { error: errorMessage(error) }))
|
|
105
|
+
}, 0)
|
|
106
|
+
timer.unref?.()
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
begin,
|
|
112
|
+
getPending,
|
|
113
|
+
clear,
|
|
114
|
+
clearForActiveRun,
|
|
115
|
+
isCompleted,
|
|
116
|
+
start,
|
|
117
|
+
maybeCompact,
|
|
118
|
+
noteStarted,
|
|
119
|
+
finalize,
|
|
120
|
+
noteCompleted,
|
|
121
|
+
}
|
|
122
|
+
}
|