@bybrawe/opencode-loop 0.5.38 → 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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +30 -0
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -9
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +12 -6
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/goal-compaction-safety-test.mjs +68 -0
  9. package/scripts/goal-companion-test.mjs +1 -1
  10. package/scripts/host-adapter-contract-test.mjs +1 -0
  11. package/scripts/install-node.mjs +105 -60
  12. package/scripts/install-test.mjs +3 -3
  13. package/scripts/install-with-goals.mjs +17 -4
  14. package/scripts/job-workspace-test.mjs +11 -1
  15. package/scripts/loopd-test.mjs +74 -4
  16. package/scripts/loopd.mjs +153 -25
  17. package/scripts/native-cli-host-canary.mjs +118 -0
  18. package/scripts/native-cli-io-test.mjs +66 -0
  19. package/scripts/native-cli-test.mjs +130 -0
  20. package/scripts/native-coexistence-test.mjs +38 -0
  21. package/scripts/native-entry-test.mjs +77 -0
  22. package/scripts/native-guide-test.mjs +45 -0
  23. package/scripts/native-install-pair-test.mjs +73 -0
  24. package/scripts/native-install-test.mjs +107 -0
  25. package/scripts/native-lifecycle-test.mjs +100 -0
  26. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  27. package/scripts/native-presets-test.mjs +227 -0
  28. package/scripts/native-process-tree-fixture.mjs +72 -0
  29. package/scripts/native-process-tree-test.mjs +40 -0
  30. package/scripts/native-release.mjs +121 -0
  31. package/scripts/native-session-location-test.mjs +86 -0
  32. package/scripts/native-shell-test.mjs +38 -0
  33. package/scripts/native-v2-test.mjs +262 -0
  34. package/scripts/opencode-host.mjs +47 -0
  35. package/scripts/opencode2-loop-canary.mjs +72 -4
  36. package/scripts/package-native-smoke.mjs +31 -0
  37. package/scripts/publish-workflow-test.mjs +60 -19
  38. package/scripts/pulse-check.mjs +246 -0
  39. package/scripts/session-status-test.mjs +12 -0
  40. package/src/index.js +87 -48
  41. package/src/native.js +3135 -0
  42. package/src/server.js +2164 -4560
  43. package/src/source/core/jobs.js +17 -0
  44. package/src/source/core/process.js +6 -4
  45. package/src/source/core/progress.js +58 -0
  46. package/src/source/legacy-v1.js +9 -3
  47. package/src/source/native.js +1 -0
  48. package/src/source/opencode/host.js +1 -0
  49. package/src/source/opencode/loop-commands.js +1 -27
  50. package/src/source/opencode/loop-registration.js +2 -12
  51. package/src/source/opencode/messages.js +1 -1
  52. package/src/source/opencode2/commands.js +9 -4
  53. package/src/source/opencode2/event-bridge.js +15 -3
  54. package/src/source/opencode2/events.js +20 -0
  55. package/src/source/opencode2/experimental.js +2 -0
  56. package/src/source/opencode2/native-companion.js +24 -0
  57. package/src/source/opencode2/native-plugin.js +153 -0
  58. package/src/source/opencode2/native-policy.js +106 -0
  59. package/src/source/opencode2/native-runtime.js +464 -0
  60. package/src/source/opencode2/native-shell.js +81 -0
  61. package/src/source/opencode2/prompt-runtime.js +2 -0
  62. package/src/source/opencode2/session-location.js +16 -0
  63. package/src/source/runtime/goal-steering.js +17 -3
  64. package/src/source/runtime/job-workspace.js +11 -2
  65. package/src/source/runtime/session-status.js +11 -1
  66. package/src/source/server.js +9 -1
  67. 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
