@bybrawe/opencode-loop 0.5.28 → 0.5.30

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