@bybrawe/opencode-loop 0.5.20 → 0.5.21
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 +225 -216
- package/README.md +1235 -1235
- package/package.json +69 -69
- package/scripts/comprehensive-test.mjs +573 -570
- package/scripts/smoke-test.mjs +2 -1
- package/src/index.js +2327 -2330
package/src/index.js
CHANGED
|
@@ -1,2330 +1,2327 @@
|
|
|
1
|
-
import { promises as fs } from "node:fs"
|
|
2
|
-
import os from "node:os"
|
|
3
|
-
import path from "node:path"
|
|
4
|
-
import { spawn } from "node:child_process"
|
|
5
|
-
import { tool } from "@opencode-ai/plugin/tool"
|
|
6
|
-
|
|
7
|
-
const SERVICE = "opencode-loop"
|
|
8
|
-
const STATE_DIR = ".opencode/opencode-loop"
|
|
9
|
-
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
10
|
-
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
11
|
-
const IDLE_DEBOUNCE_MS = 1_200
|
|
12
|
-
const BUSY_RETRY_MS = 5_000
|
|
13
|
-
const SESSION_STATUS_CACHE_MS = 1_500
|
|
14
|
-
const MIN_DUE_TIMER_MS = 250
|
|
15
|
-
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
16
|
-
const HEARTBEAT_MS = 2_500
|
|
17
|
-
const MAX_SCAN_FILES = 200
|
|
18
|
-
const MAX_SCAN_BYTES = 2_000_000
|
|
19
|
-
const GOAL_REPORT_DIR = "goals"
|
|
20
|
-
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
21
|
-
const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
|
|
22
|
-
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
23
|
-
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
24
|
-
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
25
|
-
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
26
|
-
|
|
27
|
-
const activeRuns = new Map()
|
|
28
|
-
const handledCommands = new Map()
|
|
29
|
-
const handledCommandEvents = new Map()
|
|
30
|
-
const sessionExecutionContexts = new Map()
|
|
31
|
-
const loopOwnedUserMessageGuards = new Map()
|
|
32
|
-
const idleTimers = new Map()
|
|
33
|
-
const dueTimers = new Map()
|
|
34
|
-
const watchdogTimers = new Map()
|
|
35
|
-
const runLocks = new Map()
|
|
36
|
-
const knownSessions = new Map()
|
|
37
|
-
const stateWriteLocks = 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
|
-
|
|
44
|
-
const DEFAULT_PROGRESS_MD = `# Progress
|
|
45
|
-
|
|
46
|
-
## Current Goal
|
|
47
|
-
Describe the current project goal here.
|
|
48
|
-
|
|
49
|
-
## Agent Rules
|
|
50
|
-
- Do not ask questions unless truly blocked.
|
|
51
|
-
- Make reasonable assumptions and continue.
|
|
52
|
-
- Work on unfinished TODOs in order.
|
|
53
|
-
- Mark completed TODOs with [x].
|
|
54
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
55
|
-
- Run tests, lint, or build when available.
|
|
56
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
57
|
-
|
|
58
|
-
## Active TODO
|
|
59
|
-
- [ ] Review the project structure and pick the next safe improvement.
|
|
60
|
-
|
|
61
|
-
## Completed
|
|
62
|
-
- [x] Created progress.md.
|
|
63
|
-
|
|
64
|
-
## Backlog Ideas
|
|
65
|
-
- [ ] Add more project-specific tasks here.
|
|
66
|
-
|
|
67
|
-
## Blocked
|
|
68
|
-
- None.
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
function now() {
|
|
72
|
-
return Date.now()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function safeID(value) {
|
|
76
|
-
return String(value || "job")
|
|
77
|
-
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
78
|
-
.replace(/^-+|-+$/g, "")
|
|
79
|
-
.slice(0, 80) || "job"
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function parseDuration(value) {
|
|
83
|
-
const input = String(value || "").trim()
|
|
84
|
-
if (input === "0") return 0
|
|
85
|
-
const match = input.match(/^(\d+)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i)
|
|
86
|
-
if (!match) return null
|
|
87
|
-
const amount = Number.parseInt(match[1], 10)
|
|
88
|
-
const unit = match[2].toLowerCase()
|
|
89
|
-
if (!Number.isFinite(amount) || amount < 0) return null
|
|
90
|
-
if (unit === "ms") return amount
|
|
91
|
-
if (unit.startsWith("s")) return amount * 1000
|
|
92
|
-
if (unit.startsWith("m")) return amount * 60_000
|
|
93
|
-
if (unit.startsWith("h")) return amount * 3_600_000
|
|
94
|
-
if (unit.startsWith("d")) return amount * 86_400_000
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function durationToText(ms) {
|
|
99
|
-
if (ms === 0) return "every idle"
|
|
100
|
-
if (!Number.isFinite(ms)) return "unknown"
|
|
101
|
-
if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`
|
|
102
|
-
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`
|
|
103
|
-
if (ms % 60_000 === 0) return `${ms / 60_000}m`
|
|
104
|
-
if (ms % 1000 === 0) return `${ms / 1000}s`
|
|
105
|
-
return `${ms}ms`
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function splitFirst(input) {
|
|
109
|
-
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/)
|
|
110
|
-
if (!match) return ["", ""]
|
|
111
|
-
return [match[1], (match[2] || "").trim()]
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function stripOuterQuotes(value) {
|
|
115
|
-
const input = String(value || "").trim()
|
|
116
|
-
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
|
|
117
|
-
return input.slice(1, -1)
|
|
118
|
-
}
|
|
119
|
-
return input
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function escapeRegExp(value) {
|
|
123
|
-
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function takeFlag(rest, flag) {
|
|
127
|
-
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i")
|
|
128
|
-
const found = pattern.test(rest)
|
|
129
|
-
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function takeFlagValue(rest, flag) {
|
|
133
|
-
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(?:\"([^\"]*)\"|'([^']*)'|(\\S+))`, "i")
|
|
134
|
-
const match = rest.match(pattern)
|
|
135
|
-
if (!match) return [undefined, rest]
|
|
136
|
-
const value = match[2] ?? match[3] ?? match[4]
|
|
137
|
-
return [value, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function takeAllFlagValues(rest, flag) {
|
|
141
|
-
const values = []
|
|
142
|
-
let current = rest
|
|
143
|
-
while (true) {
|
|
144
|
-
const [value, next] = takeFlagValue(current, flag)
|
|
145
|
-
if (value === undefined) return [values, current]
|
|
146
|
-
values.push(value)
|
|
147
|
-
current = next
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function parsePositiveInt(value, fallback = 0) {
|
|
152
|
-
const parsed = Number.parseInt(String(value || ""), 10)
|
|
153
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function parseNonNegativeInt(value, fallback = 0) {
|
|
157
|
-
const parsed = Number.parseInt(String(value || ""), 10)
|
|
158
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function parseCompactEvery(value) {
|
|
162
|
-
const duration = parseDuration(value)
|
|
163
|
-
if (duration !== null) return { compactEveryMs: duration }
|
|
164
|
-
const runs = parsePositiveInt(value, 0)
|
|
165
|
-
return runs > 0 ? { compactEveryRuns: runs } : {}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function parseLoopArgs(raw, defaults = {}) {
|
|
169
|
-
let input = stripOuterQuotes(String(raw || "").trim())
|
|
170
|
-
let first = ""
|
|
171
|
-
let rest = input
|
|
172
|
-
let intervalMs = defaults.intervalMs ?? null
|
|
173
|
-
|
|
174
|
-
if (!input && defaults.action) {
|
|
175
|
-
rest = defaults.action
|
|
176
|
-
} else {
|
|
177
|
-
;[first, rest] = splitFirst(input)
|
|
178
|
-
if (first === "--watch") {
|
|
179
|
-
intervalMs = defaults.intervalMs ?? 0
|
|
180
|
-
rest = input
|
|
181
|
-
} else if (first) {
|
|
182
|
-
const parsedDuration = parseDuration(first)
|
|
183
|
-
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
184
|
-
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
185
|
-
else rest = input
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (intervalMs === null) intervalMs = 0
|
|
190
|
-
|
|
191
|
-
const job = {
|
|
192
|
-
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
193
|
-
name: defaults.name,
|
|
194
|
-
action: defaults.action || "",
|
|
195
|
-
kind: defaults.kind || undefined,
|
|
196
|
-
intervalMs,
|
|
197
|
-
immediate: defaults.immediate ?? true,
|
|
198
|
-
maxRuns: defaults.maxRuns ?? 0,
|
|
199
|
-
maxRuntimeMs: defaults.maxRuntimeMs ?? 0,
|
|
200
|
-
maxFailures: defaults.maxFailures ?? 0,
|
|
201
|
-
timeoutMs: defaults.timeoutMs ?? 0,
|
|
202
|
-
until: defaults.until,
|
|
203
|
-
stopFile: defaults.stopFile,
|
|
204
|
-
progressFile: defaults.progressFile,
|
|
205
|
-
promptFile: defaults.promptFile,
|
|
206
|
-
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
207
|
-
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
208
|
-
compactEveryRuns: defaults.compactEveryRuns ?? 0,
|
|
209
|
-
compactEveryMs: defaults.compactEveryMs ?? 0,
|
|
210
|
-
testCommand: defaults.testCommand,
|
|
211
|
-
verifyCommand: defaults.verifyCommand,
|
|
212
|
-
preflightCommand: defaults.preflightCommand,
|
|
213
|
-
postrunCommand: defaults.postrunCommand,
|
|
214
|
-
notifyCommand: defaults.notifyCommand,
|
|
215
|
-
branch: defaults.branch,
|
|
216
|
-
branchDone: false,
|
|
217
|
-
goalStatus: defaults.goalStatus,
|
|
218
|
-
goalFile: defaults.goalFile,
|
|
219
|
-
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
220
|
-
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
221
|
-
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
222
|
-
goalRequireEvidence: defaults.goalRequireEvidence,
|
|
223
|
-
goalRequireChecksPass: defaults.goalRequireChecksPass,
|
|
224
|
-
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
225
|
-
goalSummary: defaults.goalSummary || "",
|
|
226
|
-
goalEvidence: defaults.goalEvidence || "",
|
|
227
|
-
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
228
|
-
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
229
|
-
maxNoProgress: defaults.maxNoProgress,
|
|
230
|
-
noProgressCount: defaults.noProgressCount ?? 0,
|
|
231
|
-
lastProgressAt: defaults.lastProgressAt ?? 0,
|
|
232
|
-
noOverlap: defaults.noOverlap ?? true,
|
|
233
|
-
safe: defaults.safe ?? false,
|
|
234
|
-
quiet: defaults.quiet ?? false,
|
|
235
|
-
askNever: defaults.askNever ?? false,
|
|
236
|
-
pauseOnVerifyFail: defaults.pauseOnVerifyFail ?? false,
|
|
237
|
-
gitCheckpoint: defaults.gitCheckpoint ?? false,
|
|
238
|
-
checkpointOnly: defaults.checkpointOnly ?? false,
|
|
239
|
-
dryRun: defaults.dryRun ?? false,
|
|
240
|
-
multi: defaults.multi ?? false,
|
|
241
|
-
batch: defaults.batch ?? 0,
|
|
242
|
-
runCount: 0,
|
|
243
|
-
failureCount: 0,
|
|
244
|
-
lastRunAt: 0,
|
|
245
|
-
lastCompactAt: 0,
|
|
246
|
-
lastCompactRunCount: 0,
|
|
247
|
-
watchSnapshot: {},
|
|
248
|
-
watchTriggered: false,
|
|
249
|
-
createdAt: new Date().toISOString(),
|
|
250
|
-
enabled: true,
|
|
251
|
-
paused: false,
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
let found
|
|
255
|
-
let value
|
|
256
|
-
|
|
257
|
-
;[found, rest] = takeFlag(rest, "--no-now"); if (found) job.immediate = false
|
|
258
|
-
;[found, rest] = takeFlag(rest, "--now"); if (found) job.immediate = true
|
|
259
|
-
;[found, rest] = takeFlag(rest, "--no-overlap"); if (found) job.noOverlap = true
|
|
260
|
-
;[found, rest] = takeFlag(rest, "--allow-overlap"); if (found) job.noOverlap = false
|
|
261
|
-
;[found, rest] = takeFlag(rest, "--safe"); if (found) job.safe = true
|
|
262
|
-
;[found, rest] = takeFlag(rest, "--quiet"); if (found) job.quiet = true
|
|
263
|
-
;[found, rest] = takeFlag(rest, "--ask-never"); if (found) job.askNever = true
|
|
264
|
-
;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
|
|
265
|
-
;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
|
|
266
|
-
;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
|
|
267
|
-
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
268
|
-
;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
|
|
269
|
-
;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
|
|
270
|
-
;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
|
|
271
|
-
;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
|
|
272
|
-
;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
|
|
273
|
-
;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
|
|
274
|
-
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
275
|
-
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
276
|
-
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
277
|
-
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
278
|
-
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
279
|
-
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
280
|
-
;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
|
|
281
|
-
;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
|
|
282
|
-
;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
|
|
283
|
-
;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
|
|
284
|
-
;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
|
|
285
|
-
|
|
286
|
-
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
287
|
-
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
288
|
-
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
289
|
-
;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
290
|
-
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
291
|
-
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
292
|
-
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
293
|
-
;[value, rest] = takeFlagValue(rest, "--until"); if (value !== undefined) job.until = stripOuterQuotes(value)
|
|
294
|
-
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
295
|
-
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
296
|
-
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
297
|
-
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
298
|
-
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
299
|
-
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
300
|
-
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
301
|
-
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
302
|
-
;[value, rest] = takeFlagValue(rest, "--postrun"); if (value !== undefined) job.postrunCommand = stripOuterQuotes(value)
|
|
303
|
-
;[value, rest] = takeFlagValue(rest, "--notify"); if (value !== undefined) job.notifyCommand = stripOuterQuotes(value)
|
|
304
|
-
;[value, rest] = takeFlagValue(rest, "--branch"); if (value !== undefined) job.branch = stripOuterQuotes(value)
|
|
305
|
-
;[value, rest] = takeFlagValue(rest, "--batch"); if (value !== undefined) job.batch = parsePositiveInt(value, 0)
|
|
306
|
-
;[value, rest] = takeFlagValue(rest, "--compact-every")
|
|
307
|
-
if (value !== undefined) Object.assign(job, parseCompactEvery(value))
|
|
308
|
-
|
|
309
|
-
const watch = takeAllFlagValues(rest, "--watch")
|
|
310
|
-
job.watchPaths.push(...watch[0].map(stripOuterQuotes).filter(Boolean))
|
|
311
|
-
rest = watch[1]
|
|
312
|
-
|
|
313
|
-
const includes = takeAllFlagValues(rest, "--include-file")
|
|
314
|
-
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
315
|
-
rest = includes[1]
|
|
316
|
-
|
|
317
|
-
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
318
|
-
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
319
|
-
rest = acceptances[1]
|
|
320
|
-
|
|
321
|
-
const success = takeAllFlagValues(rest, "--success")
|
|
322
|
-
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
323
|
-
rest = success[1]
|
|
324
|
-
|
|
325
|
-
const checks = takeAllFlagValues(rest, "--check")
|
|
326
|
-
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
327
|
-
rest = checks[1]
|
|
328
|
-
|
|
329
|
-
job.action = stripOuterQuotes(rest || job.action || "")
|
|
330
|
-
job.watchPaths = [...new Set(job.watchPaths)]
|
|
331
|
-
job.includeFiles = [...new Set(job.includeFiles)]
|
|
332
|
-
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
333
|
-
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
334
|
-
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
335
|
-
job.name = job.name || "goal"
|
|
336
|
-
job.goalStatus = job.goalStatus || "active"
|
|
337
|
-
job.safe = job.safe !== false
|
|
338
|
-
job.askNever = job.askNever !== false
|
|
339
|
-
job.noOverlap = job.noOverlap !== false
|
|
340
|
-
job.goalRequireEvidence = job.goalRequireEvidence !== false
|
|
341
|
-
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
|
|
342
|
-
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
|
|
343
|
-
}
|
|
344
|
-
job.lastRunAt = job.immediate ? 0 : now()
|
|
345
|
-
|
|
346
|
-
if (!job.action && !job.promptFile && !job.goalFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" }
|
|
347
|
-
return { ok: true, job }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function stateDir(directory) { return path.join(directory, STATE_DIR) }
|
|
351
|
-
function statePath(directory, sessionID) { return path.join(stateDir(directory), `${safeID(sessionID)}.json`) }
|
|
352
|
-
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
353
|
-
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
354
|
-
|
|
355
|
-
function stateLockKey(directory, sessionID) {
|
|
356
|
-
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function withStateWriteLock(directory, sessionID, fn) {
|
|
360
|
-
const key = stateLockKey(directory, sessionID)
|
|
361
|
-
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
362
|
-
let release
|
|
363
|
-
const current = new Promise((resolve) => { release = resolve })
|
|
364
|
-
const next = previous.catch(() => {}).then(() => current)
|
|
365
|
-
stateWriteLocks.set(key, next)
|
|
366
|
-
await previous.catch(() => {})
|
|
367
|
-
try {
|
|
368
|
-
return await fn()
|
|
369
|
-
} finally {
|
|
370
|
-
release()
|
|
371
|
-
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function readState(directory, sessionID) {
|
|
376
|
-
const target = statePath(directory, sessionID)
|
|
377
|
-
const attempts = 5
|
|
378
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
379
|
-
try {
|
|
380
|
-
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
381
|
-
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
382
|
-
} catch (error) {
|
|
383
|
-
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
384
|
-
const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
|
|
385
|
-
if (!transient || attempt === attempts - 1) break
|
|
386
|
-
await delay(25 * (attempt + 1))
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
try {
|
|
390
|
-
await ensureDir(stateDir(directory))
|
|
391
|
-
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
392
|
-
} catch {}
|
|
393
|
-
return { version: 4, jobs: [] }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function isRetriableStateWriteError(error) {
|
|
397
|
-
const code = error?.code
|
|
398
|
-
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function delay(ms) {
|
|
402
|
-
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Windows often fails POSIX-style "write temp next to target, then rename over it":
|
|
406
|
-
// existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or
|
|
407
|
-
// OpenCode's own snapshotter briefly hold the state file. Temp files next to the
|
|
408
|
-
// target also show up in project git snapshots as *.tmp pathspec noise.
|
|
409
|
-
//
|
|
410
|
-
// Write the payload outside the project first, then replace the target with
|
|
411
|
-
// rename when possible and a copy/unlink fallback with short retries.
|
|
412
|
-
async function writeFileAtomically(target, contents, options = {}) {
|
|
413
|
-
const encoding = options.encoding || "utf8"
|
|
414
|
-
const attempts = Math.max(1, Number(options.attempts) || 5)
|
|
415
|
-
const temp = path.join(
|
|
416
|
-
os.tmpdir(),
|
|
417
|
-
`opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
418
|
-
)
|
|
419
|
-
await fs.writeFile(temp, contents, encoding)
|
|
420
|
-
try {
|
|
421
|
-
let lastError
|
|
422
|
-
// Prefer an atomic rename and retry transient Windows destination locks
|
|
423
|
-
// before falling back to a non-atomic copy/overwrite.
|
|
424
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
425
|
-
try {
|
|
426
|
-
await fs.rename(temp, target)
|
|
427
|
-
return
|
|
428
|
-
} catch (error) {
|
|
429
|
-
lastError = error
|
|
430
|
-
if (error?.code === "EXDEV") break
|
|
431
|
-
if (!isRetriableStateWriteError(error)) throw error
|
|
432
|
-
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
437
|
-
try {
|
|
438
|
-
await fs.copyFile(temp, target)
|
|
439
|
-
return
|
|
440
|
-
} catch (error) {
|
|
441
|
-
lastError = error
|
|
442
|
-
if (!isRetriableStateWriteError(error)) throw error
|
|
443
|
-
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
try {
|
|
448
|
-
await fs.writeFile(target, contents, encoding)
|
|
449
|
-
return
|
|
450
|
-
} catch (error) {
|
|
451
|
-
if (lastError && !error.cause) error.cause = lastError
|
|
452
|
-
throw error
|
|
453
|
-
}
|
|
454
|
-
} finally {
|
|
455
|
-
try { await fs.rm(temp, { force: true }) } catch {}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function writeState(directory, sessionID, state) {
|
|
460
|
-
await withStateWriteLock(directory, sessionID, async () => {
|
|
461
|
-
await ensureDir(stateDir(directory))
|
|
462
|
-
const target = statePath(directory, sessionID)
|
|
463
|
-
const payload = JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2)
|
|
464
|
-
await writeFileAtomically(target, payload)
|
|
465
|
-
})
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
async function removeState(directory, sessionID) {
|
|
469
|
-
await withStateWriteLock(directory, sessionID, async () => {
|
|
470
|
-
try { await fs.unlink(statePath(directory, sessionID)) } catch {}
|
|
471
|
-
})
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function sdkError(result) {
|
|
475
|
-
if (!result || typeof result !== "object") return undefined
|
|
476
|
-
return result.error || result.error === null ? result.error : undefined
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function sdkData(result) {
|
|
480
|
-
if (!result || typeof result !== "object") return result
|
|
481
|
-
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function sdkErrorMessage(error) {
|
|
485
|
-
if (!error) return "unknown SDK error"
|
|
486
|
-
if (error instanceof Error) return error.message
|
|
487
|
-
if (typeof error === "string") return error
|
|
488
|
-
if (typeof error === "object") {
|
|
489
|
-
if (typeof error.message === "string") return error.message
|
|
490
|
-
if (typeof error.name === "string") return error.name
|
|
491
|
-
try { return JSON.stringify(error).slice(0, 400) } catch {}
|
|
492
|
-
}
|
|
493
|
-
return String(error)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async function sdkCall(method, ...argsList) {
|
|
497
|
-
let firstError
|
|
498
|
-
for (const args of argsList) {
|
|
499
|
-
if (args === undefined) continue
|
|
500
|
-
try {
|
|
501
|
-
const result = await method(args)
|
|
502
|
-
const error = sdkError(result)
|
|
503
|
-
if (!error) return sdkData(result)
|
|
504
|
-
firstError = firstError || new Error(sdkErrorMessage(error))
|
|
505
|
-
} catch (error) {
|
|
506
|
-
firstError = firstError || error
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
throw firstError || new Error("SDK call failed without arguments")
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function fireSdk(client, label, method, ...argsList) {
|
|
513
|
-
Promise.resolve()
|
|
514
|
-
.then(() => sdkCall(method, ...argsList))
|
|
515
|
-
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function executeTuiCommand(client, command) {
|
|
519
|
-
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
520
|
-
return await sdkCall(
|
|
521
|
-
client.tui.executeCommand.bind(client.tui),
|
|
522
|
-
{ body: { command } },
|
|
523
|
-
{ command },
|
|
524
|
-
)
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function compactTuiCommandName(command = "compact") {
|
|
528
|
-
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
529
|
-
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
530
|
-
return undefined
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function compactSession(client, sessionID) {
|
|
534
|
-
// OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
|
|
535
|
-
// current builds, while some older docs/examples mention the event value
|
|
536
|
-
// (session.compact). Try the alias first, then the event value, then the
|
|
537
|
-
// session summarize endpoint as a last resort.
|
|
538
|
-
for (const command of ["
|
|
539
|
-
try {
|
|
540
|
-
await executeTuiCommand(client, command)
|
|
541
|
-
return true
|
|
542
|
-
} catch (error) {
|
|
543
|
-
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
try {
|
|
547
|
-
await sdkCall(
|
|
548
|
-
client.session.summarize.bind(client.session),
|
|
549
|
-
{ path: { id: sessionID }, body: {} },
|
|
550
|
-
{ path: { sessionID }, body: {} },
|
|
551
|
-
{ sessionID },
|
|
552
|
-
)
|
|
553
|
-
return true
|
|
554
|
-
} catch (error) {
|
|
555
|
-
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
556
|
-
}
|
|
557
|
-
await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
|
|
558
|
-
return false
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
async function log(client, level, message, extra) {
|
|
562
|
-
try {
|
|
563
|
-
await sdkCall(
|
|
564
|
-
client.app.log.bind(client.app),
|
|
565
|
-
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
566
|
-
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
567
|
-
)
|
|
568
|
-
} catch {}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
async function toast(client, message, variant = "info") {
|
|
572
|
-
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function guardLoopOwnedUserMessage(sessionID) {
|
|
576
|
-
if (!sessionID) return
|
|
577
|
-
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
578
|
-
current.pending += 1
|
|
579
|
-
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
580
|
-
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
581
|
-
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
582
|
-
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
583
|
-
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
584
|
-
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
589
|
-
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
590
|
-
if (!entry || typeof entry !== "object") return false
|
|
591
|
-
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
592
|
-
const id = typeof messageID === "string" ? messageID : ""
|
|
593
|
-
if (id && entry.messageIDs.has(id)) return true
|
|
594
|
-
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
595
|
-
entry.pending -= 1
|
|
596
|
-
if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
597
|
-
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
598
|
-
return true
|
|
599
|
-
}
|
|
600
|
-
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
601
|
-
return false
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
async function say(client, sessionID, text) {
|
|
605
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
606
|
-
try {
|
|
607
|
-
await sdkCall(
|
|
608
|
-
client.session.prompt.bind(client.session),
|
|
609
|
-
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
610
|
-
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
611
|
-
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
612
|
-
)
|
|
613
|
-
} catch {}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
617
|
-
function markHandled(sessionID, name, args) {
|
|
618
|
-
const key = commandKey(sessionID, name, args)
|
|
619
|
-
const previous = handledCommands.get(key)
|
|
620
|
-
const pending = previous && now() - previous.time < 30_000 ? previous.pending + 1 : 1
|
|
621
|
-
handledCommands.set(key, { time: now(), pending })
|
|
622
|
-
for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > 30_000) handledCommands.delete(entryKey)
|
|
623
|
-
for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > 30_000) handledCommandEvents.delete(entryKey)
|
|
624
|
-
}
|
|
625
|
-
function consumeHandled(sessionID, name, args) {
|
|
626
|
-
const key = commandKey(sessionID, name, args)
|
|
627
|
-
const entry = handledCommands.get(key)
|
|
628
|
-
if (!entry || now() - entry.time >= 30_000) {
|
|
629
|
-
handledCommands.delete(key)
|
|
630
|
-
return false
|
|
631
|
-
}
|
|
632
|
-
if (entry.pending <= 1) handledCommands.delete(key)
|
|
633
|
-
else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
|
|
634
|
-
return true
|
|
635
|
-
}
|
|
636
|
-
function commandEventKey(sessionID, messageID) {
|
|
637
|
-
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function commandName(name) { return String(name || "") }
|
|
641
|
-
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) }
|
|
642
|
-
function isLoopCommandName(name) {
|
|
643
|
-
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)
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function normalizeArgsForKey(args) {
|
|
647
|
-
if (args === undefined || args === null) return ""
|
|
648
|
-
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
649
|
-
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
650
|
-
try { return JSON.stringify(args) } catch { return String(args) }
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function commandArgsText(args) {
|
|
654
|
-
if (args === undefined || args === null) return ""
|
|
655
|
-
if (typeof args === "string") return args
|
|
656
|
-
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
657
|
-
if (typeof args === "object") {
|
|
658
|
-
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
659
|
-
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
return String(args)
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function rememberSession(directory, client, sessionID) {
|
|
666
|
-
if (!sessionID) return
|
|
667
|
-
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
668
|
-
startHeartbeat()
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function normalizedModelRef(model) {
|
|
672
|
-
if (typeof model === "string") {
|
|
673
|
-
const separator = model.indexOf("/")
|
|
674
|
-
if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
|
|
675
|
-
return undefined
|
|
676
|
-
}
|
|
677
|
-
const providerID = model?.providerID
|
|
678
|
-
const modelID = model?.modelID || model?.id
|
|
679
|
-
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
680
|
-
return { providerID, modelID }
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function updateSessionExecutionContext(info) {
|
|
684
|
-
const sessionID = info?.sessionID || info?.id
|
|
685
|
-
if (typeof sessionID !== "string") return
|
|
686
|
-
const previous = sessionExecutionContexts.get(sessionID) || {}
|
|
687
|
-
const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
|
|
688
|
-
const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
|
|
689
|
-
? candidateAgent
|
|
690
|
-
: previous.agent
|
|
691
|
-
const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
|
|
692
|
-
sessionExecutionContexts.set(sessionID, { agent, model })
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
async function captureSessionExecutionContext(client, sessionID) {
|
|
696
|
-
if (client?.session?.get) {
|
|
697
|
-
try {
|
|
698
|
-
const info = await sdkCall(
|
|
699
|
-
client.session.get.bind(client.session),
|
|
700
|
-
{ path: { id: sessionID } },
|
|
701
|
-
{ path: { sessionID } },
|
|
702
|
-
{ sessionID },
|
|
703
|
-
)
|
|
704
|
-
updateSessionExecutionContext(info)
|
|
705
|
-
} catch {}
|
|
706
|
-
}
|
|
707
|
-
const context = sessionExecutionContexts.get(sessionID) || {}
|
|
708
|
-
const normalized = { agent: context.agent || "build", model: context.model }
|
|
709
|
-
sessionExecutionContexts.set(sessionID, normalized)
|
|
710
|
-
return normalized
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function hasActiveToolCalls(sessionID) {
|
|
714
|
-
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function markToolCallActive(input) {
|
|
718
|
-
const sessionID = input?.sessionID
|
|
719
|
-
const callID = input?.callID
|
|
720
|
-
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
721
|
-
const calls = activeToolCalls.get(sessionID) || new Set()
|
|
722
|
-
calls.add(callID)
|
|
723
|
-
activeToolCalls.set(sessionID, calls)
|
|
724
|
-
sessionStatuses.set(sessionID, "busy")
|
|
725
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function markToolCallFinished(input) {
|
|
729
|
-
const sessionID = input?.sessionID
|
|
730
|
-
const callID = input?.callID
|
|
731
|
-
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
732
|
-
const calls = activeToolCalls.get(sessionID)
|
|
733
|
-
if (!calls) return
|
|
734
|
-
calls.delete(callID)
|
|
735
|
-
if (!calls.size) activeToolCalls.delete(sessionID)
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
function updateSessionRelationship(info, removed = false) {
|
|
739
|
-
const sessionID = info?.id
|
|
740
|
-
if (typeof sessionID !== "string") return
|
|
741
|
-
if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
|
|
742
|
-
else sessionParents.set(sessionID, info.parentID)
|
|
743
|
-
if (!removed) updateSessionExecutionContext(info)
|
|
744
|
-
else sessionExecutionContexts.delete(sessionID)
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function updateSessionRelationshipFromEvent(event) {
|
|
748
|
-
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
|
|
749
|
-
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function isDescendantSession(sessionID, ancestorID) {
|
|
753
|
-
const visited = new Set()
|
|
754
|
-
let current = sessionID
|
|
755
|
-
while (sessionParents.has(current) && !visited.has(current)) {
|
|
756
|
-
visited.add(current)
|
|
757
|
-
current = sessionParents.get(current)
|
|
758
|
-
if (current === ancestorID) return true
|
|
759
|
-
}
|
|
760
|
-
return false
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
function hasBusyDescendant(sessionID) {
|
|
764
|
-
for (const childID of sessionParents.keys()) {
|
|
765
|
-
if (!isDescendantSession(childID, sessionID)) continue
|
|
766
|
-
const status = sessionStatuses.get(childID)
|
|
767
|
-
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
|
|
768
|
-
}
|
|
769
|
-
return false
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
async function refreshSessionRelationships(client, directory) {
|
|
773
|
-
if (!client?.session?.list) return
|
|
774
|
-
try {
|
|
775
|
-
const sessions = await sdkCall(
|
|
776
|
-
client.session.list.bind(client.session),
|
|
777
|
-
{ query: { directory } },
|
|
778
|
-
{ directory },
|
|
779
|
-
{},
|
|
780
|
-
)
|
|
781
|
-
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
782
|
-
} catch {}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function updateToolActivityFromEvent(event) {
|
|
786
|
-
const props = event?.properties || {}
|
|
787
|
-
if (event?.type === "message.part.updated") {
|
|
788
|
-
const part = props.part
|
|
789
|
-
if (part?.type !== "tool") return
|
|
790
|
-
const sessionID = part.sessionID || props.sessionID
|
|
791
|
-
if (["pending", "running"].includes(part.state?.status)) {
|
|
792
|
-
markToolCallActive({ sessionID, callID: part.callID })
|
|
793
|
-
}
|
|
794
|
-
if (["completed", "error"].includes(part.state?.status)) {
|
|
795
|
-
// Task/subagent hooks have used the part id as their hook call id in some
|
|
796
|
-
// OpenCode versions, while normal tools use part.callID. Clear either.
|
|
797
|
-
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
|
|
798
|
-
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
799
|
-
}
|
|
800
|
-
return
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
|
|
804
|
-
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
|
|
805
|
-
if (started) markToolCallActive(props)
|
|
806
|
-
if (finished) markToolCallFinished(props)
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function startHeartbeat() {
|
|
810
|
-
if (heartbeatTimer) return
|
|
811
|
-
heartbeatTimer = setInterval(() => {
|
|
812
|
-
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
813
|
-
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
814
|
-
knownSessions.delete(sessionID)
|
|
815
|
-
continue
|
|
816
|
-
}
|
|
817
|
-
Promise.resolve()
|
|
818
|
-
.then(async () => {
|
|
819
|
-
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
820
|
-
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
821
|
-
})
|
|
822
|
-
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
823
|
-
}
|
|
824
|
-
if (!knownSessions.size && heartbeatTimer) {
|
|
825
|
-
clearInterval(heartbeatTimer)
|
|
826
|
-
heartbeatTimer = undefined
|
|
827
|
-
}
|
|
828
|
-
}, HEARTBEAT_MS)
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function disposeRuntime(directory, client) {
|
|
832
|
-
const sessions = [...knownSessions.entries()]
|
|
833
|
-
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
834
|
-
.map(([sessionID]) => sessionID)
|
|
835
|
-
for (const sessionID of sessions) {
|
|
836
|
-
clearActiveRun(sessionID)
|
|
837
|
-
const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
|
|
838
|
-
const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
|
|
839
|
-
const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
|
|
840
|
-
idleTimers.delete(sessionID)
|
|
841
|
-
dueTimers.delete(sessionID)
|
|
842
|
-
watchdogTimers.delete(sessionID)
|
|
843
|
-
runLocks.delete(sessionID)
|
|
844
|
-
knownSessions.delete(sessionID)
|
|
845
|
-
loopOwnedUserMessageGuards.delete(sessionID)
|
|
846
|
-
activeToolCalls.delete(sessionID)
|
|
847
|
-
sessionParents.delete(sessionID)
|
|
848
|
-
sessionStatuses.delete(sessionID)
|
|
849
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
850
|
-
sessionExecutionContexts.delete(sessionID)
|
|
851
|
-
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
852
|
-
for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
|
|
853
|
-
}
|
|
854
|
-
if (!knownSessions.size && heartbeatTimer) {
|
|
855
|
-
clearInterval(heartbeatTimer)
|
|
856
|
-
heartbeatTimer = undefined
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function presetDefaults(name) {
|
|
861
|
-
// parseLoopArgs owns duration/flag/action parsing. Presets only provide real
|
|
862
|
-
// defaults; deriving an action from the raw string made flag-only invocations
|
|
863
|
-
// such as `/loop-compact --dry-run` use "--dry-run" as the action.
|
|
864
|
-
if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
|
|
865
|
-
if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
|
|
866
|
-
if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
|
|
867
|
-
if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
|
|
868
|
-
if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
|
|
869
|
-
if (name === "loop-testfix") return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" }
|
|
870
|
-
if (name === "loop-progress") return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
871
|
-
if (name === "loop-safe-dev") return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
872
|
-
return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function jobLabel(job) {
|
|
876
|
-
const title = job.name ? `${job.name}: ` : ""
|
|
877
|
-
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
878
|
-
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
879
|
-
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
880
|
-
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
881
|
-
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : ""
|
|
882
|
-
const verify = job.verifyCommand ? ", verify" : ""
|
|
883
|
-
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
884
|
-
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
885
|
-
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
886
|
-
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
887
|
-
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
888
|
-
const paused = job.paused ? ", paused" : ""
|
|
889
|
-
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
function matchJob(job, target, index) {
|
|
893
|
-
const text = String(target || "").trim()
|
|
894
|
-
if (!text || text.toLowerCase() === "all") return true
|
|
895
|
-
return job.id === text || job.name === text || String(index + 1) === text
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
async function appendLoopLog(directory, line, extra = {}) {
|
|
899
|
-
try {
|
|
900
|
-
await ensureDir(stateDir(directory))
|
|
901
|
-
await fs.appendFile(path.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + "\n")
|
|
902
|
-
} catch {}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async function readSmallTextFile(filePath, maxBytes = 120_000) {
|
|
906
|
-
try {
|
|
907
|
-
const stat = await fs.stat(filePath)
|
|
908
|
-
if (!stat.isFile() || stat.size > maxBytes) return ""
|
|
909
|
-
return await fs.readFile(filePath, "utf8")
|
|
910
|
-
} catch { return "" }
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
914
|
-
return await new Promise((resolve) => {
|
|
915
|
-
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
916
|
-
const stdout = []
|
|
917
|
-
const stderr = []
|
|
918
|
-
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
919
|
-
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
920
|
-
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
921
|
-
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
922
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
923
|
-
})
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
927
|
-
return await new Promise((resolve) => {
|
|
928
|
-
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
929
|
-
const stdout = []
|
|
930
|
-
const stderr = []
|
|
931
|
-
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
932
|
-
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
933
|
-
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
934
|
-
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
935
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
936
|
-
})
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
async function notifyJob(directory, job, reason) {
|
|
940
|
-
if (!job.notifyCommand) return
|
|
941
|
-
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""))
|
|
942
|
-
await runShellCommand(command, directory, 60_000)
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
function dangerousShell(command) {
|
|
946
|
-
const text = String(command || "").toLowerCase()
|
|
947
|
-
return [
|
|
948
|
-
/\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)/,
|
|
949
|
-
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
950
|
-
/\bgit\s+reset\b/,
|
|
951
|
-
/\bgit\s+clean\b/,
|
|
952
|
-
/\bgit\s+push\b/,
|
|
953
|
-
/\bdel\b[^\r\n]*\s\/s\b/,
|
|
954
|
-
/\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
|
|
955
|
-
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
956
|
-
/\bterraform\s+destroy\b/,
|
|
957
|
-
/\bkubectl\s+delete\b/,
|
|
958
|
-
/\bdeploy\b.*\bproduction\b/,
|
|
959
|
-
].some((pattern) => pattern.test(text))
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function actionKind(action, job = {}) {
|
|
963
|
-
const text = String(action || "").trim()
|
|
964
|
-
const forced = String(job.kind || "").trim().toLowerCase()
|
|
965
|
-
if (forced === "compact") return "compact"
|
|
966
|
-
if (forced === "goal") return "goal"
|
|
967
|
-
if (text === "/compact" || text === "/summarize") return "compact"
|
|
968
|
-
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
969
|
-
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
970
|
-
if (forced === "shell") return "shell"
|
|
971
|
-
if (text.startsWith("/")) return "command"
|
|
972
|
-
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
973
|
-
return "prompt"
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
function decoratePrompt(job) {
|
|
977
|
-
const additions = []
|
|
978
|
-
if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
|
|
979
|
-
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
980
|
-
if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
|
|
981
|
-
if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
|
|
982
|
-
if (job.batch > 0) additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`)
|
|
983
|
-
if (job.quiet) additions.push("Keep replies short. Summarize only what changed, tests run, and next step.")
|
|
984
|
-
if (job.testCommand) additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`)
|
|
985
|
-
if (job.checkpointOnly || job.gitCheckpoint) additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.")
|
|
986
|
-
if (!additions.length) return job.action
|
|
987
|
-
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
function isGoalJob(job) {
|
|
991
|
-
return String(job?.kind || "").toLowerCase() === "goal"
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
function goalStatusText(job) {
|
|
995
|
-
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
996
|
-
if (!status) return ""
|
|
997
|
-
if (status === "completed") return "completed"
|
|
998
|
-
if (status === "blocked") return "blocked"
|
|
999
|
-
if (job?.paused) return "paused"
|
|
1000
|
-
return status
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
async function buildGoalPrompt(directory, job) {
|
|
1004
|
-
const sections = []
|
|
1005
|
-
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\".`)
|
|
1006
|
-
const objective = String(job.action || "").trim()
|
|
1007
|
-
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
1008
|
-
if (job.goalFile) {
|
|
1009
|
-
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
1010
|
-
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
1011
|
-
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
1012
|
-
}
|
|
1013
|
-
if (job.promptFile) {
|
|
1014
|
-
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
1015
|
-
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
1016
|
-
}
|
|
1017
|
-
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
1018
|
-
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
1019
|
-
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
1020
|
-
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
1021
|
-
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
1022
|
-
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
1023
|
-
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.`)
|
|
1024
|
-
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
1025
|
-
for (const file of job.includeFiles || []) {
|
|
1026
|
-
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
1027
|
-
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
return `${GOAL_PROMPT_PREFIX}.
|
|
1031
|
-
|
|
1032
|
-
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.
|
|
1033
|
-
|
|
1034
|
-
Rules:
|
|
1035
|
-
- Work on the next smallest useful step toward the goal.
|
|
1036
|
-
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
1037
|
-
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
1038
|
-
- 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.
|
|
1039
|
-
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
1040
|
-
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
1041
|
-
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
1042
|
-
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
1043
|
-
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
1044
|
-
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
1045
|
-
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
1046
|
-
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
1047
|
-
|
|
1048
|
-
${sections.join("\n\n---\n\n")}`
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
async function buildPrompt(directory, job) {
|
|
1052
|
-
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
1053
|
-
const sections = []
|
|
1054
|
-
if (job.promptFile) {
|
|
1055
|
-
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
1056
|
-
if (text.trim()) sections.push(`Instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
1057
|
-
else sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`)
|
|
1058
|
-
}
|
|
1059
|
-
if (job.action) sections.push(decoratePrompt(job))
|
|
1060
|
-
for (const file of job.includeFiles || []) {
|
|
1061
|
-
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
1062
|
-
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
1063
|
-
}
|
|
1064
|
-
return sections.join("\n\n---\n\n") || decoratePrompt(job)
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
async function ensureBranch(directory, job, client, sessionID) {
|
|
1068
|
-
if (!job.branch || job.branchDone) return job
|
|
1069
|
-
const branch = safeID(job.branch)
|
|
1070
|
-
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
1071
|
-
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
1072
|
-
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
1073
|
-
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
1074
|
-
job.branchDone = true
|
|
1075
|
-
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
1076
|
-
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
1077
|
-
return job
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
async function maybeCompact(client, sessionID, job) {
|
|
1081
|
-
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
1082
|
-
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
1083
|
-
if (!dueRuns && !dueTime) return job
|
|
1084
|
-
if (await compactSession(client, sessionID)) {
|
|
1085
|
-
job.lastCompactAt = now()
|
|
1086
|
-
job.lastCompactRunCount = job.runCount || 0
|
|
1087
|
-
}
|
|
1088
|
-
return job
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
async function snapshotPaths(directory, files) {
|
|
1092
|
-
const snapshot = {}
|
|
1093
|
-
for (const file of files || []) {
|
|
1094
|
-
try {
|
|
1095
|
-
const stat = await fs.stat(path.resolve(directory, file))
|
|
1096
|
-
snapshot[file] = `${stat.mtimeMs}:${stat.size}`
|
|
1097
|
-
} catch { snapshot[file] = "missing" }
|
|
1098
|
-
}
|
|
1099
|
-
return snapshot
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
async function watchChanged(directory, job) {
|
|
1103
|
-
if (!job.watchPaths?.length) return false
|
|
1104
|
-
const next = await snapshotPaths(directory, job.watchPaths)
|
|
1105
|
-
const previous = job.watchSnapshot || {}
|
|
1106
|
-
const changed = job.watchPaths.some((file) => previous[file] !== next[file])
|
|
1107
|
-
if (changed) job.watchSnapshot = next
|
|
1108
|
-
return changed
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
async function fileContains(filePath, needle) {
|
|
1112
|
-
try {
|
|
1113
|
-
const stat = await fs.stat(filePath)
|
|
1114
|
-
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false
|
|
1115
|
-
return (await fs.readFile(filePath, "utf8")).includes(needle)
|
|
1116
|
-
} catch { return false }
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
async function untilReached(directory, job) {
|
|
1120
|
-
if (!job.until) return false
|
|
1121
|
-
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
1122
|
-
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
1123
|
-
let scanned = 0
|
|
1124
|
-
async function walk(current) {
|
|
1125
|
-
if (scanned >= MAX_SCAN_FILES) return false
|
|
1126
|
-
let entries
|
|
1127
|
-
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return false }
|
|
1128
|
-
for (const entry of entries) {
|
|
1129
|
-
if (scanned >= MAX_SCAN_FILES) return false
|
|
1130
|
-
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
1131
|
-
const full = path.join(current, entry.name)
|
|
1132
|
-
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
1133
|
-
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
1134
|
-
}
|
|
1135
|
-
return false
|
|
1136
|
-
}
|
|
1137
|
-
return await walk(directory)
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
async function createCheckpoint(directory, sessionID, job, client) {
|
|
1141
|
-
if (!job.checkpointOnly && !job.gitCheckpoint) return
|
|
1142
|
-
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
1143
|
-
if (inRepo.code !== 0) return
|
|
1144
|
-
const status = await runProcess("git", ["status", "--short"], directory, 30_000)
|
|
1145
|
-
if (!status.stdout.trim()) return
|
|
1146
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
1147
|
-
const checkpointDir = path.join(stateDir(directory), "checkpoints", safeID(sessionID))
|
|
1148
|
-
await ensureDir(checkpointDir)
|
|
1149
|
-
const diff = await runProcess("git", ["diff", "--binary"], directory, 120_000)
|
|
1150
|
-
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120_000)
|
|
1151
|
-
const prefix = `${timestamp}-${safeID(job.name || job.id)}`
|
|
1152
|
-
await fs.writeFile(path.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr)
|
|
1153
|
-
await fs.writeFile(path.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}\n${staged.stdout}`)
|
|
1154
|
-
if (job.gitCheckpoint) {
|
|
1155
|
-
await runProcess("git", ["add", "-A"], directory, 120_000)
|
|
1156
|
-
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120_000)
|
|
1157
|
-
}
|
|
1158
|
-
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
function updateSessionStatusFromEvent(event) {
|
|
1162
|
-
const sessionID = event?.properties?.sessionID
|
|
1163
|
-
if (typeof sessionID !== "string") return undefined
|
|
1164
|
-
if (event?.type === "session.idle") {
|
|
1165
|
-
sessionStatuses.set(sessionID, "idle")
|
|
1166
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1167
|
-
return { sessionID, idle: true }
|
|
1168
|
-
}
|
|
1169
|
-
if (event?.type === "session.status") {
|
|
1170
|
-
const status = event?.properties?.status
|
|
1171
|
-
const type = status && typeof status === "object" ? status.type : undefined
|
|
1172
|
-
if (typeof type === "string") {
|
|
1173
|
-
sessionStatuses.set(sessionID, type)
|
|
1174
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1175
|
-
}
|
|
1176
|
-
return { sessionID, idle: type === "idle" }
|
|
1177
|
-
}
|
|
1178
|
-
return undefined
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
function userInterruptSessionFromEvent(event) {
|
|
1182
|
-
if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
|
|
1183
|
-
const props = event?.properties || {}
|
|
1184
|
-
const info = props.info || props.message || props
|
|
1185
|
-
const role = info?.role
|
|
1186
|
-
const sessionID = info?.sessionID || props.sessionID
|
|
1187
|
-
const messageID = info?.id || props.messageID
|
|
1188
|
-
if (role !== "user" || typeof sessionID !== "string") return undefined
|
|
1189
|
-
if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
|
|
1190
|
-
return sessionID
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
1194
|
-
const state = await readState(directory, sessionID)
|
|
1195
|
-
const interruptedAt = now()
|
|
1196
|
-
let count = 0
|
|
1197
|
-
state.jobs = (state.jobs || []).map((job) => {
|
|
1198
|
-
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
|
|
1199
|
-
count++
|
|
1200
|
-
return {
|
|
1201
|
-
...job,
|
|
1202
|
-
paused: true,
|
|
1203
|
-
lastUserInterruptAt: interruptedAt,
|
|
1204
|
-
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
|
|
1205
|
-
}
|
|
1206
|
-
})
|
|
1207
|
-
if (!count) return false
|
|
1208
|
-
await writeState(directory, sessionID, state)
|
|
1209
|
-
await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
|
|
1210
|
-
await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
|
|
1211
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1212
|
-
return true
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
function staleActiveRun(sessionID) {
|
|
1216
|
-
const active = activeRuns.get(sessionID)
|
|
1217
|
-
if (!active) return false
|
|
1218
|
-
const age = now() - (active.startedAt || 0)
|
|
1219
|
-
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
1220
|
-
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
|
|
1221
|
-
return age >= threshold
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1225
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
1226
|
-
if (!options.requireIdle && !options.forceStale) return true
|
|
1227
|
-
if (options.forceStale && staleActiveRun(sessionID)) return true
|
|
1228
|
-
if (!options.requireIdle) return false
|
|
1229
|
-
|
|
1230
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1231
|
-
if (live?.type) return live.type === "idle"
|
|
1232
|
-
|
|
1233
|
-
const cached = sessionStatuses.get(sessionID)
|
|
1234
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1235
|
-
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
1239
|
-
const argsList = []
|
|
1240
|
-
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
1241
|
-
argsList.push({})
|
|
1242
|
-
for (const args of argsList) {
|
|
1243
|
-
try {
|
|
1244
|
-
const result = await client.session.status(args)
|
|
1245
|
-
const error = sdkError(result)
|
|
1246
|
-
if (error) continue
|
|
1247
|
-
const data = sdkData(result)
|
|
1248
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
|
1249
|
-
const observedAt = now()
|
|
1250
|
-
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
1251
|
-
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
1252
|
-
if (typeof observedType !== "string") continue
|
|
1253
|
-
sessionStatuses.set(observedSessionID, observedType)
|
|
1254
|
-
sessionStatusSeenAt.set(observedSessionID, observedAt)
|
|
1255
|
-
}
|
|
1256
|
-
// OpenCode's status list contains active sessions; idle sessions are
|
|
1257
|
-
// normally omitted. Clear a completed descendant that was previously busy.
|
|
1258
|
-
for (const childID of sessionParents.keys()) {
|
|
1259
|
-
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
1260
|
-
sessionStatuses.set(childID, "idle")
|
|
1261
|
-
sessionStatusSeenAt.set(childID, observedAt)
|
|
1262
|
-
}
|
|
1263
|
-
if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
|
|
1264
|
-
const status = data?.[sessionID]
|
|
1265
|
-
const type = status && typeof status === "object" ? status.type : undefined
|
|
1266
|
-
if (typeof type === "string") return { type, source: "sdk" }
|
|
1267
|
-
return { type: "idle", source: "sdk" }
|
|
1268
|
-
} catch {}
|
|
1269
|
-
}
|
|
1270
|
-
return undefined
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
1274
|
-
// OpenCode can briefly report an idle session while a long-running tool or
|
|
1275
|
-
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
1276
|
-
// signal here, so never enqueue another turn until every active call ends.
|
|
1277
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
1278
|
-
sessionStatuses.set(sessionID, "busy")
|
|
1279
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1280
|
-
return "busy"
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const cached = sessionStatuses.get(sessionID)
|
|
1284
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1285
|
-
|
|
1286
|
-
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
1287
|
-
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
1288
|
-
// own short assistant turn, and some TUI builds do not always emit the final
|
|
1289
|
-
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
1290
|
-
// stuck at "due in every idle" until the user types another command.
|
|
1291
|
-
if (cached === "idle") return cached
|
|
1292
|
-
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
1293
|
-
|
|
1294
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1295
|
-
if (live?.type) {
|
|
1296
|
-
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
1297
|
-
// plugin-injected turn until the next user command touches the session.
|
|
1298
|
-
// When the only reason we still think the session is busy is our own stale
|
|
1299
|
-
// active-run guard, recover instead of waiting for another manual command.
|
|
1300
|
-
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
|
|
1301
|
-
sessionStatuses.set(sessionID, "idle")
|
|
1302
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1303
|
-
return "idle"
|
|
1304
|
-
}
|
|
1305
|
-
sessionStatuses.set(sessionID, live.type)
|
|
1306
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1307
|
-
return live.type
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
1311
|
-
sessionStatuses.set(sessionID, fallback)
|
|
1312
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1313
|
-
return fallback
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
1317
|
-
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
function scheduleIdleWork(directory, client, sessionID) {
|
|
1321
|
-
const previous = idleTimers.get(sessionID)
|
|
1322
|
-
if (previous) clearTimeout(previous)
|
|
1323
|
-
const timer = setTimeout(() => {
|
|
1324
|
-
idleTimers.delete(sessionID)
|
|
1325
|
-
Promise.resolve()
|
|
1326
|
-
.then(async () => {
|
|
1327
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1328
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1329
|
-
return
|
|
1330
|
-
}
|
|
1331
|
-
await finalizeActiveRun(directory, client, sessionID)
|
|
1332
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1333
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1334
|
-
return
|
|
1335
|
-
}
|
|
1336
|
-
await maybeRunDueJobs(directory, client, sessionID)
|
|
1337
|
-
})
|
|
1338
|
-
.catch((error) => {
|
|
1339
|
-
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
1340
|
-
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
1341
|
-
})
|
|
1342
|
-
}, IDLE_DEBOUNCE_MS)
|
|
1343
|
-
idleTimers.set(sessionID, timer)
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function jobDueAt(job, current = now()) {
|
|
1347
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
1348
|
-
if (!job.enabled || job.paused) return Infinity
|
|
1349
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
1350
|
-
if (job.watchPaths?.length) return Infinity
|
|
1351
|
-
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
1352
|
-
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
1353
|
-
if (job.intervalMs === 0) return current
|
|
1354
|
-
if (!job.lastRunAt) return current
|
|
1355
|
-
return job.lastRunAt + (job.intervalMs || 0)
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
function nextDueDelay(state) {
|
|
1359
|
-
const current = now()
|
|
1360
|
-
let soonest = Infinity
|
|
1361
|
-
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
1362
|
-
if (!Number.isFinite(soonest)) return Infinity
|
|
1363
|
-
return Math.max(0, soonest - current)
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
async function startWatchdog(directory, client, sessionID) {
|
|
1367
|
-
if (watchdogTimers.has(sessionID)) return
|
|
1368
|
-
const timer = setInterval(() => {
|
|
1369
|
-
Promise.resolve()
|
|
1370
|
-
.then(async () => {
|
|
1371
|
-
const state = await readState(directory, sessionID)
|
|
1372
|
-
const delay = nextDueDelay(state)
|
|
1373
|
-
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
1374
|
-
if (!hasJobs || !Number.isFinite(delay)) {
|
|
1375
|
-
const existing = watchdogTimers.get(sessionID)
|
|
1376
|
-
if (existing) clearInterval(existing)
|
|
1377
|
-
watchdogTimers.delete(sessionID)
|
|
1378
|
-
return
|
|
1379
|
-
}
|
|
1380
|
-
if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
|
|
1381
|
-
else await scheduleDueWork(directory, client, sessionID)
|
|
1382
|
-
})
|
|
1383
|
-
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
1384
|
-
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
1385
|
-
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
1386
|
-
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
1387
|
-
// when no manual /loop-status command is typed.
|
|
1388
|
-
watchdogTimers.set(sessionID, timer)
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
function stopWatchdog(sessionID) {
|
|
1392
|
-
const timer = watchdogTimers.get(sessionID)
|
|
1393
|
-
if (timer) clearInterval(timer)
|
|
1394
|
-
watchdogTimers.delete(sessionID)
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
1398
|
-
const previous = dueTimers.get(sessionID)
|
|
1399
|
-
if (previous) clearTimeout(previous)
|
|
1400
|
-
|
|
1401
|
-
const state = await readState(directory, sessionID)
|
|
1402
|
-
const delay = nextDueDelay(state)
|
|
1403
|
-
if (!Number.isFinite(delay)) {
|
|
1404
|
-
dueTimers.delete(sessionID)
|
|
1405
|
-
return
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
1409
|
-
const timer = setTimeout(() => {
|
|
1410
|
-
dueTimers.delete(sessionID)
|
|
1411
|
-
Promise.resolve()
|
|
1412
|
-
.then(async () => {
|
|
1413
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1414
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1415
|
-
return
|
|
1416
|
-
}
|
|
1417
|
-
await finalizeActiveRun(directory, client, sessionID)
|
|
1418
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1419
|
-
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1420
|
-
return
|
|
1421
|
-
}
|
|
1422
|
-
await maybeRunDueJobs(directory, client, sessionID)
|
|
1423
|
-
})
|
|
1424
|
-
.catch((error) => {
|
|
1425
|
-
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
1426
|
-
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
1427
|
-
})
|
|
1428
|
-
}, wait)
|
|
1429
|
-
dueTimers.set(sessionID, timer)
|
|
1430
|
-
await startWatchdog(directory, client, sessionID)
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
function dueJobs(state, force = false) {
|
|
1434
|
-
const current = now()
|
|
1435
|
-
return (state.jobs || []).filter((job) => {
|
|
1436
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
1437
|
-
if (!job.enabled || job.paused) return false
|
|
1438
|
-
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
1439
|
-
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
1440
|
-
if (force) return true
|
|
1441
|
-
if (job.watchPaths?.length) return job.watchTriggered === true
|
|
1442
|
-
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
|
|
1443
|
-
})
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
function clearActiveRun(sessionID) {
|
|
1447
|
-
const active = activeRuns.get(sessionID)
|
|
1448
|
-
if (active?.timer) clearTimeout(active.timer)
|
|
1449
|
-
activeRuns.delete(sessionID)
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
function goalReportPath(directory, sessionID, job) {
|
|
1453
|
-
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
function goalReportText(job) {
|
|
1457
|
-
const lines = []
|
|
1458
|
-
lines.push(`# OpenCode Loop Goal Report`)
|
|
1459
|
-
lines.push("")
|
|
1460
|
-
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
1461
|
-
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
1462
|
-
lines.push(`Created: ${job.createdAt || ""}`)
|
|
1463
|
-
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
1464
|
-
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
1465
|
-
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
1466
|
-
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
1467
|
-
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
1468
|
-
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
1469
|
-
lines.push("")
|
|
1470
|
-
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
1471
|
-
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
1472
|
-
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
1473
|
-
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
1474
|
-
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
1475
|
-
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
1476
|
-
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
1477
|
-
if (job.lastGoalChecks?.length) {
|
|
1478
|
-
lines.push("## Latest checks", "")
|
|
1479
|
-
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
1480
|
-
lines.push("")
|
|
1481
|
-
}
|
|
1482
|
-
if (job.goalProgress?.length) {
|
|
1483
|
-
lines.push("## Progress", "")
|
|
1484
|
-
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
1485
|
-
lines.push("")
|
|
1486
|
-
}
|
|
1487
|
-
return lines.join("\n")
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
async function writeGoalReport(directory, sessionID, job) {
|
|
1491
|
-
if (!isGoalJob(job)) return
|
|
1492
|
-
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
1493
|
-
await ensureDir(path.dirname(target))
|
|
1494
|
-
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
function pickGoalJob(state, target = "") {
|
|
1498
|
-
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1499
|
-
if (!goals.length) return undefined
|
|
1500
|
-
const text = String(target || "").trim()
|
|
1501
|
-
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
1502
|
-
return goals.find((job, index) => matchJob(job, text, index))
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
function parseGoalToolText(args, fields) {
|
|
1506
|
-
const result = {}
|
|
1507
|
-
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
1508
|
-
return result
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
function hasConcreteGoalEvidence(value) {
|
|
1512
|
-
const text = String(value || "").trim()
|
|
1513
|
-
if (text.length < 24) return false
|
|
1514
|
-
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
1515
|
-
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
1516
|
-
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
1517
|
-
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
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
function goalChecksPassed(job) {
|
|
1521
|
-
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
function goalRequiresPassingChecks(job) {
|
|
1525
|
-
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1529
|
-
job.goalCompletionRejectedAt = now()
|
|
1530
|
-
job.goalCompletionRejectedReason = reason
|
|
1531
|
-
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
1532
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1533
|
-
await writeState(directory, sessionID, state)
|
|
1534
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1535
|
-
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
1536
|
-
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
function goalProgressSnapshot(job) {
|
|
1540
|
-
return {
|
|
1541
|
-
status: job?.goalStatus || "",
|
|
1542
|
-
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
1543
|
-
evidence: String(job?.goalEvidence || ""),
|
|
1544
|
-
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
1545
|
-
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
1546
|
-
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
1547
|
-
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
1552
|
-
const before = goalProgressSnapshot(beforeJob || {})
|
|
1553
|
-
const after = goalProgressSnapshot(afterJob || {})
|
|
1554
|
-
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
1555
|
-
if (after.progressCount > before.progressCount) return true
|
|
1556
|
-
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
1557
|
-
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
1558
|
-
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
1559
|
-
return false
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
1563
|
-
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
|
|
1564
|
-
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
1565
|
-
if (!Number.isFinite(limit) || limit <= 0) return job
|
|
1566
|
-
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
1567
|
-
job.noProgressCount = 0
|
|
1568
|
-
job.lastProgressAt = now()
|
|
1569
|
-
return job
|
|
1570
|
-
}
|
|
1571
|
-
job.noProgressCount = (job.noProgressCount || 0) + 1
|
|
1572
|
-
job.lastNoProgressAt = now()
|
|
1573
|
-
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1574
|
-
if (job.noProgressCount >= limit) {
|
|
1575
|
-
job.paused = true
|
|
1576
|
-
job.goalNoProgressPausedAt = now()
|
|
1577
|
-
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
|
|
1578
|
-
await toast(client, job.goalNoProgressReason, "warning")
|
|
1579
|
-
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1580
|
-
}
|
|
1581
|
-
return job
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
1585
|
-
const state = await readState(directory, sessionID)
|
|
1586
|
-
const job = pickGoalJob(state, args.target)
|
|
1587
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1588
|
-
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
1589
|
-
const manualOverride = args.manual === true || args.manualOverride === true
|
|
1590
|
-
const completionEvidence = parsed.evidence || job.goalEvidence || ""
|
|
1591
|
-
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
|
|
1592
|
-
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
|
|
1593
|
-
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
1594
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
1595
|
-
}
|
|
1596
|
-
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
1597
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
1598
|
-
}
|
|
1599
|
-
job.goalStatus = "completed"
|
|
1600
|
-
job.enabled = false
|
|
1601
|
-
job.paused = true
|
|
1602
|
-
job.goalCompletedAt = now()
|
|
1603
|
-
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
1604
|
-
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
1605
|
-
job.noProgressCount = 0
|
|
1606
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1607
|
-
await writeState(directory, sessionID, state)
|
|
1608
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1609
|
-
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
1610
|
-
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
1614
|
-
const state = await readState(directory, sessionID)
|
|
1615
|
-
const job = pickGoalJob(state, args.target)
|
|
1616
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1617
|
-
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
1618
|
-
job.goalStatus = "blocked"
|
|
1619
|
-
job.enabled = false
|
|
1620
|
-
job.paused = true
|
|
1621
|
-
job.goalBlockedAt = now()
|
|
1622
|
-
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
1623
|
-
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1624
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1625
|
-
await writeState(directory, sessionID, state)
|
|
1626
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1627
|
-
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
1628
|
-
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
1632
|
-
const state = await readState(directory, sessionID)
|
|
1633
|
-
const job = pickGoalJob(state, args.target)
|
|
1634
|
-
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1635
|
-
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
1636
|
-
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
1637
|
-
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
1638
|
-
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1639
|
-
job.noProgressCount = 0
|
|
1640
|
-
job.lastProgressAt = now()
|
|
1641
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1642
|
-
await writeState(directory, sessionID, state)
|
|
1643
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1644
|
-
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
1645
|
-
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
async function runGoalChecks(directory, sessionID, job, client) {
|
|
1649
|
-
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
1650
|
-
const results = []
|
|
1651
|
-
for (const command of job.goalChecks) {
|
|
1652
|
-
if (job.safe && dangerousShell(command)) {
|
|
1653
|
-
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
1654
|
-
continue
|
|
1655
|
-
}
|
|
1656
|
-
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
1657
|
-
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
1658
|
-
}
|
|
1659
|
-
job.lastGoalCheckAt = now()
|
|
1660
|
-
job.lastGoalChecks = results
|
|
1661
|
-
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
1662
|
-
if (allPassed) {
|
|
1663
|
-
job.goalChecksPassedAt = now()
|
|
1664
|
-
job.failureCount = 0
|
|
1665
|
-
await toast(client, "Goal checks passed.", "success")
|
|
1666
|
-
if (job.goalCompleteWhenChecksPass) {
|
|
1667
|
-
job.goalStatus = "completed"
|
|
1668
|
-
job.enabled = false
|
|
1669
|
-
job.paused = true
|
|
1670
|
-
job.goalCompletedAt = now()
|
|
1671
|
-
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
1672
|
-
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
1673
|
-
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
1674
|
-
}
|
|
1675
|
-
} else {
|
|
1676
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1677
|
-
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
1678
|
-
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
1679
|
-
}
|
|
1680
|
-
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
1681
|
-
return job
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1685
|
-
const active = activeRuns.get(sessionID)
|
|
1686
|
-
if (!active) return
|
|
1687
|
-
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1688
|
-
const recoveredStale = staleActiveRun(sessionID)
|
|
1689
|
-
clearActiveRun(sessionID)
|
|
1690
|
-
const state = await readState(directory, sessionID)
|
|
1691
|
-
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1692
|
-
if (!job) return
|
|
1693
|
-
job.lastFinishedAt = now()
|
|
1694
|
-
if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
|
|
1695
|
-
|
|
1696
|
-
if (job.verifyCommand) {
|
|
1697
|
-
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
1698
|
-
job.lastVerifyAt = now()
|
|
1699
|
-
job.lastVerifyCode = verify.code
|
|
1700
|
-
if (verify.code === 0) {
|
|
1701
|
-
job.failureCount = 0
|
|
1702
|
-
job.lastVerifyFailure = ""
|
|
1703
|
-
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
1704
|
-
} else {
|
|
1705
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1706
|
-
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
1707
|
-
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
1708
|
-
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
1709
|
-
job.paused = true
|
|
1710
|
-
await notifyJob(directory, job, "verify_failed")
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 })
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
if (job.postrunCommand) {
|
|
1717
|
-
if (job.safe && dangerousShell(job.postrunCommand)) await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand })
|
|
1718
|
-
else {
|
|
1719
|
-
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
1720
|
-
job.lastPostrunCode = postrun.code
|
|
1721
|
-
job.lastPostrunAt = now()
|
|
1722
|
-
if (postrun.code !== 0) {
|
|
1723
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1724
|
-
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
1725
|
-
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1726
|
-
job.paused = true
|
|
1727
|
-
await notifyJob(directory, job, "postrun_failed")
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code })
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
if (isGoalJob(job)) {
|
|
1735
|
-
job = await runGoalChecks(directory, sessionID, job, client)
|
|
1736
|
-
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
1737
|
-
}
|
|
1738
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1739
|
-
await writeState(directory, sessionID, state)
|
|
1740
|
-
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
1741
|
-
await createCheckpoint(directory, sessionID, job, client)
|
|
1742
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1743
|
-
return true
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
async function fireAction(directory, client, sessionID, job) {
|
|
1747
|
-
const action = String(job.action || "").trim()
|
|
1748
|
-
const kind = actionKind(action, job)
|
|
1749
|
-
const agent = job.agent || "build"
|
|
1750
|
-
const model = normalizedModelRef(job.model)
|
|
1751
|
-
if (kind === "compact") {
|
|
1752
|
-
const ok = await compactSession(client, sessionID)
|
|
1753
|
-
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
|
|
1754
|
-
}
|
|
1755
|
-
if (kind === "command") {
|
|
1756
|
-
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
1757
|
-
const [command, argumentsText] = splitFirst(normalized)
|
|
1758
|
-
if (!command) {
|
|
1759
|
-
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
1760
|
-
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
1761
|
-
}
|
|
1762
|
-
const tuiCommand = compactTuiCommandName(command)
|
|
1763
|
-
if (tuiCommand) {
|
|
1764
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1765
|
-
await compactSession(client, sessionID)
|
|
1766
|
-
return { startsAssistantTurn: true }
|
|
1767
|
-
}
|
|
1768
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1769
|
-
const commandBody = { command, arguments: argumentsText, agent }
|
|
1770
|
-
if (model) commandBody.model = `${model.providerID}/${model.modelID}`
|
|
1771
|
-
await sdkCall(
|
|
1772
|
-
client.session.command.bind(client.session),
|
|
1773
|
-
{ path: { id: sessionID }, body: commandBody },
|
|
1774
|
-
{ path: { sessionID }, body: commandBody },
|
|
1775
|
-
{ sessionID, ...commandBody },
|
|
1776
|
-
)
|
|
1777
|
-
return { startsAssistantTurn: true }
|
|
1778
|
-
}
|
|
1779
|
-
if (kind === "shell") {
|
|
1780
|
-
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
1781
|
-
if (job.safe && dangerousShell(command)) {
|
|
1782
|
-
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
1783
|
-
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1784
|
-
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
1785
|
-
}
|
|
1786
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1787
|
-
const shellBody = { command, agent }
|
|
1788
|
-
if (model) shellBody.model = model
|
|
1789
|
-
fireSdk(
|
|
1790
|
-
client,
|
|
1791
|
-
"session.shell",
|
|
1792
|
-
client.session.shell.bind(client.session),
|
|
1793
|
-
{ path: { id: sessionID }, body: shellBody },
|
|
1794
|
-
{ path: { sessionID }, body: shellBody },
|
|
1795
|
-
{ sessionID, ...shellBody },
|
|
1796
|
-
)
|
|
1797
|
-
return { startsAssistantTurn: true }
|
|
1798
|
-
}
|
|
1799
|
-
const prompt = await buildPrompt(directory, job)
|
|
1800
|
-
const prefix = kind === "goal"
|
|
1801
|
-
? "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."
|
|
1802
|
-
: "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."
|
|
1803
|
-
const promptText = `${prefix}
|
|
1804
|
-
|
|
1805
|
-
${prompt}`
|
|
1806
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1807
|
-
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
1808
|
-
if (model) promptBody.model = model
|
|
1809
|
-
fireSdk(
|
|
1810
|
-
client,
|
|
1811
|
-
"session.prompt",
|
|
1812
|
-
client.session.prompt.bind(client.session),
|
|
1813
|
-
{ path: { id: sessionID }, body: promptBody },
|
|
1814
|
-
{ path: { sessionID }, body: promptBody },
|
|
1815
|
-
{ sessionID, ...promptBody },
|
|
1816
|
-
)
|
|
1817
|
-
return { startsAssistantTurn: true }
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1821
|
-
rememberSession(directory, client, sessionID)
|
|
1822
|
-
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1823
|
-
|
|
1824
|
-
if (runLocks.has(sessionID)) {
|
|
1825
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1826
|
-
return
|
|
1827
|
-
}
|
|
1828
|
-
runLocks.set(sessionID, now())
|
|
1829
|
-
let job
|
|
1830
|
-
try {
|
|
1831
|
-
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
1832
|
-
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1833
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1834
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1835
|
-
return
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
const active = activeRuns.get(sessionID)
|
|
1839
|
-
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1840
|
-
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1841
|
-
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1842
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1843
|
-
return
|
|
1844
|
-
}
|
|
1845
|
-
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1846
|
-
|
|
1847
|
-
const state = await readState(directory, sessionID)
|
|
1848
|
-
for (const candidate of state.jobs || []) {
|
|
1849
|
-
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.watchTriggered = true
|
|
1850
|
-
}
|
|
1851
|
-
const due = dueJobs(state, options.force)
|
|
1852
|
-
if (!due.length) {
|
|
1853
|
-
await writeState(directory, sessionID, state)
|
|
1854
|
-
await reschedule()
|
|
1855
|
-
return
|
|
1856
|
-
}
|
|
1857
|
-
job = due[0]
|
|
1858
|
-
|
|
1859
|
-
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1860
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1861
|
-
await writeState(directory, sessionID, state)
|
|
1862
|
-
await notifyJob(directory, job, "max_runtime_reached")
|
|
1863
|
-
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1864
|
-
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1865
|
-
await reschedule()
|
|
1866
|
-
return
|
|
1867
|
-
}
|
|
1868
|
-
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1869
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1870
|
-
await writeState(directory, sessionID, state)
|
|
1871
|
-
await notifyJob(directory, job, "stop_file")
|
|
1872
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1873
|
-
await reschedule()
|
|
1874
|
-
return
|
|
1875
|
-
}
|
|
1876
|
-
if (await untilReached(directory, job)) {
|
|
1877
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1878
|
-
await writeState(directory, sessionID, state)
|
|
1879
|
-
await notifyJob(directory, job, "until_reached")
|
|
1880
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1881
|
-
await reschedule()
|
|
1882
|
-
return
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
if (job.preflightCommand) {
|
|
1886
|
-
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1887
|
-
job.paused = true
|
|
1888
|
-
await writeState(directory, sessionID, state)
|
|
1889
|
-
await notifyJob(directory, job, "preflight_blocked")
|
|
1890
|
-
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1891
|
-
await reschedule()
|
|
1892
|
-
return
|
|
1893
|
-
}
|
|
1894
|
-
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1895
|
-
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1896
|
-
if (preflight.code !== 0) {
|
|
1897
|
-
job.paused = true
|
|
1898
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1899
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1900
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1901
|
-
await writeState(directory, sessionID, state)
|
|
1902
|
-
await notifyJob(directory, job, "preflight_failed")
|
|
1903
|
-
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1904
|
-
await reschedule()
|
|
1905
|
-
return
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
job = await ensureBranch(directory, job, client, sessionID)
|
|
1910
|
-
job = await maybeCompact(client, sessionID, job)
|
|
1911
|
-
job.watchTriggered = false
|
|
1912
|
-
job.lastRunAt = now()
|
|
1913
|
-
job.runCount = (job.runCount || 0) + 1
|
|
1914
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1915
|
-
job.enabled = false
|
|
1916
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
1917
|
-
}
|
|
1918
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1919
|
-
await writeState(directory, sessionID, state)
|
|
1920
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1921
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1922
|
-
|
|
1923
|
-
try {
|
|
1924
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
1925
|
-
if (!result.startsAssistantTurn) {
|
|
1926
|
-
const fresh = await readState(directory, sessionID)
|
|
1927
|
-
if (result.pause) {
|
|
1928
|
-
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
1929
|
-
...candidate,
|
|
1930
|
-
paused: true,
|
|
1931
|
-
failureCount: (candidate.failureCount || 0) + 1,
|
|
1932
|
-
lastFailureReason: result.reason || "action_did_not_start",
|
|
1933
|
-
} : candidate)
|
|
1934
|
-
}
|
|
1935
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1936
|
-
await writeState(directory, sessionID, fresh)
|
|
1937
|
-
await reschedule()
|
|
1938
|
-
return
|
|
1939
|
-
}
|
|
1940
|
-
let timer
|
|
1941
|
-
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)
|
|
1942
|
-
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1943
|
-
sessionStatuses.set(sessionID, "busy")
|
|
1944
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1945
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1946
|
-
} catch (error) {
|
|
1947
|
-
clearActiveRun(sessionID)
|
|
1948
|
-
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1949
|
-
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1950
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1951
|
-
}
|
|
1952
|
-
} finally {
|
|
1953
|
-
runLocks.delete(sessionID)
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
function normalizeActionForCompare(value) {
|
|
1958
|
-
return String(value || "").replace(/\s+/g, " ").trim()
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
function sameLoopDefinition(a, b) {
|
|
1962
|
-
if (!a || !b) return false
|
|
1963
|
-
return (a.name || "") === (b.name || "") &&
|
|
1964
|
-
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
1965
|
-
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
1966
|
-
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
1967
|
-
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1971
|
-
const parsed = parseLoopArgs(args, defaults)
|
|
1972
|
-
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1973
|
-
const executionContext = sessionExecutionContexts.get(sessionID) || { agent: "build" }
|
|
1974
|
-
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
1975
|
-
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
1976
|
-
if (defaults.testfixPreset) {
|
|
1977
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
1978
|
-
const parsedAction = String(parsed.job.action || "").trim()
|
|
1979
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
1980
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
1981
|
-
parsed.job.verifyCommand = parsedAction
|
|
1982
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
1983
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
1984
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1988
|
-
if (!parsed.job.activeRecoveryMs) {
|
|
1989
|
-
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
1990
|
-
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
1991
|
-
: Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1992
|
-
}
|
|
1993
|
-
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 }
|
|
1994
|
-
const state = await readState(directory, sessionID)
|
|
1995
|
-
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
1996
|
-
|
|
1997
|
-
// Default behavior is replace/upsert, not append forever. This prevents duplicate
|
|
1998
|
-
// loops when OpenCode emits both command.execute.before and command.executed,
|
|
1999
|
-
// and it matches the common expectation that /loop configures the current loop.
|
|
2000
|
-
let replaced = false
|
|
2001
|
-
if (!parsed.job.multi) {
|
|
2002
|
-
const targetName = parsed.job.name || "default"
|
|
2003
|
-
parsed.job.name = targetName
|
|
2004
|
-
state.jobs = jobs.filter((existing) => {
|
|
2005
|
-
const existingName = existing.name || "default"
|
|
2006
|
-
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
2007
|
-
if (shouldReplace) replaced = true
|
|
2008
|
-
return !shouldReplace
|
|
2009
|
-
})
|
|
2010
|
-
} else {
|
|
2011
|
-
state.jobs = jobs
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
state.jobs.push(parsed.job)
|
|
2015
|
-
await writeState(directory, sessionID, state)
|
|
2016
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2017
|
-
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
2018
|
-
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
2019
|
-
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
async function stopLoop(directory, client, sessionID, args) {
|
|
2023
|
-
const target = String(args || "").trim()
|
|
2024
|
-
if (!target || target.toLowerCase() === "all") {
|
|
2025
|
-
await removeState(directory, sessionID)
|
|
2026
|
-
clearActiveRun(sessionID)
|
|
2027
|
-
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
2028
|
-
stopWatchdog(sessionID)
|
|
2029
|
-
await toast(client, "All loops stopped for this session.", "success")
|
|
2030
|
-
return
|
|
2031
|
-
}
|
|
2032
|
-
const state = await readState(directory, sessionID)
|
|
2033
|
-
const before = state.jobs.length
|
|
2034
|
-
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
2035
|
-
await writeState(directory, sessionID, state)
|
|
2036
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2037
|
-
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
2041
|
-
const target = String(args || "").trim() || "all"
|
|
2042
|
-
const state = await readState(directory, sessionID)
|
|
2043
|
-
let count = 0
|
|
2044
|
-
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
2045
|
-
await writeState(directory, sessionID, state)
|
|
2046
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2047
|
-
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
async function statusGoal(directory, client, sessionID) {
|
|
2051
|
-
const state = await readState(directory, sessionID)
|
|
2052
|
-
const goals = (state.jobs || []).filter(isGoalJob)
|
|
2053
|
-
const lines = goals.length ? goals.map((job, index) => {
|
|
2054
|
-
const status = goalStatusText(job)
|
|
2055
|
-
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
2056
|
-
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
2057
|
-
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
2058
|
-
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
2059
|
-
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
2060
|
-
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}`
|
|
2061
|
-
}) : ["No experimental goal jobs."]
|
|
2062
|
-
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
2063
|
-
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
async function pauseGoal(directory, client, sessionID, args) {
|
|
2067
|
-
const target = String(args || "").trim() || "goal"
|
|
2068
|
-
const state = await readState(directory, sessionID)
|
|
2069
|
-
let count = 0
|
|
2070
|
-
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
2071
|
-
await writeState(directory, sessionID, state)
|
|
2072
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2073
|
-
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
async function resumeGoal(directory, client, sessionID, args) {
|
|
2077
|
-
const target = String(args || "").trim() || "goal"
|
|
2078
|
-
const state = await readState(directory, sessionID)
|
|
2079
|
-
let count = 0
|
|
2080
|
-
state.jobs = (state.jobs || []).map((job, index) => {
|
|
2081
|
-
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
2082
|
-
count++
|
|
2083
|
-
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
|
|
2084
|
-
})
|
|
2085
|
-
await writeState(directory, sessionID, state)
|
|
2086
|
-
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
2087
|
-
if (count) {
|
|
2088
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2089
|
-
scheduleIdleWork(directory, client, sessionID)
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
async function clearGoal(directory, client, sessionID, args) {
|
|
2094
|
-
const target = String(args || "").trim()
|
|
2095
|
-
const state = await readState(directory, sessionID)
|
|
2096
|
-
const before = state.jobs.length
|
|
2097
|
-
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
2098
|
-
await writeState(directory, sessionID, state)
|
|
2099
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2100
|
-
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
2104
|
-
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
2105
|
-
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
2109
|
-
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
2110
|
-
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
async function addGoal(directory, client, sessionID, args) {
|
|
2114
|
-
const text = String(args || "").trim()
|
|
2115
|
-
const [maybeCommand, rest] = splitFirst(text)
|
|
2116
|
-
const sub = maybeCommand.toLowerCase()
|
|
2117
|
-
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
2118
|
-
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
2119
|
-
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
2120
|
-
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
2121
|
-
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
2122
|
-
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
2123
|
-
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
async function statusLoop(directory, client, sessionID) {
|
|
2127
|
-
const state = await readState(directory, sessionID)
|
|
2128
|
-
const jobs = state.jobs || []
|
|
2129
|
-
const lines = jobs.length ? jobs.map((job, index) => {
|
|
2130
|
-
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
2131
|
-
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(",")
|
|
2132
|
-
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
2133
|
-
}) : ["No active loop jobs."]
|
|
2134
|
-
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
2135
|
-
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
async function logsLoop(directory, client, sessionID) {
|
|
2139
|
-
let text = "No loop log found."
|
|
2140
|
-
try { text = (await fs.readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
2141
|
-
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
async function helpLoop(client, sessionID) {
|
|
2145
|
-
await say(client, sessionID, [
|
|
2146
|
-
"OpenCode Loop help:",
|
|
2147
|
-
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
2148
|
-
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
2149
|
-
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
2150
|
-
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
2151
|
-
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
2152
|
-
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
2153
|
-
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
2154
|
-
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
2155
|
-
"/loop 200m --command /compact same as command loop",
|
|
2156
|
-
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
2157
|
-
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
2158
|
-
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
2159
|
-
"/loop-doctor | /loop-init | /loop-export",
|
|
2160
|
-
].join("\n"))
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
async function runNow(directory, client, sessionID, args) {
|
|
2164
|
-
const target = String(args || "").trim() || "all"
|
|
2165
|
-
const state = await readState(directory, sessionID)
|
|
2166
|
-
let count = 0
|
|
2167
|
-
for (const [index, job] of (state.jobs || []).entries()) if (matchJob(job, target, index)) { job.lastRunAt = 0; job.paused = false; count++ }
|
|
2168
|
-
await writeState(directory, sessionID, state)
|
|
2169
|
-
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
2170
|
-
await maybeRunDueJobs(directory, client, sessionID, { force: true })
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
async function doctorLoop(directory, client, sessionID) {
|
|
2174
|
-
const state = await readState(directory, sessionID)
|
|
2175
|
-
await say(client, sessionID, [
|
|
2176
|
-
"OpenCode Loop doctor:",
|
|
2177
|
-
`- plugin: ${SERVICE}`,
|
|
2178
|
-
`- project directory: ${directory}`,
|
|
2179
|
-
`- state directory: ${stateDir(directory)}`,
|
|
2180
|
-
`- active jobs: ${(state.jobs || []).length}`,
|
|
2181
|
-
`- node: ${process.version}`,
|
|
2182
|
-
`- platform: ${process.platform}`,
|
|
2183
|
-
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
2184
|
-
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
2185
|
-
].join("\n"))
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
async function initLoop(directory, client, sessionID, args) {
|
|
2189
|
-
const target = String(args || "").trim() || "progress.md"
|
|
2190
|
-
const full = path.resolve(directory, target)
|
|
2191
|
-
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
2192
|
-
await fs.writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
2193
|
-
await toast(client, `Created ${target}.`, "success")
|
|
2194
|
-
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
async function exportLoop(directory, client, sessionID) {
|
|
2198
|
-
const state = await readState(directory, sessionID)
|
|
2199
|
-
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
2203
|
-
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
2204
|
-
const sessionID = input?.sessionID
|
|
2205
|
-
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
2206
|
-
if (!sessionID || !name) return false
|
|
2207
|
-
rememberSession(directory, client, sessionID)
|
|
2208
|
-
if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
|
|
2209
|
-
if (source === "event") {
|
|
2210
|
-
if (consumeHandled(sessionID, name, args)) return true
|
|
2211
|
-
const eventKey = commandEventKey(sessionID, input?.messageID)
|
|
2212
|
-
if (handledCommandEvents.has(eventKey)) return true
|
|
2213
|
-
handledCommandEvents.set(eventKey, now())
|
|
2214
|
-
} else {
|
|
2215
|
-
// Every before-hook invocation is an intentional command. Keep a pending
|
|
2216
|
-
// count only so the matching command.executed compatibility event can be
|
|
2217
|
-
// consumed without suppressing a genuine repeated command.
|
|
2218
|
-
markHandled(sessionID, name, args)
|
|
2219
|
-
}
|
|
2220
|
-
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
2221
|
-
|
|
2222
|
-
const handled = () => {
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
if (name === "loop-goal") return await
|
|
2233
|
-
if (name === "loop-goal-
|
|
2234
|
-
if (name === "loop-goal-
|
|
2235
|
-
if (name === "loop-goal-
|
|
2236
|
-
if (name === "loop
|
|
2237
|
-
if (name
|
|
2238
|
-
if (name === "loop-
|
|
2239
|
-
if (name === "loop") return await
|
|
2240
|
-
if (
|
|
2241
|
-
if (name === "loop-
|
|
2242
|
-
if (name === "loop-
|
|
2243
|
-
if (name === "loop-
|
|
2244
|
-
if (name === "loop-
|
|
2245
|
-
if (name === "loop-
|
|
2246
|
-
if (name === "loop-
|
|
2247
|
-
if (name === "loop-
|
|
2248
|
-
if (name === "loop-
|
|
2249
|
-
if (
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
tool:
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
export default OpenCodeLoopPlugin
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { spawn } from "node:child_process"
|
|
5
|
+
import { tool } from "@opencode-ai/plugin/tool"
|
|
6
|
+
|
|
7
|
+
const SERVICE = "opencode-loop"
|
|
8
|
+
const STATE_DIR = ".opencode/opencode-loop"
|
|
9
|
+
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
10
|
+
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
11
|
+
const IDLE_DEBOUNCE_MS = 1_200
|
|
12
|
+
const BUSY_RETRY_MS = 5_000
|
|
13
|
+
const SESSION_STATUS_CACHE_MS = 1_500
|
|
14
|
+
const MIN_DUE_TIMER_MS = 250
|
|
15
|
+
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
16
|
+
const HEARTBEAT_MS = 2_500
|
|
17
|
+
const MAX_SCAN_FILES = 200
|
|
18
|
+
const MAX_SCAN_BYTES = 2_000_000
|
|
19
|
+
const GOAL_REPORT_DIR = "goals"
|
|
20
|
+
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
21
|
+
const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
|
|
22
|
+
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
23
|
+
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
24
|
+
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
25
|
+
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
26
|
+
|
|
27
|
+
const activeRuns = new Map()
|
|
28
|
+
const handledCommands = new Map()
|
|
29
|
+
const handledCommandEvents = new Map()
|
|
30
|
+
const sessionExecutionContexts = new Map()
|
|
31
|
+
const loopOwnedUserMessageGuards = new Map()
|
|
32
|
+
const idleTimers = new Map()
|
|
33
|
+
const dueTimers = new Map()
|
|
34
|
+
const watchdogTimers = new Map()
|
|
35
|
+
const runLocks = new Map()
|
|
36
|
+
const knownSessions = new Map()
|
|
37
|
+
const stateWriteLocks = 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
|
+
|
|
44
|
+
const DEFAULT_PROGRESS_MD = `# Progress
|
|
45
|
+
|
|
46
|
+
## Current Goal
|
|
47
|
+
Describe the current project goal here.
|
|
48
|
+
|
|
49
|
+
## Agent Rules
|
|
50
|
+
- Do not ask questions unless truly blocked.
|
|
51
|
+
- Make reasonable assumptions and continue.
|
|
52
|
+
- Work on unfinished TODOs in order.
|
|
53
|
+
- Mark completed TODOs with [x].
|
|
54
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
55
|
+
- Run tests, lint, or build when available.
|
|
56
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
57
|
+
|
|
58
|
+
## Active TODO
|
|
59
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
60
|
+
|
|
61
|
+
## Completed
|
|
62
|
+
- [x] Created progress.md.
|
|
63
|
+
|
|
64
|
+
## Backlog Ideas
|
|
65
|
+
- [ ] Add more project-specific tasks here.
|
|
66
|
+
|
|
67
|
+
## Blocked
|
|
68
|
+
- None.
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
function now() {
|
|
72
|
+
return Date.now()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeID(value) {
|
|
76
|
+
return String(value || "job")
|
|
77
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
78
|
+
.replace(/^-+|-+$/g, "")
|
|
79
|
+
.slice(0, 80) || "job"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseDuration(value) {
|
|
83
|
+
const input = String(value || "").trim()
|
|
84
|
+
if (input === "0") return 0
|
|
85
|
+
const match = input.match(/^(\d+)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i)
|
|
86
|
+
if (!match) return null
|
|
87
|
+
const amount = Number.parseInt(match[1], 10)
|
|
88
|
+
const unit = match[2].toLowerCase()
|
|
89
|
+
if (!Number.isFinite(amount) || amount < 0) return null
|
|
90
|
+
if (unit === "ms") return amount
|
|
91
|
+
if (unit.startsWith("s")) return amount * 1000
|
|
92
|
+
if (unit.startsWith("m")) return amount * 60_000
|
|
93
|
+
if (unit.startsWith("h")) return amount * 3_600_000
|
|
94
|
+
if (unit.startsWith("d")) return amount * 86_400_000
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function durationToText(ms) {
|
|
99
|
+
if (ms === 0) return "every idle"
|
|
100
|
+
if (!Number.isFinite(ms)) return "unknown"
|
|
101
|
+
if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`
|
|
102
|
+
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`
|
|
103
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`
|
|
104
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`
|
|
105
|
+
return `${ms}ms`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function splitFirst(input) {
|
|
109
|
+
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/)
|
|
110
|
+
if (!match) return ["", ""]
|
|
111
|
+
return [match[1], (match[2] || "").trim()]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stripOuterQuotes(value) {
|
|
115
|
+
const input = String(value || "").trim()
|
|
116
|
+
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
|
|
117
|
+
return input.slice(1, -1)
|
|
118
|
+
}
|
|
119
|
+
return input
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function escapeRegExp(value) {
|
|
123
|
+
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function takeFlag(rest, flag) {
|
|
127
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i")
|
|
128
|
+
const found = pattern.test(rest)
|
|
129
|
+
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function takeFlagValue(rest, flag) {
|
|
133
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(?:\"([^\"]*)\"|'([^']*)'|(\\S+))`, "i")
|
|
134
|
+
const match = rest.match(pattern)
|
|
135
|
+
if (!match) return [undefined, rest]
|
|
136
|
+
const value = match[2] ?? match[3] ?? match[4]
|
|
137
|
+
return [value, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function takeAllFlagValues(rest, flag) {
|
|
141
|
+
const values = []
|
|
142
|
+
let current = rest
|
|
143
|
+
while (true) {
|
|
144
|
+
const [value, next] = takeFlagValue(current, flag)
|
|
145
|
+
if (value === undefined) return [values, current]
|
|
146
|
+
values.push(value)
|
|
147
|
+
current = next
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parsePositiveInt(value, fallback = 0) {
|
|
152
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
153
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseNonNegativeInt(value, fallback = 0) {
|
|
157
|
+
const parsed = Number.parseInt(String(value || ""), 10)
|
|
158
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseCompactEvery(value) {
|
|
162
|
+
const duration = parseDuration(value)
|
|
163
|
+
if (duration !== null) return { compactEveryMs: duration }
|
|
164
|
+
const runs = parsePositiveInt(value, 0)
|
|
165
|
+
return runs > 0 ? { compactEveryRuns: runs } : {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseLoopArgs(raw, defaults = {}) {
|
|
169
|
+
let input = stripOuterQuotes(String(raw || "").trim())
|
|
170
|
+
let first = ""
|
|
171
|
+
let rest = input
|
|
172
|
+
let intervalMs = defaults.intervalMs ?? null
|
|
173
|
+
|
|
174
|
+
if (!input && defaults.action) {
|
|
175
|
+
rest = defaults.action
|
|
176
|
+
} else {
|
|
177
|
+
;[first, rest] = splitFirst(input)
|
|
178
|
+
if (first === "--watch") {
|
|
179
|
+
intervalMs = defaults.intervalMs ?? 0
|
|
180
|
+
rest = input
|
|
181
|
+
} else if (first) {
|
|
182
|
+
const parsedDuration = parseDuration(first)
|
|
183
|
+
if (parsedDuration !== null) intervalMs = parsedDuration
|
|
184
|
+
else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
|
|
185
|
+
else rest = input
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (intervalMs === null) intervalMs = 0
|
|
190
|
+
|
|
191
|
+
const job = {
|
|
192
|
+
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
193
|
+
name: defaults.name,
|
|
194
|
+
action: defaults.action || "",
|
|
195
|
+
kind: defaults.kind || undefined,
|
|
196
|
+
intervalMs,
|
|
197
|
+
immediate: defaults.immediate ?? true,
|
|
198
|
+
maxRuns: defaults.maxRuns ?? 0,
|
|
199
|
+
maxRuntimeMs: defaults.maxRuntimeMs ?? 0,
|
|
200
|
+
maxFailures: defaults.maxFailures ?? 0,
|
|
201
|
+
timeoutMs: defaults.timeoutMs ?? 0,
|
|
202
|
+
until: defaults.until,
|
|
203
|
+
stopFile: defaults.stopFile,
|
|
204
|
+
progressFile: defaults.progressFile,
|
|
205
|
+
promptFile: defaults.promptFile,
|
|
206
|
+
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
207
|
+
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
208
|
+
compactEveryRuns: defaults.compactEveryRuns ?? 0,
|
|
209
|
+
compactEveryMs: defaults.compactEveryMs ?? 0,
|
|
210
|
+
testCommand: defaults.testCommand,
|
|
211
|
+
verifyCommand: defaults.verifyCommand,
|
|
212
|
+
preflightCommand: defaults.preflightCommand,
|
|
213
|
+
postrunCommand: defaults.postrunCommand,
|
|
214
|
+
notifyCommand: defaults.notifyCommand,
|
|
215
|
+
branch: defaults.branch,
|
|
216
|
+
branchDone: false,
|
|
217
|
+
goalStatus: defaults.goalStatus,
|
|
218
|
+
goalFile: defaults.goalFile,
|
|
219
|
+
goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
|
|
220
|
+
goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
|
|
221
|
+
goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
|
|
222
|
+
goalRequireEvidence: defaults.goalRequireEvidence,
|
|
223
|
+
goalRequireChecksPass: defaults.goalRequireChecksPass,
|
|
224
|
+
goalEvidenceFile: defaults.goalEvidenceFile,
|
|
225
|
+
goalSummary: defaults.goalSummary || "",
|
|
226
|
+
goalEvidence: defaults.goalEvidence || "",
|
|
227
|
+
goalBlockedReason: defaults.goalBlockedReason || "",
|
|
228
|
+
goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
|
|
229
|
+
maxNoProgress: defaults.maxNoProgress,
|
|
230
|
+
noProgressCount: defaults.noProgressCount ?? 0,
|
|
231
|
+
lastProgressAt: defaults.lastProgressAt ?? 0,
|
|
232
|
+
noOverlap: defaults.noOverlap ?? true,
|
|
233
|
+
safe: defaults.safe ?? false,
|
|
234
|
+
quiet: defaults.quiet ?? false,
|
|
235
|
+
askNever: defaults.askNever ?? false,
|
|
236
|
+
pauseOnVerifyFail: defaults.pauseOnVerifyFail ?? false,
|
|
237
|
+
gitCheckpoint: defaults.gitCheckpoint ?? false,
|
|
238
|
+
checkpointOnly: defaults.checkpointOnly ?? false,
|
|
239
|
+
dryRun: defaults.dryRun ?? false,
|
|
240
|
+
multi: defaults.multi ?? false,
|
|
241
|
+
batch: defaults.batch ?? 0,
|
|
242
|
+
runCount: 0,
|
|
243
|
+
failureCount: 0,
|
|
244
|
+
lastRunAt: 0,
|
|
245
|
+
lastCompactAt: 0,
|
|
246
|
+
lastCompactRunCount: 0,
|
|
247
|
+
watchSnapshot: {},
|
|
248
|
+
watchTriggered: false,
|
|
249
|
+
createdAt: new Date().toISOString(),
|
|
250
|
+
enabled: true,
|
|
251
|
+
paused: false,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let found
|
|
255
|
+
let value
|
|
256
|
+
|
|
257
|
+
;[found, rest] = takeFlag(rest, "--no-now"); if (found) job.immediate = false
|
|
258
|
+
;[found, rest] = takeFlag(rest, "--now"); if (found) job.immediate = true
|
|
259
|
+
;[found, rest] = takeFlag(rest, "--no-overlap"); if (found) job.noOverlap = true
|
|
260
|
+
;[found, rest] = takeFlag(rest, "--allow-overlap"); if (found) job.noOverlap = false
|
|
261
|
+
;[found, rest] = takeFlag(rest, "--safe"); if (found) job.safe = true
|
|
262
|
+
;[found, rest] = takeFlag(rest, "--quiet"); if (found) job.quiet = true
|
|
263
|
+
;[found, rest] = takeFlag(rest, "--ask-never"); if (found) job.askNever = true
|
|
264
|
+
;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
|
|
265
|
+
;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
|
|
266
|
+
;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
|
|
267
|
+
;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
|
|
268
|
+
;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
|
|
269
|
+
;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
|
|
270
|
+
;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
|
|
271
|
+
;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
|
|
272
|
+
;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
|
|
273
|
+
;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
|
|
274
|
+
;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
|
|
275
|
+
;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
|
|
276
|
+
;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
|
|
277
|
+
;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
|
|
278
|
+
;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
|
|
279
|
+
;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
|
|
280
|
+
;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
|
|
281
|
+
;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
|
|
282
|
+
;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
|
|
283
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
|
|
284
|
+
;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
|
|
285
|
+
|
|
286
|
+
;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
|
|
287
|
+
;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
288
|
+
;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
|
|
289
|
+
;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
290
|
+
;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
|
|
291
|
+
;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
|
|
292
|
+
;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
|
|
293
|
+
;[value, rest] = takeFlagValue(rest, "--until"); if (value !== undefined) job.until = stripOuterQuotes(value)
|
|
294
|
+
;[value, rest] = takeFlagValue(rest, "--stop-file"); if (value !== undefined) job.stopFile = stripOuterQuotes(value)
|
|
295
|
+
;[value, rest] = takeFlagValue(rest, "--progress-file"); if (value !== undefined) job.progressFile = stripOuterQuotes(value)
|
|
296
|
+
;[value, rest] = takeFlagValue(rest, "--prompt-file"); if (value !== undefined) job.promptFile = stripOuterQuotes(value)
|
|
297
|
+
;[value, rest] = takeFlagValue(rest, "--goal-file"); if (value !== undefined) job.goalFile = stripOuterQuotes(value)
|
|
298
|
+
;[value, rest] = takeFlagValue(rest, "--evidence-file"); if (value !== undefined) job.goalEvidenceFile = stripOuterQuotes(value)
|
|
299
|
+
;[value, rest] = takeFlagValue(rest, "--test"); if (value !== undefined) job.testCommand = stripOuterQuotes(value)
|
|
300
|
+
;[value, rest] = takeFlagValue(rest, "--verify"); if (value !== undefined) job.verifyCommand = stripOuterQuotes(value)
|
|
301
|
+
;[value, rest] = takeFlagValue(rest, "--preflight"); if (value !== undefined) job.preflightCommand = stripOuterQuotes(value)
|
|
302
|
+
;[value, rest] = takeFlagValue(rest, "--postrun"); if (value !== undefined) job.postrunCommand = stripOuterQuotes(value)
|
|
303
|
+
;[value, rest] = takeFlagValue(rest, "--notify"); if (value !== undefined) job.notifyCommand = stripOuterQuotes(value)
|
|
304
|
+
;[value, rest] = takeFlagValue(rest, "--branch"); if (value !== undefined) job.branch = stripOuterQuotes(value)
|
|
305
|
+
;[value, rest] = takeFlagValue(rest, "--batch"); if (value !== undefined) job.batch = parsePositiveInt(value, 0)
|
|
306
|
+
;[value, rest] = takeFlagValue(rest, "--compact-every")
|
|
307
|
+
if (value !== undefined) Object.assign(job, parseCompactEvery(value))
|
|
308
|
+
|
|
309
|
+
const watch = takeAllFlagValues(rest, "--watch")
|
|
310
|
+
job.watchPaths.push(...watch[0].map(stripOuterQuotes).filter(Boolean))
|
|
311
|
+
rest = watch[1]
|
|
312
|
+
|
|
313
|
+
const includes = takeAllFlagValues(rest, "--include-file")
|
|
314
|
+
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean))
|
|
315
|
+
rest = includes[1]
|
|
316
|
+
|
|
317
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance")
|
|
318
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean))
|
|
319
|
+
rest = acceptances[1]
|
|
320
|
+
|
|
321
|
+
const success = takeAllFlagValues(rest, "--success")
|
|
322
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean))
|
|
323
|
+
rest = success[1]
|
|
324
|
+
|
|
325
|
+
const checks = takeAllFlagValues(rest, "--check")
|
|
326
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean))
|
|
327
|
+
rest = checks[1]
|
|
328
|
+
|
|
329
|
+
job.action = stripOuterQuotes(rest || job.action || "")
|
|
330
|
+
job.watchPaths = [...new Set(job.watchPaths)]
|
|
331
|
+
job.includeFiles = [...new Set(job.includeFiles)]
|
|
332
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])]
|
|
333
|
+
job.goalChecks = [...new Set(job.goalChecks || [])]
|
|
334
|
+
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
335
|
+
job.name = job.name || "goal"
|
|
336
|
+
job.goalStatus = job.goalStatus || "active"
|
|
337
|
+
job.safe = job.safe !== false
|
|
338
|
+
job.askNever = job.askNever !== false
|
|
339
|
+
job.noOverlap = job.noOverlap !== false
|
|
340
|
+
job.goalRequireEvidence = job.goalRequireEvidence !== false
|
|
341
|
+
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
|
|
342
|
+
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
|
|
343
|
+
}
|
|
344
|
+
job.lastRunAt = job.immediate ? 0 : now()
|
|
345
|
+
|
|
346
|
+
if (!job.action && !job.promptFile && !job.goalFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" }
|
|
347
|
+
return { ok: true, job }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stateDir(directory) { return path.join(directory, STATE_DIR) }
|
|
351
|
+
function statePath(directory, sessionID) { return path.join(stateDir(directory), `${safeID(sessionID)}.json`) }
|
|
352
|
+
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
353
|
+
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
354
|
+
|
|
355
|
+
function stateLockKey(directory, sessionID) {
|
|
356
|
+
return `${path.resolve(directory)}:${safeID(sessionID)}`
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function withStateWriteLock(directory, sessionID, fn) {
|
|
360
|
+
const key = stateLockKey(directory, sessionID)
|
|
361
|
+
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
362
|
+
let release
|
|
363
|
+
const current = new Promise((resolve) => { release = resolve })
|
|
364
|
+
const next = previous.catch(() => {}).then(() => current)
|
|
365
|
+
stateWriteLocks.set(key, next)
|
|
366
|
+
await previous.catch(() => {})
|
|
367
|
+
try {
|
|
368
|
+
return await fn()
|
|
369
|
+
} finally {
|
|
370
|
+
release()
|
|
371
|
+
if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readState(directory, sessionID) {
|
|
376
|
+
const target = statePath(directory, sessionID)
|
|
377
|
+
const attempts = 5
|
|
378
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
381
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
384
|
+
const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
|
|
385
|
+
if (!transient || attempt === attempts - 1) break
|
|
386
|
+
await delay(25 * (attempt + 1))
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await ensureDir(stateDir(directory))
|
|
391
|
+
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
392
|
+
} catch {}
|
|
393
|
+
return { version: 4, jobs: [] }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isRetriableStateWriteError(error) {
|
|
397
|
+
const code = error?.code
|
|
398
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function delay(ms) {
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Windows often fails POSIX-style "write temp next to target, then rename over it":
|
|
406
|
+
// existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or
|
|
407
|
+
// OpenCode's own snapshotter briefly hold the state file. Temp files next to the
|
|
408
|
+
// target also show up in project git snapshots as *.tmp pathspec noise.
|
|
409
|
+
//
|
|
410
|
+
// Write the payload outside the project first, then replace the target with
|
|
411
|
+
// rename when possible and a copy/unlink fallback with short retries.
|
|
412
|
+
async function writeFileAtomically(target, contents, options = {}) {
|
|
413
|
+
const encoding = options.encoding || "utf8"
|
|
414
|
+
const attempts = Math.max(1, Number(options.attempts) || 5)
|
|
415
|
+
const temp = path.join(
|
|
416
|
+
os.tmpdir(),
|
|
417
|
+
`opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
418
|
+
)
|
|
419
|
+
await fs.writeFile(temp, contents, encoding)
|
|
420
|
+
try {
|
|
421
|
+
let lastError
|
|
422
|
+
// Prefer an atomic rename and retry transient Windows destination locks
|
|
423
|
+
// before falling back to a non-atomic copy/overwrite.
|
|
424
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
425
|
+
try {
|
|
426
|
+
await fs.rename(temp, target)
|
|
427
|
+
return
|
|
428
|
+
} catch (error) {
|
|
429
|
+
lastError = error
|
|
430
|
+
if (error?.code === "EXDEV") break
|
|
431
|
+
if (!isRetriableStateWriteError(error)) throw error
|
|
432
|
+
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
437
|
+
try {
|
|
438
|
+
await fs.copyFile(temp, target)
|
|
439
|
+
return
|
|
440
|
+
} catch (error) {
|
|
441
|
+
lastError = error
|
|
442
|
+
if (!isRetriableStateWriteError(error)) throw error
|
|
443
|
+
if (attempt < attempts - 1) await delay(25 * (attempt + 1))
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await fs.writeFile(target, contents, encoding)
|
|
449
|
+
return
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (lastError && !error.cause) error.cause = lastError
|
|
452
|
+
throw error
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
try { await fs.rm(temp, { force: true }) } catch {}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function writeState(directory, sessionID, state) {
|
|
460
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
461
|
+
await ensureDir(stateDir(directory))
|
|
462
|
+
const target = statePath(directory, sessionID)
|
|
463
|
+
const payload = JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2)
|
|
464
|
+
await writeFileAtomically(target, payload)
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function removeState(directory, sessionID) {
|
|
469
|
+
await withStateWriteLock(directory, sessionID, async () => {
|
|
470
|
+
try { await fs.unlink(statePath(directory, sessionID)) } catch {}
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sdkError(result) {
|
|
475
|
+
if (!result || typeof result !== "object") return undefined
|
|
476
|
+
return result.error || result.error === null ? result.error : undefined
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function sdkData(result) {
|
|
480
|
+
if (!result || typeof result !== "object") return result
|
|
481
|
+
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function sdkErrorMessage(error) {
|
|
485
|
+
if (!error) return "unknown SDK error"
|
|
486
|
+
if (error instanceof Error) return error.message
|
|
487
|
+
if (typeof error === "string") return error
|
|
488
|
+
if (typeof error === "object") {
|
|
489
|
+
if (typeof error.message === "string") return error.message
|
|
490
|
+
if (typeof error.name === "string") return error.name
|
|
491
|
+
try { return JSON.stringify(error).slice(0, 400) } catch {}
|
|
492
|
+
}
|
|
493
|
+
return String(error)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function sdkCall(method, ...argsList) {
|
|
497
|
+
let firstError
|
|
498
|
+
for (const args of argsList) {
|
|
499
|
+
if (args === undefined) continue
|
|
500
|
+
try {
|
|
501
|
+
const result = await method(args)
|
|
502
|
+
const error = sdkError(result)
|
|
503
|
+
if (!error) return sdkData(result)
|
|
504
|
+
firstError = firstError || new Error(sdkErrorMessage(error))
|
|
505
|
+
} catch (error) {
|
|
506
|
+
firstError = firstError || error
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
throw firstError || new Error("SDK call failed without arguments")
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function fireSdk(client, label, method, ...argsList) {
|
|
513
|
+
Promise.resolve()
|
|
514
|
+
.then(() => sdkCall(method, ...argsList))
|
|
515
|
+
.catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function executeTuiCommand(client, command) {
|
|
519
|
+
if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
|
|
520
|
+
return await sdkCall(
|
|
521
|
+
client.tui.executeCommand.bind(client.tui),
|
|
522
|
+
{ body: { command } },
|
|
523
|
+
{ command },
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function compactTuiCommandName(command = "compact") {
|
|
528
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
529
|
+
if (normalized === "compact" || normalized === "summarize") return "session_compact"
|
|
530
|
+
return undefined
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function compactSession(client, sessionID) {
|
|
534
|
+
// OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
|
|
535
|
+
// current builds, while some older docs/examples mention the event value
|
|
536
|
+
// (session.compact). Try the alias first, then the event value, then the
|
|
537
|
+
// session summarize endpoint as a last resort.
|
|
538
|
+
for (const command of ["session.compact", "session_compact"]) {
|
|
539
|
+
try {
|
|
540
|
+
await executeTuiCommand(client, command)
|
|
541
|
+
return true
|
|
542
|
+
} catch (error) {
|
|
543
|
+
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await sdkCall(
|
|
548
|
+
client.session.summarize.bind(client.session),
|
|
549
|
+
{ path: { id: sessionID }, body: {} },
|
|
550
|
+
{ path: { sessionID }, body: {} },
|
|
551
|
+
{ sessionID },
|
|
552
|
+
)
|
|
553
|
+
return true
|
|
554
|
+
} catch (error) {
|
|
555
|
+
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
556
|
+
}
|
|
557
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function log(client, level, message, extra) {
|
|
562
|
+
try {
|
|
563
|
+
await sdkCall(
|
|
564
|
+
client.app.log.bind(client.app),
|
|
565
|
+
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
566
|
+
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
567
|
+
)
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function toast(client, message, variant = "info") {
|
|
572
|
+
try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function guardLoopOwnedUserMessage(sessionID) {
|
|
576
|
+
if (!sessionID) return
|
|
577
|
+
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
578
|
+
current.pending += 1
|
|
579
|
+
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
580
|
+
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
581
|
+
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
582
|
+
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
583
|
+
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
584
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
589
|
+
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
590
|
+
if (!entry || typeof entry !== "object") return false
|
|
591
|
+
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
592
|
+
const id = typeof messageID === "string" ? messageID : ""
|
|
593
|
+
if (id && entry.messageIDs.has(id)) return true
|
|
594
|
+
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
595
|
+
entry.pending -= 1
|
|
596
|
+
if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
597
|
+
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
598
|
+
return true
|
|
599
|
+
}
|
|
600
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
601
|
+
return false
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function say(client, sessionID, text) {
|
|
605
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
606
|
+
try {
|
|
607
|
+
await sdkCall(
|
|
608
|
+
client.session.prompt.bind(client.session),
|
|
609
|
+
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
610
|
+
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
611
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
612
|
+
)
|
|
613
|
+
} catch {}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
617
|
+
function markHandled(sessionID, name, args) {
|
|
618
|
+
const key = commandKey(sessionID, name, args)
|
|
619
|
+
const previous = handledCommands.get(key)
|
|
620
|
+
const pending = previous && now() - previous.time < 30_000 ? previous.pending + 1 : 1
|
|
621
|
+
handledCommands.set(key, { time: now(), pending })
|
|
622
|
+
for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > 30_000) handledCommands.delete(entryKey)
|
|
623
|
+
for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > 30_000) handledCommandEvents.delete(entryKey)
|
|
624
|
+
}
|
|
625
|
+
function consumeHandled(sessionID, name, args) {
|
|
626
|
+
const key = commandKey(sessionID, name, args)
|
|
627
|
+
const entry = handledCommands.get(key)
|
|
628
|
+
if (!entry || now() - entry.time >= 30_000) {
|
|
629
|
+
handledCommands.delete(key)
|
|
630
|
+
return false
|
|
631
|
+
}
|
|
632
|
+
if (entry.pending <= 1) handledCommands.delete(key)
|
|
633
|
+
else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
|
|
634
|
+
return true
|
|
635
|
+
}
|
|
636
|
+
function commandEventKey(sessionID, messageID) {
|
|
637
|
+
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function commandName(name) { return String(name || "") }
|
|
641
|
+
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) }
|
|
642
|
+
function isLoopCommandName(name) {
|
|
643
|
+
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)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function normalizeArgsForKey(args) {
|
|
647
|
+
if (args === undefined || args === null) return ""
|
|
648
|
+
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
649
|
+
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
650
|
+
try { return JSON.stringify(args) } catch { return String(args) }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function commandArgsText(args) {
|
|
654
|
+
if (args === undefined || args === null) return ""
|
|
655
|
+
if (typeof args === "string") return args
|
|
656
|
+
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
657
|
+
if (typeof args === "object") {
|
|
658
|
+
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
659
|
+
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return String(args)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function rememberSession(directory, client, sessionID) {
|
|
666
|
+
if (!sessionID) return
|
|
667
|
+
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
668
|
+
startHeartbeat()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function normalizedModelRef(model) {
|
|
672
|
+
if (typeof model === "string") {
|
|
673
|
+
const separator = model.indexOf("/")
|
|
674
|
+
if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
|
|
675
|
+
return undefined
|
|
676
|
+
}
|
|
677
|
+
const providerID = model?.providerID
|
|
678
|
+
const modelID = model?.modelID || model?.id
|
|
679
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
680
|
+
return { providerID, modelID }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function updateSessionExecutionContext(info) {
|
|
684
|
+
const sessionID = info?.sessionID || info?.id
|
|
685
|
+
if (typeof sessionID !== "string") return
|
|
686
|
+
const previous = sessionExecutionContexts.get(sessionID) || {}
|
|
687
|
+
const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
|
|
688
|
+
const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
|
|
689
|
+
? candidateAgent
|
|
690
|
+
: previous.agent
|
|
691
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
|
|
692
|
+
sessionExecutionContexts.set(sessionID, { agent, model })
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function captureSessionExecutionContext(client, sessionID) {
|
|
696
|
+
if (client?.session?.get) {
|
|
697
|
+
try {
|
|
698
|
+
const info = await sdkCall(
|
|
699
|
+
client.session.get.bind(client.session),
|
|
700
|
+
{ path: { id: sessionID } },
|
|
701
|
+
{ path: { sessionID } },
|
|
702
|
+
{ sessionID },
|
|
703
|
+
)
|
|
704
|
+
updateSessionExecutionContext(info)
|
|
705
|
+
} catch {}
|
|
706
|
+
}
|
|
707
|
+
const context = sessionExecutionContexts.get(sessionID) || {}
|
|
708
|
+
const normalized = { agent: context.agent || "build", model: context.model }
|
|
709
|
+
sessionExecutionContexts.set(sessionID, normalized)
|
|
710
|
+
return normalized
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function hasActiveToolCalls(sessionID) {
|
|
714
|
+
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function markToolCallActive(input) {
|
|
718
|
+
const sessionID = input?.sessionID
|
|
719
|
+
const callID = input?.callID
|
|
720
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
721
|
+
const calls = activeToolCalls.get(sessionID) || new Set()
|
|
722
|
+
calls.add(callID)
|
|
723
|
+
activeToolCalls.set(sessionID, calls)
|
|
724
|
+
sessionStatuses.set(sessionID, "busy")
|
|
725
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function markToolCallFinished(input) {
|
|
729
|
+
const sessionID = input?.sessionID
|
|
730
|
+
const callID = input?.callID
|
|
731
|
+
if (typeof sessionID !== "string" || typeof callID !== "string") return
|
|
732
|
+
const calls = activeToolCalls.get(sessionID)
|
|
733
|
+
if (!calls) return
|
|
734
|
+
calls.delete(callID)
|
|
735
|
+
if (!calls.size) activeToolCalls.delete(sessionID)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function updateSessionRelationship(info, removed = false) {
|
|
739
|
+
const sessionID = info?.id
|
|
740
|
+
if (typeof sessionID !== "string") return
|
|
741
|
+
if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
|
|
742
|
+
else sessionParents.set(sessionID, info.parentID)
|
|
743
|
+
if (!removed) updateSessionExecutionContext(info)
|
|
744
|
+
else sessionExecutionContexts.delete(sessionID)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function updateSessionRelationshipFromEvent(event) {
|
|
748
|
+
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
|
|
749
|
+
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function isDescendantSession(sessionID, ancestorID) {
|
|
753
|
+
const visited = new Set()
|
|
754
|
+
let current = sessionID
|
|
755
|
+
while (sessionParents.has(current) && !visited.has(current)) {
|
|
756
|
+
visited.add(current)
|
|
757
|
+
current = sessionParents.get(current)
|
|
758
|
+
if (current === ancestorID) return true
|
|
759
|
+
}
|
|
760
|
+
return false
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function hasBusyDescendant(sessionID) {
|
|
764
|
+
for (const childID of sessionParents.keys()) {
|
|
765
|
+
if (!isDescendantSession(childID, sessionID)) continue
|
|
766
|
+
const status = sessionStatuses.get(childID)
|
|
767
|
+
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
|
|
768
|
+
}
|
|
769
|
+
return false
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function refreshSessionRelationships(client, directory) {
|
|
773
|
+
if (!client?.session?.list) return
|
|
774
|
+
try {
|
|
775
|
+
const sessions = await sdkCall(
|
|
776
|
+
client.session.list.bind(client.session),
|
|
777
|
+
{ query: { directory } },
|
|
778
|
+
{ directory },
|
|
779
|
+
{},
|
|
780
|
+
)
|
|
781
|
+
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
782
|
+
} catch {}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function updateToolActivityFromEvent(event) {
|
|
786
|
+
const props = event?.properties || {}
|
|
787
|
+
if (event?.type === "message.part.updated") {
|
|
788
|
+
const part = props.part
|
|
789
|
+
if (part?.type !== "tool") return
|
|
790
|
+
const sessionID = part.sessionID || props.sessionID
|
|
791
|
+
if (["pending", "running"].includes(part.state?.status)) {
|
|
792
|
+
markToolCallActive({ sessionID, callID: part.callID })
|
|
793
|
+
}
|
|
794
|
+
if (["completed", "error"].includes(part.state?.status)) {
|
|
795
|
+
// Task/subagent hooks have used the part id as their hook call id in some
|
|
796
|
+
// OpenCode versions, while normal tools use part.callID. Clear either.
|
|
797
|
+
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
|
|
798
|
+
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
799
|
+
}
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
|
|
804
|
+
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
|
|
805
|
+
if (started) markToolCallActive(props)
|
|
806
|
+
if (finished) markToolCallFinished(props)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function startHeartbeat() {
|
|
810
|
+
if (heartbeatTimer) return
|
|
811
|
+
heartbeatTimer = setInterval(() => {
|
|
812
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
813
|
+
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
814
|
+
knownSessions.delete(sessionID)
|
|
815
|
+
continue
|
|
816
|
+
}
|
|
817
|
+
Promise.resolve()
|
|
818
|
+
.then(async () => {
|
|
819
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true })
|
|
820
|
+
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
821
|
+
})
|
|
822
|
+
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
823
|
+
}
|
|
824
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
825
|
+
clearInterval(heartbeatTimer)
|
|
826
|
+
heartbeatTimer = undefined
|
|
827
|
+
}
|
|
828
|
+
}, HEARTBEAT_MS)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function disposeRuntime(directory, client) {
|
|
832
|
+
const sessions = [...knownSessions.entries()]
|
|
833
|
+
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
834
|
+
.map(([sessionID]) => sessionID)
|
|
835
|
+
for (const sessionID of sessions) {
|
|
836
|
+
clearActiveRun(sessionID)
|
|
837
|
+
const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
|
|
838
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
|
|
839
|
+
const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
|
|
840
|
+
idleTimers.delete(sessionID)
|
|
841
|
+
dueTimers.delete(sessionID)
|
|
842
|
+
watchdogTimers.delete(sessionID)
|
|
843
|
+
runLocks.delete(sessionID)
|
|
844
|
+
knownSessions.delete(sessionID)
|
|
845
|
+
loopOwnedUserMessageGuards.delete(sessionID)
|
|
846
|
+
activeToolCalls.delete(sessionID)
|
|
847
|
+
sessionParents.delete(sessionID)
|
|
848
|
+
sessionStatuses.delete(sessionID)
|
|
849
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
850
|
+
sessionExecutionContexts.delete(sessionID)
|
|
851
|
+
for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
|
|
852
|
+
for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
|
|
853
|
+
}
|
|
854
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
855
|
+
clearInterval(heartbeatTimer)
|
|
856
|
+
heartbeatTimer = undefined
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function presetDefaults(name) {
|
|
861
|
+
// parseLoopArgs owns duration/flag/action parsing. Presets only provide real
|
|
862
|
+
// defaults; deriving an action from the raw string made flag-only invocations
|
|
863
|
+
// such as `/loop-compact --dry-run` use "--dry-run" as the action.
|
|
864
|
+
if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
|
|
865
|
+
if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
|
|
866
|
+
if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
|
|
867
|
+
if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
|
|
868
|
+
if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
|
|
869
|
+
if (name === "loop-testfix") return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" }
|
|
870
|
+
if (name === "loop-progress") return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
871
|
+
if (name === "loop-safe-dev") return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
872
|
+
return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function jobLabel(job) {
|
|
876
|
+
const title = job.name ? `${job.name}: ` : ""
|
|
877
|
+
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
878
|
+
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
879
|
+
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
880
|
+
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
881
|
+
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : ""
|
|
882
|
+
const verify = job.verifyCommand ? ", verify" : ""
|
|
883
|
+
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
884
|
+
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
885
|
+
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
886
|
+
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
887
|
+
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
888
|
+
const paused = job.paused ? ", paused" : ""
|
|
889
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function matchJob(job, target, index) {
|
|
893
|
+
const text = String(target || "").trim()
|
|
894
|
+
if (!text || text.toLowerCase() === "all") return true
|
|
895
|
+
return job.id === text || job.name === text || String(index + 1) === text
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function appendLoopLog(directory, line, extra = {}) {
|
|
899
|
+
try {
|
|
900
|
+
await ensureDir(stateDir(directory))
|
|
901
|
+
await fs.appendFile(path.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + "\n")
|
|
902
|
+
} catch {}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function readSmallTextFile(filePath, maxBytes = 120_000) {
|
|
906
|
+
try {
|
|
907
|
+
const stat = await fs.stat(filePath)
|
|
908
|
+
if (!stat.isFile() || stat.size > maxBytes) return ""
|
|
909
|
+
return await fs.readFile(filePath, "utf8")
|
|
910
|
+
} catch { return "" }
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
914
|
+
return await new Promise((resolve) => {
|
|
915
|
+
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
916
|
+
const stdout = []
|
|
917
|
+
const stderr = []
|
|
918
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
919
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
920
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
921
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
922
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
927
|
+
return await new Promise((resolve) => {
|
|
928
|
+
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
929
|
+
const stdout = []
|
|
930
|
+
const stderr = []
|
|
931
|
+
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
932
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
933
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
934
|
+
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
935
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function notifyJob(directory, job, reason) {
|
|
940
|
+
if (!job.notifyCommand) return
|
|
941
|
+
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""))
|
|
942
|
+
await runShellCommand(command, directory, 60_000)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function dangerousShell(command) {
|
|
946
|
+
const text = String(command || "").toLowerCase()
|
|
947
|
+
return [
|
|
948
|
+
/\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)/,
|
|
949
|
+
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
950
|
+
/\bgit\s+reset\b/,
|
|
951
|
+
/\bgit\s+clean\b/,
|
|
952
|
+
/\bgit\s+push\b/,
|
|
953
|
+
/\bdel\b[^\r\n]*\s\/s\b/,
|
|
954
|
+
/\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
|
|
955
|
+
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
956
|
+
/\bterraform\s+destroy\b/,
|
|
957
|
+
/\bkubectl\s+delete\b/,
|
|
958
|
+
/\bdeploy\b.*\bproduction\b/,
|
|
959
|
+
].some((pattern) => pattern.test(text))
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function actionKind(action, job = {}) {
|
|
963
|
+
const text = String(action || "").trim()
|
|
964
|
+
const forced = String(job.kind || "").trim().toLowerCase()
|
|
965
|
+
if (forced === "compact") return "compact"
|
|
966
|
+
if (forced === "goal") return "goal"
|
|
967
|
+
if (text === "/compact" || text === "/summarize") return "compact"
|
|
968
|
+
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
969
|
+
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
970
|
+
if (forced === "shell") return "shell"
|
|
971
|
+
if (text.startsWith("/")) return "command"
|
|
972
|
+
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
973
|
+
return "prompt"
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function decoratePrompt(job) {
|
|
977
|
+
const additions = []
|
|
978
|
+
if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
|
|
979
|
+
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
980
|
+
if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
|
|
981
|
+
if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
|
|
982
|
+
if (job.batch > 0) additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`)
|
|
983
|
+
if (job.quiet) additions.push("Keep replies short. Summarize only what changed, tests run, and next step.")
|
|
984
|
+
if (job.testCommand) additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`)
|
|
985
|
+
if (job.checkpointOnly || job.gitCheckpoint) additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.")
|
|
986
|
+
if (!additions.length) return job.action
|
|
987
|
+
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function isGoalJob(job) {
|
|
991
|
+
return String(job?.kind || "").toLowerCase() === "goal"
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function goalStatusText(job) {
|
|
995
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
996
|
+
if (!status) return ""
|
|
997
|
+
if (status === "completed") return "completed"
|
|
998
|
+
if (status === "blocked") return "blocked"
|
|
999
|
+
if (job?.paused) return "paused"
|
|
1000
|
+
return status
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function buildGoalPrompt(directory, job) {
|
|
1004
|
+
const sections = []
|
|
1005
|
+
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\".`)
|
|
1006
|
+
const objective = String(job.action || "").trim()
|
|
1007
|
+
if (objective) sections.push(`Goal objective:\n${objective}`)
|
|
1008
|
+
if (job.goalFile) {
|
|
1009
|
+
const text = await readSmallTextFile(path.resolve(directory, job.goalFile), 120_000)
|
|
1010
|
+
if (text.trim()) sections.push(`Goal file ${job.goalFile}:\n${text.trim()}`)
|
|
1011
|
+
else sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`)
|
|
1012
|
+
}
|
|
1013
|
+
if (job.promptFile) {
|
|
1014
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile), 120_000)
|
|
1015
|
+
if (text.trim()) sections.push(`Extra goal instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
1016
|
+
}
|
|
1017
|
+
if (job.goalAcceptance?.length) sections.push("Acceptance criteria:\n" + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
1018
|
+
if (job.goalChecks?.length) sections.push("Verification commands that define useful evidence:\n" + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join("\n"))
|
|
1019
|
+
if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
|
|
1020
|
+
if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
|
|
1021
|
+
if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
|
|
1022
|
+
if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
|
|
1023
|
+
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.`)
|
|
1024
|
+
if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
|
|
1025
|
+
for (const file of job.includeFiles || []) {
|
|
1026
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
1027
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return `${GOAL_PROMPT_PREFIX}.
|
|
1031
|
+
|
|
1032
|
+
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.
|
|
1033
|
+
|
|
1034
|
+
Rules:
|
|
1035
|
+
- Work on the next smallest useful step toward the goal.
|
|
1036
|
+
- Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
|
|
1037
|
+
- Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
|
|
1038
|
+
- 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.
|
|
1039
|
+
- Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
|
|
1040
|
+
- When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
|
|
1041
|
+
- If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
|
|
1042
|
+
- If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
|
|
1043
|
+
- If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
|
|
1044
|
+
- Do not call completion tools just to be polite; only call them when the state is real.
|
|
1045
|
+
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
1046
|
+
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
1047
|
+
|
|
1048
|
+
${sections.join("\n\n---\n\n")}`
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function buildPrompt(directory, job) {
|
|
1052
|
+
if (isGoalJob(job)) return await buildGoalPrompt(directory, job)
|
|
1053
|
+
const sections = []
|
|
1054
|
+
if (job.promptFile) {
|
|
1055
|
+
const text = await readSmallTextFile(path.resolve(directory, job.promptFile))
|
|
1056
|
+
if (text.trim()) sections.push(`Instructions from ${job.promptFile}:\n${text.trim()}`)
|
|
1057
|
+
else sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`)
|
|
1058
|
+
}
|
|
1059
|
+
if (job.action) sections.push(decoratePrompt(job))
|
|
1060
|
+
for (const file of job.includeFiles || []) {
|
|
1061
|
+
const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
|
|
1062
|
+
if (text.trim()) sections.push(`Context from ${file}:\n${text.trim().slice(0, 20_000)}`)
|
|
1063
|
+
}
|
|
1064
|
+
return sections.join("\n\n---\n\n") || decoratePrompt(job)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function ensureBranch(directory, job, client, sessionID) {
|
|
1068
|
+
if (!job.branch || job.branchDone) return job
|
|
1069
|
+
const branch = safeID(job.branch)
|
|
1070
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
1071
|
+
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
1072
|
+
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
1073
|
+
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
1074
|
+
job.branchDone = true
|
|
1075
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
1076
|
+
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
1077
|
+
return job
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function maybeCompact(client, sessionID, job) {
|
|
1081
|
+
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
1082
|
+
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
1083
|
+
if (!dueRuns && !dueTime) return job
|
|
1084
|
+
if (await compactSession(client, sessionID)) {
|
|
1085
|
+
job.lastCompactAt = now()
|
|
1086
|
+
job.lastCompactRunCount = job.runCount || 0
|
|
1087
|
+
}
|
|
1088
|
+
return job
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function snapshotPaths(directory, files) {
|
|
1092
|
+
const snapshot = {}
|
|
1093
|
+
for (const file of files || []) {
|
|
1094
|
+
try {
|
|
1095
|
+
const stat = await fs.stat(path.resolve(directory, file))
|
|
1096
|
+
snapshot[file] = `${stat.mtimeMs}:${stat.size}`
|
|
1097
|
+
} catch { snapshot[file] = "missing" }
|
|
1098
|
+
}
|
|
1099
|
+
return snapshot
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function watchChanged(directory, job) {
|
|
1103
|
+
if (!job.watchPaths?.length) return false
|
|
1104
|
+
const next = await snapshotPaths(directory, job.watchPaths)
|
|
1105
|
+
const previous = job.watchSnapshot || {}
|
|
1106
|
+
const changed = job.watchPaths.some((file) => previous[file] !== next[file])
|
|
1107
|
+
if (changed) job.watchSnapshot = next
|
|
1108
|
+
return changed
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function fileContains(filePath, needle) {
|
|
1112
|
+
try {
|
|
1113
|
+
const stat = await fs.stat(filePath)
|
|
1114
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return false
|
|
1115
|
+
return (await fs.readFile(filePath, "utf8")).includes(needle)
|
|
1116
|
+
} catch { return false }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function untilReached(directory, job) {
|
|
1120
|
+
if (!job.until) return false
|
|
1121
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
1122
|
+
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
1123
|
+
let scanned = 0
|
|
1124
|
+
async function walk(current) {
|
|
1125
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
1126
|
+
let entries
|
|
1127
|
+
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return false }
|
|
1128
|
+
for (const entry of entries) {
|
|
1129
|
+
if (scanned >= MAX_SCAN_FILES) return false
|
|
1130
|
+
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
1131
|
+
const full = path.join(current, entry.name)
|
|
1132
|
+
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
1133
|
+
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
1134
|
+
}
|
|
1135
|
+
return false
|
|
1136
|
+
}
|
|
1137
|
+
return await walk(directory)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function createCheckpoint(directory, sessionID, job, client) {
|
|
1141
|
+
if (!job.checkpointOnly && !job.gitCheckpoint) return
|
|
1142
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
1143
|
+
if (inRepo.code !== 0) return
|
|
1144
|
+
const status = await runProcess("git", ["status", "--short"], directory, 30_000)
|
|
1145
|
+
if (!status.stdout.trim()) return
|
|
1146
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
1147
|
+
const checkpointDir = path.join(stateDir(directory), "checkpoints", safeID(sessionID))
|
|
1148
|
+
await ensureDir(checkpointDir)
|
|
1149
|
+
const diff = await runProcess("git", ["diff", "--binary"], directory, 120_000)
|
|
1150
|
+
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120_000)
|
|
1151
|
+
const prefix = `${timestamp}-${safeID(job.name || job.id)}`
|
|
1152
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr)
|
|
1153
|
+
await fs.writeFile(path.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}\n${staged.stdout}`)
|
|
1154
|
+
if (job.gitCheckpoint) {
|
|
1155
|
+
await runProcess("git", ["add", "-A"], directory, 120_000)
|
|
1156
|
+
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120_000)
|
|
1157
|
+
}
|
|
1158
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function updateSessionStatusFromEvent(event) {
|
|
1162
|
+
const sessionID = event?.properties?.sessionID
|
|
1163
|
+
if (typeof sessionID !== "string") return undefined
|
|
1164
|
+
if (event?.type === "session.idle") {
|
|
1165
|
+
sessionStatuses.set(sessionID, "idle")
|
|
1166
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1167
|
+
return { sessionID, idle: true }
|
|
1168
|
+
}
|
|
1169
|
+
if (event?.type === "session.status") {
|
|
1170
|
+
const status = event?.properties?.status
|
|
1171
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
1172
|
+
if (typeof type === "string") {
|
|
1173
|
+
sessionStatuses.set(sessionID, type)
|
|
1174
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1175
|
+
}
|
|
1176
|
+
return { sessionID, idle: type === "idle" }
|
|
1177
|
+
}
|
|
1178
|
+
return undefined
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function userInterruptSessionFromEvent(event) {
|
|
1182
|
+
if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
|
|
1183
|
+
const props = event?.properties || {}
|
|
1184
|
+
const info = props.info || props.message || props
|
|
1185
|
+
const role = info?.role
|
|
1186
|
+
const sessionID = info?.sessionID || props.sessionID
|
|
1187
|
+
const messageID = info?.id || props.messageID
|
|
1188
|
+
if (role !== "user" || typeof sessionID !== "string") return undefined
|
|
1189
|
+
if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
|
|
1190
|
+
return sessionID
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
1194
|
+
const state = await readState(directory, sessionID)
|
|
1195
|
+
const interruptedAt = now()
|
|
1196
|
+
let count = 0
|
|
1197
|
+
state.jobs = (state.jobs || []).map((job) => {
|
|
1198
|
+
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
|
|
1199
|
+
count++
|
|
1200
|
+
return {
|
|
1201
|
+
...job,
|
|
1202
|
+
paused: true,
|
|
1203
|
+
lastUserInterruptAt: interruptedAt,
|
|
1204
|
+
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
|
|
1205
|
+
}
|
|
1206
|
+
})
|
|
1207
|
+
if (!count) return false
|
|
1208
|
+
await writeState(directory, sessionID, state)
|
|
1209
|
+
await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
|
|
1210
|
+
await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
|
|
1211
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1212
|
+
return true
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function staleActiveRun(sessionID) {
|
|
1216
|
+
const active = activeRuns.get(sessionID)
|
|
1217
|
+
if (!active) return false
|
|
1218
|
+
const age = now() - (active.startedAt || 0)
|
|
1219
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
|
|
1220
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS
|
|
1221
|
+
return age >= threshold
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1225
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
1226
|
+
if (!options.requireIdle && !options.forceStale) return true
|
|
1227
|
+
if (options.forceStale && staleActiveRun(sessionID)) return true
|
|
1228
|
+
if (!options.requireIdle) return false
|
|
1229
|
+
|
|
1230
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1231
|
+
if (live?.type) return live.type === "idle"
|
|
1232
|
+
|
|
1233
|
+
const cached = sessionStatuses.get(sessionID)
|
|
1234
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1235
|
+
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
1239
|
+
const argsList = []
|
|
1240
|
+
if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
|
|
1241
|
+
argsList.push({})
|
|
1242
|
+
for (const args of argsList) {
|
|
1243
|
+
try {
|
|
1244
|
+
const result = await client.session.status(args)
|
|
1245
|
+
const error = sdkError(result)
|
|
1246
|
+
if (error) continue
|
|
1247
|
+
const data = sdkData(result)
|
|
1248
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
|
1249
|
+
const observedAt = now()
|
|
1250
|
+
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
1251
|
+
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
1252
|
+
if (typeof observedType !== "string") continue
|
|
1253
|
+
sessionStatuses.set(observedSessionID, observedType)
|
|
1254
|
+
sessionStatusSeenAt.set(observedSessionID, observedAt)
|
|
1255
|
+
}
|
|
1256
|
+
// OpenCode's status list contains active sessions; idle sessions are
|
|
1257
|
+
// normally omitted. Clear a completed descendant that was previously busy.
|
|
1258
|
+
for (const childID of sessionParents.keys()) {
|
|
1259
|
+
if (!isDescendantSession(childID, sessionID) || data[childID]) continue
|
|
1260
|
+
sessionStatuses.set(childID, "idle")
|
|
1261
|
+
sessionStatusSeenAt.set(childID, observedAt)
|
|
1262
|
+
}
|
|
1263
|
+
if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
|
|
1264
|
+
const status = data?.[sessionID]
|
|
1265
|
+
const type = status && typeof status === "object" ? status.type : undefined
|
|
1266
|
+
if (typeof type === "string") return { type, source: "sdk" }
|
|
1267
|
+
return { type: "idle", source: "sdk" }
|
|
1268
|
+
} catch {}
|
|
1269
|
+
}
|
|
1270
|
+
return undefined
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
1274
|
+
// OpenCode can briefly report an idle session while a long-running tool or
|
|
1275
|
+
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
1276
|
+
// signal here, so never enqueue another turn until every active call ends.
|
|
1277
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
1278
|
+
sessionStatuses.set(sessionID, "busy")
|
|
1279
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1280
|
+
return "busy"
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const cached = sessionStatuses.get(sessionID)
|
|
1284
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1285
|
+
|
|
1286
|
+
// Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
|
|
1287
|
+
// trusted briefly: OpenCode custom commands such as /loop-status create their
|
|
1288
|
+
// own short assistant turn, and some TUI builds do not always emit the final
|
|
1289
|
+
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
1290
|
+
// stuck at "due in every idle" until the user types another command.
|
|
1291
|
+
if (cached === "idle") return cached
|
|
1292
|
+
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
1293
|
+
|
|
1294
|
+
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1295
|
+
if (live?.type) {
|
|
1296
|
+
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
1297
|
+
// plugin-injected turn until the next user command touches the session.
|
|
1298
|
+
// When the only reason we still think the session is busy is our own stale
|
|
1299
|
+
// active-run guard, recover instead of waiting for another manual command.
|
|
1300
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
|
|
1301
|
+
sessionStatuses.set(sessionID, "idle")
|
|
1302
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1303
|
+
return "idle"
|
|
1304
|
+
}
|
|
1305
|
+
sessionStatuses.set(sessionID, live.type)
|
|
1306
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1307
|
+
return live.type
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
|
|
1311
|
+
sessionStatuses.set(sessionID, fallback)
|
|
1312
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1313
|
+
return fallback
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
1317
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function scheduleIdleWork(directory, client, sessionID) {
|
|
1321
|
+
const previous = idleTimers.get(sessionID)
|
|
1322
|
+
if (previous) clearTimeout(previous)
|
|
1323
|
+
const timer = setTimeout(() => {
|
|
1324
|
+
idleTimers.delete(sessionID)
|
|
1325
|
+
Promise.resolve()
|
|
1326
|
+
.then(async () => {
|
|
1327
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1328
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
1332
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1333
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
1337
|
+
})
|
|
1338
|
+
.catch((error) => {
|
|
1339
|
+
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
1340
|
+
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
1341
|
+
})
|
|
1342
|
+
}, IDLE_DEBOUNCE_MS)
|
|
1343
|
+
idleTimers.set(sessionID, timer)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function jobDueAt(job, current = now()) {
|
|
1347
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return Infinity
|
|
1348
|
+
if (!job.enabled || job.paused) return Infinity
|
|
1349
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return Infinity
|
|
1350
|
+
if (job.watchPaths?.length) return Infinity
|
|
1351
|
+
const created = Date.parse(job.createdAt || new Date().toISOString())
|
|
1352
|
+
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs) return current
|
|
1353
|
+
if (job.intervalMs === 0) return current
|
|
1354
|
+
if (!job.lastRunAt) return current
|
|
1355
|
+
return job.lastRunAt + (job.intervalMs || 0)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function nextDueDelay(state) {
|
|
1359
|
+
const current = now()
|
|
1360
|
+
let soonest = Infinity
|
|
1361
|
+
for (const job of state.jobs || []) soonest = Math.min(soonest, jobDueAt(job, current))
|
|
1362
|
+
if (!Number.isFinite(soonest)) return Infinity
|
|
1363
|
+
return Math.max(0, soonest - current)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function startWatchdog(directory, client, sessionID) {
|
|
1367
|
+
if (watchdogTimers.has(sessionID)) return
|
|
1368
|
+
const timer = setInterval(() => {
|
|
1369
|
+
Promise.resolve()
|
|
1370
|
+
.then(async () => {
|
|
1371
|
+
const state = await readState(directory, sessionID)
|
|
1372
|
+
const delay = nextDueDelay(state)
|
|
1373
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)))
|
|
1374
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
1375
|
+
const existing = watchdogTimers.get(sessionID)
|
|
1376
|
+
if (existing) clearInterval(existing)
|
|
1377
|
+
watchdogTimers.delete(sessionID)
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
1380
|
+
if (delay <= 0) await maybeRunDueJobs(directory, client, sessionID)
|
|
1381
|
+
else await scheduleDueWork(directory, client, sessionID)
|
|
1382
|
+
})
|
|
1383
|
+
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
1384
|
+
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
1385
|
+
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
1386
|
+
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
1387
|
+
// when no manual /loop-status command is typed.
|
|
1388
|
+
watchdogTimers.set(sessionID, timer)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function stopWatchdog(sessionID) {
|
|
1392
|
+
const timer = watchdogTimers.get(sessionID)
|
|
1393
|
+
if (timer) clearInterval(timer)
|
|
1394
|
+
watchdogTimers.delete(sessionID)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
1398
|
+
const previous = dueTimers.get(sessionID)
|
|
1399
|
+
if (previous) clearTimeout(previous)
|
|
1400
|
+
|
|
1401
|
+
const state = await readState(directory, sessionID)
|
|
1402
|
+
const delay = nextDueDelay(state)
|
|
1403
|
+
if (!Number.isFinite(delay)) {
|
|
1404
|
+
dueTimers.delete(sessionID)
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
1409
|
+
const timer = setTimeout(() => {
|
|
1410
|
+
dueTimers.delete(sessionID)
|
|
1411
|
+
Promise.resolve()
|
|
1412
|
+
.then(async () => {
|
|
1413
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1414
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1417
|
+
await finalizeActiveRun(directory, client, sessionID)
|
|
1418
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1419
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1420
|
+
return
|
|
1421
|
+
}
|
|
1422
|
+
await maybeRunDueJobs(directory, client, sessionID)
|
|
1423
|
+
})
|
|
1424
|
+
.catch((error) => {
|
|
1425
|
+
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {})
|
|
1426
|
+
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {})
|
|
1427
|
+
})
|
|
1428
|
+
}, wait)
|
|
1429
|
+
dueTimers.set(sessionID, timer)
|
|
1430
|
+
await startWatchdog(directory, client, sessionID)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function dueJobs(state, force = false) {
|
|
1434
|
+
const current = now()
|
|
1435
|
+
return (state.jobs || []).filter((job) => {
|
|
1436
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus)) return false
|
|
1437
|
+
if (!job.enabled || job.paused) return false
|
|
1438
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns) return false
|
|
1439
|
+
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) return true
|
|
1440
|
+
if (force) return true
|
|
1441
|
+
if (job.watchPaths?.length) return job.watchTriggered === true
|
|
1442
|
+
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function clearActiveRun(sessionID) {
|
|
1447
|
+
const active = activeRuns.get(sessionID)
|
|
1448
|
+
if (active?.timer) clearTimeout(active.timer)
|
|
1449
|
+
activeRuns.delete(sessionID)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function goalReportPath(directory, sessionID, job) {
|
|
1453
|
+
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function goalReportText(job) {
|
|
1457
|
+
const lines = []
|
|
1458
|
+
lines.push(`# OpenCode Loop Goal Report`)
|
|
1459
|
+
lines.push("")
|
|
1460
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
1461
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
1462
|
+
lines.push(`Created: ${job.createdAt || ""}`)
|
|
1463
|
+
if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
|
|
1464
|
+
if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
|
|
1465
|
+
if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
|
|
1466
|
+
if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
|
|
1467
|
+
if (job.runCount) lines.push(`Turns: ${job.runCount}`)
|
|
1468
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
|
|
1469
|
+
lines.push("")
|
|
1470
|
+
if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
|
|
1471
|
+
if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
|
|
1472
|
+
if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
|
|
1473
|
+
if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
|
|
1474
|
+
if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
|
|
1475
|
+
if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
|
|
1476
|
+
if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
|
|
1477
|
+
if (job.lastGoalChecks?.length) {
|
|
1478
|
+
lines.push("## Latest checks", "")
|
|
1479
|
+
for (const item of job.lastGoalChecks) lines.push(`- ${item.command}: exit ${item.code}`)
|
|
1480
|
+
lines.push("")
|
|
1481
|
+
}
|
|
1482
|
+
if (job.goalProgress?.length) {
|
|
1483
|
+
lines.push("## Progress", "")
|
|
1484
|
+
for (const item of job.goalProgress) lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`)
|
|
1485
|
+
lines.push("")
|
|
1486
|
+
}
|
|
1487
|
+
return lines.join("\n")
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
async function writeGoalReport(directory, sessionID, job) {
|
|
1491
|
+
if (!isGoalJob(job)) return
|
|
1492
|
+
const target = job.goalEvidenceFile ? path.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job)
|
|
1493
|
+
await ensureDir(path.dirname(target))
|
|
1494
|
+
await fs.writeFile(target, goalReportText(job), "utf8")
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function pickGoalJob(state, target = "") {
|
|
1498
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1499
|
+
if (!goals.length) return undefined
|
|
1500
|
+
const text = String(target || "").trim()
|
|
1501
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase())) return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0]
|
|
1502
|
+
return goals.find((job, index) => matchJob(job, text, index))
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function parseGoalToolText(args, fields) {
|
|
1506
|
+
const result = {}
|
|
1507
|
+
for (const field of fields) result[field] = String(args?.[field] || "").trim()
|
|
1508
|
+
return result
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function hasConcreteGoalEvidence(value) {
|
|
1512
|
+
const text = String(value || "").trim()
|
|
1513
|
+
if (text.length < 24) return false
|
|
1514
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ")
|
|
1515
|
+
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
|
|
1516
|
+
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
|
|
1517
|
+
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
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function goalChecksPassed(job) {
|
|
1521
|
+
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function goalRequiresPassingChecks(job) {
|
|
1525
|
+
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1529
|
+
job.goalCompletionRejectedAt = now()
|
|
1530
|
+
job.goalCompletionRejectedReason = reason
|
|
1531
|
+
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
1532
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1533
|
+
await writeState(directory, sessionID, state)
|
|
1534
|
+
await writeGoalReport(directory, sessionID, job)
|
|
1535
|
+
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
1536
|
+
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function goalProgressSnapshot(job) {
|
|
1540
|
+
return {
|
|
1541
|
+
status: job?.goalStatus || "",
|
|
1542
|
+
progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
|
|
1543
|
+
evidence: String(job?.goalEvidence || ""),
|
|
1544
|
+
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
1545
|
+
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
1546
|
+
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
1547
|
+
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
1552
|
+
const before = goalProgressSnapshot(beforeJob || {})
|
|
1553
|
+
const after = goalProgressSnapshot(afterJob || {})
|
|
1554
|
+
if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
|
|
1555
|
+
if (after.progressCount > before.progressCount) return true
|
|
1556
|
+
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
|
|
1557
|
+
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
|
|
1558
|
+
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
|
|
1559
|
+
return false
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
1563
|
+
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
|
|
1564
|
+
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
|
|
1565
|
+
if (!Number.isFinite(limit) || limit <= 0) return job
|
|
1566
|
+
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
1567
|
+
job.noProgressCount = 0
|
|
1568
|
+
job.lastProgressAt = now()
|
|
1569
|
+
return job
|
|
1570
|
+
}
|
|
1571
|
+
job.noProgressCount = (job.noProgressCount || 0) + 1
|
|
1572
|
+
job.lastNoProgressAt = now()
|
|
1573
|
+
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1574
|
+
if (job.noProgressCount >= limit) {
|
|
1575
|
+
job.paused = true
|
|
1576
|
+
job.goalNoProgressPausedAt = now()
|
|
1577
|
+
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
|
|
1578
|
+
await toast(client, job.goalNoProgressReason, "warning")
|
|
1579
|
+
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
1580
|
+
}
|
|
1581
|
+
return job
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
1585
|
+
const state = await readState(directory, sessionID)
|
|
1586
|
+
const job = pickGoalJob(state, args.target)
|
|
1587
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1588
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"])
|
|
1589
|
+
const manualOverride = args.manual === true || args.manualOverride === true
|
|
1590
|
+
const completionEvidence = parsed.evidence || job.goalEvidence || ""
|
|
1591
|
+
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
|
|
1592
|
+
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
|
|
1593
|
+
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
1594
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
1595
|
+
}
|
|
1596
|
+
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
1597
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
1598
|
+
}
|
|
1599
|
+
job.goalStatus = "completed"
|
|
1600
|
+
job.enabled = false
|
|
1601
|
+
job.paused = true
|
|
1602
|
+
job.goalCompletedAt = now()
|
|
1603
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
1604
|
+
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
1605
|
+
job.noProgressCount = 0
|
|
1606
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1607
|
+
await writeState(directory, sessionID, state)
|
|
1608
|
+
await writeGoalReport(directory, sessionID, job)
|
|
1609
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
1610
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
1614
|
+
const state = await readState(directory, sessionID)
|
|
1615
|
+
const job = pickGoalJob(state, args.target)
|
|
1616
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1617
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"])
|
|
1618
|
+
job.goalStatus = "blocked"
|
|
1619
|
+
job.enabled = false
|
|
1620
|
+
job.paused = true
|
|
1621
|
+
job.goalBlockedAt = now()
|
|
1622
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join("\n") || "Goal blocked."
|
|
1623
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1624
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1625
|
+
await writeState(directory, sessionID, state)
|
|
1626
|
+
await writeGoalReport(directory, sessionID, job)
|
|
1627
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason })
|
|
1628
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` }
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
1632
|
+
const state = await readState(directory, sessionID)
|
|
1633
|
+
const job = pickGoalJob(state, args.target)
|
|
1634
|
+
if (!job) return { ok: false, message: "No active experimental goal was found." }
|
|
1635
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"])
|
|
1636
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
|
|
1637
|
+
job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
|
|
1638
|
+
if (parsed.evidence) job.goalEvidence = parsed.evidence
|
|
1639
|
+
job.noProgressCount = 0
|
|
1640
|
+
job.lastProgressAt = now()
|
|
1641
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1642
|
+
await writeState(directory, sessionID, state)
|
|
1643
|
+
await writeGoalReport(directory, sessionID, job)
|
|
1644
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary })
|
|
1645
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` }
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
async function runGoalChecks(directory, sessionID, job, client) {
|
|
1649
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus)) return job
|
|
1650
|
+
const results = []
|
|
1651
|
+
for (const command of job.goalChecks) {
|
|
1652
|
+
if (job.safe && dangerousShell(command)) {
|
|
1653
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
1654
|
+
continue
|
|
1655
|
+
}
|
|
1656
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300_000)
|
|
1657
|
+
results.push({ command, code: result.code, output: (result.stdout + "\n" + result.stderr).slice(0, 1200) })
|
|
1658
|
+
}
|
|
1659
|
+
job.lastGoalCheckAt = now()
|
|
1660
|
+
job.lastGoalChecks = results
|
|
1661
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
1662
|
+
if (allPassed) {
|
|
1663
|
+
job.goalChecksPassedAt = now()
|
|
1664
|
+
job.failureCount = 0
|
|
1665
|
+
await toast(client, "Goal checks passed.", "success")
|
|
1666
|
+
if (job.goalCompleteWhenChecksPass) {
|
|
1667
|
+
job.goalStatus = "completed"
|
|
1668
|
+
job.enabled = false
|
|
1669
|
+
job.paused = true
|
|
1670
|
+
job.goalCompletedAt = now()
|
|
1671
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
1672
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join("\n")
|
|
1673
|
+
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id })
|
|
1674
|
+
}
|
|
1675
|
+
} else {
|
|
1676
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1677
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}\nexit=${item.code}\n${item.output}`).join("\n\n").slice(0, 4000)
|
|
1678
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning")
|
|
1679
|
+
}
|
|
1680
|
+
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
1681
|
+
return job
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1685
|
+
const active = activeRuns.get(sessionID)
|
|
1686
|
+
if (!active) return
|
|
1687
|
+
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
|
|
1688
|
+
const recoveredStale = staleActiveRun(sessionID)
|
|
1689
|
+
clearActiveRun(sessionID)
|
|
1690
|
+
const state = await readState(directory, sessionID)
|
|
1691
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1692
|
+
if (!job) return
|
|
1693
|
+
job.lastFinishedAt = now()
|
|
1694
|
+
if (recoveredStale) await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt })
|
|
1695
|
+
|
|
1696
|
+
if (job.verifyCommand) {
|
|
1697
|
+
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300_000)
|
|
1698
|
+
job.lastVerifyAt = now()
|
|
1699
|
+
job.lastVerifyCode = verify.code
|
|
1700
|
+
if (verify.code === 0) {
|
|
1701
|
+
job.failureCount = 0
|
|
1702
|
+
job.lastVerifyFailure = ""
|
|
1703
|
+
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
1704
|
+
} else {
|
|
1705
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1706
|
+
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
1707
|
+
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|
|
1708
|
+
if (job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) {
|
|
1709
|
+
job.paused = true
|
|
1710
|
+
await notifyJob(directory, job, "verify_failed")
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 })
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (job.postrunCommand) {
|
|
1717
|
+
if (job.safe && dangerousShell(job.postrunCommand)) await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand })
|
|
1718
|
+
else {
|
|
1719
|
+
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300_000)
|
|
1720
|
+
job.lastPostrunCode = postrun.code
|
|
1721
|
+
job.lastPostrunAt = now()
|
|
1722
|
+
if (postrun.code !== 0) {
|
|
1723
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1724
|
+
job.lastPostrunFailure = (job.postrunCommand + "\nexit=" + postrun.code + "\n" + postrun.stdout + "\n" + postrun.stderr).slice(0, 4000)
|
|
1725
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1726
|
+
job.paused = true
|
|
1727
|
+
await notifyJob(directory, job, "postrun_failed")
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code })
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (isGoalJob(job)) {
|
|
1735
|
+
job = await runGoalChecks(directory, sessionID, job, client)
|
|
1736
|
+
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
1737
|
+
}
|
|
1738
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1739
|
+
await writeState(directory, sessionID, state)
|
|
1740
|
+
if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
|
|
1741
|
+
await createCheckpoint(directory, sessionID, job, client)
|
|
1742
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
1743
|
+
return true
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async function fireAction(directory, client, sessionID, job) {
|
|
1747
|
+
const action = String(job.action || "").trim()
|
|
1748
|
+
const kind = actionKind(action, job)
|
|
1749
|
+
const agent = job.agent || "build"
|
|
1750
|
+
const model = normalizedModelRef(job.model)
|
|
1751
|
+
if (kind === "compact") {
|
|
1752
|
+
const ok = await compactSession(client, sessionID)
|
|
1753
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
|
|
1754
|
+
}
|
|
1755
|
+
if (kind === "command") {
|
|
1756
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
1757
|
+
const [command, argumentsText] = splitFirst(normalized)
|
|
1758
|
+
if (!command) {
|
|
1759
|
+
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
1760
|
+
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
1761
|
+
}
|
|
1762
|
+
const tuiCommand = compactTuiCommandName(command)
|
|
1763
|
+
if (tuiCommand) {
|
|
1764
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1765
|
+
await compactSession(client, sessionID)
|
|
1766
|
+
return { startsAssistantTurn: true }
|
|
1767
|
+
}
|
|
1768
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1769
|
+
const commandBody = { command, arguments: argumentsText, agent }
|
|
1770
|
+
if (model) commandBody.model = `${model.providerID}/${model.modelID}`
|
|
1771
|
+
await sdkCall(
|
|
1772
|
+
client.session.command.bind(client.session),
|
|
1773
|
+
{ path: { id: sessionID }, body: commandBody },
|
|
1774
|
+
{ path: { sessionID }, body: commandBody },
|
|
1775
|
+
{ sessionID, ...commandBody },
|
|
1776
|
+
)
|
|
1777
|
+
return { startsAssistantTurn: true }
|
|
1778
|
+
}
|
|
1779
|
+
if (kind === "shell") {
|
|
1780
|
+
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
1781
|
+
if (job.safe && dangerousShell(command)) {
|
|
1782
|
+
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
1783
|
+
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
1784
|
+
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
1785
|
+
}
|
|
1786
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1787
|
+
const shellBody = { command, agent }
|
|
1788
|
+
if (model) shellBody.model = model
|
|
1789
|
+
fireSdk(
|
|
1790
|
+
client,
|
|
1791
|
+
"session.shell",
|
|
1792
|
+
client.session.shell.bind(client.session),
|
|
1793
|
+
{ path: { id: sessionID }, body: shellBody },
|
|
1794
|
+
{ path: { sessionID }, body: shellBody },
|
|
1795
|
+
{ sessionID, ...shellBody },
|
|
1796
|
+
)
|
|
1797
|
+
return { startsAssistantTurn: true }
|
|
1798
|
+
}
|
|
1799
|
+
const prompt = await buildPrompt(directory, job)
|
|
1800
|
+
const prefix = kind === "goal"
|
|
1801
|
+
? "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."
|
|
1802
|
+
: "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."
|
|
1803
|
+
const promptText = `${prefix}
|
|
1804
|
+
|
|
1805
|
+
${prompt}`
|
|
1806
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
1807
|
+
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
1808
|
+
if (model) promptBody.model = model
|
|
1809
|
+
fireSdk(
|
|
1810
|
+
client,
|
|
1811
|
+
"session.prompt",
|
|
1812
|
+
client.session.prompt.bind(client.session),
|
|
1813
|
+
{ path: { id: sessionID }, body: promptBody },
|
|
1814
|
+
{ path: { sessionID }, body: promptBody },
|
|
1815
|
+
{ sessionID, ...promptBody },
|
|
1816
|
+
)
|
|
1817
|
+
return { startsAssistantTurn: true }
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1821
|
+
rememberSession(directory, client, sessionID)
|
|
1822
|
+
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1823
|
+
|
|
1824
|
+
if (runLocks.has(sessionID)) {
|
|
1825
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
runLocks.set(sessionID, now())
|
|
1829
|
+
let job
|
|
1830
|
+
try {
|
|
1831
|
+
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
1832
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1833
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1834
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1835
|
+
return
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
const active = activeRuns.get(sessionID)
|
|
1839
|
+
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1840
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1841
|
+
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1842
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1843
|
+
return
|
|
1844
|
+
}
|
|
1845
|
+
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1846
|
+
|
|
1847
|
+
const state = await readState(directory, sessionID)
|
|
1848
|
+
for (const candidate of state.jobs || []) {
|
|
1849
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.watchTriggered = true
|
|
1850
|
+
}
|
|
1851
|
+
const due = dueJobs(state, options.force)
|
|
1852
|
+
if (!due.length) {
|
|
1853
|
+
await writeState(directory, sessionID, state)
|
|
1854
|
+
await reschedule()
|
|
1855
|
+
return
|
|
1856
|
+
}
|
|
1857
|
+
job = due[0]
|
|
1858
|
+
|
|
1859
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1860
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1861
|
+
await writeState(directory, sessionID, state)
|
|
1862
|
+
await notifyJob(directory, job, "max_runtime_reached")
|
|
1863
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1864
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1865
|
+
await reschedule()
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1869
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1870
|
+
await writeState(directory, sessionID, state)
|
|
1871
|
+
await notifyJob(directory, job, "stop_file")
|
|
1872
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1873
|
+
await reschedule()
|
|
1874
|
+
return
|
|
1875
|
+
}
|
|
1876
|
+
if (await untilReached(directory, job)) {
|
|
1877
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1878
|
+
await writeState(directory, sessionID, state)
|
|
1879
|
+
await notifyJob(directory, job, "until_reached")
|
|
1880
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1881
|
+
await reschedule()
|
|
1882
|
+
return
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (job.preflightCommand) {
|
|
1886
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1887
|
+
job.paused = true
|
|
1888
|
+
await writeState(directory, sessionID, state)
|
|
1889
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
1890
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1891
|
+
await reschedule()
|
|
1892
|
+
return
|
|
1893
|
+
}
|
|
1894
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1895
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1896
|
+
if (preflight.code !== 0) {
|
|
1897
|
+
job.paused = true
|
|
1898
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1899
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1900
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1901
|
+
await writeState(directory, sessionID, state)
|
|
1902
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
1903
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1904
|
+
await reschedule()
|
|
1905
|
+
return
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
job = await ensureBranch(directory, job, client, sessionID)
|
|
1910
|
+
job = await maybeCompact(client, sessionID, job)
|
|
1911
|
+
job.watchTriggered = false
|
|
1912
|
+
job.lastRunAt = now()
|
|
1913
|
+
job.runCount = (job.runCount || 0) + 1
|
|
1914
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1915
|
+
job.enabled = false
|
|
1916
|
+
await notifyJob(directory, job, "max_runs_reached")
|
|
1917
|
+
}
|
|
1918
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1919
|
+
await writeState(directory, sessionID, state)
|
|
1920
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1921
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1922
|
+
|
|
1923
|
+
try {
|
|
1924
|
+
const result = await fireAction(directory, client, sessionID, job)
|
|
1925
|
+
if (!result.startsAssistantTurn) {
|
|
1926
|
+
const fresh = await readState(directory, sessionID)
|
|
1927
|
+
if (result.pause) {
|
|
1928
|
+
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
1929
|
+
...candidate,
|
|
1930
|
+
paused: true,
|
|
1931
|
+
failureCount: (candidate.failureCount || 0) + 1,
|
|
1932
|
+
lastFailureReason: result.reason || "action_did_not_start",
|
|
1933
|
+
} : candidate)
|
|
1934
|
+
}
|
|
1935
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1936
|
+
await writeState(directory, sessionID, fresh)
|
|
1937
|
+
await reschedule()
|
|
1938
|
+
return
|
|
1939
|
+
}
|
|
1940
|
+
let timer
|
|
1941
|
+
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)
|
|
1942
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1943
|
+
sessionStatuses.set(sessionID, "busy")
|
|
1944
|
+
sessionStatusSeenAt.set(sessionID, now())
|
|
1945
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
clearActiveRun(sessionID)
|
|
1948
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1949
|
+
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1950
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1951
|
+
}
|
|
1952
|
+
} finally {
|
|
1953
|
+
runLocks.delete(sessionID)
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function normalizeActionForCompare(value) {
|
|
1958
|
+
return String(value || "").replace(/\s+/g, " ").trim()
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function sameLoopDefinition(a, b) {
|
|
1962
|
+
if (!a || !b) return false
|
|
1963
|
+
return (a.name || "") === (b.name || "") &&
|
|
1964
|
+
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
1965
|
+
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
1966
|
+
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
1967
|
+
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1971
|
+
const parsed = parseLoopArgs(args, defaults)
|
|
1972
|
+
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1973
|
+
const executionContext = sessionExecutionContexts.get(sessionID) || { agent: "build" }
|
|
1974
|
+
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
1975
|
+
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
1976
|
+
if (defaults.testfixPreset) {
|
|
1977
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
1978
|
+
const parsedAction = String(parsed.job.action || "").trim()
|
|
1979
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
1980
|
+
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
1981
|
+
parsed.job.verifyCommand = parsedAction
|
|
1982
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
1983
|
+
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
1984
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1988
|
+
if (!parsed.job.activeRecoveryMs) {
|
|
1989
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
1990
|
+
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
1991
|
+
: Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1992
|
+
}
|
|
1993
|
+
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 }
|
|
1994
|
+
const state = await readState(directory, sessionID)
|
|
1995
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
1996
|
+
|
|
1997
|
+
// Default behavior is replace/upsert, not append forever. This prevents duplicate
|
|
1998
|
+
// loops when OpenCode emits both command.execute.before and command.executed,
|
|
1999
|
+
// and it matches the common expectation that /loop configures the current loop.
|
|
2000
|
+
let replaced = false
|
|
2001
|
+
if (!parsed.job.multi) {
|
|
2002
|
+
const targetName = parsed.job.name || "default"
|
|
2003
|
+
parsed.job.name = targetName
|
|
2004
|
+
state.jobs = jobs.filter((existing) => {
|
|
2005
|
+
const existingName = existing.name || "default"
|
|
2006
|
+
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
2007
|
+
if (shouldReplace) replaced = true
|
|
2008
|
+
return !shouldReplace
|
|
2009
|
+
})
|
|
2010
|
+
} else {
|
|
2011
|
+
state.jobs = jobs
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
state.jobs.push(parsed.job)
|
|
2015
|
+
await writeState(directory, sessionID, state)
|
|
2016
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2017
|
+
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
2018
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
2019
|
+
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
async function stopLoop(directory, client, sessionID, args) {
|
|
2023
|
+
const target = String(args || "").trim()
|
|
2024
|
+
if (!target || target.toLowerCase() === "all") {
|
|
2025
|
+
await removeState(directory, sessionID)
|
|
2026
|
+
clearActiveRun(sessionID)
|
|
2027
|
+
const due = dueTimers.get(sessionID); if (due) clearTimeout(due); dueTimers.delete(sessionID)
|
|
2028
|
+
stopWatchdog(sessionID)
|
|
2029
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
2030
|
+
return
|
|
2031
|
+
}
|
|
2032
|
+
const state = await readState(directory, sessionID)
|
|
2033
|
+
const before = state.jobs.length
|
|
2034
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
2035
|
+
await writeState(directory, sessionID, state)
|
|
2036
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2037
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
2041
|
+
const target = String(args || "").trim() || "all"
|
|
2042
|
+
const state = await readState(directory, sessionID)
|
|
2043
|
+
let count = 0
|
|
2044
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
2045
|
+
await writeState(directory, sessionID, state)
|
|
2046
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2047
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
async function statusGoal(directory, client, sessionID) {
|
|
2051
|
+
const state = await readState(directory, sessionID)
|
|
2052
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
2053
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
2054
|
+
const status = goalStatusText(job)
|
|
2055
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
2056
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
2057
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
2058
|
+
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
2059
|
+
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
2060
|
+
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}`
|
|
2061
|
+
}) : ["No experimental goal jobs."]
|
|
2062
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
2063
|
+
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
2067
|
+
const target = String(args || "").trim() || "goal"
|
|
2068
|
+
const state = await readState(directory, sessionID)
|
|
2069
|
+
let count = 0
|
|
2070
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
2071
|
+
await writeState(directory, sessionID, state)
|
|
2072
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2073
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
2077
|
+
const target = String(args || "").trim() || "goal"
|
|
2078
|
+
const state = await readState(directory, sessionID)
|
|
2079
|
+
let count = 0
|
|
2080
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
2081
|
+
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
2082
|
+
count++
|
|
2083
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
|
|
2084
|
+
})
|
|
2085
|
+
await writeState(directory, sessionID, state)
|
|
2086
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
2087
|
+
if (count) {
|
|
2088
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2089
|
+
scheduleIdleWork(directory, client, sessionID)
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
2094
|
+
const target = String(args || "").trim()
|
|
2095
|
+
const state = await readState(directory, sessionID)
|
|
2096
|
+
const before = state.jobs.length
|
|
2097
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
2098
|
+
await writeState(directory, sessionID, state)
|
|
2099
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
2100
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
2104
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
2105
|
+
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
2109
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
2110
|
+
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
2114
|
+
const text = String(args || "").trim()
|
|
2115
|
+
const [maybeCommand, rest] = splitFirst(text)
|
|
2116
|
+
const sub = maybeCommand.toLowerCase()
|
|
2117
|
+
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
2118
|
+
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
2119
|
+
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
2120
|
+
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
2121
|
+
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
2122
|
+
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
2123
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
async function statusLoop(directory, client, sessionID) {
|
|
2127
|
+
const state = await readState(directory, sessionID)
|
|
2128
|
+
const jobs = state.jobs || []
|
|
2129
|
+
const lines = jobs.length ? jobs.map((job, index) => {
|
|
2130
|
+
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
2131
|
+
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(",")
|
|
2132
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
2133
|
+
}) : ["No active loop jobs."]
|
|
2134
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
2135
|
+
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
async function logsLoop(directory, client, sessionID) {
|
|
2139
|
+
let text = "No loop log found."
|
|
2140
|
+
try { text = (await fs.readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
2141
|
+
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
async function helpLoop(client, sessionID) {
|
|
2145
|
+
await say(client, sessionID, [
|
|
2146
|
+
"OpenCode Loop help:",
|
|
2147
|
+
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
2148
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
2149
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
2150
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
2151
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
2152
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
2153
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
2154
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
2155
|
+
"/loop 200m --command /compact same as command loop",
|
|
2156
|
+
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
2157
|
+
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
2158
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
2159
|
+
"/loop-doctor | /loop-init | /loop-export",
|
|
2160
|
+
].join("\n"))
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
async function runNow(directory, client, sessionID, args) {
|
|
2164
|
+
const target = String(args || "").trim() || "all"
|
|
2165
|
+
const state = await readState(directory, sessionID)
|
|
2166
|
+
let count = 0
|
|
2167
|
+
for (const [index, job] of (state.jobs || []).entries()) if (matchJob(job, target, index)) { job.lastRunAt = 0; job.paused = false; count++ }
|
|
2168
|
+
await writeState(directory, sessionID, state)
|
|
2169
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
2170
|
+
await maybeRunDueJobs(directory, client, sessionID, { force: true })
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
async function doctorLoop(directory, client, sessionID) {
|
|
2174
|
+
const state = await readState(directory, sessionID)
|
|
2175
|
+
await say(client, sessionID, [
|
|
2176
|
+
"OpenCode Loop doctor:",
|
|
2177
|
+
`- plugin: ${SERVICE}`,
|
|
2178
|
+
`- project directory: ${directory}`,
|
|
2179
|
+
`- state directory: ${stateDir(directory)}`,
|
|
2180
|
+
`- active jobs: ${(state.jobs || []).length}`,
|
|
2181
|
+
`- node: ${process.version}`,
|
|
2182
|
+
`- platform: ${process.platform}`,
|
|
2183
|
+
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
2184
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
2185
|
+
].join("\n"))
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
async function initLoop(directory, client, sessionID, args) {
|
|
2189
|
+
const target = String(args || "").trim() || "progress.md"
|
|
2190
|
+
const full = path.resolve(directory, target)
|
|
2191
|
+
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
2192
|
+
await fs.writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
2193
|
+
await toast(client, `Created ${target}.`, "success")
|
|
2194
|
+
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
async function exportLoop(directory, client, sessionID) {
|
|
2198
|
+
const state = await readState(directory, sessionID)
|
|
2199
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
2203
|
+
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
2204
|
+
const sessionID = input?.sessionID
|
|
2205
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
2206
|
+
if (!sessionID || !name) return false
|
|
2207
|
+
rememberSession(directory, client, sessionID)
|
|
2208
|
+
if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
|
|
2209
|
+
if (source === "event") {
|
|
2210
|
+
if (consumeHandled(sessionID, name, args)) return true
|
|
2211
|
+
const eventKey = commandEventKey(sessionID, input?.messageID)
|
|
2212
|
+
if (handledCommandEvents.has(eventKey)) return true
|
|
2213
|
+
handledCommandEvents.set(eventKey, now())
|
|
2214
|
+
} else {
|
|
2215
|
+
// Every before-hook invocation is an intentional command. Keep a pending
|
|
2216
|
+
// count only so the matching command.executed compatibility event can be
|
|
2217
|
+
// consumed without suppressing a genuine repeated command.
|
|
2218
|
+
markHandled(sessionID, name, args)
|
|
2219
|
+
}
|
|
2220
|
+
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
2221
|
+
|
|
2222
|
+
const handled = () => {
|
|
2223
|
+
// OpenCode server plugins cannot currently cancel the command prompt turn.
|
|
2224
|
+
// Keep the command markdown acknowledgement intact so opencode-loop-local
|
|
2225
|
+
// receives a valid, tool-denied message instead of an empty parts array.
|
|
2226
|
+
return true
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (name === "loop-goal") return await addGoal(directory, client, sessionID, args), handled()
|
|
2230
|
+
if (name === "loop-goal-status") return await statusGoal(directory, client, sessionID), handled()
|
|
2231
|
+
if (name === "loop-goal-pause") return await pauseGoal(directory, client, sessionID, args), handled()
|
|
2232
|
+
if (name === "loop-goal-resume") return await resumeGoal(directory, client, sessionID, args), handled()
|
|
2233
|
+
if (name === "loop-goal-clear") return await clearGoal(directory, client, sessionID, args), handled()
|
|
2234
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete") return await completeGoalCommand(directory, client, sessionID, args), handled()
|
|
2235
|
+
if (name === "loop-goal-blocked") return await blockGoalCommand(directory, client, sessionID, args), handled()
|
|
2236
|
+
if (name === "loop") return await addLoop(directory, client, sessionID, args), handled()
|
|
2237
|
+
if (isPreset(name)) return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
2238
|
+
if (name === "loop-stop" || name === "loop-remove") return await stopLoop(directory, client, sessionID, args), handled()
|
|
2239
|
+
if (name === "loop-clear") return await stopLoop(directory, client, sessionID, "all"), handled()
|
|
2240
|
+
if (name === "loop-status") return await statusLoop(directory, client, sessionID), handled()
|
|
2241
|
+
if (name === "loop-logs") return await logsLoop(directory, client, sessionID), handled()
|
|
2242
|
+
if (name === "loop-help") return await helpLoop(client, sessionID), handled()
|
|
2243
|
+
if (name === "loop-now") return await runNow(directory, client, sessionID, args), handled()
|
|
2244
|
+
if (name === "loop-pause") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
|
|
2245
|
+
if (name === "loop-resume") return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
|
|
2246
|
+
if (name === "loop-doctor") return await doctorLoop(directory, client, sessionID), handled()
|
|
2247
|
+
if (name === "loop-init") return await initLoop(directory, client, sessionID, args), handled()
|
|
2248
|
+
if (name === "loop-export") return await exportLoop(directory, client, sessionID), handled()
|
|
2249
|
+
if (source === "event") handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID))
|
|
2250
|
+
else consumeHandled(sessionID, name, args)
|
|
2251
|
+
return false
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function goalTools(defaultDirectory) {
|
|
2255
|
+
return {
|
|
2256
|
+
opencode_loop_goal_complete: tool({
|
|
2257
|
+
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.",
|
|
2258
|
+
args: {
|
|
2259
|
+
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
2260
|
+
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
2261
|
+
},
|
|
2262
|
+
execute: async (args, context) => {
|
|
2263
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
2264
|
+
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
2265
|
+
},
|
|
2266
|
+
}),
|
|
2267
|
+
opencode_loop_goal_blocked: tool({
|
|
2268
|
+
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
2269
|
+
args: {
|
|
2270
|
+
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
2271
|
+
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
2272
|
+
},
|
|
2273
|
+
execute: async (args, context) => {
|
|
2274
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
2275
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
2276
|
+
},
|
|
2277
|
+
}),
|
|
2278
|
+
opencode_loop_goal_progress: tool({
|
|
2279
|
+
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
2280
|
+
args: {
|
|
2281
|
+
summary: tool.schema.string().describe("What useful progress was made."),
|
|
2282
|
+
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
2283
|
+
},
|
|
2284
|
+
execute: async (args, context) => {
|
|
2285
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
2286
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
2287
|
+
},
|
|
2288
|
+
}),
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
2293
|
+
// OpenCode's local SDK can be slow or unavailable while a project instance
|
|
2294
|
+
// is still waiting for its plugins to return their hooks. Defer bootstrap
|
|
2295
|
+
// calls so headless/server sessions cannot deadlock during plugin loading.
|
|
2296
|
+
const bootstrap = setTimeout(() => {
|
|
2297
|
+
log(client, "info", "Plugin initialized", { directory }).catch(() => {})
|
|
2298
|
+
refreshSessionRelationships(client, directory).catch(() => {})
|
|
2299
|
+
}, 0)
|
|
2300
|
+
bootstrap.unref?.()
|
|
2301
|
+
return {
|
|
2302
|
+
dispose: async () => { disposeRuntime(directory, client) },
|
|
2303
|
+
tool: goalTools(directory),
|
|
2304
|
+
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
2305
|
+
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
2306
|
+
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
2307
|
+
event: async ({ event }) => {
|
|
2308
|
+
updateSessionRelationshipFromEvent(event)
|
|
2309
|
+
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
2310
|
+
updateToolActivityFromEvent(event)
|
|
2311
|
+
if (event.type === "command.executed") {
|
|
2312
|
+
const props = event.properties || {}
|
|
2313
|
+
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
|
|
2314
|
+
}
|
|
2315
|
+
const interruptedSessionID = userInterruptSessionFromEvent(event)
|
|
2316
|
+
if (interruptedSessionID) {
|
|
2317
|
+
rememberSession(directory, client, interruptedSessionID)
|
|
2318
|
+
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
|
|
2319
|
+
}
|
|
2320
|
+
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
2321
|
+
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
2322
|
+
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
2323
|
+
},
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
export default OpenCodeLoopPlugin
|