@bybrawe/opencode-loop 0.5.18 → 0.5.20

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