@bybrawe/opencode-loop 0.5.38 → 0.6.1

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 (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +33 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/examples/OPENCODE2-0.6.1.md +60 -0
  5. package/package.json +15 -9
  6. package/scripts/branch-hygiene.mjs +326 -0
  7. package/scripts/comprehensive-test.mjs +12 -6
  8. package/scripts/control-plane-until-test.mjs +39 -0
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +118 -60
  13. package/scripts/install-test.mjs +3 -3
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +114 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/opencode-host.mjs +47 -0
  36. package/scripts/opencode2-loop-canary.mjs +72 -4
  37. package/scripts/package-native-smoke.mjs +31 -0
  38. package/scripts/publish-workflow-test.mjs +60 -19
  39. package/scripts/pulse-check.mjs +246 -0
  40. package/scripts/session-status-test.mjs +12 -0
  41. package/src/index.js +87 -48
  42. package/src/native.js +3135 -0
  43. package/src/plugin.js +19 -0
  44. package/src/server.js +2164 -4560
  45. package/src/source/core/jobs.js +17 -0
  46. package/src/source/core/process.js +6 -4
  47. package/src/source/core/progress.js +58 -0
  48. package/src/source/legacy-v1.js +9 -3
  49. package/src/source/native.js +1 -0
  50. package/src/source/opencode/host.js +1 -0
  51. package/src/source/opencode/loop-commands.js +1 -27
  52. package/src/source/opencode/loop-registration.js +2 -12
  53. package/src/source/opencode/messages.js +1 -1
  54. package/src/source/opencode2/commands.js +9 -4
  55. package/src/source/opencode2/event-bridge.js +15 -3
  56. package/src/source/opencode2/events.js +20 -0
  57. package/src/source/opencode2/experimental.js +2 -0
  58. package/src/source/opencode2/native-companion.js +24 -0
  59. package/src/source/opencode2/native-plugin.js +153 -0
  60. package/src/source/opencode2/native-policy.js +106 -0
  61. package/src/source/opencode2/native-runtime.js +464 -0
  62. package/src/source/opencode2/native-shell.js +81 -0
  63. package/src/source/opencode2/prompt-runtime.js +2 -0
  64. package/src/source/opencode2/session-location.js +16 -0
  65. package/src/source/runtime/goal-steering.js +17 -3
  66. package/src/source/runtime/job-workspace.js +11 -2
  67. package/src/source/runtime/session-status.js +11 -1
  68. package/src/source/server.js +9 -1
  69. package/src/v1.js +1 -0
  70. package/src/v2.js +10 -0
@@ -0,0 +1,246 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import { dedicatedGoalOwnsContinuation } from "../src/source/runtime/companion-goal.js"
4
+
5
+ const LOCK_STALE_MS = 30_000
6
+ const LOCK_RETRY_MS = 25
7
+ const LOCK_RETRIES = 80
8
+
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms))
11
+ }
12
+
13
+ export function parsePulseDurationMs(value) {
14
+ const text = String(value ?? "").trim().toLowerCase()
15
+ if (text === "0" || text === "0s" || text === "now") return 0
16
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
17
+ if (!match) throw new Error(`Invalid duration: ${value}`)
18
+ const amount = Number(match[1])
19
+ const unit = match[2]
20
+ if (unit === "ms") return amount
21
+ if (unit.startsWith("s")) return amount * 1000
22
+ if (unit.startsWith("m")) return amount * 60_000
23
+ if (unit.startsWith("h")) return amount * 3_600_000
24
+ if (unit.startsWith("d")) return amount * 86_400_000
25
+ return amount
26
+ }
27
+
28
+ function pulseDirectory(project) {
29
+ return path.join(project, ".opencode", "opencode-loop")
30
+ }
31
+
32
+ function pulseStateFile(project) {
33
+ return path.join(pulseDirectory(project), "pulse-check.json")
34
+ }
35
+
36
+ function pulseLockDirectory(project) {
37
+ return path.join(pulseDirectory(project), "pulse-check.lock")
38
+ }
39
+
40
+ function goalDirectory(project) {
41
+ return path.join(project, ".opencode", "goals")
42
+ }
43
+
44
+ function timestamp(value) {
45
+ const parsed = Number(value)
46
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
47
+ }
48
+
49
+ function revision(value) {
50
+ const parsed = Number(value)
51
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
52
+ }
53
+
54
+ function goalFingerprint(goal) {
55
+ return [
56
+ String(goal?.status || "unknown"),
57
+ timestamp(goal?.updatedAt) ?? 0,
58
+ revision(goal?.progressRevision),
59
+ revision(goal?.revision),
60
+ revision(goal?.storageGeneration),
61
+ ].join(":")
62
+ }
63
+
64
+ function formatAge(ms) {
65
+ if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`
66
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
67
+ if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(ms < 36_000_000 ? 1 : 0)}h`
68
+ return `${(ms / 86_400_000).toFixed(ms < 864_000_000 ? 1 : 0)}d`
69
+ }
70
+
71
+ function iso(value) {
72
+ const parsed = timestamp(value)
73
+ if (parsed === undefined) return "unknown"
74
+ try { return new Date(parsed).toISOString() } catch { return "unknown" }
75
+ }
76
+
77
+ async function readJson(file, fallback) {
78
+ try {
79
+ return JSON.parse(await fs.readFile(file, "utf8"))
80
+ } catch (error) {
81
+ if (error?.code === "ENOENT") return fallback
82
+ throw error
83
+ }
84
+ }
85
+
86
+ async function writeJsonAtomic(file, value) {
87
+ await fs.mkdir(path.dirname(file), { recursive: true })
88
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
89
+ await fs.writeFile(temporary, JSON.stringify(value, null, 2) + "\n", "utf8")
90
+ try {
91
+ await fs.rename(temporary, file)
92
+ } catch (error) {
93
+ try { await fs.rm(temporary, { force: true }) } catch {}
94
+ throw error
95
+ }
96
+ }
97
+
98
+ async function acquireLock(project) {
99
+ const directory = pulseDirectory(project)
100
+ const lock = pulseLockDirectory(project)
101
+ await fs.mkdir(directory, { recursive: true })
102
+
103
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {
104
+ try {
105
+ await fs.mkdir(lock)
106
+ return async () => {
107
+ try { await fs.rm(lock, { recursive: true, force: true }) } catch {}
108
+ }
109
+ } catch (error) {
110
+ if (error?.code !== "EEXIST") throw error
111
+ try {
112
+ const stat = await fs.stat(lock)
113
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
114
+ await fs.rm(lock, { recursive: true, force: true })
115
+ continue
116
+ }
117
+ } catch (statError) {
118
+ if (statError?.code !== "ENOENT") throw statError
119
+ }
120
+ await sleep(LOCK_RETRY_MS)
121
+ }
122
+ }
123
+
124
+ throw new Error("Goal pulse-check state is busy; another checker still holds the lock")
125
+ }
126
+
127
+ async function readGoalSnapshots(project, target) {
128
+ let names
129
+ try {
130
+ names = await fs.readdir(goalDirectory(project))
131
+ } catch (error) {
132
+ if (error?.code === "ENOENT") return []
133
+ throw error
134
+ }
135
+
136
+ const goals = []
137
+ for (const name of names.sort()) {
138
+ if (!name.endsWith(".json")) continue
139
+ try {
140
+ const goal = JSON.parse(await fs.readFile(path.join(goalDirectory(project), name), "utf8"))
141
+ if (!goal?.id || !goal?.sessionID) continue
142
+ if (target && goal.id !== target && goal.sessionID !== target) continue
143
+ goals.push(goal)
144
+ } catch {}
145
+ }
146
+ return goals
147
+ }
148
+
149
+ export async function checkGoalPulse(project, options = {}) {
150
+ const staleAfterMs = Number(options.staleAfterMs)
151
+ if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new Error("staleAfterMs must be a non-negative number")
152
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
153
+ const target = options.target ? String(options.target) : undefined
154
+ const sessionActivity = options.sessionActivity && typeof options.sessionActivity === "object" ? options.sessionActivity : {}
155
+ const release = await acquireLock(project)
156
+
157
+ try {
158
+ const goals = await readGoalSnapshots(project, target)
159
+ const stateFile = pulseStateFile(project)
160
+ const state = await readJson(stateFile, { schemaVersion: 1, goals: {} })
161
+ if (!state.goals || typeof state.goals !== "object") state.goals = {}
162
+
163
+ const alerts = []
164
+ for (const goal of goals) {
165
+ const id = String(goal.id)
166
+ const status = String(goal.status || "unknown")
167
+ const progressRevision = revision(goal.progressRevision)
168
+ const goalUpdatedAt = timestamp(goal.updatedAt) ?? now
169
+ const fingerprint = goalFingerprint(goal)
170
+ const previous = state.goals[id]
171
+ const changed = !previous || previous.fingerprint !== fingerprint
172
+ const statusChanged = Boolean(previous && previous.status !== status)
173
+ const progressChanged = Boolean(previous && Number(previous.progressRevision || 0) !== progressRevision)
174
+ const updatedAdvanced = Boolean(previous && goalUpdatedAt > Number(previous.goalUpdatedAt || 0))
175
+
176
+ let lastActivityAt
177
+ if (!previous) {
178
+ lastActivityAt = goalUpdatedAt
179
+ } else if (!changed) {
180
+ lastActivityAt = Number(previous.lastActivityAt || goalUpdatedAt)
181
+ } else if (updatedAdvanced) {
182
+ lastActivityAt = goalUpdatedAt
183
+ } else if (statusChanged || progressChanged || Number(previous.revision || 0) !== revision(goal.revision)) {
184
+ lastActivityAt = now
185
+ } else {
186
+ lastActivityAt = Math.max(Number(previous.lastActivityAt || 0), goalUpdatedAt)
187
+ }
188
+
189
+ let alerted = changed ? false : Boolean(previous?.alerted)
190
+ let newAlert = false
191
+ const active = dedicatedGoalOwnsContinuation(goal)
192
+ const ageMs = Math.max(0, now - lastActivityAt)
193
+ const sessionUpdatedAt = timestamp(sessionActivity[goal.sessionID])
194
+
195
+ if (!active) {
196
+ alerted = false
197
+ } else if (ageMs >= staleAfterMs && !alerted) {
198
+ alerted = true
199
+ newAlert = true
200
+ const message = [
201
+ "[opencode-loopd] GOAL PULSE STALLED",
202
+ `goal=${id}`,
203
+ `session=${goal.sessionID}`,
204
+ `stale=${formatAge(ageMs)}`,
205
+ `goalUpdated=${iso(goalUpdatedAt)}`,
206
+ sessionUpdatedAt === undefined ? undefined : `sessionUpdated=${iso(sessionUpdatedAt)}`,
207
+ 'action="/goal resume"',
208
+ ].filter(Boolean).join(" ")
209
+ alerts.push({
210
+ goalID: id,
211
+ sessionID: String(goal.sessionID),
212
+ ageMs,
213
+ goalUpdatedAt,
214
+ sessionUpdatedAt,
215
+ progressRevision,
216
+ message,
217
+ })
218
+ } else if (ageMs < staleAfterMs) {
219
+ alerted = false
220
+ }
221
+
222
+ state.goals[id] = {
223
+ fingerprint,
224
+ status,
225
+ progressRevision,
226
+ revision: revision(goal.revision),
227
+ goalUpdatedAt,
228
+ lastActivityAt,
229
+ alerted,
230
+ ...(alerted ? { lastAlertAt: newAlert ? now : previous?.lastAlertAt } : {}),
231
+ sessionID: String(goal.sessionID),
232
+ }
233
+ }
234
+
235
+ state.schemaVersion = 1
236
+ state.updatedAt = now
237
+ await writeJsonAtomic(stateFile, state)
238
+ return {
239
+ alerts,
240
+ checked: goals.length,
241
+ stateFile,
242
+ }
243
+ } finally {
244
+ await release()
245
+ }
246
+ }
@@ -231,6 +231,17 @@ try {
231
231
  track(sessionID)
232
232
  runtime.clearSessionStatus(sessionID)
233
233
  const active = { jobId: "job", job: {}, startedAt: clock - 100 }
234
+ completion = "unknown"
235
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
236
+
237
+ completion = "incomplete"
238
+ assert.equal(
239
+ await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active),
240
+ false,
241
+ "a transient idle boundary must not finalize a Loop-owned run while the assistant turn is still incomplete",
242
+ )
243
+
244
+ completion = "completed"
234
245
  assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
