@bybrawe/opencode-loop 0.5.27 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { sdkErrorMessage, sdkCall } from "./sdk.js"
|
|
2
|
+
import { normalizedModelRef, captureSessionExecutionContext, getSessionExecutionContext, setSessionExecutionContext } from "./session-context.js"
|
|
3
|
+
|
|
4
|
+
const SERVICE = "opencode-loop"
|
|
5
|
+
|
|
6
|
+
export function fireSdk(client, label, method, ...argsList) {
|
|
7
|
+
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
|
|
8
|
+
void pending.catch((error) => {
|
|
9
|
+
log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
|
|
10
|
+
})
|
|
11
|
+
return pending
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function executeTuiCommand(client, command) {
|
|
15
|
+
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
16
|
+
return await sdkCall(
|
|
17
|
+
client.tui.executeCommand.bind(client.tui),
|
|
18
|
+
{ body: { command } },
|
|
19
|
+
{ command },
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function compactTuiCommandName(command = "compact") {
|
|
24
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
25
|
+
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
|
|
30
|
+
if (!client?.session?.messages) return undefined
|
|
31
|
+
const query = { limit }
|
|
32
|
+
if (directory) query.directory = directory
|
|
33
|
+
try {
|
|
34
|
+
const messages = await sdkCall(
|
|
35
|
+
client.session.messages.bind(client.session),
|
|
36
|
+
{ path: { id: sessionID }, query },
|
|
37
|
+
{ path: { sessionID }, query },
|
|
38
|
+
{ sessionID, ...query },
|
|
39
|
+
)
|
|
40
|
+
return Array.isArray(messages) ? messages : undefined
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function orderedSessionMessages(messages) {
|
|
47
|
+
return (messages || [])
|
|
48
|
+
.map((message, index) => {
|
|
49
|
+
const info = message?.info || message || {}
|
|
50
|
+
const created = Number(info?.time?.created || 0)
|
|
51
|
+
return { message, index, created: Number.isFinite(created) ? created : 0 }
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => a.created - b.created || a.index - b.index)
|
|
54
|
+
.map((entry) => entry.message)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
58
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
59
|
+
if (!messages) return "unknown"
|
|
60
|
+
const tail = orderedSessionMessages(messages).at(-1)
|
|
61
|
+
const info = tail?.info || tail
|
|
62
|
+
if (!info || info.role !== "assistant") return "incomplete"
|
|
63
|
+
const completed = Number(info?.time?.completed || 0)
|
|
64
|
+
const created = Number(info?.time?.created || 0)
|
|
65
|
+
if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
|
|
66
|
+
const startedAt = Number(active?.startedAt || 0)
|
|
67
|
+
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
|
|
68
|
+
return "completed"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
72
|
+
const preferred = normalizedModelRef(preferredModel)
|
|
73
|
+
if (preferred) return preferred
|
|
74
|
+
const cached = normalizedModelRef(getSessionExecutionContext(sessionID)?.model)
|
|
75
|
+
if (cached) return cached
|
|
76
|
+
const captured = await captureSessionExecutionContext(client, sessionID)
|
|
77
|
+
const capturedModel = normalizedModelRef(captured?.model)
|
|
78
|
+
if (capturedModel) return capturedModel
|
|
79
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
80
|
+
for (const message of orderedSessionMessages(messages).reverse()) {
|
|
81
|
+
const info = message?.info || message
|
|
82
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
|
|
83
|
+
if (!model) continue
|
|
84
|
+
const previous = getSessionExecutionContext(sessionID) || {}
|
|
85
|
+
setSessionExecutionContext(sessionID, { ...previous, model })
|
|
86
|
+
return model
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function compactSession(directory, client, sessionID, preferredModel) {
|
|
92
|
+
// Prefer the native TUI command when a TUI is present. Headless/server hosts
|
|
93
|
+
// fall back to session.summarize, whose current API requires an explicit
|
|
94
|
+
// provider/model pair.
|
|
95
|
+
for (const command of ["session.compact", "session_compact"]) {
|
|
96
|
+
try {
|
|
97
|
+
await executeTuiCommand(client, command)
|
|
98
|
+
return true
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if (!client?.session?.summarize) throw new Error("client.session.summarize is not available")
|
|
105
|
+
const model = await resolveCompactionModel(directory, client, sessionID, preferredModel)
|
|
106
|
+
if (!model) throw new Error("could not resolve a provider/model for session.summarize")
|
|
107
|
+
const body = { providerID: model.providerID, modelID: model.modelID, auto: false }
|
|
108
|
+
await sdkCall(
|
|
109
|
+
client.session.summarize.bind(client.session),
|
|
110
|
+
{ path: { id: sessionID }, body },
|
|
111
|
+
{ path: { sessionID }, body },
|
|
112
|
+
{ sessionID, ...body },
|
|
113
|
+
)
|
|
114
|
+
return true
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
117
|
+
}
|
|
118
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function log(client, level, message, extra) {
|
|
123
|
+
try {
|
|
124
|
+
await sdkCall(
|
|
125
|
+
client.app.log.bind(client.app),
|
|
126
|
+
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
127
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
128
|
+
)
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function toast(client, message, variant = "info") {
|
|
133
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
134
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { now as defaultNow, durationToText } from "../core/args.js"
|
|
4
|
+
import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
|
+
import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
|
|
6
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
7
|
+
|
|
8
|
+
const SERVICE = "opencode-loop"
|
|
9
|
+
const DEFAULT_PROGRESS_MD = `# Progress
|
|
10
|
+
|
|
11
|
+
## Current Goal
|
|
12
|
+
Describe the current project goal here.
|
|
13
|
+
|
|
14
|
+
## Agent Rules
|
|
15
|
+
- Do not ask questions unless truly blocked.
|
|
16
|
+
- Make reasonable assumptions and continue.
|
|
17
|
+
- Work on unfinished TODOs in order.
|
|
18
|
+
- Mark completed TODOs with [x].
|
|
19
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
20
|
+
- Run tests, lint, or build when available.
|
|
21
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
22
|
+
|
|
23
|
+
## Active TODO
|
|
24
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
25
|
+
|
|
26
|
+
## Completed
|
|
27
|
+
- [x] Created progress.md.
|
|
28
|
+
|
|
29
|
+
## Backlog Ideas
|
|
30
|
+
- [ ] Add more project-specific tasks here.
|
|
31
|
+
|
|
32
|
+
## Blocked
|
|
33
|
+
- None.
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
function requireFunction(value, name) {
|
|
37
|
+
if (typeof value !== "function") throw new TypeError(`createLoopCommandHandlers requires ${name}`)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createLoopCommandHandlers(options = {}) {
|
|
42
|
+
const clearActiveRun = requireFunction(options.clearActiveRun, "clearActiveRun")
|
|
43
|
+
const cancelDueWork = requireFunction(options.cancelDueWork, "cancelDueWork")
|
|
44
|
+
const stopWatchdog = requireFunction(options.stopWatchdog, "stopWatchdog")
|
|
45
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
46
|
+
const maybeRunDueJobs = requireFunction(options.maybeRunDueJobs, "maybeRunDueJobs")
|
|
47
|
+
const toast = requireFunction(options.toast, "toast")
|
|
48
|
+
const say = requireFunction(options.say, "say")
|
|
49
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
50
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
51
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
52
|
+
const removeState = typeof options.removeState === "function" ? options.removeState : defaultRemoveState
|
|
53
|
+
const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
|
|
54
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
55
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
|
|
56
|
+
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs.writeFile(...args)
|
|
57
|
+
const runtimeVersion = options.runtimeVersion || process.version
|
|
58
|
+
const runtimePlatform = options.runtimePlatform || process.platform
|
|
59
|
+
|
|
60
|
+
async function stopLoop(directory, client, sessionID, args) {
|
|
61
|
+
const target = String(args || "").trim()
|
|
62
|
+
if (!target || target.toLowerCase() === "all") {
|
|
63
|
+
await removeState(directory, sessionID)
|
|
64
|
+
clearActiveRun(sessionID)
|
|
65
|
+
cancelDueWork(sessionID)
|
|
66
|
+
stopWatchdog(sessionID)
|
|
67
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const state = await readState(directory, sessionID)
|
|
71
|
+
const before = state.jobs.length
|
|
72
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
73
|
+
await writeState(directory, sessionID, state)
|
|
74
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
75
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
79
|
+
const target = String(args || "").trim() || "all"
|
|
80
|
+
const state = await readState(directory, sessionID)
|
|
81
|
+
let count = 0
|
|
82
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
83
|
+
await writeState(directory, sessionID, state)
|
|
84
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
85
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function statusLoop(directory, client, sessionID) {
|
|
89
|
+
const state = await readState(directory, sessionID)
|
|
90
|
+
const jobs = state.jobs || []
|
|
91
|
+
const lines = jobs.length ? jobs.map((job, index) => {
|
|
92
|
+
const current = now()
|
|
93
|
+
const intervalMs = Number(job.intervalMs || 0)
|
|
94
|
+
const lastRunAt = Number(job.lastRunAt || 0)
|
|
95
|
+
const createdAt = Date.parse(job.createdAt || "")
|
|
96
|
+
const dueAt = Number(job.runNowRequestedAt || 0) > 0
|
|
97
|
+
? current
|
|
98
|
+
: lastRunAt > 0
|
|
99
|
+
? lastRunAt + intervalMs
|
|
100
|
+
: job.immediate === false && Number.isFinite(createdAt)
|
|
101
|
+
? createdAt + intervalMs
|
|
102
|
+
: current
|
|
103
|
+
const dueIn = Math.max(0, dueAt - current)
|
|
104
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
105
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
106
|
+
}) : ["No active loop jobs."]
|
|
107
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
108
|
+
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function logsLoop(directory, client, sessionID) {
|
|
112
|
+
let text = "No loop log found."
|
|
113
|
+
try { text = (await readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
114
|
+
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function helpLoop(client, sessionID) {
|
|
118
|
+
await say(client, sessionID, [
|
|
119
|
+
"OpenCode Loop help:",
|
|
120
|
+
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
121
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
122
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
123
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
124
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
125
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
126
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
127
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
128
|
+
"/loop 200m --command /compact same as command loop",
|
|
129
|
+
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
130
|
+
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
131
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
132
|
+
"/loop-doctor | /loop-init | /loop-export",
|
|
133
|
+
].join("\n"))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runNow(directory, client, sessionID, args) {
|
|
137
|
+
const target = String(args || "").trim() || "all"
|
|
138
|
+
const state = await readState(directory, sessionID)
|
|
139
|
+
const requestedAt = Math.max(1, Number(now()) || Date.now())
|
|
140
|
+
let count = 0
|
|
141
|
+
for (const [index, job] of (state.jobs || []).entries()) {
|
|
142
|
+
if (!matchJob(job, target, index)) continue
|
|
143
|
+
job.lastRunAt = 0
|
|
144
|
+
job.paused = false
|
|
145
|
+
job.runNowRequestedAt = requestedAt
|
|
146
|
+
count += 1
|
|
147
|
+
}
|
|
148
|
+
await writeState(directory, sessionID, state)
|
|
149
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
150
|
+
// /loop-now is handled in command.execute.before. Do not start a model turn re-entrantly
|
|
151
|
+
// inside that hook; schedule the persisted request and let the idle-safe timer dispatch it.
|
|
152
|
+
if (count) await scheduleDueWork(directory, client, sessionID)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function doctorLoop(directory, client, sessionID) {
|
|
156
|
+
const state = await readState(directory, sessionID)
|
|
157
|
+
await say(client, sessionID, [
|
|
158
|
+
"OpenCode Loop doctor:",
|
|
159
|
+
`- plugin: ${SERVICE}`,
|
|
160
|
+
`- project directory: ${directory}`,
|
|
161
|
+
`- state directory: ${stateDir(directory)}`,
|
|
162
|
+
`- active jobs: ${(state.jobs || []).length}`,
|
|
163
|
+
`- node: ${runtimeVersion}`,
|
|
164
|
+
`- platform: ${runtimePlatform}`,
|
|
165
|
+
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
166
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
167
|
+
].join("\n"))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function initLoop(directory, client, sessionID, args) {
|
|
171
|
+
const target = String(args || "").trim() || "progress.md"
|
|
172
|
+
const full = path.resolve(directory, target)
|
|
173
|
+
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
174
|
+
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
175
|
+
await toast(client, `Created ${target}.`, "success")
|
|
176
|
+
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function exportLoop(directory, client, sessionID) {
|
|
180
|
+
const state = await readState(directory, sessionID)
|
|
181
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
stopLoop,
|
|
186
|
+
updateJobState,
|
|
187
|
+
statusLoop,
|
|
188
|
+
logsLoop,
|
|
189
|
+
helpLoop,
|
|
190
|
+
runNow,
|
|
191
|
+
doctorLoop,
|
|
192
|
+
initLoop,
|
|
193
|
+
exportLoop,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
|
|
2
|
+
import { jobLabel, isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
4
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
5
|
+
import { normalizedModelRef as defaultNormalizedModelRef, getSessionExecutionContext as defaultGetSessionExecutionContext } from "./session-context.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
8
|
+
const FALLBACK_ACTIVE_GUARD_MS = 45_000
|
|
9
|
+
|
|
10
|
+
function requireFunction(value, name) {
|
|
11
|
+
if (typeof value !== "function") throw new TypeError(`createLoopRegistration requires ${name}`)
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeActionForCompare(value) {
|
|
16
|
+
return String(value || "").replace(/\s+/g, " ").trim()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sameLoopDefinition(a, b) {
|
|
20
|
+
if (!a || !b) return false
|
|
21
|
+
return (a.name || "") === (b.name || "") &&
|
|
22
|
+
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
23
|
+
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
24
|
+
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
25
|
+
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLoopRegistration(options = {}) {
|
|
29
|
+
const snapshotPaths = requireFunction(options.snapshotPaths, "snapshotPaths")
|
|
30
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
31
|
+
const scheduleIdleWork = requireFunction(options.scheduleIdleWork, "scheduleIdleWork")
|
|
32
|
+
const toast = requireFunction(options.toast, "toast")
|
|
33
|
+
const say = requireFunction(options.say, "say")
|
|
34
|
+
const parseLoopArgs = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : defaultParseLoopArgs
|
|
35
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
36
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
37
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
38
|
+
const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
|
|
39
|
+
const getSessionExecutionContext = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : defaultGetSessionExecutionContext
|
|
40
|
+
const configuredGuard = Number(options.defaultActiveGuardMs)
|
|
41
|
+
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS
|
|
42
|
+
|
|
43
|
+
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
44
|
+
const parsed = parseLoopArgs(args, defaults)
|
|
45
|
+
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
46
|
+
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
47
|
+
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
48
|
+
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
49
|
+
if (defaults.testfixPreset) {
|
|
50
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
51
|
+
const parsedAction = String(parsed.job.action || "").trim()
|
|
52
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
53
|
+
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
54
|
+
parsed.job.verifyCommand = parsedAction
|
|
55
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
56
|
+
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
57
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
61
|
+
if (!parsed.job.activeRecoveryMs) {
|
|
62
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
63
|
+
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
64
|
+
: Math.max(defaultActiveGuardMs, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
65
|
+
}
|
|
66
|
+
if (parsed.job.dryRun) {
|
|
67
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info")
|
|
68
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const state = await readState(directory, sessionID)
|
|
72
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
73
|
+
|
|
74
|
+
// Default behavior is replace/upsert, not append forever. This prevents duplicate
|
|
75
|
+
// loops when OpenCode emits both command.execute.before and command.executed,
|
|
76
|
+
// and it matches the common expectation that /loop configures the current loop.
|
|
77
|
+
let replaced = false
|
|
78
|
+
if (!parsed.job.multi) {
|
|
79
|
+
const targetName = parsed.job.name || "default"
|
|
80
|
+
parsed.job.name = targetName
|
|
81
|
+
state.jobs = jobs.filter((existing) => {
|
|
82
|
+
const existingName = existing.name || "default"
|
|
83
|
+
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
84
|
+
if (shouldReplace) replaced = true
|
|
85
|
+
return !shouldReplace
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
state.jobs = jobs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.jobs.push(parsed.job)
|
|
92
|
+
await writeState(directory, sessionID, state)
|
|
93
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
94
|
+
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
95
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
96
|
+
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { addLoop }
|
|
100
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { now } from "../core/args.js"
|
|
2
|
+
import { sdkCall } from "./sdk.js"
|
|
3
|
+
|
|
4
|
+
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
5
|
+
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
6
|
+
const loopOwnedUserMessageGuards = new Map()
|
|
7
|
+
|
|
8
|
+
export function guardLoopOwnedUserMessage(sessionID) {
|
|
9
|
+
if (!sessionID) return
|
|
10
|
+
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
11
|
+
current.pending += 1
|
|
12
|
+
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
13
|
+
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
14
|
+
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
15
|
+
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
16
|
+
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
17
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
22
|
+
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
23
|
+
if (!entry || typeof entry !== "object") return false
|
|
24
|
+
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
25
|
+
const id = typeof messageID === "string" ? messageID : ""
|
|
26
|
+
if (id && entry.messageIDs.has(id)) return true
|
|
27
|
+
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
28
|
+
// chat.message can fire before OpenCode assigns the synthetic user message
|
|
29
|
+
// an ID. Treat that pre-dispatch hook as Loop-owned without consuming the
|
|
30
|
+
// pending guard; message.updated will later bind and retain the real ID.
|
|
31
|
+
if (!id) return true
|
|
32
|
+
entry.pending -= 1
|
|
33
|
+
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
34
|
+
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function say(client, sessionID, text) {
|
|
42
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
43
|
+
try {
|
|
44
|
+
await sdkCall(
|
|
45
|
+
client.session.prompt.bind(client.session),
|
|
46
|
+
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
47
|
+
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
48
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
49
|
+
)
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearLoopOwnedUserMessageGuard(sessionID) {
|
|
54
|
+
loopOwnedUserMessageGuards.delete(sessionID)
|
|
55
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// OpenCode SDK compatibility helpers.
|
|
2
|
+
export function sdkError(result) {
|
|
3
|
+
if (!result || typeof result !== "object") return undefined
|
|
4
|
+
return result.error || result.error === null ? result.error : undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sdkData(result) {
|
|
8
|
+
if (!result || typeof result !== "object") return result
|
|
9
|
+
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sdkErrorMessage(error) {
|
|
13
|
+
if (!error) return "unknown SDK error"
|
|
14
|
+
if (error instanceof Error) return error.message
|
|
15
|
+
if (typeof error === "string") return error
|
|
16
|
+
if (typeof error === "object") {
|
|
17
|
+
if (typeof error.message === "string") return error.message
|
|
18
|
+
if (typeof error.name === "string") return error.name
|
|
19
|
+
try { return JSON.stringify(error).slice(0, 400) } catch {}
|
|
20
|
+
}
|
|
21
|
+
return String(error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function sdkCall(method, ...argsList) {
|
|
25
|
+
let firstError
|
|
26
|
+
for (const args of argsList) {
|
|
27
|
+
if (args === undefined) continue
|
|
28
|
+
try {
|
|
29
|
+
const result = await method(args)
|
|
30
|
+
const error = sdkError(result)
|
|
31
|
+
if (!error) return sdkData(result)
|
|
32
|
+
firstError = firstError || new Error(sdkErrorMessage(error))
|
|
33
|
+
} catch (error) {
|
|
34
|
+
firstError = firstError || error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw firstError || new Error("SDK call failed without arguments")
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sdkCall } from "./sdk.js"
|
|
2
|
+
|
|
3
|
+
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
4
|
+
const sessionExecutionContexts = new Map()
|
|
5
|
+
|
|
6
|
+
export function normalizedModelRef(model) {
|
|
7
|
+
if (typeof model === "string") {
|
|
8
|
+
const separator = model.indexOf("/")
|
|
9
|
+
if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
const providerID = model?.providerID
|
|
13
|
+
const modelID = model?.modelID || model?.id
|
|
14
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
15
|
+
return { providerID, modelID }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function updateSessionExecutionContext(info) {
|
|
19
|
+
const sessionID = info?.sessionID || info?.id
|
|
20
|
+
if (typeof sessionID !== "string") return
|
|
21
|
+
const previous = sessionExecutionContexts.get(sessionID) || {}
|
|
22
|
+
const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
|
|
23
|
+
const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
|
|
24
|
+
? candidateAgent
|
|
25
|
+
: previous.agent
|
|
26
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
|
|
27
|
+
sessionExecutionContexts.set(sessionID, { agent, model })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function captureSessionExecutionContext(client, sessionID) {
|
|
31
|
+
if (client?.session?.get) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await sdkCall(
|
|
34
|
+
client.session.get.bind(client.session),
|
|
35
|
+
{ path: { id: sessionID } },
|
|
36
|
+
{ path: { sessionID } },
|
|
37
|
+
{ sessionID },
|
|
38
|
+
)
|
|
39
|
+
updateSessionExecutionContext(info)
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
const context = sessionExecutionContexts.get(sessionID) || {}
|
|
43
|
+
const normalized = { agent: context.agent || "build", model: context.model }
|
|
44
|
+
sessionExecutionContexts.set(sessionID, normalized)
|
|
45
|
+
return normalized
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getSessionExecutionContext(sessionID) { return sessionExecutionContexts.get(sessionID) }
|
|
49
|
+
export function setSessionExecutionContext(sessionID, context) { sessionExecutionContexts.set(sessionID, context); return context }
|
|
50
|
+
export function deleteSessionExecutionContext(sessionID) { sessionExecutionContexts.delete(sessionID) }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function hasFunction(value, key) {
|
|
2
|
+
return Boolean(value && typeof value[key] === "function")
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function frozenRecord(value) {
|
|
6
|
+
return Object.freeze(value)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const OPENCODE_LOOP_V2_HOST_REQUIREMENTS = Object.freeze([
|
|
10
|
+
"event.subscribe",
|
|
11
|
+
"session.prompt",
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
export const OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED = false
|
|
15
|
+
|
|
16
|
+
export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
|
|
17
|
+
...OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
|
|
18
|
+
"runtime.adapter",
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "plugin-transform"
|
|
22
|
+
|
|
23
|
+
export function inspectOpenCode2Context(ctx) {
|
|
24
|
+
const command = ctx?.command
|
|
25
|
+
const session = ctx?.session
|
|
26
|
+
const event = ctx?.event
|
|
27
|
+
const tool = ctx?.tool
|
|
28
|
+
|
|
29
|
+
return frozenRecord({
|
|
30
|
+
commandTransform: hasFunction(command, "transform"),
|
|
31
|
+
eventSubscribe: hasFunction(event, "subscribe"),
|
|
32
|
+
sessionHook: hasFunction(session, "hook"),
|
|
33
|
+
sessionPrompt: hasFunction(session, "prompt"),
|
|
34
|
+
sessionCommand: hasFunction(session, "command"),
|
|
35
|
+
sessionShell: hasFunction(session, "shell"),
|
|
36
|
+
toolTransform: hasFunction(tool, "transform"),
|
|
37
|
+
toolHook: hasFunction(tool, "hook"),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function inspectOpenCode2CommandDraft(draft) {
|
|
42
|
+
return frozenRecord({
|
|
43
|
+
list: hasFunction(draft, "list"),
|
|
44
|
+
get: hasFunction(draft, "get"),
|
|
45
|
+
update: hasFunction(draft, "update"),
|
|
46
|
+
remove: hasFunction(draft, "remove"),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function openCode2LoopRuntimeStatus(ctx, commandDraft) {
|
|
51
|
+
const context = inspectOpenCode2Context(ctx)
|
|
52
|
+
const command = inspectOpenCode2CommandDraft(commandDraft)
|
|
53
|
+
const hostBlockers = []
|
|
54
|
+
|
|
55
|
+
if (!context.eventSubscribe) hostBlockers.push("event.subscribe")
|
|
56
|
+
if (!context.sessionPrompt) hostBlockers.push("session.prompt")
|
|
57
|
+
|
|
58
|
+
const blockers = [...hostBlockers]
|
|
59
|
+
if (!OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED) blockers.push("runtime.adapter")
|
|
60
|
+
|
|
61
|
+
return frozenRecord({
|
|
62
|
+
ready: blockers.length === 0,
|
|
63
|
+
hostReady: hostBlockers.length === 0,
|
|
64
|
+
implementationReady: OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED,
|
|
65
|
+
blockers: Object.freeze(blockers),
|
|
66
|
+
hostBlockers: Object.freeze(hostBlockers),
|
|
67
|
+
commandSource: OPENCODE_LOOP_V2_COMMAND_SOURCE,
|
|
68
|
+
context,
|
|
69
|
+
command,
|
|
70
|
+
})
|
|
71
|
+
}
|