@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,252 @@
1
+ import { tool } from "@opencode-ai/plugin/tool"
2
+ import { now } from "./core/args.js"
3
+ import { appendLoopLog, runShellCommand } from "./core/process.js"
4
+ import { sdkErrorMessage } from "./opencode/sdk.js"
5
+ import { updateSessionExecutionContext } from "./opencode/session-context.js"
6
+ import { log, toast } from "./opencode/host.js"
7
+ import { loopOwnedUserMessageGuardActive, say, clearLoopOwnedUserMessageGuard } from "./opencode/messages.js"
8
+ import { clearCommandLifecycle } from "./opencode/commands.js"
9
+ import { createCommandRouter } from "./opencode/command-router.js"
10
+ import { createGoalCommandHandlers } from "./opencode/goal-commands.js"
11
+ import { createLoopCommandHandlers } from "./opencode/loop-commands.js"
12
+ import { createLoopRegistration } from "./opencode/loop-registration.js"
13
+ import { markToolCallActive, markToolCallFinished, updateSessionRelationshipFromEvent, refreshSessionRelationships, updateToolActivityFromEvent, clearSessionActivity } from "./runtime/session-activity.js"
14
+ import { createSchedulerRuntime } from "./runtime/scheduler.js"
15
+ import { setGoalComplete, setGoalBlocked, setGoalProgress } from "./runtime/goal-runtime.js"
16
+ import { createGoalExecutionPolicy } from "./runtime/goal-policy.js"
17
+ import { createJobWorkspaceRuntime, dangerousShell } from "./runtime/job-workspace.js"
18
+ import { createLoopExecutor } from "./runtime/executor.js"
19
+ import { createGoalSteeringRuntime } from "./runtime/goal-steering.js"
20
+
21
+ const DEFAULT_ACTIVE_GUARD_MS = 45_000
22
+
23
+ const workspaceRuntime = createJobWorkspaceRuntime({ toast })
24
+ const { snapshotPaths } = workspaceRuntime
25
+ const goalPolicy = createGoalExecutionPolicy({ runShellCommand, dangerousShell, toast, appendLoopLog, now })
26
+
27
+ let schedulerRuntime
28
+ let goalSteeringRuntime
29
+ const schedulerBridge = {
30
+ rememberSession: (...args) => schedulerRuntime.rememberSession(...args),
31
+ scheduleDueWork: (...args) => schedulerRuntime.scheduleDueWork(...args),
32
+ }
33
+ const executorRuntime = createLoopExecutor({
34
+ workspace: workspaceRuntime,
35
+ goalPolicy,
36
+ scheduler: schedulerBridge,
37
+ toast,
38
+ log,
39
+ appendLoopLog,
40
+ errorMessage: sdkErrorMessage,
41
+ now,
42
+ })
43
+ const {
44
+ clearActiveRun,
45
+ finalizeActiveRun,
46
+ maybeRunDueJobs: runDueJobs,
47
+ sessionIsIdle,
48
+ updateSessionStatusFromEvent,
49
+ noteLoopCompactionStarted,
50
+ noteLoopCompactionCompleted,
51
+ } = executorRuntime
52
+
53
+ async function maybeRunDueJobs(directory, client, sessionID, runOptions) {
54
+ if (goalSteeringRuntime?.shouldSuppressIdle(sessionID)) return
55
+ return await runDueJobs(directory, client, sessionID, runOptions)
56
+ }
57
+
58
+ schedulerRuntime = createSchedulerRuntime({
59
+ sessionIsIdle,
60
+ finalizeActiveRun,
61
+ maybeRunDueJobs,
62
+ appendLoopLog,
63
+ toast,
64
+ errorMessage: sdkErrorMessage,
65
+ })
66
+ const { rememberSession, scheduleIdleWork, scheduleDueWork, stopWatchdog, cancelDueWork } = schedulerRuntime
67
+
68
+ goalSteeringRuntime = createGoalSteeringRuntime({
69
+ getActiveRun: executorRuntime.getActiveRun,
70
+ clearActiveRun,
71
+ isLoopOwnedUserMessage: loopOwnedUserMessageGuardActive,
72
+ appendLoopLog,
73
+ now,
74
+ })
75
+
76
+ const { addLoop } = createLoopRegistration({
77
+ snapshotPaths,
78
+ scheduleDueWork,
79
+ scheduleIdleWork,
80
+ toast,
81
+ say,
82
+ defaultActiveGuardMs: DEFAULT_ACTIVE_GUARD_MS,
83
+ })
84
+
85
+ const {
86
+ addGoal,
87
+ statusGoal,
88
+ pauseGoal,
89
+ resumeGoal,
90
+ clearGoal,
91
+ completeGoalCommand,
92
+ blockGoalCommand,
93
+ } = createGoalCommandHandlers({
94
+ addLoop,
95
+ scheduleDueWork,
96
+ scheduleIdleWork,
97
+ toast,
98
+ say,
99
+ })
100
+
101
+ const {
102
+ stopLoop,
103
+ updateJobState,
104
+ statusLoop,
105
+ logsLoop,
106
+ helpLoop,
107
+ runNow,
108
+ doctorLoop,
109
+ initLoop,
110
+ exportLoop,
111
+ } = createLoopCommandHandlers({
112
+ clearActiveRun,
113
+ cancelDueWork,
114
+ stopWatchdog,
115
+ scheduleDueWork,
116
+ maybeRunDueJobs,
117
+ toast,
118
+ say,
119
+ now,
120
+ })
121
+
122
+ const handleCommand = createCommandRouter({
123
+ rememberSession,
124
+ handlers: {
125
+ addGoal,
126
+ statusGoal,
127
+ pauseGoal,
128
+ resumeGoal,
129
+ clearGoal,
130
+ completeGoalCommand,
131
+ blockGoalCommand,
132
+ addLoop,
133
+ stopLoop,
134
+ statusLoop,
135
+ logsLoop,
136
+ helpLoop,
137
+ runNow,
138
+ updateJobState,
139
+ doctorLoop,
140
+ initLoop,
141
+ exportLoop,
142
+ },
143
+ })
144
+
145
+ function disposeRuntime(directory, client) {
146
+ const sessions = schedulerRuntime.sessionIDsForHost(directory, client)
147
+ for (const sessionID of sessions) {
148
+ executorRuntime.disposeSession(sessionID)
149
+ schedulerRuntime.clearSessionScheduling(sessionID)
150
+ goalSteeringRuntime.clearSession(sessionID)
151
+ clearLoopOwnedUserMessageGuard(sessionID)
152
+ clearSessionActivity(sessionID)
153
+ clearCommandLifecycle(sessionID)
154
+ }
155
+ }
156
+
157
+ function steeringToolRejection(sessionID) {
158
+ if (!goalSteeringRuntime.hasPendingSteering(sessionID)) return undefined
159
+ return {
160
+ title: "Goal steering pending",
161
+ output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged.",
162
+ }
163
+ }
164
+
165
+ function goalTools(defaultDirectory) {
166
+ return {
167
+ opencode_loop_goal_complete: tool({
168
+ description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
169
+ args: {
170
+ summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
171
+ evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
172
+ },
173
+ execute: async (args, context) => {
174
+ const steering = steeringToolRejection(context.sessionID)
175
+ if (steering) return steering
176
+ const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
177
+ return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
178
+ },
179
+ }),
180
+ opencode_loop_goal_blocked: tool({
181
+ description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
182
+ args: {
183
+ reason: tool.schema.string().describe("Why the goal is blocked."),
184
+ needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
185
+ },
186
+ execute: async (args, context) => {
187
+ const steering = steeringToolRejection(context.sessionID)
188
+ if (steering) return steering
189
+ const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
190
+ return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
191
+ },
192
+ }),
193
+ opencode_loop_goal_progress: tool({
194
+ description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
195
+ args: {
196
+ summary: tool.schema.string().describe("What useful progress was made."),
197
+ next: tool.schema.string().describe("The next step toward completing the goal."),
198
+ },
199
+ execute: async (args, context) => {
200
+ const steering = steeringToolRejection(context.sessionID)
201
+ if (steering) return steering
202
+ const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
203
+ return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
204
+ },
205
+ }),
206
+ }
207
+ }
208
+
209
+ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
210
+ // OpenCode's local SDK can be slow or unavailable while a project instance
211
+ // is still waiting for its plugins to return their hooks. Defer bootstrap
212
+ // calls so headless/server sessions cannot deadlock during plugin loading.
213
+ const bootstrap = setTimeout(() => {
214
+ log(client, "info", "Plugin initialized", { directory }).catch(() => {})
215
+ refreshSessionRelationships(client, directory).catch(() => {})
216
+ }, 0)
217
+ bootstrap.unref?.()
218
+ return {
219
+ dispose: async () => { disposeRuntime(directory, client) },
220
+ tool: goalTools(directory),
221
+ "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
222
+ "chat.message": async (input) => {
223
+ const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
224
+ sessionID: input?.sessionID,
225
+ messageID: input?.messageID,
226
+ })
227
+ if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
228
+ },
229
+ "tool.execute.before": async (input) => { markToolCallActive(input) },
230
+ "tool.execute.after": async (input) => { markToolCallFinished(input) },
231
+ "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
232
+ event: async ({ event }) => {
233
+ if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
234
+ updateSessionRelationshipFromEvent(event)
235
+ if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
236
+ updateToolActivityFromEvent(event)
237
+ if (event.type === "command.executed") {
238
+ const props = event.properties || {}
239
+ await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
240
+ }
241
+ const steering = await goalSteeringRuntime.handleEvent(directory, client, event)
242
+ if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
243
+ const statusUpdate = updateSessionStatusFromEvent(event)
244
+ if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
245
+ if (statusUpdate?.idle && !goalSteeringRuntime.shouldSuppressIdle(statusUpdate.sessionID)) {
246
+ scheduleIdleWork(directory, client, statusUpdate.sessionID)
247
+ }
248
+ },
249
+ }
250
+ }
251
+
252
+ export default OpenCodeLoopPlugin
@@ -0,0 +1,21 @@
1
+ import {
2
+ activeRunCompletionFromMessages,
3
+ compactSession,
4
+ executeTuiCommand,
5
+ log,
6
+ readRecentSessionMessages,
7
+ resolveCompactionModel,
8
+ toast,
9
+ } from "./host.js"
10
+
11
+ export function createOpenCodeHostAdapter(client, directory) {
12
+ return Object.freeze({
13
+ executeTuiCommand: (command) => executeTuiCommand(client, command),
14
+ readRecentMessages: (sessionID, limit = 12) => readRecentSessionMessages(client, sessionID, directory, limit),
15
+ activeRunCompletion: (sessionID, active) => activeRunCompletionFromMessages(directory, client, sessionID, active),
16
+ resolveCompactionModel: (sessionID, preferredModel) => resolveCompactionModel(directory, client, sessionID, preferredModel),
17
+ compactSession: (sessionID, preferredModel) => compactSession(directory, client, sessionID, preferredModel),
18
+ log: (level, message, extra) => log(client, level, message, extra),
19
+ toast: (message, variant) => toast(client, message, variant),
20
+ })
21
+ }
@@ -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
+ }