@bybrawe/opencode-loop 0.5.26 → 0.5.28

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