@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +105 -58
  13. package/scripts/install-test.mjs +6 -1
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +107 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. 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
- await options.prompt(request)
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
- idle.set(scope.key, event.status === "idle")
336
- return await enqueueScope(scope, async () => {
337
- await scheduleScope(scope)
338
- return { handled: true, dispatched: false }
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()