@bybrawe/opencode-loop 0.5.27 → 0.5.28

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