@bybrawe/opencode-loop 0.5.27 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,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
|
+
}
|