@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. 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
+ }