+ }
@@ -230,6 +230,8 @@ export function createOpenCode2PromptRuntime(options = {}) {
230
230
  const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
231
231
  if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
232
232
 
233
+ parsed.job.createdAt = new Date(now()).toISOString()
234
+ parsed.job.lastRunAt = parsed.job.immediate === false ? now() : 0
233
235
  parsed.job.name = jobName(parsed.job)
234
236
  const state = await readState(scope.directory, scope.sessionID)
235
237
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -0,0 +1,16 @@
1
+ import path from 'node:path'
2
+
3
+ // The plugin location is not evidence about an arbitrary session.
4
+ // Resolve through the host before using the instance's local files.
5
+ export function createNativeSessionLocationGuard({ directory, workspaceID, getSession }) {
6
+ const expected = path.resolve(directory)
7
+ return async (sessionID) => {
8
+ if (typeof sessionID !== 'string' || !sessionID || typeof getSession !== 'function') return false
9
+ try {
10
+ const session = await getSession({ sessionID })
11
+ if (session?.id !== sessionID || typeof session.location?.directory !== 'string' || !path.isAbsolute(session.location.directory)) return false
12
+ if (path.relative(expected, path.resolve(session.location.directory)) !== '') return false
13
+ return (session.location.workspaceID ?? '') === (workspaceID ?? '')
14
+ } catch { return false }
15
+ }
16
+ }
@@ -62,6 +62,7 @@ export function createGoalSteeringRuntime(options = {}) {
62
62
  ? Number(options.seenUserMessageMs)
63
63
  : DEFAULT_SEEN_USER_MESSAGE_MS
64
64
 
65
+ const compactingSessions = new Set()
65
66
  const pendingSteering = new Map()
66
67
  const seenUserMessages = new Map()
67
68
 
@@ -134,7 +135,7 @@ export function createGoalSteeringRuntime(options = {}) {
134
135
 
135
136
  const active = getActiveRun(sessionID)
136
137
  const activeGoalIDs = new Set(goals.map((goal) => goal.id))
137
- const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
138
+ const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
138
139
  let preempted = false
139
140
  let abortError = ""
140
141
 
@@ -154,7 +155,8 @@ export function createGoalSteeringRuntime(options = {}) {
154
155
  { path: { sessionID }, body: {} },
155
156
  { sessionID },
156
157
  )
157
- clearActiveRun(sessionID)
158
+ // A foreground turn can replace the active run while abort settles.
159
+ if (getActiveRun(sessionID) === active) clearActiveRun(sessionID)
158
160
  preempted = true
159
161
  } catch (error) {
160
162
  pendingSteering.delete(sessionID)
@@ -177,10 +179,21 @@ export function createGoalSteeringRuntime(options = {}) {
177
179
  observeAssistantMessage(event)
178
180
  const user = userMessageFromEvent(event)
179
181
  if (!user) return undefined
180
- return await handleUserMessage(directory, client, user)
182
+ // Persisted user-role events include core compaction markers, synthetic
183
+ // continuations and replayed history. Parts may not even be persisted yet.
184
+ // Only the pre-dispatch chat.message admission hook is steering authority.
185
+ if (alreadyHandled(user.sessionID, user.messageID)) return { handled: false, duplicate: true, ...user }
186
+ return { handled: false, reason: "untrusted-message-event", ...user }
187
+ }
188
+
189
+ function setCompacting(sessionID, value = true) {
190
+ if (typeof sessionID !== "string" || !sessionID) return
191
+ if (value) compactingSessions.add(sessionID)
192
+ else compactingSessions.delete(sessionID)
181
193
  }
182
194
 
183
195
  function clearSession(sessionID) {
196
+ compactingSessions.delete(sessionID)
184
197
  pendingSteering.delete(sessionID)
185
198
  const prefix = `${sessionID}\u0000`
186
199
  for (const key of seenUserMessages.keys()) if (key.startsWith(prefix)) seenUserMessages.delete(key)
@@ -193,6 +206,7 @@ export function createGoalSteeringRuntime(options = {}) {
193
206
  shouldSuppressIdle,
194
207
  hasPendingSteering: shouldSuppressIdle,
195
208
  pendingForSession,
209
+ setCompacting,
196
210
  clearSession,
197
211
  }
198
212
  }
@@ -58,10 +58,10 @@ export function createJobWorkspaceRuntime(options = {}) {
58
58
  if (!job.branch || job.branchDone) return job
59
59
  const branch = safeID(job.branch)
60
60
  const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
61
- if (inRepo.code !== 0) { job.branchDone = true; return job }
61
+ if (inRepo.code !== 0) { job.branchDone = false; job.branchUnavailable = true; return job }
62
62
  let result = await runProcess("git", ["switch", branch], directory, 30_000)
63
63
  if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
64
- job.branchDone = true
64
+ job.branchDone = result.code === 0
65
65
  await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
66
66
  await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
67
67
  return job
@@ -97,6 +97,13 @@ export function createJobWorkspaceRuntime(options = {}) {
97
97
 
98
98
  async function untilReached(directory, job) {
99
99
  if (!job.until) return false
100
+ // Goal contracts/archives/leases can contain the configured marker too.
101
+ // None of this control state proves completion of user-project work.
102
+ const controlRoots = new Set([
103
+ stateDir(directory),
104
+ ...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"]
105
+ .map((name) => path.join(directory, ".opencode", name)),
106
+ ].map((root) => path.resolve(root).toLowerCase()))
100
107
  const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
101
108
  for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
102
109
  let scanned = 0
@@ -108,6 +115,8 @@ export function createJobWorkspaceRuntime(options = {}) {
108
115
  if (scanned >= MAX_SCAN_FILES) return false
109
116
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
110
117
  const full = path.join(current, entry.name)
118
+ // Keep the explicit until.txt marker above, but never scan plugin state.
119
+ if (controlRoots.has(path.resolve(full).toLowerCase())) continue
111
120
  if (entry.isDirectory()) { if (await walk(full)) return true }
112
121
  else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
113
122
  }