235
246
 
236
247
  markToolCallActive({ sessionID, callID: "tool-finalize" })
@@ -240,6 +251,7 @@ try {
240
251
  runtime.markSessionStatus(sessionID, "idle", clock + 1)
241
252
  const client = { session: { status: async () => { throw new Error("status unavailable") } } }
242
253
  assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
254
+ completion = "unknown"
243
255
  }
244
256
 
245
257
  {
package/src/index.js CHANGED
@@ -619,7 +619,9 @@ async function runProcess(command, args, cwd, timeoutMs = 60000) {
619
619
  const child = spawn(command, args, { cwd, shell: false, windowsHide: true });
620
620
  const stdout = [];
621
621
  const stderr = [];
622
+ let timedOut = false;
622
623
  const timer = setTimeout(() => {
624
+ timedOut = true;
623
625
  try {
624
626
  child.kill("SIGTERM");
625
627
  } catch {}
@@ -632,7 +634,7 @@ async function runProcess(command, args, cwd, timeoutMs = 60000) {
632
634
  });
633
635
  child.on("close", (code) => {
634
636
  clearTimeout(timer);
635
- resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
637
+ resolve({ code: timedOut ? 124 : code ?? -1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
636
638
  });
637
639
  });
638
640
  }
@@ -641,7 +643,9 @@ async function runShellCommand(command, cwd, timeoutMs = 120000) {
641
643
  const child = spawn(command, [], { cwd, shell: true, windowsHide: true });
642
644
  const stdout = [];
643
645
  const stderr = [];
646
+ let timedOut = false;
644
647
  const timer = setTimeout(() => {
648
+ timedOut = true;
645
649
  try {
646
650
  child.kill("SIGTERM");
647
651
  } catch {}
@@ -654,7 +658,7 @@ async function runShellCommand(command, cwd, timeoutMs = 120000) {
654
658
  });
655
659
  child.on("close", (code) => {
656
660
  clearTimeout(timer);
657
- resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
661
+ resolve({ code: timedOut ? 124 : code ?? -1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
658
662
  });
659
663
  });
660
664
  }
@@ -819,6 +823,8 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
819
823
  if (!messages)
820
824
  return "unknown";
821
825
  const ordered = orderedSessionMessages(messages);
826
+ if (ordered.length === 0)
827
+ return "unknown";
822
828
  const tail = ordered.at(-1);
823
829
  const info = tail?.info || tail;
824
830
  if (!info || info.role !== "assistant")
@@ -1238,6 +1244,20 @@ function goalStatusText(job) {
1238
1244
  return "paused";
1239
1245
  return status;
1240
1246
  }
1247
+ function applyTestfixPreset(job, defaults = {}) {
1248
+ if (defaults.testfixPreset) {
1249
+ const defaultCommand = String(defaults.verifyCommand || "npm test");
1250
+ const parsedAction = String(job.action || "").trim();
1251
+ const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
1252
+ if (!usedDefaultAction && job.verifyCommand === defaults.verifyCommand) {
1253
+ job.verifyCommand = parsedAction;
1254
+ job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
1255
+ } else if (usedDefaultAction && job.verifyCommand !== defaults.verifyCommand) {
1256
+ job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${job.verifyCommand || defaultCommand}`;
1257
+ }
1258
+ }
1259
+ return job;
1260
+ }
1241
1261
 
1242
1262
  // src/source/opencode/command-router.js
1243
1263
  var HANDLER_NAMES = [
@@ -1739,6 +1759,34 @@ function createGoalCommandHandlers(options = {}) {
1739
1759
  import { promises as fs6 } from "fs";
1740
1760
  import path7 from "path";
1741
1761
 
1762
+ // src/source/core/progress.js
1763
+ var DEFAULT_PROGRESS_MD = `# Progress
1764
+
1765
+ ## Current Goal
1766
+ Describe the current project goal here.
1767
+
1768
+ ## Agent Rules
1769
+ - Do not ask questions unless truly blocked.
1770
+ - Make reasonable assumptions and continue.
1771
+ - Work on unfinished TODOs in order.
1772
+ - Mark completed TODOs with [x].
1773
+ - Add new bugs, ideas, and follow-up work as TODOs.
1774
+ - Run tests, lint, or build when available.
1775
+ - Do not run destructive commands, force pushes, production deploys, or database resets.
1776
+
1777
+ ## Active TODO
1778
+ - [ ] Review the project structure and pick the next safe improvement.
1779
+
1780
+ ## Completed
1781
+ - [x] Created progress.md.
1782
+
1783
+ ## Backlog Ideas
1784
+ - [ ] Add more project-specific tasks here.
1785
+
1786
+ ## Blocked
1787
+ - None.
1788
+ `;
1789
+
1742
1790
  // src/source/runtime/companion-goal.js
1743
1791
  import { promises as fs4 } from "fs";
1744
1792
  import path5 from "path";
@@ -1916,32 +1964,6 @@ async function listPersistedLoopSessions(directory, currentSessionID) {
1916
1964
 
1917
1965
  // src/source/opencode/loop-commands.js
1918
1966
  var SERVICE2 = "opencode-loop";
1919
- var DEFAULT_PROGRESS_MD = `# Progress
1920
-
1921
- ## Current Goal
1922
- Describe the current project goal here.
1923
-
1924
- ## Agent Rules
1925
- - Do not ask questions unless truly blocked.
1926
- - Make reasonable assumptions and continue.
1927
- - Work on unfinished TODOs in order.
1928
- - Mark completed TODOs with [x].
1929
- - Add new bugs, ideas, and follow-up work as TODOs.
1930
- - Run tests, lint, or build when available.
1931
- - Do not run destructive commands, force pushes, production deploys, or database resets.
1932
-
1933
- ## Active TODO
1934
- - [ ] Review the project structure and pick the next safe improvement.
1935
-
1936
- ## Completed
1937
- - [x] Created progress.md.
1938
-
1939
- ## Backlog Ideas
1940
- - [ ] Add more project-specific tasks here.
1941
-
1942
- ## Blocked
1943
- - None.
1944
- `;
1945
1967
  function requireFunction3(value, name) {
1946
1968
  if (typeof value !== "function")
1947
1969
  throw new TypeError(`createLoopCommandHandlers requires ${name}`);
@@ -2241,17 +2263,7 @@ function createLoopRegistration(options = {}) {
2241
2263
  const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
2242
2264
  parsed.job.agent = defaults.agent || executionContext.agent || "build";
2243
2265
  parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
2244
- if (defaults.testfixPreset) {
2245
- const defaultCommand = String(defaults.verifyCommand || "npm test");
2246
- const parsedAction = String(parsed.job.action || "").trim();
2247
- const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
2248
- if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
2249
- parsed.job.verifyCommand = parsedAction;
2250
- parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
2251
- } else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
2252
- parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
2253
- }
2254
- }
2266
+ applyTestfixPreset(parsed.job, defaults);
2255
2267
  if (parsed.job.watchPaths.length)
2256
2268
  parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
2257
2269
  if (!parsed.job.activeRecoveryMs) {
@@ -2776,13 +2788,14 @@ ${text.trim().slice(0, 20000)}`);
2776
2788
  const branch = safeID(job.branch);
2777
2789
  const inRepo = await runProcess2("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
2778
2790
  if (inRepo.code !== 0) {
2779
- job.branchDone = true;
2791
+ job.branchDone = false;
2792
+ job.branchUnavailable = true;
2780
2793
  return job;
2781
2794
  }
2782
2795
  let result = await runProcess2("git", ["switch", branch], directory, 30000);
2783
2796
  if (result.code !== 0)
2784
2797
  result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
2785
- job.branchDone = true;
2798
+ job.branchDone = result.code === 0;
2786
2799
  await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
2787
2800
  await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
2788
2801
  return job;
@@ -2822,6 +2835,10 @@ ${text.trim().slice(0, 20000)}`);
2822
2835
  async function untilReached(directory, job) {
2823
2836
  if (!job.until)
2824
2837
  return false;
2838
+ const controlRoots = new Set([
2839
+ stateDir(directory),
2840
+ ...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"].map((name) => path8.join(directory, ".opencode", name))
2841
+ ].map((root) => path8.resolve(root).toLowerCase()));
2825
2842
  const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
2826
2843
  for (const file of files)
2827
2844
  if (await fileContains(path8.resolve(directory, file), job.until))
@@ -2842,6 +2859,8 @@ ${text.trim().slice(0, 20000)}`);
2842
2859
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
2843
2860
  continue;
2844
2861
  const full = path8.join(current, entry.name);
2862
+ if (controlRoots.has(path8.resolve(full).toLowerCase()))
2863
+ continue;
2845
2864
  if (entry.isDirectory()) {
2846
2865
  if (await walk(full))
2847
2866
  return true;
@@ -2994,8 +3013,10 @@ function createSessionStatusRuntime(options = {}) {
2994
3013
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
2995
3014
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
2996
3015
  return false;
2997
- if (!options.requireIdle && !options.forceStale)
2998
- return true;
3016
+ if (!options.requireIdle && !options.forceStale) {
3017
+ const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
3018
+ return completion !== "incomplete";
3019
+ }
2999
3020
  const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
3000
3021
  if (settledAssistantCompletion(completion))
3001
3022
  return true;
@@ -4268,6 +4289,7 @@ function createGoalSteeringRuntime(options = {}) {
4268
4289
  const now2 = typeof options.now === "function" ? options.now : now;
4269
4290
  const suppressionMs = Number.isFinite(Number(options.suppressionMs)) && Number(options.suppressionMs) > 0 ? Number(options.suppressionMs) : DEFAULT_STEERING_SUPPRESSION_MS;
4270
4291
  const seenUserMessageMs = Number.isFinite(Number(options.seenUserMessageMs)) && Number(options.seenUserMessageMs) > 0 ? Number(options.seenUserMessageMs) : DEFAULT_SEEN_USER_MESSAGE_MS;
4292
+ const compactingSessions = new Set;
4271
4293
  const pendingSteering = new Map;
4272
4294
  const seenUserMessages = new Map;
4273
4295
  function pendingForSession(sessionID) {
@@ -4341,7 +4363,7 @@ function createGoalSteeringRuntime(options = {}) {
4341
4363
  return { handled: false, sessionID, messageID };
4342
4364
  const active = getActiveRun(sessionID);
4343
4365
  const activeGoalIDs = new Set(goals.map((goal) => goal.id));
4344
- const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function";
4366
+ const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function";
4345
4367
  let preempted = false;
4346
4368
  let abortError = "";
4347
4369
  if (canPreempt) {
@@ -4353,7 +4375,8 @@ function createGoalSteeringRuntime(options = {}) {
4353
4375
  });
4354
4376
  try {
4355
4377
  await fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
4356
- clearActiveRun(sessionID);
4378
+ if (getActiveRun(sessionID) === active)
4379
+ clearActiveRun(sessionID);
4357
4380
  preempted = true;
4358
4381
  } catch (error) {
4359
4382
  pendingSteering.delete(sessionID);
@@ -4374,9 +4397,20 @@ function createGoalSteeringRuntime(options = {}) {
4374
4397
  const user = userMessageFromEvent(event);
4375
4398
  if (!user)
4376
4399
  return;
4377
- return await handleUserMessage(directory, client, user);
4400
+ if (alreadyHandled(user.sessionID, user.messageID))
4401
+ return { handled: false, duplicate: true, ...user };
4402
+ return { handled: false, reason: "untrusted-message-event", ...user };
4403
+ }
4404
+ function setCompacting(sessionID, value = true) {
4405
+ if (typeof sessionID !== "string" || !sessionID)
4406
+ return;
4407
+ if (value)
4408
+ compactingSessions.add(sessionID);
4409
+ else
4410
+ compactingSessions.delete(sessionID);
4378
4411
  }
4379
4412
  function clearSession(sessionID) {
4413
+ compactingSessions.delete(sessionID);
4380
4414
  pendingSteering.delete(sessionID);
4381
4415
  const prefix = `${sessionID}\x00`;
4382
4416
  for (const key of seenUserMessages.keys())
@@ -4390,6 +4424,7 @@ function createGoalSteeringRuntime(options = {}) {
4390
4424
  shouldSuppressIdle,
4391
4425
  hasPendingSteering: shouldSuppressIdle,
4392
4426
  pendingForSession,
4427
+ setCompacting,
4393
4428
  clearSession
4394
4429
  };
4395
4430
  }
@@ -4616,10 +4651,10 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4616
4651
  "command.execute.before": async (input, output) => {
4617
4652
  await handleCommand(directory, client, input, undefined, undefined, output);
4618
4653
  },
4619
- "chat.message": async (input) => {
4654
+ "chat.message": async (input, output) => {
4620
4655
  const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
4621
4656
  sessionID: input?.sessionID,
4622
- messageID: input?.messageID
4657
+ messageID: input?.messageID || output?.message?.id
4623
4658
  });
4624
4659
  if (steering?.handled && steering.sessionID)
4625
4660
  rememberSession(directory, client, steering.sessionID);
@@ -4631,9 +4666,13 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4631
4666
  markToolCallFinished(input);
4632
4667
  },
4633
4668
  "experimental.session.compacting": async (input) => {
4669
+ goalSteeringRuntime.setCompacting(input?.sessionID);
4634
4670
  await noteLoopCompactionStarted(directory, input?.sessionID);
4635
4671
  },
4636
4672
  event: async ({ event }) => {
4673
+ if (["session.compacted", "session.error", "session.deleted"].includes(event?.type)) {
4674
+ goalSteeringRuntime.setCompacting(event?.properties?.sessionID || event?.properties?.info?.id, false);
4675
+ }
4637
4676
  if (event.type === "session.compacted")
4638
4677
  await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID);
4639
4678
  updateSessionRelationshipFromEvent(event);