@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. 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
+ }