@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/package.json +4 -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 +11 -3
- 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-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -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-loop-canary.mjs +433 -0
- package/scripts/publish-workflow-test.mjs +25 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -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-diagnostics-test.mjs +74 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +60 -8
- 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 +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- 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/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/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +72 -0
- package/src/source/opencode2/diagnostics.js +80 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +41 -1
- package/src/source/opencode2/host-contract.js +26 -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/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/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
package/src/source/legacy-v1.js
CHANGED
|
@@ -1,1869 +1,165 @@
|
|
|
1
|
-
import { promises as fs } from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import { spawn } from "node:child_process"
|
|
4
1
|
import { tool } from "@opencode-ai/plugin/tool"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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"
|
|
11
20
|
|
|
12
|
-
const SERVICE = "opencode-loop"
|
|
13
21
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
14
|
-
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
15
|
-
const IDLE_DEBOUNCE_MS = 1_200
|
|
16
|
-
const BUSY_RETRY_MS = 5_000
|
|
17
|
-
const SESSION_STATUS_CACHE_MS = 1_500
|
|
18
|
-
const MIN_DUE_TIMER_MS = 250
|
|
19
|
-
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
20
|
-
const HEARTBEAT_MS = 2_500
|
|
21
|
-
const MAX_SCAN_FILES = 200
|
|
22
|
-
const MAX_SCAN_BYTES = 2_000_000
|
|
23
|
-
const GOAL_REPORT_DIR = "goals"
|
|
24
|
-
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
25
|
-
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
26
|
-
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
27
|
-
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
28
22
|
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const info = message?.info || message
|
|
151
|
-
const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
|
|
152
|
-
if (!model) continue
|
|
153
|
-
const previous = getSessionExecutionContext(sessionID) || {}
|
|
154
|
-
setSessionExecutionContext(sessionID, { ...previous, model })
|
|
155
|
-
return model
|
|
156
|
-
}
|
|
157
|
-
return undefined
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function compactSession(directory, client, sessionID, preferredModel) {
|
|
161
|
-
// Prefer the native TUI command when a TUI is present. Headless/server hosts
|
|
162
|
-
// fall back to session.summarize, whose current API requires an explicit
|
|
163
|
-
// provider/model pair.
|
|
164
|
-
for (const command of ["session.compact", "session_compact"]) {
|
|
165
|
-
try {
|
|
166
|
-
await executeTuiCommand(client, command)
|
|
167
|
-
return true
|
|
168
|
-
} catch (error) {
|
|
169
|
-
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
try {
|
|
173
|
-
if (!client?.session?.summarize) throw new Error("client.session.summarize is not available")
|
|
174
|
-
const model = await resolveCompactionModel(directory, client, sessionID, preferredModel)
|
|
175
|
-
if (!model) throw new Error("could not resolve a provider/model for session.summarize")
|
|
176
|
-
const body = { providerID: model.providerID, modelID: model.modelID, auto: false }
|
|
177
|
-
await sdkCall(
|
|
178
|
-
client.session.summarize.bind(client.session),
|
|
179
|
-
{ path: { id: sessionID }, body },
|
|
180
|
-
{ path: { sessionID }, body },
|
|
181
|
-
{ sessionID, ...body },
|
|
182
|
-
)
|
|
183
|
-
return true
|
|
184
|
-
} catch (error) {
|
|
185
|
-
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
186
|
-
}
|
|
187
|
-
await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
|
|
188
|
-
return false
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function log(client, level, message, extra) {
|
|
192
|
-
try {
|
|
193
|
-
await sdkCall(
|
|
194
|
-
client.app.log.bind(client.app),
|
|
195
|
-
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
196
|
-
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
197
|
-
)
|
|
198
|
-
} catch {}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function toast(client, message, variant = "info") {
|
|
202
|
-
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function guardLoopOwnedUserMessage(sessionID) {
|
|
206
|
-
if (!sessionID) return
|
|
207
|
-
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
208
|
-
current.pending += 1
|
|
209
|
-
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
210
|
-
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
211
|
-
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
212
|
-
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
213
|
-
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
214
|
-
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
219
|
-
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
220
|
-
if (!entry || typeof entry !== "object") return false
|
|
221
|
-
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
222
|
-
const id = typeof messageID === "string" ? messageID : ""
|
|
223
|
-
if (id && entry.messageIDs.has(id)) return true
|
|
224
|
-
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
225
|
-
entry.pending -= 1
|
|
226
|
-
if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
227
|
-
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
228
|
-
return true
|
|
229
|
-
}
|
|
230
|
-
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
231
|
-
return false
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function say(client, sessionID, text) {
|
|
235
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
236
|
-
try {
|
|
237
|
-
await sdkCall(
|
|
238
|
-
client.session.prompt.bind(client.session),
|
|
239
|
-
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
240
|
-
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
241
|
-
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
242
|
-
)
|
|
243
|
-
} catch {}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
247
|
-
function markHandled(sessionID, name, args) {
|
|
248
|
-
const key = commandKey(sessionID, name, args)
|
|
249
|
-
const previous = handledCommands.get(key)
|
|
250
|
-
const pending = previous && now() - previous.time < 30_000 ? previous.pending + 1 : 1
|
|
251
|
-
handledCommands.set(key, { time: now(), pending })
|
|
252
|
-
for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > 30_000) handledCommands.delete(entryKey)
|
|
253
|
-
for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > 30_000) handledCommandEvents.delete(entryKey)
|
|
254
|
-
}
|
|
255
|
-
function consumeHandled(sessionID, name, args) {
|
|
256
|
-
const key = commandKey(sessionID, name, args)
|
|
257
|
-
const entry = handledCommands.get(key)
|
|
258
|
-
if (!entry || now() - entry.time >= 30_000) {
|
|
259
|
-
handledCommands.delete(key)
|
|
260
|
-
return false
|
|
261
|
-
}
|
|
262
|
-
if (entry.pending <= 1) handledCommands.delete(key)
|
|
263
|
-
else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
|
|
264
|
-
return true
|
|
265
|
-
}
|
|
266
|
-
function commandEventKey(sessionID, messageID) {
|
|
267
|
-
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function commandName(name) { return String(name || "") }
|
|
271
|
-
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
|
|
272
|
-
function isLoopCommandName(name) {
|
|
273
|
-
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)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function normalizeArgsForKey(args) {
|
|
277
|
-
if (args === undefined || args === null) return ""
|
|
278
|
-
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
279
|
-
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
280
|
-
try { return JSON.stringify(args) } catch { return String(args) }
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function commandArgsText(args) {
|
|
284
|
-
if (args === undefined || args === null) return ""
|
|
285
|
-
if (typeof args === "string") return args
|
|
286
|
-
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
287
|
-
if (typeof args === "object") {
|
|
288
|
-
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
289
|
-
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return String(args)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function rememberSession(directory, client, sessionID) {
|
|
296
|
-
if (!sessionID) return
|
|
297
|
-
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
298
|
-
startHeartbeat()
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
function hasActiveToolCalls(sessionID) {
|
|
303
|
-
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function markToolCallActive(input) {
|
|
307
|
-
const sessionID = input?.sessionID
|
|
308
|
-
const callID = input?.callID
|
|
309
|
-
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
310
|
-
const calls = activeToolCalls.get(sessionID) || new Set()
|
|
311
|
-
calls.add(callID)
|
|
312
|
-
activeToolCalls.set(sessionID, calls)
|
|
313
|
-
sessionStatuses.set(sessionID, "busy")
|
|
314
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function markToolCallFinished(input) {
|
|
318
|
-
const sessionID = input?.sessionID
|
|
319
|
-
const callID = input?.callID
|
|
320
|
-
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
321
|
-
const calls = activeToolCalls.get(sessionID)
|
|
322
|
-
if (!calls) return
|
|
323
|
-
calls.delete(callID)
|
|
324
|
-
if (!calls.size) activeToolCalls.delete(sessionID)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function updateSessionRelationship(info, removed = false) {
|
|
328
|
-
const sessionID = info?.id
|
|
329
|
-
if (typeof sessionID !== "string") return
|
|
330
|
-
if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
|
|
331
|
-
else sessionParents.set(sessionID, info.parentID)
|
|
332
|
-
if (!removed) updateSessionExecutionContext(info)
|
|
333
|
-
else deleteSessionExecutionContext(sessionID)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function updateSessionRelationshipFromEvent(event) {
|
|
337
|
-
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
|
|
338
|
-
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function isDescendantSession(sessionID, ancestorID) {
|
|
342
|
-
const visited = new Set()
|
|
343
|
-
let current = sessionID
|
|
344
|
-
while (sessionParents.has(current) && !visited.has(current)) {
|
|
345
|
-
visited.add(current)
|
|
346
|
-
current = sessionParents.get(current)
|
|
347
|
-
if (current === ancestorID) return true
|
|
348
|
-
}
|
|
349
|
-
return false
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function hasBusyDescendant(sessionID) {
|
|
353
|
-
for (const childID of sessionParents.keys()) {
|
|
354
|
-
if (!isDescendantSession(childID, sessionID)) continue
|
|
355
|
-
const status = sessionStatuses.get(childID)
|
|
356
|
-
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
|
|
357
|
-
}
|
|
358
|
-
return false
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async function refreshSessionRelationships(client, directory) {
|
|
362
|
-
if (!client?.session?.list) return
|
|
363
|
-
try {
|
|
364
|
-
const sessions = await sdkCall(
|
|
365
|
-
client.session.list.bind(client.session),
|
|
366
|
-
{ query: { directory } },
|
|
367
|
-
{ directory },
|
|
368
|
-
{},
|
|
369
|
-
)
|
|
370
|
-
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
371
|
-
} catch {}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function updateToolActivityFromEvent(event) {
|
|
375
|
-
const props = event?.properties || {}
|
|
376
|
-
if (event?.type === "message.part.updated") {
|
|
377
|
-
const part = props.part
|
|
378
|
-
if (part?.type !== "tool") return
|
|
379
|
-
const sessionID = part.sessionID || props.sessionID
|
|
380
|
-
if (["pending", "running"].includes(part.state?.status)) {
|
|
381
|
-
markToolCallActive({ sessionID, callID: part.callID })
|
|
382
|
-
}
|
|
383
|
-
if (["completed", "error"].includes(part.state?.status)) {
|
|
384
|
-
// Task/subagent hooks have used the part id as their hook call id in some
|
|
385
|
-
// OpenCode versions, while normal tools use part.callID. Clear either.
|
|
386
|
-
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
|
|
387
|
-
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
388
|
-
}
|
|
389
|
-
return
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
|
|
393
|
-
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
|
|
394
|
-
if (started) markToolCallActive(props)
|
|
395
|
-
if (finished) markToolCallFinished(props)
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function startHeartbeat() {
|
|
399
|
-
if (heartbeatTimer) return
|
|
400
|
-
heartbeatTimer = setInterval(() => {
|
|
401
|
-
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
402
|
-
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
403
|
-
knownSessions.delete(sessionID)
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
Promise.resolve()
|
|
407
|
-
.then(async () => {
|
|
408
|
-
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
409
|
-
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
410
|
-
})
|
|
411
|
-
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
412
|
-
}
|
|
413
|
-
if (!knownSessions.size && heartbeatTimer) {
|
|
414
|
-
clearInterval(heartbeatTimer)
|
|
415
|
-
heartbeatTimer = undefined
|
|
416
|
-
}
|
|
417
|
-
}, HEARTBEAT_MS)
|
|
418
|
-
}
|
|
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
|
+
})
|
|
419
144
|
|
|
420
145
|
function disposeRuntime(directory, client) {
|
|
421
|
-
const sessions =
|
|
422
|
-
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
423
|
-
.map(([sessionID]) => sessionID)
|
|
146
|
+
const sessions = schedulerRuntime.sessionIDsForHost(directory, client)
|
|
424
147
|
for (const sessionID of sessions) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
watchdogTimers.delete(sessionID)
|
|
432
|
-
runLocks.delete(sessionID)
|
|
433
|
-
knownSessions.delete(sessionID)
|
|
434
|
-
loopOwnedUserMessageGuards.delete(sessionID)
|
|
435
|
-
activeToolCalls.delete(sessionID)
|
|
436
|
-
sessionParents.delete(sessionID)
|
|
437
|
-
sessionStatuses.delete(sessionID)
|
|
438
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
439
|
-
deleteSessionExecutionContext(sessionID)
|
|
440
|
-
loopCompactionRequests.delete(sessionID)
|
|
441
|
-
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
442
|
-
for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
|
|
443
|
-
}
|
|
444
|
-
if (!knownSessions.size && heartbeatTimer) {
|
|
445
|
-
clearInterval(heartbeatTimer)
|
|
446
|
-
heartbeatTimer = undefined
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
function dangerousShell(command) {
|
|
453
|
-
const text = String(command || "").toLowerCase()
|
|
454
|
-
return [
|
|
455
|
-
/\brm\b(?=[^\r\n]*\s-{1,2}(?:[a-z]*r[a-z]*|recursive)\b)(?=[^\r\n]*\s-{1,2}(?:[a-z]*f[a-z]*|force)\b)/,
|
|
456
|
-
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
457
|
-
/\bgit\s+reset\b/,
|
|
458
|
-
/\bgit\s+clean\b/,
|
|
459
|
-
/\bgit\s+push\b/,
|
|
460
|
-
/\bdel\b[^\r\n]*\s\/s\b/,
|
|
461
|
-
/\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
|
|
462
|
-
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
463
|
-
/\bterraform\s+destroy\b/,
|
|
464
|
-
/\bkubectl\s+delete\b/,
|
|
465
|
-
/\bdeploy\b.*\bproduction\b/,
|
|
466
|
-
].some((pattern) => pattern.test(text))
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
async function buildGoalPrompt(directory, job) {
|
|
471
|
-
const sections = []
|
|
472
|
-
sections.push(`Working directory:\n${path.resolve(directory)}\nKeep every file operation inside this directory. Prefer workspace-relative paths such as \"src/index.js\"; never turn a relative path into a root path such as \"/src/index.js\".`)
|
|
473
|
-
const objective = String(job.action || "").trim()
|
|
474
|
-
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
475
|
-
if (job.goalFile) {
|
|
476
|
-
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
477
|
-
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
478
|
-
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
479
|
-
}
|
|
480
|
-
if (job.promptFile) {
|
|
481
|
-
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
482
|
-
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
483
|
-
}
|
|
484
|
-
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
485
|
-
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
486
|
-
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
487
|
-
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
488
|
-
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
489
|
-
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
490
|
-
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) sections.push(`No-progress guard:\n${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`)
|
|
491
|
-
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
492
|
-
for (const file of job.includeFiles || []) {
|
|
493
|
-
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
494
|
-
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
return `${GOAL_PROMPT_PREFIX}.
|
|
498
|
-
|
|
499
|
-
You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
|
|
500
|
-
|
|
501
|
-
Rules:
|
|
502
|
-
- Work on the next smallest useful step toward the goal.
|
|
503
|
-
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
504
|
-
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
505
|
-
- If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
|
|
506
|
-
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
507
|
-
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
508
|
-
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
509
|
-
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
510
|
-
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
511
|
-
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
512
|
-
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
513
|
-
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
514
|
-
|
|
515
|
-
${sections.join("\n\n---\n\n")}`
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function buildPrompt(directory, job) {
|
|
519
|
-
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
520
|
-
const sections = []
|
|
521
|
-
if (job.promptFile) {
|
|
522
|
-
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
523
|
-
if (text.trim()) sections.push(`Instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
524
|
-
else sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`)
|
|
525
|
-
}
|
|
526
|
-
if (job.action) sections.push(decoratePrompt(job))
|
|
527
|
-
for (const file of job.includeFiles || []) {
|
|
528
|
-
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
529
|
-
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
530
|
-
}
|
|
531
|
-
return sections.join("\n\n---\n\n") || decoratePrompt(job)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
async function ensureBranch(directory, job, client, sessionID) {
|
|
535
|
-
if (!job.branch || job.branchDone) return job
|
|
536
|
-
const branch = safeID(job.branch)
|
|
537
|
-
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
538
|
-
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
539
|
-
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
540
|
-
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
541
|
-
job.branchDone = true
|
|
542
|
-
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
543
|
-
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
544
|
-
return job
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
async function maybeCompact(directory, client, sessionID, job) {
|
|
548
|
-
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
549
|
-
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
550
|
-
if (!dueRuns && !dueTime) return { job, started: false }
|
|
551
|
-
beginLoopCompaction(sessionID, job.id, true)
|
|
552
|
-
if (await compactSession(directory, client, sessionID, job.model)) {
|
|
553
|
-
job.lastCompactAt = now()
|
|
554
|
-
job.lastCompactRunCount = job.runCount || 0
|
|
555
|
-
return { job, started: true }
|
|
556
|
-
}
|
|
557
|
-
loopCompactionRequests.delete(sessionID)
|
|
558
|
-
return { job, started: false }
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
async function snapshotPaths(directory, files) {
|
|
562
|
-
const snapshot = {}
|
|
563
|
-
for (const file of files || []) {
|
|
564
|
-
try {
|
|
565
|
-
const stat = await fs.stat(path.resolve(directory, file))
|
|
566
|
-
snapshot[file] = `${stat.mtimeMs}:${stat.size}`
|
|
567
|
-
} catch { snapshot[file] = "missing" }
|
|
568
|
-
}
|
|
569
|
-
return snapshot
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
async function watchChanged(directory, job) {
|
|
573
|
-
if (!job.watchPaths?.length) return false
|
|
574
|
-
const next = await snapshotPaths(directory, job.watchPaths)
|
|
575
|
-
const previous = job.watchSnapshot || {}
|
|
576
|
-
const changed = job.watchPaths.some((file) => previous[file] !== next[file])
|
|
577
|
-
if (changed) job.watchSnapshot = next
|
|
578
|
-
return changed
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async function fileContains(filePath, needle) {
|
|
582
|
-
try {
|
|
583
|
-
const stat = await fs.stat(filePath)
|
|
584
|
-
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false
|
|
585
|
-
return (await fs.readFile(filePath, "utf8")).includes(needle)
|
|
586
|
-
} catch { return false }
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
async function untilReached(directory, job) {
|
|
590
|
-
if (!job.until) return false
|
|
591
|
-
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
592
|
-
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
593
|
-
let scanned = 0
|
|
594
|
-
async function walk(current) {
|
|
595
|
-
if (scanned >= MAX_SCAN_FILES) return false
|
|
596
|
-
let entries
|
|
597
|
-
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return false }
|
|
598
|
-
for (const entry of entries) {
|
|
599
|
-
if (scanned >= MAX_SCAN_FILES) return false
|
|
600
|
-
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
601
|
-
const full = path.join(current, entry.name)
|
|
602
|
-
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
603
|
-
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
604
|
-
}
|
|
605
|
-
return false
|
|
606
|
-
}
|
|
607
|
-
return await walk(directory)
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async function createCheckpoint(directory, sessionID, job, client) {
|
|
611
|
-
if (!job.checkpointOnly && !job.gitCheckpoint) return
|
|
612
|
-
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
613
|
-
if (inRepo.code !== 0) return
|
|
614
|
-
const status = await runProcess("git", ["status", "--short"], directory, 30_000)
|
|
615
|
-
if (!status.stdout.trim()) return
|
|
616
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
617
|
-
const checkpointDir = path.join(stateDir(directory), "checkpoints", safeID(sessionID))
|
|
618
|
-
await ensureDir(checkpointDir)
|
|
619
|
-
const diff = await runProcess("git", ["diff", "--binary"], directory, 120_000)
|
|
620
|
-
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120_000)
|
|
621
|
-
const prefix = `${timestamp}-${safeID(job.name || job.id)}`
|
|
622
|
-
await fs.writeFile(path.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr)
|
|
623
|
-
await fs.writeFile(path.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}\n${staged.stdout}`)
|
|
624
|
-
if (job.gitCheckpoint) {
|
|
625
|
-
await runProcess("git", ["add", "-A"], directory, 120_000)
|
|
626
|
-
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120_000)
|
|
627
|
-
}
|
|
628
|
-
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function updateSessionStatusFromEvent(event) {
|
|
632
|
-
const sessionID = event?.properties?.sessionID
|
|
633
|
-
if (typeof sessionID !== "string") return undefined
|
|
634
|
-
if (event?.type === "session.idle") {
|
|
635
|
-
sessionStatuses.set(sessionID, "idle")
|
|
636
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
637
|
-
return { sessionID, idle: true }
|
|
638
|
-
}
|
|
639
|
-
if (event?.type === "session.status") {
|
|
640
|
-
const status = event?.properties?.status
|
|
641
|
-
const type = status && typeof status === "object" ? status.type : undefined
|
|
642
|
-
if (typeof type === "string") {
|
|
643
|
-
sessionStatuses.set(sessionID, type)
|
|
644
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
645
|
-
}
|
|
646
|
-
return { sessionID, idle: type === "idle" }
|
|
647
|
-
}
|
|
648
|
-
return undefined
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
function userInterruptSessionFromEvent(event) {
|
|
652
|
-
if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
|
|
653
|
-
const props = event?.properties || {}
|
|
654
|
-
const info = props.info || props.message || props
|
|
655
|
-
const role = info?.role
|
|
656
|
-
const sessionID = info?.sessionID || props.sessionID
|
|
657
|
-
const messageID = info?.id || props.messageID
|
|
658
|
-
if (role !== "user" || typeof sessionID !== "string") return undefined
|
|
659
|
-
if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
|
|
660
|
-
return sessionID
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
664
|
-
const state = await readState(directory, sessionID)
|
|
665
|
-
const interruptedAt = now()
|
|
666
|
-
let count = 0
|
|
667
|
-
state.jobs = (state.jobs || []).map((job) => {
|
|
668
|
-
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
|
|
669
|
-
count++
|
|
670
|
-
return {
|
|
671
|
-
...job,
|
|
672
|
-
paused: true,
|
|
673
|
-
lastUserInterruptAt: interruptedAt,
|
|
674
|
-
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
|
|
675
|
-
}
|
|
676
|
-
})
|
|
677
|
-
if (!count) return false
|
|
678
|
-
await writeState(directory, sessionID, state)
|
|
679
|
-
await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
|
|
680
|
-
await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
|
|
681
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
682
|
-
return true
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function staleActiveRun(sessionID) {
|
|
686
|
-
const active = activeRuns.get(sessionID)
|
|
687
|
-
if (!active) return false
|
|
688
|
-
const age = now() - (active.startedAt || 0)
|
|
689
|
-
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
690
|
-
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
|
|
691
|
-
return age >= threshold
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
695
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
696
|
-
if (!options.requireIdle && !options.forceStale) return true
|
|
697
|
-
|
|
698
|
-
const completion = options.forceStale
|
|
699
|
-
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
700
|
-
: undefined
|
|
701
|
-
if (completion === "completed") return true
|
|
702
|
-
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
703
|
-
|
|
704
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
705
|
-
if (live?.type === "idle") return true
|
|
706
|
-
if (live?.type) {
|
|
707
|
-
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
708
|
-
return false
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
|
|
712
|
-
const cached = sessionStatuses.get(sessionID)
|
|
713
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
714
|
-
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
718
|
-
const argsList = []
|
|
719
|
-
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
720
|
-
argsList.push({})
|
|
721
|
-
for (const args of argsList) {
|
|
722
|
-
try {
|
|
723
|
-
const result = await client.session.status(args)
|
|
724
|
-
const error = sdkError(result)
|
|
725
|
-
if (error) continue
|
|
726
|
-
const data = sdkData(result)
|
|
727
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
|
728
|
-
const observedAt = now()
|
|
729
|
-
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
730
|
-
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
731
|
-
if (typeof observedType !== "string") continue
|
|
732
|
-
sessionStatuses.set(observedSessionID, observedType)
|
|
733
|
-
sessionStatusSeenAt.set(observedSessionID, observedAt)
|
|
734
|
-
}
|
|
735
|
-
// OpenCode's status list contains active sessions; idle sessions are
|
|
736
|
-
// normally omitted. Clear a completed descendant that was previously busy.
|
|
737
|
-
for (const childID of sessionParents.keys()) {
|
|
738
|
-
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
739
|
-
sessionStatuses.set(childID, "idle")
|
|
740
|
-
sessionStatusSeenAt.set(childID, observedAt)
|
|
741
|
-
}
|
|
742
|
-
if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
|
|
743
|
-
const status = data?.[sessionID]
|
|
744
|
-
const type = status && typeof status === "object" ? status.type : undefined
|
|
745
|
-
if (typeof type === "string") return { type, source: "sdk" }
|
|
746
|
-
return { type: "idle", source: "sdk" }
|
|
747
|
-
} catch {}
|
|
748
|
-
}
|
|
749
|
-
return undefined
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
753
|
-
// OpenCode can briefly report an idle session while a long-running tool or
|
|
754
|
-
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
755
|
-
// signal here, so never enqueue another turn until every active call ends.
|
|
756
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
757
|
-
sessionStatuses.set(sessionID, "busy")
|
|
758
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
759
|
-
return "busy"
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const cached = sessionStatuses.get(sessionID)
|
|
763
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
764
|
-
|
|
765
|
-
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
766
|
-
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
767
|
-
// own short assistant turn, and some TUI builds do not always emit the final
|
|
768
|
-
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
769
|
-
// stuck at "due in every idle" until the user types another command.
|
|
770
|
-
if (cached === "idle") return cached
|
|
771
|
-
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
772
|
-
|
|
773
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
774
|
-
if (live?.type) {
|
|
775
|
-
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
776
|
-
// plugin-injected turn until the next user command touches the session.
|
|
777
|
-
// When the only reason we still think the session is busy is our own stale
|
|
778
|
-
// active-run guard, recover instead of waiting for another manual command.
|
|
779
|
-
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
780
|
-
const active = activeRuns.get(sessionID)
|
|
781
|
-
if (active) {
|
|
782
|
-
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
783
|
-
if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
|
|
784
|
-
sessionStatuses.set(sessionID, "idle")
|
|
785
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
786
|
-
await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
787
|
-
sessionID,
|
|
788
|
-
job: active.job?.name || active.jobId,
|
|
789
|
-
startedAt: active.startedAt,
|
|
790
|
-
})
|
|
791
|
-
return "idle"
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
sessionStatuses.set(sessionID, live.type)
|
|
796
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
797
|
-
return live.type
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
801
|
-
sessionStatuses.set(sessionID, fallback)
|
|
802
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
803
|
-
return fallback
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
807
|
-
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function scheduleIdleWork(directory, client, sessionID) {
|
|
811
|
-
const previous = idleTimers.get(sessionID)
|
|
812
|
-
if (previous) clearTimeout(previous)
|
|
813
|
-
const timer = setTimeout(() => {
|
|
814
|
-
idleTimers.delete(sessionID)
|
|
815
|
-
Promise.resolve()
|
|
816
|
-
.then(async () => {
|
|
817
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
818
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
819
|
-
return
|
|
820
|
-
}
|
|
821
|
-
await finalizeActiveRun(directory, client, sessionID)
|
|
822
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
823
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
824
|
-
return
|
|
825
|
-
}
|
|
826
|
-
await maybeRunDueJobs(directory, client, sessionID)
|
|
827
|
-
})
|
|
828
|
-
.catch((error) => {
|
|
829
|
-
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
830
|
-
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
831
|
-
})
|
|
832
|
-
}, IDLE_DEBOUNCE_MS)
|
|
833
|
-
idleTimers.set(sessionID, timer)
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function jobDueAt(job, current = now()) {
|
|
837
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
838
|
-
if (!job.enabled || job.paused) return Infinity
|
|
839
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
840
|
-
if (job.watchPaths?.length) return Infinity
|
|
841
|
-
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
842
|
-
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
843
|
-
if (job.intervalMs === 0) return current
|
|
844
|
-
if (!job.lastRunAt) return current
|
|
845
|
-
return job.lastRunAt + (job.intervalMs || 0)
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function nextDueDelay(state) {
|
|
849
|
-
const current = now()
|
|
850
|
-
let soonest = Infinity
|
|
851
|
-
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
852
|
-
if (!Number.isFinite(soonest)) return Infinity
|
|
853
|
-
return Math.max(0, soonest - current)
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
async function startWatchdog(directory, client, sessionID) {
|
|
857
|
-
if (watchdogTimers.has(sessionID)) return
|
|
858
|
-
const timer = setInterval(() => {
|
|
859
|
-
Promise.resolve()
|
|
860
|
-
.then(async () => {
|
|
861
|
-
const state = await readState(directory, sessionID)
|
|
862
|
-
const delay = nextDueDelay(state)
|
|
863
|
-
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
864
|
-
if (!hasJobs || !Number.isFinite(delay)) {
|
|
865
|
-
const existing = watchdogTimers.get(sessionID)
|
|
866
|
-
if (existing) clearInterval(existing)
|
|
867
|
-
watchdogTimers.delete(sessionID)
|
|
868
|
-
return
|
|
869
|
-
}
|
|
870
|
-
if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
|
|
871
|
-
else await scheduleDueWork(directory, client, sessionID)
|
|
872
|
-
})
|
|
873
|
-
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
874
|
-
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
875
|
-
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
876
|
-
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
877
|
-
// when no manual /loop-status command is typed.
|
|
878
|
-
watchdogTimers.set(sessionID, timer)
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function stopWatchdog(sessionID) {
|
|
882
|
-
const timer = watchdogTimers.get(sessionID)
|
|
883
|
-
if (timer) clearInterval(timer)
|
|
884
|
-
watchdogTimers.delete(sessionID)
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
888
|
-
const previous = dueTimers.get(sessionID)
|
|
889
|
-
if (previous) clearTimeout(previous)
|
|
890
|
-
|
|
891
|
-
const state = await readState(directory, sessionID)
|
|
892
|
-
const delay = nextDueDelay(state)
|
|
893
|
-
if (!Number.isFinite(delay)) {
|
|
894
|
-
dueTimers.delete(sessionID)
|
|
895
|
-
return
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
899
|
-
const timer = setTimeout(() => {
|
|
900
|
-
dueTimers.delete(sessionID)
|
|
901
|
-
Promise.resolve()
|
|
902
|
-
.then(async () => {
|
|
903
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
904
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
905
|
-
return
|
|
906
|
-
}
|
|
907
|
-
await finalizeActiveRun(directory, client, sessionID)
|
|
908
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
909
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
910
|
-
return
|
|
911
|
-
}
|
|
912
|
-
await maybeRunDueJobs(directory, client, sessionID)
|
|
913
|
-
})
|
|
914
|
-
.catch((error) => {
|
|
915
|
-
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
916
|
-
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
917
|
-
})
|
|
918
|
-
}, wait)
|
|
919
|
-
dueTimers.set(sessionID, timer)
|
|
920
|
-
await startWatchdog(directory, client, sessionID)
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
function dueJobs(state, force = false) {
|
|
924
|
-
const current = now()
|
|
925
|
-
return (state.jobs || []).filter((job) => {
|
|
926
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
927
|
-
if (!job.enabled || job.paused) return false
|
|
928
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
929
|
-
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
930
|
-
if (force) return true
|
|
931
|
-
if (job.watchPaths?.length) return job.watchTriggered === true
|
|
932
|
-
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
|
|
933
|
-
})
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
function clearActiveRun(sessionID) {
|
|
937
|
-
const active = activeRuns.get(sessionID)
|
|
938
|
-
if (active?.timer) clearTimeout(active.timer)
|
|
939
|
-
const compact = loopCompactionRequests.get(sessionID)
|
|
940
|
-
if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID)
|
|
941
|
-
activeRuns.delete(sessionID)
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function beginLoopCompaction(sessionID, jobId, resumeAfter = false) {
|
|
945
|
-
loopCompactionRequests.set(sessionID, {
|
|
946
|
-
jobId,
|
|
947
|
-
resumeAfter,
|
|
948
|
-
requestedAt: now(),
|
|
949
|
-
startedAt: 0,
|
|
950
|
-
completedAt: 0,
|
|
951
|
-
})
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
async function noteLoopCompactionStarted(directory, sessionID) {
|
|
955
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
956
|
-
if (!pending) return false
|
|
957
|
-
if (!pending.startedAt) {
|
|
958
|
-
pending.startedAt = now()
|
|
959
|
-
loopCompactionRequests.set(sessionID, pending)
|
|
960
|
-
await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
|
|
961
|
-
}
|
|
962
|
-
return true
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
async function finalizeLoopCompaction(directory, client, sessionID) {
|
|
966
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
967
|
-
const active = activeRuns.get(sessionID)
|
|
968
|
-
if (!pending || !active || pending.jobId !== active.jobId) return false
|
|
969
|
-
return await finalizeActiveRun(directory, client, sessionID)
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
async function noteLoopCompactionCompleted(directory, client, sessionID) {
|
|
973
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
974
|
-
if (!pending) return false
|
|
975
|
-
pending.completedAt = now()
|
|
976
|
-
loopCompactionRequests.set(sessionID, pending)
|
|
977
|
-
await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
|
|
978
|
-
const timer = setTimeout(() => {
|
|
979
|
-
finalizeLoopCompaction(directory, client, sessionID)
|
|
980
|
-
.catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) }))
|
|
981
|
-
}, 0)
|
|
982
|
-
timer.unref?.()
|
|
983
|
-
return true
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
987
|
-
const active = activeRuns.get(sessionID)
|
|
988
|
-
if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
|
|
989
|
-
|
|
990
|
-
clearActiveRun(sessionID)
|
|
991
|
-
sessionStatuses.delete(sessionID)
|
|
992
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
993
|
-
|
|
994
|
-
const message = sdkErrorMessage(error)
|
|
995
|
-
const state = await readState(directory, sessionID)
|
|
996
|
-
const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
|
|
997
|
-
if (job) {
|
|
998
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
999
|
-
job.lastFailureReason = "dispatch_failed"
|
|
1000
|
-
job.lastDispatchFailure = message.slice(0, 4000)
|
|
1001
|
-
job.lastDispatchFailureAt = now()
|
|
1002
|
-
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1003
|
-
job.paused = true
|
|
1004
|
-
await notifyJob(directory, job, "dispatch_failed")
|
|
1005
|
-
}
|
|
1006
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1007
|
-
await writeState(directory, sessionID, state)
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message })
|
|
1011
|
-
await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
|
|
1012
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1013
|
-
return true
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
function goalReportPath(directory, sessionID, job) {
|
|
1017
|
-
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
function goalReportText(job) {
|
|
1021
|
-
const lines = []
|
|
1022
|
-
lines.push(`# OpenCode Loop Goal Report`)
|
|
1023
|
-
lines.push("")
|
|
1024
|
-
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
1025
|
-
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
1026
|
-
lines.push(`Created: ${job.createdAt || ""}`)
|
|
1027
|
-
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
1028
|
-
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
1029
|
-
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
1030
|
-
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
1031
|
-
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
1032
|
-
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
1033
|
-
lines.push("")
|
|
1034
|
-
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
1035
|
-
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
1036
|
-
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
1037
|
-
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
1038
|
-
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
1039
|
-
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
1040
|
-
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
1041
|
-
if (job.lastGoalChecks?.length) {
|
|
1042
|
-
lines.push("## Latest checks", "")
|
|
1043
|
-
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
1044
|
-
lines.push("")
|
|
1045
|
-
}
|
|
1046
|
-
if (job.goalProgress?.length) {
|
|
1047
|
-
lines.push("## Progress", "")
|
|
1048
|
-
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
1049
|
-
lines.push("")
|
|
148
|
+
executorRuntime.disposeSession(sessionID)
|
|
149
|
+
schedulerRuntime.clearSessionScheduling(sessionID)
|
|
150
|
+
goalSteeringRuntime.clearSession(sessionID)
|
|
151
|
+
clearLoopOwnedUserMessageGuard(sessionID)
|
|
152
|
+
clearSessionActivity(sessionID)
|
|
153
|
+
clearCommandLifecycle(sessionID)
|
|
1050
154
|
}
|
|
1051
|
-
return lines.join("\n")
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
async function writeGoalReport(directory, sessionID, job) {
|
|
1055
|
-
if (!isGoalJob(job)) return
|
|
1056
|
-
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
1057
|
-
await ensureDir(path.dirname(target))
|
|
1058
|
-
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
1059
155
|
}
|
|
1060
156
|
|
|
1061
|
-
function
|
|
1062
|
-
|
|
1063
|
-
if (!goals.length) return undefined
|
|
1064
|
-
const text = String(target || "").trim()
|
|
1065
|
-
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
1066
|
-
return goals.find((job, index) => matchJob(job, text, index))
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function parseGoalToolText(args, fields) {
|
|
1070
|
-
const result = {}
|
|
1071
|
-
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
1072
|
-
return result
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function hasConcreteGoalEvidence(value) {
|
|
1076
|
-
const text = String(value || "").trim()
|
|
1077
|
-
if (text.length < 24) return false
|
|
1078
|
-
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
1079
|
-
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
1080
|
-
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
1081
|
-
return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function goalChecksPassed(job) {
|
|
1085
|
-
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
function goalRequiresPassingChecks(job) {
|
|
1089
|
-
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1093
|
-
job.goalCompletionRejectedAt = now()
|
|
1094
|
-
job.goalCompletionRejectedReason = reason
|
|
1095
|
-
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
1096
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1097
|
-
await writeState(directory, sessionID, state)
|
|
1098
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1099
|
-
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
1100
|
-
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
function goalProgressSnapshot(job) {
|
|
157
|
+
function steeringToolRejection(sessionID) {
|
|
158
|
+
if (!goalSteeringRuntime.hasPendingSteering(sessionID)) return undefined
|
|
1104
159
|
return {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
evidence: String(job?.goalEvidence || ""),
|
|
1108
|
-
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
1109
|
-
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
1110
|
-
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
1111
|
-
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
1116
|
-
const before = goalProgressSnapshot(beforeJob || {})
|
|
1117
|
-
const after = goalProgressSnapshot(afterJob || {})
|
|
1118
|
-
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
1119
|
-
if (after.progressCount > before.progressCount) return true
|
|
1120
|
-
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
1121
|
-
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
1122
|
-
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
1123
|
-
return false
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
1127
|
-
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
|
|
1128
|
-
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
1129
|
-
if (!Number.isFinite(limit) || limit <= 0) return job
|
|
1130
|
-
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
1131
|
-
job.noProgressCount = 0
|
|
1132
|
-
job.lastProgressAt = now()
|
|
1133
|
-
return job
|
|
1134
|
-
}
|
|
1135
|
-
job.noProgressCount = (job.noProgressCount || 0) + 1
|
|
1136
|
-
job.lastNoProgressAt = now()
|
|
1137
|
-
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1138
|
-
if (job.noProgressCount >= limit) {
|
|
1139
|
-
job.paused = true
|
|
1140
|
-
job.goalNoProgressPausedAt = now()
|
|
1141
|
-
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
|
|
1142
|
-
await toast(client, job.goalNoProgressReason, "warning")
|
|
1143
|
-
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1144
|
-
}
|
|
1145
|
-
return job
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
1149
|
-
const state = await readState(directory, sessionID)
|
|
1150
|
-
const job = pickGoalJob(state, args.target)
|
|
1151
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1152
|
-
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
1153
|
-
const manualOverride = args.manual === true || args.manualOverride === true
|
|
1154
|
-
const completionEvidence = parsed.evidence || job.goalEvidence || ""
|
|
1155
|
-
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
|
|
1156
|
-
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
|
|
1157
|
-
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
1158
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
1159
|
-
}
|
|
1160
|
-
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
1161
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
1162
|
-
}
|
|
1163
|
-
job.goalStatus = "completed"
|
|
1164
|
-
job.enabled = false
|
|
1165
|
-
job.paused = true
|
|
1166
|
-
job.goalCompletedAt = now()
|
|
1167
|
-
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
1168
|
-
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
1169
|
-
job.noProgressCount = 0
|
|
1170
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1171
|
-
await writeState(directory, sessionID, state)
|
|
1172
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1173
|
-
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
1174
|
-
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
1178
|
-
const state = await readState(directory, sessionID)
|
|
1179
|
-
const job = pickGoalJob(state, args.target)
|
|
1180
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1181
|
-
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
1182
|
-
job.goalStatus = "blocked"
|
|
1183
|
-
job.enabled = false
|
|
1184
|
-
job.paused = true
|
|
1185
|
-
job.goalBlockedAt = now()
|
|
1186
|
-
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
1187
|
-
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1188
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1189
|
-
await writeState(directory, sessionID, state)
|
|
1190
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1191
|
-
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
1192
|
-
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
1196
|
-
const state = await readState(directory, sessionID)
|
|
1197
|
-
const job = pickGoalJob(state, args.target)
|
|
1198
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1199
|
-
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
1200
|
-
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
1201
|
-
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
1202
|
-
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1203
|
-
job.noProgressCount = 0
|
|
1204
|
-
job.lastProgressAt = now()
|
|
1205
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1206
|
-
await writeState(directory, sessionID, state)
|
|
1207
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1208
|
-
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
1209
|
-
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
async function runGoalChecks(directory, sessionID, job, client) {
|
|
1213
|
-
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
1214
|
-
const results = []
|
|
1215
|
-
for (const command of job.goalChecks) {
|
|
1216
|
-
if (job.safe && dangerousShell(command)) {
|
|
1217
|
-
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
1218
|
-
continue
|
|
1219
|
-
}
|
|
1220
|
-
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
1221
|
-
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
1222
|
-
}
|
|
1223
|
-
job.lastGoalCheckAt = now()
|
|
1224
|
-
job.lastGoalChecks = results
|
|
1225
|
-
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
1226
|
-
if (allPassed) {
|
|
1227
|
-
job.goalChecksPassedAt = now()
|
|
1228
|
-
job.failureCount = 0
|
|
1229
|
-
await toast(client, "Goal checks passed.", "success")
|
|
1230
|
-
if (job.goalCompleteWhenChecksPass) {
|
|
1231
|
-
job.goalStatus = "completed"
|
|
1232
|
-
job.enabled = false
|
|
1233
|
-
job.paused = true
|
|
1234
|
-
job.goalCompletedAt = now()
|
|
1235
|
-
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
1236
|
-
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
1237
|
-
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
1238
|
-
}
|
|
1239
|
-
} else {
|
|
1240
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1241
|
-
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
1242
|
-
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
160
|
+
title: "Goal steering pending",
|
|
161
|
+
output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged.",
|
|
1243
162
|
}
|
|
1244
|
-
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
1245
|
-
return job
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1249
|
-
const active = activeRuns.get(sessionID)
|
|
1250
|
-
if (!active) return
|
|
1251
|
-
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1252
|
-
const recoveredStale = staleActiveRun(sessionID)
|
|
1253
|
-
if (active.compactionOnly) {
|
|
1254
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1255
|
-
clearActiveRun(sessionID)
|
|
1256
|
-
sessionStatuses.delete(sessionID)
|
|
1257
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
1258
|
-
await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
|
|
1259
|
-
sessionID,
|
|
1260
|
-
job: active.job?.name || active.jobId,
|
|
1261
|
-
startedAt: active.startedAt,
|
|
1262
|
-
nativeEvent: Boolean(pending?.completedAt),
|
|
1263
|
-
})
|
|
1264
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1265
|
-
return true
|
|
1266
|
-
}
|
|
1267
|
-
clearActiveRun(sessionID)
|
|
1268
|
-
const state = await readState(directory, sessionID)
|
|
1269
|
-
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1270
|
-
if (!job) return
|
|
1271
|
-
job.lastFinishedAt = now()
|
|
1272
|
-
if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
|
|
1273
|
-
|
|
1274
|
-
if (job.verifyCommand) {
|
|
1275
|
-
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
1276
|
-
job.lastVerifyAt = now()
|
|
1277
|
-
job.lastVerifyCode = verify.code
|
|
1278
|
-
if (verify.code === 0) {
|
|
1279
|
-
job.failureCount = 0
|
|
1280
|
-
job.lastVerifyFailure = ""
|
|
1281
|
-
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
1282
|
-
} else {
|
|
1283
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1284
|
-
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
1285
|
-
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
1286
|
-
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
1287
|
-
job.paused = true
|
|
1288
|
-
await notifyJob(directory, job, "verify_failed")
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 })
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (job.postrunCommand) {
|
|
1295
|
-
if (job.safe && dangerousShell(job.postrunCommand)) await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand })
|
|
1296
|
-
else {
|
|
1297
|
-
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
1298
|
-
job.lastPostrunCode = postrun.code
|
|
1299
|
-
job.lastPostrunAt = now()
|
|
1300
|
-
if (postrun.code !== 0) {
|
|
1301
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1302
|
-
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
1303
|
-
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1304
|
-
job.paused = true
|
|
1305
|
-
await notifyJob(directory, job, "postrun_failed")
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code })
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
if (isGoalJob(job)) {
|
|
1313
|
-
job = await runGoalChecks(directory, sessionID, job, client)
|
|
1314
|
-
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
1315
|
-
}
|
|
1316
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1317
|
-
await writeState(directory, sessionID, state)
|
|
1318
|
-
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
1319
|
-
await createCheckpoint(directory, sessionID, job, client)
|
|
1320
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1321
|
-
return true
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
async function fireAction(directory, client, sessionID, job) {
|
|
1325
|
-
const action = String(job.action || "").trim()
|
|
1326
|
-
const kind = actionKind(action, job)
|
|
1327
|
-
const agent = job.agent || "build"
|
|
1328
|
-
const model = normalizedModelRef(job.model)
|
|
1329
|
-
if (kind === "compact") {
|
|
1330
|
-
beginLoopCompaction(sessionID, job.id, false)
|
|
1331
|
-
const ok = await compactSession(directory, client, sessionID, model)
|
|
1332
|
-
if (!ok) loopCompactionRequests.delete(sessionID)
|
|
1333
|
-
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
1334
|
-
}
|
|
1335
|
-
if (kind === "command") {
|
|
1336
|
-
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
1337
|
-
const [command, argumentsText] = splitFirst(normalized)
|
|
1338
|
-
if (!command) {
|
|
1339
|
-
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
1340
|
-
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
1341
|
-
}
|
|
1342
|
-
const tuiCommand = compactTuiCommandName(command)
|
|
1343
|
-
if (tuiCommand) {
|
|
1344
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1345
|
-
beginLoopCompaction(sessionID, job.id, false)
|
|
1346
|
-
const ok = await compactSession(directory, client, sessionID, model)
|
|
1347
|
-
if (!ok) loopCompactionRequests.delete(sessionID)
|
|
1348
|
-
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
1349
|
-
}
|
|
1350
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1351
|
-
const commandBody = { command, arguments: argumentsText, agent }
|
|
1352
|
-
if (model) commandBody.model = `${model.providerID}/${model.modelID}`
|
|
1353
|
-
await sdkCall(
|
|
1354
|
-
client.session.command.bind(client.session),
|
|
1355
|
-
{ path: { id: sessionID }, body: commandBody },
|
|
1356
|
-
{ path: { sessionID }, body: commandBody },
|
|
1357
|
-
{ sessionID, ...commandBody },
|
|
1358
|
-
)
|
|
1359
|
-
return { startsAssistantTurn: true }
|
|
1360
|
-
}
|
|
1361
|
-
if (kind === "shell") {
|
|
1362
|
-
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
1363
|
-
if (job.safe && dangerousShell(command)) {
|
|
1364
|
-
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
1365
|
-
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1366
|
-
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
1367
|
-
}
|
|
1368
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1369
|
-
const shellBody = { command, agent }
|
|
1370
|
-
if (model) shellBody.model = model
|
|
1371
|
-
const dispatch = fireSdk(
|
|
1372
|
-
client,
|
|
1373
|
-
"session.shell",
|
|
1374
|
-
client.session.shell.bind(client.session),
|
|
1375
|
-
{ path: { id: sessionID }, body: shellBody },
|
|
1376
|
-
{ path: { sessionID }, body: shellBody },
|
|
1377
|
-
{ sessionID, ...shellBody },
|
|
1378
|
-
)
|
|
1379
|
-
return { startsAssistantTurn: true, dispatch }
|
|
1380
|
-
}
|
|
1381
|
-
const prompt = await buildPrompt(directory, job)
|
|
1382
|
-
const prefix = kind === "goal"
|
|
1383
|
-
? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
|
|
1384
|
-
: "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
|
|
1385
|
-
const promptText = `${prefix}
|
|
1386
|
-
|
|
1387
|
-
${prompt}`
|
|
1388
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1389
|
-
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
1390
|
-
if (model) promptBody.model = model
|
|
1391
|
-
const dispatch = fireSdk(
|
|
1392
|
-
client,
|
|
1393
|
-
"session.prompt",
|
|
1394
|
-
client.session.prompt.bind(client.session),
|
|
1395
|
-
{ path: { id: sessionID }, body: promptBody },
|
|
1396
|
-
{ path: { sessionID }, body: promptBody },
|
|
1397
|
-
{ sessionID, ...promptBody },
|
|
1398
|
-
)
|
|
1399
|
-
return { startsAssistantTurn: true, dispatch }
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1403
|
-
rememberSession(directory, client, sessionID)
|
|
1404
|
-
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1405
|
-
|
|
1406
|
-
if (runLocks.has(sessionID)) {
|
|
1407
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1408
|
-
return
|
|
1409
|
-
}
|
|
1410
|
-
runLocks.set(sessionID, now())
|
|
1411
|
-
let job
|
|
1412
|
-
try {
|
|
1413
|
-
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
1414
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1415
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1416
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1417
|
-
return
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
const active = activeRuns.get(sessionID)
|
|
1421
|
-
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1422
|
-
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1423
|
-
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1424
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1425
|
-
return
|
|
1426
|
-
}
|
|
1427
|
-
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1428
|
-
|
|
1429
|
-
const state = await readState(directory, sessionID)
|
|
1430
|
-
for (const candidate of state.jobs || []) {
|
|
1431
|
-
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.watchTriggered = true
|
|
1432
|
-
}
|
|
1433
|
-
const due = dueJobs(state, options.force)
|
|
1434
|
-
if (!due.length) {
|
|
1435
|
-
await writeState(directory, sessionID, state)
|
|
1436
|
-
await reschedule()
|
|
1437
|
-
return
|
|
1438
|
-
}
|
|
1439
|
-
job = due[0]
|
|
1440
|
-
|
|
1441
|
-
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1442
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1443
|
-
await writeState(directory, sessionID, state)
|
|
1444
|
-
await notifyJob(directory, job, "max_runtime_reached")
|
|
1445
|
-
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1446
|
-
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1447
|
-
await reschedule()
|
|
1448
|
-
return
|
|
1449
|
-
}
|
|
1450
|
-
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1451
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1452
|
-
await writeState(directory, sessionID, state)
|
|
1453
|
-
await notifyJob(directory, job, "stop_file")
|
|
1454
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1455
|
-
await reschedule()
|
|
1456
|
-
return
|
|
1457
|
-
}
|
|
1458
|
-
if (await untilReached(directory, job)) {
|
|
1459
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1460
|
-
await writeState(directory, sessionID, state)
|
|
1461
|
-
await notifyJob(directory, job, "until_reached")
|
|
1462
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1463
|
-
await reschedule()
|
|
1464
|
-
return
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
if (job.preflightCommand) {
|
|
1468
|
-
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1469
|
-
job.paused = true
|
|
1470
|
-
await writeState(directory, sessionID, state)
|
|
1471
|
-
await notifyJob(directory, job, "preflight_blocked")
|
|
1472
|
-
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1473
|
-
await reschedule()
|
|
1474
|
-
return
|
|
1475
|
-
}
|
|
1476
|
-
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1477
|
-
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1478
|
-
if (preflight.code !== 0) {
|
|
1479
|
-
job.paused = true
|
|
1480
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1481
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1482
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1483
|
-
await writeState(directory, sessionID, state)
|
|
1484
|
-
await notifyJob(directory, job, "preflight_failed")
|
|
1485
|
-
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1486
|
-
await reschedule()
|
|
1487
|
-
return
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
job = await ensureBranch(directory, job, client, sessionID)
|
|
1492
|
-
const compactResult = await maybeCompact(directory, client, sessionID, job)
|
|
1493
|
-
job = compactResult.job
|
|
1494
|
-
if (compactResult.started) {
|
|
1495
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1496
|
-
await writeState(directory, sessionID, state)
|
|
1497
|
-
let timer
|
|
1498
|
-
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1499
|
-
const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
1500
|
-
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true })
|
|
1501
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1502
|
-
if (pending?.jobId === job.id && pending.completedAt) {
|
|
1503
|
-
await finalizeLoopCompaction(directory, client, sessionID)
|
|
1504
|
-
return
|
|
1505
|
-
}
|
|
1506
|
-
sessionStatuses.set(sessionID, "busy")
|
|
1507
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1508
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1511
|
-
job.watchTriggered = false
|
|
1512
|
-
job.lastRunAt = now()
|
|
1513
|
-
job.runCount = (job.runCount || 0) + 1
|
|
1514
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1515
|
-
job.enabled = false
|
|
1516
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
1517
|
-
}
|
|
1518
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1519
|
-
await writeState(directory, sessionID, state)
|
|
1520
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1521
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1522
|
-
|
|
1523
|
-
try {
|
|
1524
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
1525
|
-
if (!result.startsAssistantTurn) {
|
|
1526
|
-
const fresh = await readState(directory, sessionID)
|
|
1527
|
-
if (result.pause) {
|
|
1528
|
-
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
1529
|
-
...candidate,
|
|
1530
|
-
paused: true,
|
|
1531
|
-
failureCount: (candidate.failureCount || 0) + 1,
|
|
1532
|
-
lastFailureReason: result.reason || "action_did_not_start",
|
|
1533
|
-
} : candidate)
|
|
1534
|
-
}
|
|
1535
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1536
|
-
await writeState(directory, sessionID, fresh)
|
|
1537
|
-
await reschedule()
|
|
1538
|
-
return
|
|
1539
|
-
}
|
|
1540
|
-
let timer
|
|
1541
|
-
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1542
|
-
const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
1543
|
-
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true })
|
|
1544
|
-
if (result.compaction) {
|
|
1545
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1546
|
-
if (pending?.jobId === job.id && pending.completedAt) {
|
|
1547
|
-
await finalizeLoopCompaction(directory, client, sessionID)
|
|
1548
|
-
return
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
if (result.dispatch) {
|
|
1552
|
-
void result.dispatch.catch((error) => {
|
|
1553
|
-
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
|
|
1554
|
-
.catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }))
|
|
1555
|
-
})
|
|
1556
|
-
}
|
|
1557
|
-
sessionStatuses.set(sessionID, "busy")
|
|
1558
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1559
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1560
|
-
} catch (error) {
|
|
1561
|
-
clearActiveRun(sessionID)
|
|
1562
|
-
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1563
|
-
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1564
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1565
|
-
}
|
|
1566
|
-
} finally {
|
|
1567
|
-
runLocks.delete(sessionID)
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
function normalizeActionForCompare(value) {
|
|
1572
|
-
return String(value || "").replace(/\s+/g, " ").trim()
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
function sameLoopDefinition(a, b) {
|
|
1576
|
-
if (!a || !b) return false
|
|
1577
|
-
return (a.name || "") === (b.name || "") &&
|
|
1578
|
-
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
1579
|
-
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
1580
|
-
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
1581
|
-
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1585
|
-
const parsed = parseLoopArgs(args, defaults)
|
|
1586
|
-
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1587
|
-
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
1588
|
-
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
1589
|
-
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
1590
|
-
if (defaults.testfixPreset) {
|
|
1591
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
1592
|
-
const parsedAction = String(parsed.job.action || "").trim()
|
|
1593
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
1594
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
1595
|
-
parsed.job.verifyCommand = parsedAction
|
|
1596
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
1597
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
1598
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1602
|
-
if (!parsed.job.activeRecoveryMs) {
|
|
1603
|
-
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
1604
|
-
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
1605
|
-
: Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1606
|
-
}
|
|
1607
|
-
if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
|
|
1608
|
-
const state = await readState(directory, sessionID)
|
|
1609
|
-
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
1610
|
-
|
|
1611
|
-
// Default behavior is replace/upsert, not append forever. This prevents duplicate
|
|
1612
|
-
// loops when OpenCode emits both command.execute.before and command.executed,
|
|
1613
|
-
// and it matches the common expectation that /loop configures the current loop.
|
|
1614
|
-
let replaced = false
|
|
1615
|
-
if (!parsed.job.multi) {
|
|
1616
|
-
const targetName = parsed.job.name || "default"
|
|
1617
|
-
parsed.job.name = targetName
|
|
1618
|
-
state.jobs = jobs.filter((existing) => {
|
|
1619
|
-
const existingName = existing.name || "default"
|
|
1620
|
-
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
1621
|
-
if (shouldReplace) replaced = true
|
|
1622
|
-
return !shouldReplace
|
|
1623
|
-
})
|
|
1624
|
-
} else {
|
|
1625
|
-
state.jobs = jobs
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
state.jobs.push(parsed.job)
|
|
1629
|
-
await writeState(directory, sessionID, state)
|
|
1630
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1631
|
-
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
1632
|
-
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
1633
|
-
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
async function stopLoop(directory, client, sessionID, args) {
|
|
1637
|
-
const target = String(args || "").trim()
|
|
1638
|
-
if (!target || target.toLowerCase() === "all") {
|
|
1639
|
-
await removeState(directory, sessionID)
|
|
1640
|
-
clearActiveRun(sessionID)
|
|
1641
|
-
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
1642
|
-
stopWatchdog(sessionID)
|
|
1643
|
-
await toast(client, "All loops stopped for this session.", "success")
|
|
1644
|
-
return
|
|
1645
|
-
}
|
|
1646
|
-
const state = await readState(directory, sessionID)
|
|
1647
|
-
const before = state.jobs.length
|
|
1648
|
-
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
1649
|
-
await writeState(directory, sessionID, state)
|
|
1650
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1651
|
-
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
1655
|
-
const target = String(args || "").trim() || "all"
|
|
1656
|
-
const state = await readState(directory, sessionID)
|
|
1657
|
-
let count = 0
|
|
1658
|
-
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
1659
|
-
await writeState(directory, sessionID, state)
|
|
1660
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1661
|
-
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
async function statusGoal(directory, client, sessionID) {
|
|
1665
|
-
const state = await readState(directory, sessionID)
|
|
1666
|
-
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1667
|
-
const lines = goals.length ? goals.map((job, index) => {
|
|
1668
|
-
const status = goalStatusText(job)
|
|
1669
|
-
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
1670
|
-
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
1671
|
-
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
1672
|
-
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
1673
|
-
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
1674
|
-
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}`
|
|
1675
|
-
}) : ["No experimental goal jobs."]
|
|
1676
|
-
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
1677
|
-
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
async function pauseGoal(directory, client, sessionID, args) {
|
|
1681
|
-
const target = String(args || "").trim() || "goal"
|
|
1682
|
-
const state = await readState(directory, sessionID)
|
|
1683
|
-
let count = 0
|
|
1684
|
-
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
1685
|
-
await writeState(directory, sessionID, state)
|
|
1686
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1687
|
-
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
async function resumeGoal(directory, client, sessionID, args) {
|
|
1691
|
-
const target = String(args || "").trim() || "goal"
|
|
1692
|
-
const state = await readState(directory, sessionID)
|
|
1693
|
-
let count = 0
|
|
1694
|
-
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1695
|
-
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
1696
|
-
count++
|
|
1697
|
-
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
|
|
1698
|
-
})
|
|
1699
|
-
await writeState(directory, sessionID, state)
|
|
1700
|
-
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
1701
|
-
if (count) {
|
|
1702
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1703
|
-
scheduleIdleWork(directory, client, sessionID)
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
async function clearGoal(directory, client, sessionID, args) {
|
|
1708
|
-
const target = String(args || "").trim()
|
|
1709
|
-
const state = await readState(directory, sessionID)
|
|
1710
|
-
const before = state.jobs.length
|
|
1711
|
-
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
1712
|
-
await writeState(directory, sessionID, state)
|
|
1713
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1714
|
-
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1718
|
-
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
1719
|
-
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1723
|
-
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
1724
|
-
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
async function addGoal(directory, client, sessionID, args) {
|
|
1728
|
-
const text = String(args || "").trim()
|
|
1729
|
-
const [maybeCommand, rest] = splitFirst(text)
|
|
1730
|
-
const sub = maybeCommand.toLowerCase()
|
|
1731
|
-
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
1732
|
-
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
1733
|
-
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
1734
|
-
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
1735
|
-
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
1736
|
-
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
1737
|
-
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
async function statusLoop(directory, client, sessionID) {
|
|
1741
|
-
const state = await readState(directory, sessionID)
|
|
1742
|
-
const jobs = state.jobs || []
|
|
1743
|
-
const lines = jobs.length ? jobs.map((job, index) => {
|
|
1744
|
-
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
1745
|
-
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", 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(",")
|
|
1746
|
-
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
1747
|
-
}) : ["No active loop jobs."]
|
|
1748
|
-
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
1749
|
-
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
async function logsLoop(directory, client, sessionID) {
|
|
1753
|
-
let text = "No loop log found."
|
|
1754
|
-
try { text = (await fs.readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
1755
|
-
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
async function helpLoop(client, sessionID) {
|
|
1759
|
-
await say(client, sessionID, [
|
|
1760
|
-
"OpenCode Loop help:",
|
|
1761
|
-
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
1762
|
-
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
1763
|
-
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
1764
|
-
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
1765
|
-
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1766
|
-
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1767
|
-
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
1768
|
-
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
1769
|
-
"/loop 200m --command /compact same as command loop",
|
|
1770
|
-
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
1771
|
-
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
1772
|
-
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
1773
|
-
"/loop-doctor | /loop-init | /loop-export",
|
|
1774
|
-
].join("\n"))
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
async function runNow(directory, client, sessionID, args) {
|
|
1778
|
-
const target = String(args || "").trim() || "all"
|
|
1779
|
-
const state = await readState(directory, sessionID)
|
|
1780
|
-
let count = 0
|
|
1781
|
-
for (const [index, job] of (state.jobs || []).entries()) if (matchJob(job, target, index)) { job.lastRunAt = 0; job.paused = false; count++ }
|
|
1782
|
-
await writeState(directory, sessionID, state)
|
|
1783
|
-
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
1784
|
-
await maybeRunDueJobs(directory, client, sessionID, { force: true })
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
async function doctorLoop(directory, client, sessionID) {
|
|
1788
|
-
const state = await readState(directory, sessionID)
|
|
1789
|
-
await say(client, sessionID, [
|
|
1790
|
-
"OpenCode Loop doctor:",
|
|
1791
|
-
`- plugin: ${SERVICE}`,
|
|
1792
|
-
`- project directory: ${directory}`,
|
|
1793
|
-
`- state directory: ${stateDir(directory)}`,
|
|
1794
|
-
`- active jobs: ${(state.jobs || []).length}`,
|
|
1795
|
-
`- node: ${process.version}`,
|
|
1796
|
-
`- platform: ${process.platform}`,
|
|
1797
|
-
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
1798
|
-
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
1799
|
-
].join("\n"))
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
async function initLoop(directory, client, sessionID, args) {
|
|
1803
|
-
const target = String(args || "").trim() || "progress.md"
|
|
1804
|
-
const full = path.resolve(directory, target)
|
|
1805
|
-
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
1806
|
-
await fs.writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
1807
|
-
await toast(client, `Created ${target}.`, "success")
|
|
1808
|
-
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
async function exportLoop(directory, client, sessionID) {
|
|
1812
|
-
const state = await readState(directory, sessionID)
|
|
1813
|
-
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
1817
|
-
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
1818
|
-
const sessionID = input?.sessionID
|
|
1819
|
-
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
1820
|
-
if (!sessionID || !name) return false
|
|
1821
|
-
rememberSession(directory, client, sessionID)
|
|
1822
|
-
if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
|
|
1823
|
-
if (source === "event") {
|
|
1824
|
-
if (consumeHandled(sessionID, name, args)) return true
|
|
1825
|
-
const eventKey = commandEventKey(sessionID, input?.messageID)
|
|
1826
|
-
if (handledCommandEvents.has(eventKey)) return true
|
|
1827
|
-
handledCommandEvents.set(eventKey, now())
|
|
1828
|
-
} else {
|
|
1829
|
-
// Every before-hook invocation is an intentional command. Keep a pending
|
|
1830
|
-
// count only so the matching command.executed compatibility event can be
|
|
1831
|
-
// consumed without suppressing a genuine repeated command.
|
|
1832
|
-
markHandled(sessionID, name, args)
|
|
1833
|
-
}
|
|
1834
|
-
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
1835
|
-
|
|
1836
|
-
const handled = () => {
|
|
1837
|
-
// OpenCode 1.18.x ignores unknown hook output fields, while proposed/newer
|
|
1838
|
-
// hosts can honor noReply to skip the otherwise unavoidable model turn.
|
|
1839
|
-
// Keep acknowledgement parts intact as the compatibility fallback.
|
|
1840
|
-
if (output && typeof output === "object") output.noReply = true
|
|
1841
|
-
return true
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
if (name === "loop-goal") return await addGoal(directory, client, sessionID, args), handled()
|
|
1845
|
-
if (name === "loop-goal-status") return await statusGoal(directory, client, sessionID), handled()
|
|
1846
|
-
if (name === "loop-goal-pause") return await pauseGoal(directory, client, sessionID, args), handled()
|
|
1847
|
-
if (name === "loop-goal-resume") return await resumeGoal(directory, client, sessionID, args), handled()
|
|
1848
|
-
if (name === "loop-goal-clear") return await clearGoal(directory, client, sessionID, args), handled()
|
|
1849
|
-
if (name === "loop-goal-done" || name === "loop-goal-complete") return await completeGoalCommand(directory, client, sessionID, args), handled()
|
|
1850
|
-
if (name === "loop-goal-blocked") return await blockGoalCommand(directory, client, sessionID, args), handled()
|
|
1851
|
-
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
1852
|
-
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
1853
|
-
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
1854
|
-
if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), handled()
|
|
1855
|
-
if (name === "loop-status") return await statusLoop(directory, client, sessionID), handled()
|
|
1856
|
-
if (name === "loop-logs") return await logsLoop(directory, client, sessionID), handled()
|
|
1857
|
-
if (name === "loop-help") return await helpLoop(client, sessionID), handled()
|
|
1858
|
-
if (name === "loop-now") return await runNow(directory, client, sessionID, args), handled()
|
|
1859
|
-
if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
|
|
1860
|
-
if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
|
|
1861
|
-
if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
|
|
1862
|
-
if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
|
|
1863
|
-
if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
|
|
1864
|
-
if (source === "event") handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID))
|
|
1865
|
-
else consumeHandled(sessionID, name, args)
|
|
1866
|
-
return false
|
|
1867
163
|
}
|
|
1868
164
|
|
|
1869
165
|
function goalTools(defaultDirectory) {
|
|
@@ -1875,6 +171,8 @@ function goalTools(defaultDirectory) {
|
|
|
1875
171
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
1876
172
|
},
|
|
1877
173
|
execute: async (args, context) => {
|
|
174
|
+
const steering = steeringToolRejection(context.sessionID)
|
|
175
|
+
if (steering) return steering
|
|
1878
176
|
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1879
177
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
1880
178
|
},
|
|
@@ -1886,6 +184,8 @@ function goalTools(defaultDirectory) {
|
|
|
1886
184
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
1887
185
|
},
|
|
1888
186
|
execute: async (args, context) => {
|
|
187
|
+
const steering = steeringToolRejection(context.sessionID)
|
|
188
|
+
if (steering) return steering
|
|
1889
189
|
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1890
190
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1891
191
|
},
|
|
@@ -1897,6 +197,8 @@ function goalTools(defaultDirectory) {
|
|
|
1897
197
|
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
1898
198
|
},
|
|
1899
199
|
execute: async (args, context) => {
|
|
200
|
+
const steering = steeringToolRejection(context.sessionID)
|
|
201
|
+
if (steering) return steering
|
|
1900
202
|
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1901
203
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1902
204
|
},
|
|
@@ -1917,6 +219,13 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
1917
219
|
dispose: async () => { disposeRuntime(directory, client) },
|
|
1918
220
|
tool: goalTools(directory),
|
|
1919
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
|
+
},
|
|
1920
229
|
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
1921
230
|
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
1922
231
|
"experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
|
|
@@ -1929,14 +238,13 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
1929
238
|
const props = event.properties || {}
|
|
1930
239
|
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
|
|
1931
240
|
}
|
|
1932
|
-
const
|
|
1933
|
-
if (
|
|
1934
|
-
rememberSession(directory, client, interruptedSessionID)
|
|
1935
|
-
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
|
|
1936
|
-
}
|
|
241
|
+
const steering = await goalSteeringRuntime.handleEvent(directory, client, event)
|
|
242
|
+
if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
|
|
1937
243
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1938
244
|
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1939
|
-
if (statusUpdate?.idle
|
|
245
|
+
if (statusUpdate?.idle && !goalSteeringRuntime.shouldSuppressIdle(statusUpdate.sessionID)) {
|
|
246
|
+
scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
247
|
+
}
|
|
1940
248
|
},
|
|
1941
249
|
}
|
|
1942
250
|
}
|