@bybrawe/opencode-loop 0.5.26 → 0.5.28
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 +10 -0
- package/README.md +9 -3
- package/package.json +8 -4
- package/scripts/comprehensive-test.mjs +49 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +357 -0
- package/scripts/loopd-test.mjs +45 -18
- package/scripts/loopd.mjs +92 -22
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2304 -1864
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
|
@@ -0,0 +1,1944 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { tool } from "@opencode-ai/plugin/tool"
|
|
5
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS, now, safeID, parseDuration, durationToText, splitFirst, stripOuterQuotes, escapeRegExp, takeFlag, takeFlagValue, takeAllFlagValues, parsePositiveInt, parseNonNegativeInt, parseCompactEvery, parseLoopArgs } from "./core/args.js"
|
|
6
|
+
import { presetDefaults, jobLabel, matchJob, actionKind, decoratePrompt, isGoalJob, goalStatusText } from "./core/jobs.js"
|
|
7
|
+
import { stateDir, ensureDir, pathExists, readState, writeState, removeState } from "./core/state.js"
|
|
8
|
+
import { appendLoopLog, readSmallTextFile, runProcess, runShellCommand, notifyJob } from "./core/process.js"
|
|
9
|
+
import { sdkErrorMessage, sdkCall } from "./opencode/sdk.js"
|
|
10
|
+
import { normalizedModelRef, updateSessionExecutionContext, captureSessionExecutionContext, getSessionExecutionContext, setSessionExecutionContext, deleteSessionExecutionContext } from "./opencode/session-context.js"
|
|
11
|
+
|
|
12
|
+
const SERVICE = "opencode-loop"
|
|
13
|
+
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
|
+
|
|
29
|
+
const activeRuns = new Map()
|
|
30
|
+
const handledCommands = new Map()
|
|
31
|
+
const handledCommandEvents = new Map()
|
|
32
|
+
const loopOwnedUserMessageGuards = new Map()
|
|
33
|
+
const idleTimers = new Map()
|
|
34
|
+
const dueTimers = new Map()
|
|
35
|
+
const watchdogTimers = new Map()
|
|
36
|
+
const runLocks = new Map()
|
|
37
|
+
const knownSessions = new Map()
|
|
38
|
+
const activeToolCalls = new Map()
|
|
39
|
+
const sessionParents = new Map()
|
|
40
|
+
let heartbeatTimer
|
|
41
|
+
const sessionStatuses = new Map()
|
|
42
|
+
const sessionStatusSeenAt = new Map()
|
|
43
|
+
const loopCompactionRequests = new Map()
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PROGRESS_MD = `# Progress
|
|
46
|
+
|
|
47
|
+
## Current Goal
|
|
48
|
+
Describe the current project goal here.
|
|
49
|
+
|
|
50
|
+
## Agent Rules
|
|
51
|
+
- Do not ask questions unless truly blocked.
|
|
52
|
+
- Make reasonable assumptions and continue.
|
|
53
|
+
- Work on unfinished TODOs in order.
|
|
54
|
+
- Mark completed TODOs with [x].
|
|
55
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
56
|
+
- Run tests, lint, or build when available.
|
|
57
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
58
|
+
|
|
59
|
+
## Active TODO
|
|
60
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
61
|
+
|
|
62
|
+
## Completed
|
|
63
|
+
- [x] Created progress.md.
|
|
64
|
+
|
|
65
|
+
## Backlog Ideas
|
|
66
|
+
- [ ] Add more project-specific tasks here.
|
|
67
|
+
|
|
68
|
+
## Blocked
|
|
69
|
+
- None.
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
function fireSdk(client, label, method, ...argsList) {
|
|
76
|
+
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
|
|
77
|
+
void pending.catch((error) => {
|
|
78
|
+
log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
|
|
79
|
+
})
|
|
80
|
+
return pending
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function executeTuiCommand(client, command) {
|
|
84
|
+
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
85
|
+
return await sdkCall(
|
|
86
|
+
client.tui.executeCommand.bind(client.tui),
|
|
87
|
+
{ body: { command } },
|
|
88
|
+
{ command },
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compactTuiCommandName(command = "compact") {
|
|
93
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
94
|
+
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
|
|
99
|
+
if (!client?.session?.messages) return undefined
|
|
100
|
+
const query = { limit }
|
|
101
|
+
if (directory) query.directory = directory
|
|
102
|
+
try {
|
|
103
|
+
const messages = await sdkCall(
|
|
104
|
+
client.session.messages.bind(client.session),
|
|
105
|
+
{ path: { id: sessionID }, query },
|
|
106
|
+
{ path: { sessionID }, query },
|
|
107
|
+
{ sessionID, ...query },
|
|
108
|
+
)
|
|
109
|
+
return Array.isArray(messages) ? messages : undefined
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function orderedSessionMessages(messages) {
|
|
116
|
+
return (messages || [])
|
|
117
|
+
.map((message, index) => {
|
|
118
|
+
const info = message?.info || message || {}
|
|
119
|
+
const created = Number(info?.time?.created || 0)
|
|
120
|
+
return { message, index, created: Number.isFinite(created) ? created : 0 }
|
|
121
|
+
})
|
|
122
|
+
.sort((a, b) => a.created - b.created || a.index - b.index)
|
|
123
|
+
.map((entry) => entry.message)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
127
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
128
|
+
if (!messages) return "unknown"
|
|
129
|
+
const tail = orderedSessionMessages(messages).at(-1)
|
|
130
|
+
const info = tail?.info || tail
|
|
131
|
+
if (!info || info.role !== "assistant") return "incomplete"
|
|
132
|
+
const completed = Number(info?.time?.completed || 0)
|
|
133
|
+
const created = Number(info?.time?.created || 0)
|
|
134
|
+
if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
|
|
135
|
+
const startedAt = Number(active?.startedAt || 0)
|
|
136
|
+
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
|
|
137
|
+
return "completed"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
141
|
+
const preferred = normalizedModelRef(preferredModel)
|
|
142
|
+
if (preferred) return preferred
|
|
143
|
+
const cached = normalizedModelRef(getSessionExecutionContext(sessionID)?.model)
|
|
144
|
+
if (cached) return cached
|
|
145
|
+
const captured = await captureSessionExecutionContext(client, sessionID)
|
|
146
|
+
const capturedModel = normalizedModelRef(captured?.model)
|
|
147
|
+
if (capturedModel) return capturedModel
|
|
148
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
149
|
+
for (const message of orderedSessionMessages(messages).reverse()) {
|
|
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
|
+
}
|
|
419
|
+
|
|
420
|
+
function disposeRuntime(directory, client) {
|
|
421
|
+
const sessions = [...knownSessions.entries()]
|
|
422
|
+
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
423
|
+
.map(([sessionID]) => sessionID)
|
|
424
|
+
for (const sessionID of sessions) {
|
|
425
|
+
clearActiveRun(sessionID)
|
|
426
|
+
const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
|
|
427
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
|
|
428
|
+
const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
|
|
429
|
+
idleTimers.delete(sessionID)
|
|
430
|
+
dueTimers.delete(sessionID)
|
|
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("")
|
|
1050
|
+
}
|
|
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
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function pickGoalJob(state, target = "") {
|
|
1062
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
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) {
|
|
1104
|
+
return {
|
|
1105
|
+
status: job?.goalStatus || "",
|
|
1106
|
+
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
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")
|
|
1243
|
+
}
|
|
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
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function goalTools(defaultDirectory) {
|
|
1870
|
+
return {
|
|
1871
|
+
opencode_loop_goal_complete: tool({
|
|
1872
|
+
description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
1873
|
+
args: {
|
|
1874
|
+
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
1875
|
+
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
1876
|
+
},
|
|
1877
|
+
execute: async (args, context) => {
|
|
1878
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
1879
|
+
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
1880
|
+
},
|
|
1881
|
+
}),
|
|
1882
|
+
opencode_loop_goal_blocked: tool({
|
|
1883
|
+
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
1884
|
+
args: {
|
|
1885
|
+
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
1886
|
+
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
1887
|
+
},
|
|
1888
|
+
execute: async (args, context) => {
|
|
1889
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
1890
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
1891
|
+
},
|
|
1892
|
+
}),
|
|
1893
|
+
opencode_loop_goal_progress: tool({
|
|
1894
|
+
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
1895
|
+
args: {
|
|
1896
|
+
summary: tool.schema.string().describe("What useful progress was made."),
|
|
1897
|
+
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
1898
|
+
},
|
|
1899
|
+
execute: async (args, context) => {
|
|
1900
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
1901
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
1902
|
+
},
|
|
1903
|
+
}),
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
1908
|
+
// OpenCode's local SDK can be slow or unavailable while a project instance
|
|
1909
|
+
// is still waiting for its plugins to return their hooks. Defer bootstrap
|
|
1910
|
+
// calls so headless/server sessions cannot deadlock during plugin loading.
|
|
1911
|
+
const bootstrap = setTimeout(() => {
|
|
1912
|
+
log(client, "info", "Plugin initialized", { directory }).catch(() => {})
|
|
1913
|
+
refreshSessionRelationships(client, directory).catch(() => {})
|
|
1914
|
+
}, 0)
|
|
1915
|
+
bootstrap.unref?.()
|
|
1916
|
+
return {
|
|
1917
|
+
dispose: async () => { disposeRuntime(directory, client) },
|
|
1918
|
+
tool: goalTools(directory),
|
|
1919
|
+
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
1920
|
+
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
1921
|
+
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
1922
|
+
"experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
|
|
1923
|
+
event: async ({ event }) => {
|
|
1924
|
+
if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
|
|
1925
|
+
updateSessionRelationshipFromEvent(event)
|
|
1926
|
+
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
1927
|
+
updateToolActivityFromEvent(event)
|
|
1928
|
+
if (event.type === "command.executed") {
|
|
1929
|
+
const props = event.properties || {}
|
|
1930
|
+
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
|
|
1931
|
+
}
|
|
1932
|
+
const interruptedSessionID = userInterruptSessionFromEvent(event)
|
|
1933
|
+
if (interruptedSessionID) {
|
|
1934
|
+
rememberSession(directory, client, interruptedSessionID)
|
|
1935
|
+
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
|
|
1936
|
+
}
|
|
1937
|
+
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1938
|
+
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1939
|
+
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
1940
|
+
},
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
export default OpenCodeLoopPlugin
|