@bybrawe/opencode-loop 0.5.28 → 0.5.30

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 (59) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -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 +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/publish-workflow-test.mjs +25 -0
  21. package/scripts/scheduler-runtime-test.mjs +147 -0
  22. package/scripts/session-activity-test.mjs +95 -0
  23. package/scripts/session-status-test.mjs +220 -0
  24. package/scripts/v2-command-adapter-test.mjs +113 -0
  25. package/scripts/v2-command-runtime-test.mjs +98 -0
  26. package/scripts/v2-diagnostics-test.mjs +74 -0
  27. package/scripts/v2-host-contract-test.mjs +27 -0
  28. package/scripts/v2-plugin-sentinel-test.mjs +60 -8
  29. package/scripts/v2-prompt-interval-test.mjs +171 -0
  30. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  31. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  32. package/src/index.js +2751 -1594
  33. package/src/source/legacy-v1.js +168 -1860
  34. package/src/source/opencode/command-router.js +103 -0
  35. package/src/source/opencode/commands.js +83 -0
  36. package/src/source/opencode/goal-commands.js +107 -0
  37. package/src/source/opencode/loop-commands.js +195 -0
  38. package/src/source/opencode/loop-registration.js +100 -0
  39. package/src/source/opencode/messages.js +55 -0
  40. package/src/source/opencode2/capabilities.js +3 -1
  41. package/src/source/opencode2/commands.js +72 -0
  42. package/src/source/opencode2/diagnostics.js +80 -0
  43. package/src/source/opencode2/event-bridge.js +19 -1
  44. package/src/source/opencode2/events.js +64 -0
  45. package/src/source/opencode2/experimental.js +41 -1
  46. package/src/source/opencode2/host-contract.js +26 -0
  47. package/src/source/opencode2/prompt-runtime.js +381 -0
  48. package/src/source/opencode2/runtime-adapter.js +56 -0
  49. package/src/source/opencode2/status.js +40 -0
  50. package/src/source/runtime/compaction.js +122 -0
  51. package/src/source/runtime/executor.js +558 -0
  52. package/src/source/runtime/goal-policy.js +77 -0
  53. package/src/source/runtime/goal-runtime.js +231 -0
  54. package/src/source/runtime/goal-steering.js +198 -0
  55. package/src/source/runtime/job-workspace.js +148 -0
  56. package/src/source/runtime/scheduler.js +212 -0
  57. package/src/source/runtime/session-activity.js +112 -0
  58. package/src/source/runtime/session-status.js +197 -0
  59. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,103 @@
