@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,153 @@
1
+ import path from "node:path"
2
+ import { createNativeSessionLocationGuard } from "./session-location.js"
3
+ import { createNativeShellHost } from "./native-shell.js"
4
+ import { createNativeLoopRuntime } from "./native-runtime.js"
5
+ import { createOpenCode2EventBridge } from "./event-bridge.js"
6
+ import { registerOpenCode2LoopCommands, OPENCODE_LOOP_V2_PRESET_NAMES, commandArguments } from "./commands.js"
7
+ import { readSmallTextFile, appendLoopLog } from "../core/process.js"
8
+ import { stateDir } from "../core/state.js"
9
+ import { initializeProgressFile } from "../core/progress.js"
10
+
11
+ const HELP = `OpenCode Loop: native OpenCode 2 runtime
12
+ /loop 0s <task> --max-runs 3
13
+ /loop 5m <task> --verify "npm test" --pause-on-verify-fail
14
+ /loop --watch progress.md <task>
15
+ /loop 0s <task> --prompt-file instructions.md --include-file progress.md
16
+ /loop 0s <task> --compact-every 3
17
+ /loop 5m --shell npm test
18
+ /loop 15m --compact /compact
19
+ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /loop-prompt, /loop-command, /loop-cmd, /loop-shell, /loop-compact.
20
+ /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
21
+ Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
22
+ Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
23
+ Scheduled shell commands run as bounded local child processes when the plugin context has no session.shell.
24
+ Scheduled/manual compaction requires session.compact on the host; unavailable capability is rejected before creating a job. Native automatic compaction is always left to OpenCode.
25
+ An unfinished dedicated Goal reserves its session; Loop will not override it.
26
+ --timeout pauses future iterations without aborting the current native model/tool/compaction operation.
27
+ --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
28
+ Native sessions stay serialized; --allow-overlap is rejected.
29
+ Use @bybrawe/opencode-goal and /goal for native Goal contracts. Legacy Loop Goal state is not silently migrated.
30
+ An uncertain admission after a restart is paused for review instead of being replayed automatically.`
31
+
32
+ async function cleanup(value) {
33
+ if (typeof value === "function") await value()
34
+ else if (typeof value?.dispose === "function") await value.dispose()
35
+ else if (typeof value?.unsubscribe === "function") await value.unsubscribe()
36
+ }
37
+
38
+ export const OpenCodeLoopNativePlugin = Object.freeze({
39
+ id: "@bybrawe/opencode-loop",
40
+ async setup(ctx) {
41
+ for (const [label, fn] of [["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
42
+ if (typeof fn !== "function") throw new Error(`Native OpenCode Loop requires ${label}; use OpenCode 2.0.18 or newer.`)
43
+ }
44
+ const directory = String(ctx.location?.directory || ctx.options?.directory || "").trim()
45
+ if (!directory) throw new Error("Native OpenCode Loop requires a project directory")
46
+ const ownsSession = createNativeSessionLocationGuard({
47
+ directory, workspaceID: ctx.location?.workspaceID,
48
+ getSession: (input) => ctx.session.get(input),
49
+ })
50
+ const registrations = []
51
+ const lifecycleAbort = new AbortController()
52
+ const prompt = (request) => ctx.session.prompt({ ...request, delivery: request.delivery || "queue", metadata: { ...request.metadata, opencode_loop_v2: true } })
53
+ const shellHost = createNativeShellHost({
54
+ directory,
55
+ onTerminal: (event) => runtime.onEvent(event),
56
+ onError: (error) => { void appendLoopLog(directory, "v2-shell-error", { message: String(error?.message || error) }).catch(() => {}) },
57
+ })
58
+ const runtime = createNativeLoopRuntime({
59
+ directory,
60
+ scopeAllowed: (scope) => ownsSession(scope.sessionID),
61
+ prompt,
62
+ command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
63
+ wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
64
+ compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact({ ...request, delivery: "queue" }) : undefined,
65
+ shell: typeof ctx.session.shell === "function" ? (request) => ctx.session.shell(request) : (request) => shellHost.dispatch(request),
66
+ // cancel() returns void. The durable inbox.cancelled event, not this
67
+ // response alone, proves that an undelivered input can be refunded.
68
+ cancel: typeof ctx.session.inbox?.cancel === "function" ? async (request) => { await ctx.session.inbox.cancel(request); return false } : undefined,
69
+ onError: (error) => { void appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {}) },
70
+ })
71
+ const bridge = createOpenCode2EventBridge({ directory, allowInboxCommands: false, onEvent: async (event) => {
72
+ if (event.sessionID && !(event.kind === "session" && event.action === "deleted") && !await ownsSession(event.sessionID)) return
73
+ return runtime.onEvent(event)
74
+ }, onError: (error) => { void appendLoopLog(directory, "v2-native-event-error", { message: String(error?.message || error) }).catch(() => {}) } })
75
+ let closed = false
76
+ let disposeTask
77
+ function dispose() {
78
+ if (disposeTask) return disposeTask
79
+ closed = true
80
+ disposeTask = Promise.resolve().then(async () => {
81
+ const errors = []
82
+ // Stop accepting events before aborting next(); iterator.return() alone
83
+ // cannot wake an async generator blocked on a public server stream.
84
+ const stopped = bridge.dispose("native-plugin-disposed").catch((error) => { errors.push(error) })
85
+ lifecycleAbort.abort()
86
+ for (const action of [() => runtime.dispose(), () => shellHost.dispose()]) {
87
+ try { await action() } catch (error) { errors.push(error) }
88
+ }
89
+ await stopped
90
+ for (const registration of [...registrations].reverse()) {
91
+ try { await cleanup(registration) } catch (error) { errors.push(error) }
92
+ }
93
+ if (errors.length) throw new AggregateError(errors, "Native Loop cleanup failed")
94
+ })
95
+ return disposeTask
96
+ }
97
+ async function execute({ name, sessionID, arguments: argumentsText }) {
98
+ if (closed) throw new Error("Native Loop is disposed")
99
+ if (!await ownsSession(sessionID)) throw new Error("Native Loop cannot resolve this session in the plugin location; use its own project/worktree.")
100
+ if (closed) throw new Error("Native Loop is disposed")
101
+ if (name === "loop-init") {
102
+ const output = await initializeProgressFile(directory, argumentsText)
103
+ await prompt({ sessionID, text: output.created ? `Created ${output.file}.` : `${output.file} already exists; preserved unchanged.`, resume: false })
104
+ return { handled: true, accepted: true, ...output }
105
+ }
106
+ if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
107
+ const text = name === "loop-logs"
108
+ ? (await readSmallTextFile(path.join(stateDir(directory), "loop.log"), 2_000_000)).split("\n").slice(-60).join("\n") || "No Loop log entries."
109
+ : name === "loop-doctor"
110
+ ? `Native OpenCode Loop\nHost: ${ctx.app?.version || "unknown"}\nDirectory: ${directory}\nPrompt hooks: enabled\nCompaction ownership: native host\nDelivery: durable queue\nScheduled timers: ${runtime.scheduledCount()}\nUse /loop-status for paused/admitted job state.`
111
+ : HELP
112
+ await prompt({ sessionID, text, resume: false })
113
+ return { handled: true, accepted: true }
114
+ }
115
+ const event = { kind: "command", action: "executed", directory, sessionID, name, arguments: argumentsText }
116
+ const result = await runtime.onEvent(event)
117
+ if (result?.accepted === false) throw new Error(result.error || result.blockers?.join(" ") || "Loop command rejected")
118
+ const shouldWake = result?.job ? result.job.immediate !== false : ["loop-now", "loop-resume"].includes(name)
119
+ if (result?.accepted && shouldWake) await runtime.wake(event)
120
+ return result
121
+ }
122
+ try {
123
+ registrations.push(await ctx.session.hook("prompt", async (event) => {
124
+ if (closed || event.metadata?.opencode_loop_v2 === true || !await ownsSession(event.sessionID)) return
125
+ if (closed) return
126
+ return runtime.onEvent({ kind: "foreground", directory, sessionID: event.sessionID })
127
+ }))
128
+ registrations.push(await ctx.session.hook("compaction", async (event) => {
129
+ if (closed || !await ownsSession(event.sessionID)) return
130
+ if (!closed) return runtime.onEvent({ kind: "compaction", action: "started", directory, sessionID: event.sessionID })
131
+ }))
132
+ registrations.push(await ctx.command.transform((draft) => {
133
+ registerOpenCode2LoopCommands(draft, { execute })
134
+ if (typeof draft.add !== "function") return
135
+ for (const name of [...OPENCODE_LOOP_V2_PRESET_NAMES, "loop-init"]) {
136
+ draft.add({
137
+ name,
138
+ description: name === "loop-init" ? "Create a missing project-local progress file." : `Native Loop ${name.slice(5)} preset`,
139
+ execute: (input) => execute({ name, sessionID: input.sessionID, arguments: commandArguments(input) }),
140
+ })
141
+ }
142
+ }))
143
+ await bridge.attach(() => ctx.event.subscribe({ signal: lifecycleAbort.signal }))
144
+ await appendLoopLog(directory, "v2-native-ready", { host: ctx.app?.version || "unknown" })
145
+ return dispose
146
+ } catch (error) {
147
+ await dispose().catch(() => {})
148
+ throw error
149
+ }
150
+ },
151
+ })
152
+
153
+ export default OpenCodeLoopNativePlugin
@@ -0,0 +1,106 @@
1
+ import { stat } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { runShellCommand, appendLoopLog } from "../core/process.js"
4
+ import { createJobWorkspaceRuntime, dangerousShell } from "../runtime/job-workspace.js"
5
+
6
+ export function createNativeJobPolicy(options = {}) {
7
+ const now = options.now || Date.now
8
+ const run = options.runShellCommand || runShellCommand
9
+ const workspace = options.workspace || createJobWorkspaceRuntime({ toast: async () => {} })
10
+
11
+ function pause(job, reason, failed = false) {
12
+ job.paused = true
13
+ job.pauseReason = reason
14
+ if (failed) job.failureCount = (job.failureCount || 0) + 1
15
+ return false
16
+ }
17
+
18
+ async function shell(scope, job, command, phase) {
19
+ if (job.safe && dangerousShell(command)) {
20
+ return { code: -1, stdout: "", stderr: `Blocked ${phase} by --safe command guard`, blocked: true }
21
+ }
22
+ const result = await run(command, scope.directory, Math.max(1, Number(job.timeoutMs) || 120_000))
23
+ await appendLoopLog(scope.directory, `v2-${phase}`, {
24
+ sessionID: scope.sessionID, job: job.name || job.id, code: result.code,
25
+ output: String(result.stdout || "").slice(-8000), error: String(result.stderr || "").slice(-8000),
26
+ })
27
+ return result
28
+ }
29
+
30
+ async function checkStop(scope, job) {
31
+ const created = Date.parse(job.createdAt)
32
+ if (job.maxRuntimeMs > 0 && Number.isFinite(created) && now() - created >= job.maxRuntimeMs) {
33
+ job.enabled = false
34
+ return pause(job, "max-runtime")
35
+ }
36
+ if (job.stopFile) {
37
+ try {
38
+ await stat(path.resolve(scope.directory, job.stopFile))
39
+ job.enabled = false
40
+ return pause(job, "stop-file")
41
+ } catch (error) { if (error?.code !== "ENOENT") throw error }
42
+ }
43
+ if (await workspace.untilReached(scope.directory, job)) {
44
+ job.enabled = false
45
+ return pause(job, "until-reached")
46
+ }
47
+ return true
48
+ }
49
+
50
+ async function prepare(scope, job, isCurrent = () => true) {
51
+ if (!await checkStop(scope, job) || !isCurrent()) return false
52
+ if (job.dryRun) return true
53
+ if (job.preflightCommand) {
54
+ const result = await shell(scope, job, job.preflightCommand, "preflight")
55
+ job.lastPreflightCode = result.code
56
+ if (result.code !== 0) return pause(job, "preflight-failed", true)
57
+ }
58
+ if (!isCurrent()) return false
59
+ if (job.branch) {
60
+ await workspace.ensureBranch(scope.directory, job, undefined, scope.sessionID)
61
+ if (!job.branchDone) return pause(job, "branch-unavailable", true)
62
+ }
63
+ return isCurrent()
64
+ }
65
+
66
+ async function finish(scope, job, isCurrent = () => true) {
67
+ if (!isCurrent() || job.dryRun) return
68
+ if (job.verifyCommand) {
69
+ const result = await shell(scope, job, job.verifyCommand, "verify")
70
+ job.lastVerifyCode = result.code
71
+ job.lastVerifyAt = now()
72
+ job.lastVerifyOutput = `${result.stdout || ""}\n${result.stderr || ""}`.trim().slice(-8000)
73
+ if (result.code !== 0) {
74
+ job.failureCount = (job.failureCount || 0) + 1
75
+ if (result.blocked || job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) pause(job, "verification-failed")
76
+ return
77
+ }
78
+ job.failureCount = 0
79
+ }
80
+ if (!isCurrent()) return
81
+ if (job.postrunCommand) {
82
+ const result = await shell(scope, job, job.postrunCommand, "postrun")
83
+ job.lastPostrunCode = result.code
84
+ if (result.code !== 0) {
85
+ pause(job, "postrun-failed", true)
86
+ return
87
+ }
88
+ }
89
+ if (isCurrent()) await workspace.createCheckpoint(scope.directory, scope.sessionID, job, undefined)
90
+ }
91
+
92
+ async function notify(scope, job, reason) {
93
+ if (!job.notifyCommand || job.dryRun) return
94
+ // Placeholders are data, not a route for event/error strings to become shell syntax.
95
+ const literal = (value) => String(value || "").replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 160)
96
+ const command = String(job.notifyCommand).replace(/\{reason\}/g, literal(reason)).replace(/\{job\}/g, literal(job.name || job.id))
97
+ await shell(scope, job, command, "notify")
98
+ }
99
+
100
+ return {
101
+ pause, checkStop, prepare, finish, notify, shell,
102
+ buildPrompt: workspace.buildPrompt,
103
+ snapshotPaths: workspace.snapshotPaths,
104
+ watchChanged: workspace.watchChanged,
105
+ }
106
+ }