@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.
- package/CHANGELOG.md +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- 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
|
+
}
|