@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.
- package/CHANGELOG.md +10 -0
- package/README.md +30 -0
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -9
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +12 -6
- package/scripts/control-plane-until-test.mjs +39 -0
- 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 -60
- package/scripts/install-test.mjs +3 -3
- 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/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +72 -4
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -19
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/session-status-test.mjs +12 -0
- package/src/index.js +87 -48
- package/src/native.js +3135 -0
- package/src/server.js +2164 -4560
- 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 +9 -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/commands.js +9 -4
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +20 -0
- package/src/source/opencode2/experimental.js +2 -0
- 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 +2 -0
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/session-status.js +11 -1
- package/src/source/server.js +9 -1
- package/src/v1.js +1 -0
package/src/source/core/jobs.js
CHANGED
|
@@ -81,3 +81,20 @@ export function goalStatusText(job) {
|
|
|
81
81
|
if (job?.paused) return "paused"
|
|
82
82
|
return status
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// Preserve the same custom test-command semantics in both host adapters.
|
|
87
|
+
export function applyTestfixPreset(job, defaults = {}) {
|
|
88
|
+
if (defaults.testfixPreset) {
|
|
89
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
90
|
+
const parsedAction = String(job.action || "").trim()
|
|
91
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
92
|
+
if (!usedDefaultAction && job.verifyCommand === defaults.verifyCommand) {
|
|
93
|
+
job.verifyCommand = parsedAction
|
|
94
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
95
|
+
} else if (usedDefaultAction && job.verifyCommand !== defaults.verifyCommand) {
|
|
96
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${job.verifyCommand || defaultCommand}`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return job
|
|
100
|
+
}
|
|
@@ -23,11 +23,12 @@ export async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
|
23
23
|
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
24
24
|
const stdout = []
|
|
25
25
|
const stderr = []
|
|
26
|
-
|
|
26
|
+
let timedOut = false
|
|
27
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
27
28
|
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
28
29
|
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
29
30
|
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
30
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ??
|
|
31
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: timedOut ? 124 : (code ?? -1), stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
31
32
|
})
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -36,11 +37,12 @@ export async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
|
36
37
|
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
37
38
|
const stdout = []
|
|
38
39
|
const stderr = []
|
|
39
|
-
|
|
40
|
+
let timedOut = false
|
|
41
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
40
42
|
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
41
43
|
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
42
44
|
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
43
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ??
|
|
45
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: timedOut ? 124 : (code ?? -1), stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
44
46
|
})
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROGRESS_MD = `# Progress
|
|
5
|
+
|
|
6
|
+
## Current Goal
|
|
7
|
+
Describe the current project goal here.
|
|
8
|
+
|
|
9
|
+
## Agent Rules
|
|
10
|
+
- Do not ask questions unless truly blocked.
|
|
11
|
+
- Make reasonable assumptions and continue.
|
|
12
|
+
- Work on unfinished TODOs in order.
|
|
13
|
+
- Mark completed TODOs with [x].
|
|
14
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
15
|
+
- Run tests, lint, or build when available.
|
|
16
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
17
|
+
|
|
18
|
+
## Active TODO
|
|
19
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
20
|
+
|
|
21
|
+
## Completed
|
|
22
|
+
- [x] Created progress.md.
|
|
23
|
+
|
|
24
|
+
## Backlog Ideas
|
|
25
|
+
- [ ] Add more project-specific tasks here.
|
|
26
|
+
|
|
27
|
+
## Blocked
|
|
28
|
+
- None.
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
function inside(root, candidate) {
|
|
32
|
+
const relative = path.relative(root, candidate)
|
|
33
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// This direct command creates only a missing progress file. It does not own
|
|
37
|
+
// native sessions, mutate Goal/Loop records, or start a model execution.
|
|
38
|
+
export async function initializeProgressFile(directory, argumentsText = "") {
|
|
39
|
+
const root = await fs.realpath(directory)
|
|
40
|
+
const target = String(argumentsText || "").trim() || "progress.md"
|
|
41
|
+
const file = path.resolve(root, target)
|
|
42
|
+
const relative = path.relative(root, file)
|
|
43
|
+
if (!relative || !inside(root, file)) throw new Error("Progress file must be inside the project")
|
|
44
|
+
if ([".git", ".opencode"].includes(relative.split(path.sep)[0].toLowerCase())) {
|
|
45
|
+
throw new Error("Progress file cannot replace a control-plane path")
|
|
46
|
+
}
|
|
47
|
+
const parent = await fs.realpath(path.dirname(file))
|
|
48
|
+
if (!inside(root, parent)) throw new Error("Progress file must remain inside the project; symlink escape refused")
|
|
49
|
+
try {
|
|
50
|
+
// Exclusive creation closes the exists-then-write race and never follows
|
|
51
|
+
// an existing target symlink. Existing parent directories are required.
|
|
52
|
+
await fs.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" })
|
|
53
|
+
return { created: true, file: relative }
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.code === "EEXIST") return { created: false, file: relative }
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/source/legacy-v1.js
CHANGED
|
@@ -243,17 +243,23 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
243
243
|
dispose: async () => { disposeRuntime(directory, client) },
|
|
244
244
|
tool: goalTools(directory),
|
|
245
245
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
246
|
-
"chat.message": async (input) => {
|
|
246
|
+
"chat.message": async (input, output) => {
|
|
247
247
|
const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
|
|
248
248
|
sessionID: input?.sessionID,
|
|
249
|
-
messageID: input?.messageID,
|
|
249
|
+
messageID: input?.messageID || output?.message?.id,
|
|
250
250
|
})
|
|
251
251
|
if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
|
|
252
252
|
},
|
|
253
253
|
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
254
254
|
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
255
|
-
"experimental.session.compacting": async (input) => {
|
|
255
|
+
"experimental.session.compacting": async (input) => {
|
|
256
|
+
goalSteeringRuntime.setCompacting(input?.sessionID)
|
|
257
|
+
await noteLoopCompactionStarted(directory, input?.sessionID)
|
|
258
|
+
},
|
|
256
259
|
event: async ({ event }) => {
|
|
260
|
+
if (["session.compacted", "session.error", "session.deleted"].includes(event?.type)) {
|
|
261
|
+
goalSteeringRuntime.setCompacting(event?.properties?.sessionID || event?.properties?.info?.id, false)
|
|
262
|
+
}
|
|
257
263
|
if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
|
|
258
264
|
updateSessionRelationshipFromEvent(event)
|
|
259
265
|
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenCodeLoopNativePlugin as default, OpenCodeLoopNativePlugin } from "./opencode2/native-plugin.js"
|
|
@@ -69,6 +69,7 @@ export async function activeRunCompletionFromMessages(directory, client, session
|
|
|
69
69
|
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
70
70
|
if (!messages) return "unknown"
|
|
71
71
|
const ordered = orderedSessionMessages(messages)
|
|
72
|
+
if (ordered.length === 0) return "unknown"
|
|
72
73
|
const tail = ordered.at(-1)
|
|
73
74
|
const info = tail?.info || tail
|
|
74
75
|
if (!info || info.role !== "assistant") return "incomplete"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
+
import { DEFAULT_PROGRESS_MD } from "../core/progress.js"
|
|
3
4
|
import { now as defaultNow } from "../core/args.js"
|
|
4
5
|
import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
6
|
import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
|
|
@@ -8,33 +9,6 @@ import { dedicatedGoalSummary, findDedicatedGoalForSession as defaultFindDedicat
|
|
|
8
9
|
import { describeJobScheduling, listPersistedLoopSessions as defaultListPersistedLoopSessions } from "../runtime/loop-diagnostics.js"
|
|
9
10
|
|
|
10
11
|
const SERVICE = "opencode-loop"
|
|
11
|
-
const DEFAULT_PROGRESS_MD = `# Progress
|
|
12
|
-
|
|
13
|
-
## Current Goal
|
|
14
|
-
Describe the current project goal here.
|
|
15
|
-
|
|
16
|
-
## Agent Rules
|
|
17
|
-
- Do not ask questions unless truly blocked.
|
|
18
|
-
- Make reasonable assumptions and continue.
|
|
19
|
-
- Work on unfinished TODOs in order.
|
|
20
|
-
- Mark completed TODOs with [x].
|
|
21
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
22
|
-
- Run tests, lint, or build when available.
|
|
23
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
24
|
-
|
|
25
|
-
## Active TODO
|
|
26
|
-
- [ ] Review the project structure and pick the next safe improvement.
|
|
27
|
-
|
|
28
|
-
## Completed
|
|
29
|
-
- [x] Created progress.md.
|
|
30
|
-
|
|
31
|
-
## Backlog Ideas
|
|
32
|
-
- [ ] Add more project-specific tasks here.
|
|
33
|
-
|
|
34
|
-
## Blocked
|
|
35
|
-
- None.
|
|
36
|
-
`
|
|
37
|
-
|
|
38
12
|
function requireFunction(value, name) {
|
|
39
13
|
if (typeof value !== "function") throw new TypeError(`createLoopCommandHandlers requires ${name}`)
|
|
40
14
|
return value
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
|
|
2
|
-
import { actionKind, jobLabel, isGoalJob } from "../core/jobs.js"
|
|
2
|
+
import { actionKind, jobLabel, isGoalJob, applyTestfixPreset } from "../core/jobs.js"
|
|
3
3
|
import { normalizeLoopScheduleArgs as defaultNormalizeLoopScheduleArgs } from "../core/schedule-syntax.js"
|
|
4
4
|
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
5
5
|
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
@@ -62,17 +62,7 @@ export function createLoopRegistration(options = {}) {
|
|
|
62
62
|
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
63
63
|
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
64
64
|
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
65
|
-
|
|
66
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
67
|
-
const parsedAction = String(parsed.job.action || "").trim()
|
|
68
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
69
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
70
|
-
parsed.job.verifyCommand = parsedAction
|
|
71
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
72
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
73
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
74
|
-
}
|
|
75
|
-
}
|
|
65
|
+
applyTestfixPreset(parsed.job, defaults)
|
|
76
66
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
77
67
|
if (!parsed.job.activeRecoveryMs) {
|
|
78
68
|
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
@@ -27,7 +27,7 @@ export function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
27
27
|
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
28
28
|
// chat.message can fire before OpenCode assigns the synthetic user message
|
|
29
29
|
// an ID. Treat that pre-dispatch hook as Loop-owned without consuming the
|
|
30
|
-
// pending guard
|
|
30
|
+
// pending guard. Prefer the resolved ID from the trusted hook output.
|
|
31
31
|
if (!id) return true
|
|
32
32
|
entry.pending -= 1
|
|
33
33
|
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export const OPENCODE_LOOP_V2_PRESET_NAMES = Object.freeze([
|
|
2
|
+
"loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev",
|
|
3
|
+
"loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell",
|
|
4
|
+
])
|
|
5
|
+
|
|
1
6
|
export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
2
7
|
loop: Object.freeze({
|
|
3
8
|
description: "Start an OpenCode auto-continue loop. Usage: /loop 5m <task>",
|
|
@@ -36,20 +41,20 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
|
36
41
|
template: "OpenCode Loop export command handled locally. Reply exactly: OK.",
|
|
37
42
|
}),
|
|
38
43
|
"loop-help": Object.freeze({
|
|
39
|
-
description: "Show the
|
|
44
|
+
description: "Show the native OpenCode 2 Loop command help.",
|
|
40
45
|
template: "OpenCode Loop help command handled locally. Reply exactly: OK.",
|
|
41
46
|
}),
|
|
42
47
|
"loop-doctor": Object.freeze({
|
|
43
|
-
description: "Show
|
|
48
|
+
description: "Show native OpenCode 2 Loop diagnostics.",
|
|
44
49
|
template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
|
|
45
50
|
}),
|
|
46
51
|
"loop-logs": Object.freeze({
|
|
47
|
-
description: "Show recent
|
|
52
|
+
description: "Show recent native OpenCode 2 Loop runtime events.",
|
|
48
53
|
template: "OpenCode Loop logs command handled locally. Reply exactly: OK.",
|
|
49
54
|
}),
|
|
50
55
|
})
|
|
51
56
|
|
|
52
|
-
function commandArguments(input) {
|
|
57
|
+
export function commandArguments(input) {
|
|
53
58
|
const prompt = input?.prompt
|
|
54
59
|
if (typeof prompt?.text === "string") return prompt.text.trim()
|
|
55
60
|
if (typeof input?.arguments === "string") return input.arguments.trim()
|
|
@@ -26,6 +26,7 @@ function sameDirectory(expected, actual) {
|
|
|
26
26
|
|
|
27
27
|
export function createOpenCode2EventBridge({
|
|
28
28
|
directory,
|
|
29
|
+
allowInboxCommands = true,
|
|
29
30
|
onEvent = async () => {},
|
|
30
31
|
onError = () => {},
|
|
31
32
|
runtimeManager = createSessionRuntimeManager(),
|
|
@@ -43,6 +44,7 @@ export function createOpenCode2EventBridge({
|
|
|
43
44
|
let managerDisposed = false
|
|
44
45
|
let queue = Promise.resolve()
|
|
45
46
|
const sessionDirectories = new Map()
|
|
47
|
+
const seenEventIDs = new Set()
|
|
46
48
|
|
|
47
49
|
function report(error) {
|
|
48
50
|
try { onError(error) } catch {}
|
|
@@ -78,8 +80,14 @@ export function createOpenCode2EventBridge({
|
|
|
78
80
|
|
|
79
81
|
async function process(raw) {
|
|
80
82
|
if (stopped) return undefined
|
|
83
|
+
if (!allowInboxCommands && raw?.type === "session.inbox.enqueued") return undefined
|
|
81
84
|
const event = normalize(raw)
|
|
82
85
|
if (!event || !sameDirectory(directory, event.directory)) return undefined
|
|
86
|
+
if (typeof raw?.id === "string") {
|
|
87
|
+
if (seenEventIDs.has(raw.id)) return undefined
|
|
88
|
+
seenEventIDs.add(raw.id)
|
|
89
|
+
if (seenEventIDs.size > 2048) seenEventIDs.delete(seenEventIDs.values().next().value)
|
|
90
|
+
}
|
|
83
91
|
|
|
84
92
|
const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
|
|
85
93
|
await onEvent(event, runtime)
|
|
@@ -157,9 +165,13 @@ export function createOpenCode2EventBridge({
|
|
|
157
165
|
|
|
158
166
|
const cleanup = cleanupRegistration(registration)
|
|
159
167
|
registration = undefined
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
try {
|
|
169
|
+
if (cleanup) await cleanup()
|
|
170
|
+
} finally {
|
|
171
|
+
// A host unsubscription failure must not keep our per-session scopes alive.
|
|
172
|
+
sessionDirectories.clear()
|
|
173
|
+
disposeManager(reason)
|
|
174
|
+
}
|
|
163
175
|
return true
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -61,6 +61,26 @@ export function normalizeOpenCode2NativeEvent(raw) {
|
|
|
61
61
|
return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
if (type === "session.inbox.delivered" || type === "session.inbox.cancelled") {
|
|
65
|
+
if (!sessionID || !text(data.inboxID)) return undefined
|
|
66
|
+
return Object.freeze({ kind: "inbox", action: type.endsWith("delivered") ? "delivered" : "cancelled", sessionID, directory, inboxID: data.inboxID })
|
|
67
|
+
}
|
|
68
|
+
if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
|
|
69
|
+
if (!sessionID) return undefined
|
|
70
|
+
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory })
|
|
71
|
+
}
|
|
72
|
+
if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
|
|
73
|
+
if (!sessionID) return undefined
|
|
74
|
+
return Object.freeze({ kind: "session", action: "error", sessionID, directory, reason: type })
|
|
75
|
+
}
|
|
76
|
+
if (["session.shell.started", "session.shell.ended"].includes(type)) {
|
|
77
|
+
if (!sessionID) return undefined
|
|
78
|
+
const shell = record(data.shell) || {}
|
|
79
|
+
return Object.freeze({ kind: "shell", action: type.endsWith("started") ? "started" : "ended", sessionID, directory, shellID: shell.id, command: shell.command, code: typeof shell.exit === "number" ? shell.exit : -1, status: shell.status, metadata: shell.metadata })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (type === "location.shutdown") return Object.freeze({ kind: "server", action: "disposed", directory })
|
|
83
|
+
|
|
64
84
|
if (type === "session.created") {
|
|
65
85
|
if (!sessionID) return undefined
|
|
66
86
|
return Object.freeze({ kind: "session", action: "created", sessionID, directory: directory || text(record(data.location)?.directory) })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OpenCodeLoopNativePlugin } from "./native-plugin.js"
|
|
1
2
|
import { inspectOpenCode2Context } from "./capabilities.js"
|
|
2
3
|
import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
|
|
3
4
|
import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
|
|
@@ -11,6 +12,7 @@ export { OPENCODE_LOOP_V2_COMMANDS }
|
|
|
11
12
|
export const OpenCodeLoopV2ExperimentalPlugin = {
|
|
12
13
|
id: OPENCODE_LOOP_V2_PLUGIN_ID,
|
|
13
14
|
async setup(ctx) {
|
|
15
|
+
if (typeof ctx?.session?.hook === "function") return OpenCodeLoopNativePlugin.setup(ctx)
|
|
14
16
|
const capabilities = inspectOpenCode2Context(ctx)
|
|
15
17
|
if (!capabilities.commandTransform) {
|
|
16
18
|
throw new Error("OpenCode 2 command.transform capability is unavailable")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
// Read-only interoperation with Goal's session-sharded store. An unreadable or
|
|
6
|
+
// unsafe live shard is not permission to take over autonomous continuation.
|
|
7
|
+
export async function nativeGoalReservesSession(directory, sessionID) {
|
|
8
|
+
const shard = createHash("sha256").update(sessionID).digest("hex").slice(0, 32)
|
|
9
|
+
const root = path.resolve(directory)
|
|
10
|
+
const parts = [".opencode", "goals", `${shard}.json`]
|
|
11
|
+
let file = root
|
|
12
|
+
try {
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
file = path.join(file, part)
|
|
15
|
+
const info = await lstat(file)
|
|
16
|
+
if (info.isSymbolicLink()) return true
|
|
17
|
+
}
|
|
18
|
+
const goal = JSON.parse(await readFile(file, "utf8"))
|
|
19
|
+
if (goal?.schemaVersion !== 1 || goal?.sessionID !== sessionID || typeof goal?.id !== "string") return true
|
|
20
|
+
return !["completed", "cleared"].includes(goal.status)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return error?.code !== "ENOENT"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -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
|
+
}
|