1
+ import { presetDefaults } from "../core/jobs.js"
2
+ import { captureSessionExecutionContext as defaultCaptureSessionExecutionContext } from "./session-context.js"
3
+ import { guardLoopOwnedUserMessage as defaultGuardLoopOwnedUserMessage } from "./messages.js"
4
+ import {
5
+ markHandled,
6
+ consumeHandled,
7
+ hasHandledCommandEvent,
8
+ markHandledCommandEvent,
9
+ forgetHandledCommandEvent,
10
+ commandName,
11
+ isPreset,
12
+ isLoopCommandName,
13
+ commandArgsText,
14
+ } from "./commands.js"
15
+
16
+ const HANDLER_NAMES = [
17
+ "addGoal",
18
+ "statusGoal",
19
+ "pauseGoal",
20
+ "resumeGoal",
21
+ "clearGoal",
22
+ "completeGoalCommand",
23
+ "blockGoalCommand",
24
+ "addLoop",
25
+ "stopLoop",
26
+ "statusLoop",
27
+ "logsLoop",
28
+ "helpLoop",
29
+ "runNow",
30
+ "updateJobState",
31
+ "doctorLoop",
32
+ "initLoop",
33
+ "exportLoop",
34
+ ]
35
+
36
+ function requireFunction(value, name) {
37
+ if (typeof value !== "function") throw new TypeError(`createCommandRouter requires ${name}`)
38
+ return value
39
+ }
40
+
41
+ export function createCommandRouter(options = {}) {
42
+ const rememberSession = requireFunction(options.rememberSession, "rememberSession")
43
+ const captureSessionExecutionContext = typeof options.captureSessionExecutionContext === "function"
44
+ ? options.captureSessionExecutionContext
45
+ : defaultCaptureSessionExecutionContext
46
+ const guardLoopOwnedUserMessage = typeof options.guardLoopOwnedUserMessage === "function"
47
+ ? options.guardLoopOwnedUserMessage
48
+ : defaultGuardLoopOwnedUserMessage
49
+ const handlers = {}
50
+ for (const name of HANDLER_NAMES) handlers[name] = requireFunction(options.handlers?.[name], `handlers.${name}`)
51
+
52
+ return async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
53
+ const name = commandName(input?.command ?? input?.name ?? fallbackName)
54
+ const sessionID = input?.sessionID
55
+ const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
56
+ if (!sessionID || !name) return false
57
+ rememberSession(directory, client, sessionID)
58
+ if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
59
+ if (source === "event") {
60
+ if (consumeHandled(sessionID, name, args)) return true
61
+ if (hasHandledCommandEvent(sessionID, input?.messageID)) return true
62
+ markHandledCommandEvent(sessionID, input?.messageID)
63
+ } else {
64
+ // Every before-hook invocation is an intentional command. Keep a pending
65
+ // count only so the matching command.executed compatibility event can be
66
+ // consumed without suppressing a genuine repeated command.
67
+ markHandled(sessionID, name, args)
68
+ }
69
+ if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
70
+
71
+ const handled = () => {
72
+ // OpenCode 1.18.x ignores unknown hook output fields, while proposed/newer
73
+ // hosts can honor noReply to skip the otherwise unavoidable model turn.
74
+ // Keep acknowledgement parts intact as the compatibility fallback.
75
+ if (output && typeof output === "object") output.noReply = true
76
+ return true
77
+ }
78
+
79
+ if (name === "loop-goal") return await handlers.addGoal(directory, client, sessionID, args), handled()
80
+ if (name === "loop-goal-status") return await handlers.statusGoal(directory, client, sessionID), handled()
81
+ if (name === "loop-goal-pause") return await handlers.pauseGoal(directory, client, sessionID, args), handled()
82
+ if (name === "loop-goal-resume") return await handlers.resumeGoal(directory, client, sessionID, args), handled()
83
+ if (name === "loop-goal-clear") return await handlers.clearGoal(directory, client, sessionID, args), handled()
84
+ if (name === "loop-goal-done" || name === "loop-goal-complete") return await handlers.completeGoalCommand(directory, client, sessionID, args), handled()
85
+ if (name === "loop-goal-blocked") return await handlers.blockGoalCommand(directory, client, sessionID, args), handled()
86
+ if (name === "loop") return await handlers.addLoop(directory, client, sessionID, args), handled()
87
+ if (isPreset(name)) return await handlers.addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
88
+ if (name === "loop-stop" || name === "loop-remove") return await handlers.stopLoop(directory, client, sessionID, args), handled()
89
+ if (name === "loop-clear") return await handlers.stopLoop(directory, client, sessionID, "all"), handled()
90
+ if (name === "loop-status") return await handlers.statusLoop(directory, client, sessionID), handled()
91
+ if (name === "loop-logs") return await handlers.logsLoop(directory, client, sessionID), handled()
92
+ if (name === "loop-help") return await handlers.helpLoop(client, sessionID), handled()
93
+ if (name === "loop-now") return await handlers.runNow(directory, client, sessionID, args), handled()
94
+ if (name === "loop-pause") return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
95
+ if (name === "loop-resume") return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
96
+ if (name === "loop-doctor") return await handlers.doctorLoop(directory, client, sessionID), handled()
97
+ if (name === "loop-init") return await handlers.initLoop(directory, client, sessionID, args), handled()
98
+ if (name === "loop-export") return await handlers.exportLoop(directory, client, sessionID), handled()
99
+ if (source === "event") forgetHandledCommandEvent(sessionID, input?.messageID)
100
+ else consumeHandled(sessionID, name, args)
101
+ return false
102
+ }
103
+ }
@@ -0,0 +1,83 @@
1
+ import { now } from "../core/args.js"
2
+
3
+ const COMMAND_DEDUPE_MS = 30_000
4
+ const handledCommands = new Map()
5
+ const handledCommandEvents = new Map()
6
+
7
+ function normalizeArgsForKey(args) {
8
+ if (args === undefined || args === null) return ""
9
+ if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
10
+ if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
11
+ try { return JSON.stringify(args) } catch { return String(args) }
12
+ }
13
+
14
+ function commandKey(sessionID, name, args) {
15
+ return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}`
16
+ }
17
+
18
+ function commandEventKey(sessionID, messageID) {
19
+ return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
20
+ }
21
+
22
+ export function markHandled(sessionID, name, args) {
23
+ const key = commandKey(sessionID, name, args)
24
+ const previous = handledCommands.get(key)
25
+ const pending = previous && now() - previous.time < COMMAND_DEDUPE_MS ? previous.pending + 1 : 1
26
+ handledCommands.set(key, { time: now(), pending })
27
+ for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > COMMAND_DEDUPE_MS) handledCommands.delete(entryKey)
28
+ for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > COMMAND_DEDUPE_MS) handledCommandEvents.delete(entryKey)
29
+ }
30
+
31
+ export function consumeHandled(sessionID, name, args) {
32
+ const key = commandKey(sessionID, name, args)
33
+ const entry = handledCommands.get(key)
34
+ if (!entry || now() - entry.time >= COMMAND_DEDUPE_MS) {
35
+ handledCommands.delete(key)
36
+ return false
37
+ }
38
+ if (entry.pending <= 1) handledCommands.delete(key)
39
+ else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
40
+ return true
41
+ }
42
+
43
+ export function hasHandledCommandEvent(sessionID, messageID) {
44
+ return handledCommandEvents.has(commandEventKey(sessionID, messageID))
45
+ }
46
+
47
+ export function markHandledCommandEvent(sessionID, messageID) {
48
+ handledCommandEvents.set(commandEventKey(sessionID, messageID), now())
49
+ }
50
+
51
+ export function forgetHandledCommandEvent(sessionID, messageID) {
52
+ handledCommandEvents.delete(commandEventKey(sessionID, messageID))
53
+ }
54
+
55
+ export function clearCommandLifecycle(sessionID) {
56
+ const prefix = `${sessionID}:`
57
+ for (const key of handledCommands.keys()) if (key.startsWith(prefix)) handledCommands.delete(key)
58
+ for (const key of handledCommandEvents.keys()) if (key.startsWith(prefix)) handledCommandEvents.delete(key)
59
+ }
60
+
61
+ export function commandName(name) {
62
+ return String(name || "")
63
+ }
64
+
65
+ export function isPreset(name) {
66
+ return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name)
67
+ }
68
+
69
+ export function isLoopCommandName(name) {
70
+ return name === "loop" || name === "loop-stop" || name === "loop-remove" || name === "loop-clear" || name === "loop-status" || name === "loop-logs" || name === "loop-help" || name === "loop-now" || name === "loop-pause" || name === "loop-resume" || name === "loop-doctor" || name === "loop-init" || name === "loop-export" || name === "loop-goal" || name === "loop-goal-status" || name === "loop-goal-pause" || name === "loop-goal-resume" || name === "loop-goal-clear" || name === "loop-goal-done" || name === "loop-goal-complete" || name === "loop-goal-blocked" || isPreset(name)
71
+ }
72
+
73
+ export function commandArgsText(args) {
74
+ if (args === undefined || args === null) return ""
75
+ if (typeof args === "string") return args
76
+ if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
77
+ if (typeof args === "object") {
78
+ for (const key of ["arguments", "args", "message", "text", "value"]) {
79
+ if (args[key] !== undefined) return commandArgsText(args[key])
80
+ }
81
+ }
82
+ return String(args)
83
+ }
@@ -0,0 +1,107 @@
1
+ import { DEFAULT_GOAL_MAX_NO_PROGRESS, splitFirst } from "../core/args.js"
2
+ import { isGoalJob, matchJob, goalStatusText } from "../core/jobs.js"
3
+ import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
4
+ import { setGoalComplete as defaultSetGoalComplete, setGoalBlocked as defaultSetGoalBlocked } from "../runtime/goal-runtime.js"
5
+
6
+ function requireFunction(value, name) {
7
+ if (typeof value !== "function") throw new TypeError(`createGoalCommandHandlers requires ${name}`)
8
+ return value
9
+ }
10
+
11
+ export function createGoalCommandHandlers(options = {}) {
12
+ const addLoop = requireFunction(options.addLoop, "addLoop")
13
+ const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
14
+ const scheduleIdleWork = requireFunction(options.scheduleIdleWork, "scheduleIdleWork")
15
+ const toast = requireFunction(options.toast, "toast")
16
+ const say = requireFunction(options.say, "say")
17
+ const readState = typeof options.readState === "function" ? options.readState : defaultReadState
18
+ const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
19
+ const setGoalComplete = typeof options.setGoalComplete === "function" ? options.setGoalComplete : defaultSetGoalComplete
20
+ const setGoalBlocked = typeof options.setGoalBlocked === "function" ? options.setGoalBlocked : defaultSetGoalBlocked
21
+
22
+ async function statusGoal(directory, client, sessionID) {
23
+ const state = await readState(directory, sessionID)
24
+ const goals = (state.jobs || []).filter(isGoalJob)
25
+ const lines = goals.length ? goals.map((job, index) => {
26
+ const status = goalStatusText(job)
27
+ const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
28
+ const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
29
+ const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
30
+ const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
31
+ const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
32
+ return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`
33
+ }) : ["No experimental goal jobs."]
34
+ await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
35
+ await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
36
+ }
37
+
38
+ async function pauseGoal(directory, client, sessionID, args) {
39
+ const target = String(args || "").trim() || "goal"
40
+ const state = await readState(directory, sessionID)
41
+ let count = 0
42
+ state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
43
+ await writeState(directory, sessionID, state)
44
+ await scheduleDueWork(directory, client, sessionID)
45
+ await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
46
+ }
47
+
48
+ async function resumeGoal(directory, client, sessionID, args) {
49
+ const target = String(args || "").trim() || "goal"
50
+ const state = await readState(directory, sessionID)
51
+ let count = 0
52
+ state.jobs = (state.jobs || []).map((job, index) => {
53
+ if (!isGoalJob(job) || !matchJob(job, target, index)) return job
54
+ count++
55
+ return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
56
+ })
57
+ await writeState(directory, sessionID, state)
58
+ await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
59
+ if (count) {
60
+ await scheduleDueWork(directory, client, sessionID)
61
+ scheduleIdleWork(directory, client, sessionID)
62
+ }
63
+ }
64
+
65
+ async function clearGoal(directory, client, sessionID, args) {
66
+ const target = String(args || "").trim()
67
+ const state = await readState(directory, sessionID)
68
+ const before = state.jobs.length
69
+ state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
70
+ await writeState(directory, sessionID, state)
71
+ await scheduleDueWork(directory, client, sessionID)
72
+ await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
73
+ }
74
+
75
+ async function completeGoalCommand(directory, client, sessionID, args) {
76
+ const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
77
+ await toast(client, result.message, result.ok ? "success" : "warning")
78
+ }
79
+
80
+ async function blockGoalCommand(directory, client, sessionID, args) {
81
+ const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
82
+ await toast(client, result.message, "warning")
83
+ }
84
+
85
+ async function addGoal(directory, client, sessionID, args) {
86
+ const text = String(args || "").trim()
87
+ const [maybeCommand, rest] = splitFirst(text)
88
+ const sub = maybeCommand.toLowerCase()
89
+ if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
90
+ if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
91
+ if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
92
+ if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
93
+ if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
94
+ if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
95
+ return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
96
+ }
97
+
98
+ return {
99
+ addGoal,
100
+ statusGoal,
101
+ pauseGoal,
102
+ resumeGoal,
103
+ clearGoal,
104
+ completeGoalCommand,
105
+ blockGoalCommand,
106
+ }
107
+ }
@@ -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
+ }