@bybrawe/opencode-loop 0.5.37 → 0.6.0

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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  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 +105 -58
  13. package/scripts/install-test.mjs +6 -1
  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 +107 -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/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. package/src/v1.js +1 -0
@@ -3,7 +3,7 @@ import plugin, {
3
3
  OPENCODE_LOOP_V2_COMMANDS,
4
4
  OPENCODE_LOOP_V2_PLUGIN_ID,
5
5
  } from "../src/source/opencode2/experimental.js"
6
- import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
6
+ import { parseOpenCode2LoopCommandText, registerOpenCode2LoopCommands } from "../src/source/opencode2/commands.js"
7
7
  import {
8
8
  OPENCODE_LOOP_V2_COMMAND_SOURCE,
9
9
  OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
@@ -77,13 +77,43 @@ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
77
77
  assert.equal(command?.description, definition.description)
78
78
  }
79
79
 
80
- let missingDraftUpdateFailed = false
80
+ let missingDraftMutationFailed = false
81
81
  try {
82
82
  await registered({})
83
83
  } catch (error) {
84
- missingDraftUpdateFailed = /command draft\.update capability is unavailable/.test(String(error))
84
+ missingDraftMutationFailed = /neither add\(\) nor update\(\)/.test(String(error))
85
85
  }
86
- assert.equal(missingDraftUpdateFailed, true)
86
+ assert.equal(missingDraftMutationFailed, true)
87
+
88
+ const addedCommands = new Map()
89
+ const executedCommands = []
90
+ registerOpenCode2LoopCommands({
91
+ add(definition) {
92
+ addedCommands.set(definition.name, definition)
93
+ },
94
+ }, {
95
+ execute: async (event) => {
96
+ executedCommands.push(structuredClone(event))
97
+ },
98
+ })
99
+ assert.deepEqual([...addedCommands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
100
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
101
+ const command = addedCommands.get(name)
102
+ assert.equal(command?.name, name)
103
+ assert.equal(command?.description, definition.description)
104
+ assert.equal(typeof command?.execute, "function")
105
+ }
106
+ await addedCommands.get("loop").execute({
107
+ sessionID: "ses_2_0_11",
108
+ prompt: { text: "devam et" },
109
+ delivery: "steer",
110
+ })
111
+ assert.deepEqual(executedCommands, [{
112
+ name: "loop",
113
+ sessionID: "ses_2_0_11",
114
+ arguments: "devam et",
115
+ delivery: "steer",
116
+ }])
87
117
 
88
118
  assert.deepEqual(inspectOpenCode2Context(currentContext), {
89
119
  commandTransform: true,
@@ -98,6 +128,7 @@ assert.deepEqual(inspectOpenCode2Context(currentContext), {
98
128
  assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
99
129
  list: true,
100
130
  get: true,
131
+ add: false,
101
132
  update: true,
102
133
  remove: true,
103
134
  })
@@ -164,6 +164,8 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
164
164
  sessionID: "ses_v2",
165
165
  noReply: true,
166
166
  parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
167
+ text: "OpenCode loop status:\nNo active loop jobs.",
168
+ resume: false,
167
169
  agent: "build",
168
170
  model: { providerID: "canary", modelID: "canary" },
169
171
  })
@@ -301,6 +303,79 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
301
303
  }
302
304
  }
303
305
 
306
+ {
307
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-2-0-11-command-add-"))
308
+ const sessionID = "ses_v2_2_0_11_add"
309
+ const events = controllableStream()
310
+ const prompts = []
311
+ const commands = new Map()
312
+ const ctx = {
313
+ app: { name: "opencode", version: "2.0.11", channel: "latest" },
314
+ location: { directory },
315
+ command: {
316
+ transform: async (callback) => {
317
+ await callback({
318
+ add(definition) {
319
+ commands.set(definition.name, definition)
320
+ },
321
+ })
322
+ return { dispose: async () => {} }
323
+ },
324
+ },
325
+ event: {
326
+ // OpenCode 2.0.11 exposes subscribe(options), so Function.length === 1
327
+ // even though it returns an AsyncIterable rather than accepting a callback.
328
+ subscribe(options) {
329
+ assert.equal(options, undefined)
330
+ return events.stream
331
+ },
332
+ },
333
+ session: {
334
+ prompt: async (input) => {
335
+ prompts.push(structuredClone(input))
336
+ return { accepted: true }
337
+ },
338
+ },
339
+ }
340
+
341
+ const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
342
+ try {
343
+ assert.ok(commands.has("loop"), "OpenCode 2.0.11 add() command editor must receive /loop")
344
+ await commands.get("loop").execute({
345
+ sessionID,
346
+ prompt: { text: "0s --max-runs 2 continue exact 2.0.11 command path" },
347
+ delivery: "steer",
348
+ })
349
+
350
+ const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
351
+ await waitFor(async () => {
352
+ try { return JSON.parse(await readFile(stateFile, "utf8")).jobs?.length === 1 } catch { return false }
353
+ }, "OpenCode 2.0.11 direct command state creation")
354
+
355
+ await waitFor(() => prompts.length === 1, "OpenCode 2.0.11 direct command first local-kick dispatch")
356
+ assert.equal(prompts[0].sessionID, sessionID)
357
+ assert.match(prompts[0].text || "", /continue exact 2\.0\.11 command path/)
358
+
359
+ events.push({
360
+ type: "session.status",
361
+ location: { directory },
362
+ data: { sessionID, status: { type: "busy" } },
363
+ })
364
+ events.push({
365
+ type: "session.status",
366
+ location: { directory },
367
+ data: { sessionID, status: { type: "idle" } },
368
+ })
369
+ await waitFor(() => prompts.length === 2, "OpenCode 2.0.11 second dispatch from native idle status")
370
+ const state = JSON.parse(await readFile(stateFile, "utf8"))
371
+ assert.equal(state.jobs[0].runCount, 2)
372
+ assert.equal(state.jobs[0].enabled, false)
373
+ } finally {
374
+ await cleanup?.()
375
+ await rm(directory, { recursive: true, force: true })
376
+ }
377
+ }
378
+
304
379
  {
305
380
  let commandDisposals = 0
306
381
  const result = await OpenCodeLoopV2ExperimentalPlugin.setup({
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")
@@ -1086,6 +1092,19 @@ var NEXT_WORK_PATTERNS = [
1086
1092
  /\bremaining (?:work|task|todo|step)/i,
1087
1093
  /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1088
1094
  ];
1095
+ var WAITING_USER_PATTERNS = [
1096
+ /(?:^|\n)\s*onay bekleyenler\s*:/i,
1097
+ /(?:^|\n)\s*(?:kullan\u0131c\u0131|user) (?:onay\u0131|onayi|aksiyonu|i\u015Flemi|islemi|eri\u015Fimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1098
+ /\b(?:senin|sizin) (?:onay\u0131n|onay\u0131n\u0131z|onayiniz|aksiyonun|aksiyonunuz|eri\u015Fimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1099
+ /\b(?:onay|eri\u015Fim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|i\u015Flem yapamam|islem yapamam)\b/i,
1100
+ /(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
1101
+ /\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
1102
+ /\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i
1103
+ ];
1104
+ var WAITING_USER_NEGATIONS = [
1105
+ /\b(?:onay|eri\u015Fim|erisim|yetki) (?:gerekmiyor|gerekli de\u011Fil|gerekli degil|beklenmiyor)\b/i,
1106
+ /\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i
1107
+ ];
1089
1108
  function isContinuationShorthand(value) {
1090
1109
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1091
1110
  }
@@ -1093,6 +1112,16 @@ function isCompletionBoundedContinuation(value) {
1093
1112
  const text = String(value || "").trim();
1094
1113
  return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1095
1114
  }
1115
+ function isWaitingUserReply(value) {
1116
+ const text = String(value || "").trim();
1117
+ if (!text)
1118
+ return false;
1119
+ if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text)))
1120
+ return false;
1121
+ if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1122
+ return false;
1123
+ return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text));
1124
+ }
1096
1125
  function isTerminalNoWorkReply(value) {
1097
1126
  const text = String(value || "").trim();
1098
1127
  if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
@@ -1215,6 +1244,20 @@ function goalStatusText(job) {
1215
1244
  return "paused";
1216
1245
  return status;
1217
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
+ }
1218
1261
 
1219
1262
  // src/source/opencode/command-router.js
1220
1263
  var HANDLER_NAMES = [
@@ -1716,6 +1759,34 @@ function createGoalCommandHandlers(options = {}) {
1716
1759
  import { promises as fs6 } from "fs";
1717
1760
  import path7 from "path";
1718
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
+
1719
1790
  // src/source/runtime/companion-goal.js
1720
1791
  import { promises as fs4 } from "fs";
1721
1792
  import path5 from "path";
@@ -1893,32 +1964,6 @@ async function listPersistedLoopSessions(directory, currentSessionID) {
1893
1964
 
1894
1965
  // src/source/opencode/loop-commands.js
1895
1966
  var SERVICE2 = "opencode-loop";
1896
- var DEFAULT_PROGRESS_MD = `# Progress
1897
-
1898
- ## Current Goal
1899
- Describe the current project goal here.
1900
-
1901
- ## Agent Rules
1902
- - Do not ask questions unless truly blocked.
1903
- - Make reasonable assumptions and continue.
1904
- - Work on unfinished TODOs in order.
1905
- - Mark completed TODOs with [x].
1906
- - Add new bugs, ideas, and follow-up work as TODOs.
1907
- - Run tests, lint, or build when available.
1908
- - Do not run destructive commands, force pushes, production deploys, or database resets.
1909
-
1910
- ## Active TODO
1911
- - [ ] Review the project structure and pick the next safe improvement.
1912
-
1913
- ## Completed
1914
- - [x] Created progress.md.
1915
-
1916
- ## Backlog Ideas
1917
- - [ ] Add more project-specific tasks here.
1918
-
1919
- ## Blocked
1920
- - None.
1921
- `;
1922
1967
  function requireFunction3(value, name) {
1923
1968
  if (typeof value !== "function")
1924
1969
  throw new TypeError(`createLoopCommandHandlers requires ${name}`);
@@ -2218,17 +2263,7 @@ function createLoopRegistration(options = {}) {
2218
2263
  const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
2219
2264
  parsed.job.agent = defaults.agent || executionContext.agent || "build";
2220
2265
  parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
2221
- if (defaults.testfixPreset) {
2222
- const defaultCommand = String(defaults.verifyCommand || "npm test");
2223
- const parsedAction = String(parsed.job.action || "").trim();
2224
- const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
2225
- if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
2226
- parsed.job.verifyCommand = parsedAction;
2227
- parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
2228
- } else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
2229
- parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
2230
- }
2231
- }
2266
+ applyTestfixPreset(parsed.job, defaults);
2232
2267
  if (parsed.job.watchPaths.length)
2233
2268
  parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
2234
2269
  if (!parsed.job.activeRecoveryMs) {
@@ -2753,13 +2788,14 @@ ${text.trim().slice(0, 20000)}`);
2753
2788
  const branch = safeID(job.branch);
2754
2789
  const inRepo = await runProcess2("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
2755
2790
  if (inRepo.code !== 0) {
2756
- job.branchDone = true;
2791
+ job.branchDone = false;
2792
+ job.branchUnavailable = true;
2757
2793
  return job;
2758
2794
  }
2759
2795
  let result = await runProcess2("git", ["switch", branch], directory, 30000);
2760
2796
  if (result.code !== 0)
2761
2797
  result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
2762
- job.branchDone = true;
2798
+ job.branchDone = result.code === 0;
2763
2799
  await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
2764
2800
  await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
2765
2801
  return job;
@@ -2799,6 +2835,10 @@ ${text.trim().slice(0, 20000)}`);
2799
2835
  async function untilReached(directory, job) {
2800
2836
  if (!job.until)
2801
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()));
2802
2842
  const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
2803
2843
  for (const file of files)
2804
2844
  if (await fileContains(path8.resolve(directory, file), job.until))
@@ -2819,6 +2859,8 @@ ${text.trim().slice(0, 20000)}`);
2819
2859
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
2820
2860
  continue;
2821
2861
  const full = path8.join(current, entry.name);
2862
+ if (controlRoots.has(path8.resolve(full).toLowerCase()))
2863
+ continue;
2822
2864
  if (entry.isDirectory()) {
2823
2865
  if (await walk(full))
2824
2866
  return true;
@@ -2971,8 +3013,10 @@ function createSessionStatusRuntime(options = {}) {
2971
3013
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
2972
3014
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
2973
3015
  return false;
2974
- if (!options.requireIdle && !options.forceStale)
2975
- return true;
3016
+ if (!options.requireIdle && !options.forceStale) {
3017
+ const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
3018
+ return completion !== "incomplete";
3019
+ }
2976
3020
  const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2977
3021
  if (settledAssistantCompletion(completion))
2978
3022
  return true;
@@ -3048,7 +3092,11 @@ function createSessionStatusRuntime(options = {}) {
3048
3092
  ...completion === "completed" ? {} : { staleStatus: live.type }
3049
3093
  };
3050
3094
  const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
3051
- await appendLoopLog2(directory, recoveryEvent, logDetails);
3095
+ active.statusRecoveryLogEvents ??= new Set;
3096
+ if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
3097
+ active.statusRecoveryLogEvents.add(recoveryEvent);
3098
+ await appendLoopLog2(directory, recoveryEvent, logDetails);
3099
+ }
3052
3100
  return "idle";
3053
3101
  }
3054
3102
  }
@@ -3280,8 +3328,10 @@ function messageText(message) {
3280
3328
  return "";
3281
3329
  }
3282
3330
  async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3283
- if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3284
- return { job, terminal: false, pausedNow: false };
3331
+ const completionBounded = isCompletionBoundedContinuation(job?.action);
3332
+ const continuation = completionBounded || isContinuationShorthand(job?.action);
3333
+ if (job?.scheduleMode !== "idle" || !continuation) {
3334
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false };
3285
3335
  }
3286
3336
  const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3287
3337
  if (!messages)
@@ -3299,11 +3349,31 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3299
3349
  return { job, terminal: false, pausedNow: false };
3300
3350
  }
3301
3351
  const text = messageText(tail);
3352
+ const waitingUser = isWaitingUserReply(text);
3353
+ if (waitingUser) {
3354
+ if (job.terminalNoWorkCount)
3355
+ job.terminalNoWorkCount = 0;
3356
+ job.waitingUserCount = (job.waitingUserCount || 0) + 1;
3357
+ job.lastWaitingUserAt = Date.now();
3358
+ job.lastWaitingUserSummary = text.slice(0, 1000);
3359
+ const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2);
3360
+ const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused;
3361
+ if (waitingUserPausedNow) {
3362
+ job.paused = true;
3363
+ job.lastFailureReason = "waiting_user";
3364
+ }
3365
+ return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text };
3366
+ }
3367
+ if (job.waitingUserCount)
3368
+ job.waitingUserCount = 0;
3369
+ if (!completionBounded) {
3370
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3371
+ }
3302
3372
  const terminal = isTerminalNoWorkReply(text);
3303
3373
  if (!terminal) {
3304
3374
  if (job.terminalNoWorkCount)
3305
3375
  job.terminalNoWorkCount = 0;
3306
- return { job, terminal: false, pausedNow: false, text };
3376
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3307
3377
  }
3308
3378
  job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3309
3379
  job.lastTerminalNoWorkAt = Date.now();
@@ -3314,7 +3384,7 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3314
3384
  job.paused = true;
3315
3385
  job.lastFailureReason = "terminal_no_work";
3316
3386
  }
3317
- return { job, terminal: true, pausedNow, text };
3387
+ return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
3318
3388
  }
3319
3389
 
3320
3390
  // src/source/runtime/run-finalization.js
@@ -3408,6 +3478,16 @@ exit=` + postrun.code + `
3408
3478
  }
3409
3479
  const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3410
3480
  job = terminal.job;
3481
+ if (terminal.waitingUserPausedNow) {
3482
+ await appendLoopLog2(directory, "waiting-user", {
3483
+ sessionID,
3484
+ job: job.name || job.id,
3485
+ count: job.waitingUserCount,
3486
+ summary: String(terminal.text || "").slice(0, 1000)
3487
+ });
3488
+ await notifyJob2(directory, job, "waiting_user");
3489
+ await toast2(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning");
3490
+ }
3411
3491
  if (terminal.pausedNow) {
3412
3492
  await appendLoopLog2(directory, "terminal-no-work", {
3413
3493
  sessionID,
@@ -3615,6 +3695,25 @@ function requireFunction10(value, label) {
3615
3695
  throw new TypeError(`createLoopExecutor requires ${label}`);
3616
3696
  return value;
3617
3697
  }
3698
+ function sessionErrorDetails(event, fallbackMessage) {
3699
+ if (event?.type !== "session.error")
3700
+ return;
3701
+ const sessionID = event?.properties?.sessionID;
3702
+ if (typeof sessionID !== "string" || !sessionID)
3703
+ return;
3704
+ const error = event?.properties?.error;
3705
+ const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "SessionError";
3706
+ const data = error && typeof error === "object" && error.data && typeof error.data === "object" ? error.data : undefined;
3707
+ const message = typeof data?.message === "string" && data.message.trim() ? data.message.trim() : fallbackMessage(error);
3708
+ return {
3709
+ sessionID,
3710
+ error,
3711
+ name,
3712
+ message,
3713
+ aborted: name === "MessageAbortedError",
3714
+ retryable: name === "APIError" && data?.isRetryable === true
3715
+ };
3716
+ }
3618
3717
  function createLoopExecutor(options = {}) {
3619
3718
  const workspace = options.workspace || {};
3620
3719
  const goalPolicy = options.goalPolicy || {};
@@ -3793,6 +3892,56 @@ function createLoopExecutor(options = {}) {
3793
3892
  await scheduleDueWork(directory, client, sessionID, retryDelay);
3794
3893
  return true;
3795
3894
  }
3895
+ async function handleSessionError(directory, client, event) {
3896
+ const details = sessionErrorDetails(event, errorMessage);
3897
+ if (!details || details.aborted)
3898
+ return false;
3899
+ const active = activeRuns.get(details.sessionID);
3900
+ if (!active || active.compactionOnly)
3901
+ return false;
3902
+ const snapshot = active;
3903
+ clearActiveRun(details.sessionID);
3904
+ clearSessionStatus(details.sessionID);
3905
+ if (details.retryable) {
3906
+ const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
3907
+ reason: "session_error_retryable",
3908
+ error: details.message
3909
+ });
3910
+ await appendLoopLog2(directory, "provider-session-error-retry", {
3911
+ sessionID: details.sessionID,
3912
+ job: refunded.job?.name || snapshot.jobId,
3913
+ errorName: details.name,
3914
+ error: details.message,
3915
+ retryInMs: refunded.delayMs
3916
+ });
3917
+ await toast2(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning");
3918
+ await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs);
3919
+ return true;
3920
+ }
3921
+ const state = await readState2(directory, details.sessionID);
3922
+ const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId);
3923
+ if (job) {
3924
+ job.failureCount = (job.failureCount || 0) + 1;
3925
+ job.paused = true;
3926
+ job.lastFailureReason = "session_error";
3927
+ job.lastSessionErrorName = details.name;
3928
+ job.lastSessionError = details.message.slice(0, 4000);
3929
+ job.lastSessionErrorAt = now2();
3930
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3931
+ await writeState2(directory, details.sessionID, state);
3932
+ await notifyJob2(directory, job, "session_error");
3933
+ }
3934
+ await appendLoopLog2(directory, "session-error", {
3935
+ sessionID: details.sessionID,
3936
+ job: job?.name || snapshot.jobId,
3937
+ errorName: details.name,
3938
+ error: details.message,
3939
+ paused: Boolean(job?.paused)
3940
+ });
3941
+ await toast2(client, `Loop paused after a terminal OpenCode session error: ${details.message}`, "error");
3942
+ await scheduleDueWork(directory, client, details.sessionID);
3943
+ return true;
3944
+ }
3796
3945
  async function recoverStuckProviderRetry(directory, client, sessionID) {
3797
3946
  const active = activeRuns.get(sessionID);
3798
3947
  if (!active || active.compactionOnly) {
@@ -4067,6 +4216,7 @@ function createLoopExecutor(options = {}) {
4067
4216
  clearActiveRun,
4068
4217
  disposeSession,
4069
4218
  recoverActiveDispatchFailure,
4219
+ handleSessionError,
4070
4220
  recoverStuckProviderRetry,
4071
4221
  finalizeActiveRun,
4072
4222
  fireAction,
@@ -4139,6 +4289,7 @@ function createGoalSteeringRuntime(options = {}) {
4139
4289
  const now2 = typeof options.now === "function" ? options.now : now;
4140
4290
  const suppressionMs = Number.isFinite(Number(options.suppressionMs)) && Number(options.suppressionMs) > 0 ? Number(options.suppressionMs) : DEFAULT_STEERING_SUPPRESSION_MS;
4141
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;
4142
4293
  const pendingSteering = new Map;
4143
4294
  const seenUserMessages = new Map;
4144
4295
  function pendingForSession(sessionID) {
@@ -4212,7 +4363,7 @@ function createGoalSteeringRuntime(options = {}) {
4212
4363
  return { handled: false, sessionID, messageID };
4213
4364
  const active = getActiveRun(sessionID);
4214
4365
  const activeGoalIDs = new Set(goals.map((goal) => goal.id));
4215
- 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";
4216
4367
  let preempted = false;
4217
4368
  let abortError = "";
4218
4369
  if (canPreempt) {
@@ -4224,7 +4375,8 @@ function createGoalSteeringRuntime(options = {}) {
4224
4375
  });
4225
4376
  try {
4226
4377
  await fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
4227
- clearActiveRun(sessionID);
4378
+ if (getActiveRun(sessionID) === active)
4379
+ clearActiveRun(sessionID);
4228
4380
  preempted = true;
4229
4381
  } catch (error) {
4230
4382
  pendingSteering.delete(sessionID);
@@ -4245,9 +4397,20 @@ function createGoalSteeringRuntime(options = {}) {
4245
4397
  const user = userMessageFromEvent(event);
4246
4398
  if (!user)
4247
4399
  return;
4248
- 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);
4249
4411
  }
4250
4412
  function clearSession(sessionID) {
4413
+ compactingSessions.delete(sessionID);
4251
4414
  pendingSteering.delete(sessionID);
4252
4415
  const prefix = `${sessionID}\x00`;
4253
4416
  for (const key of seenUserMessages.keys())
@@ -4261,6 +4424,7 @@ function createGoalSteeringRuntime(options = {}) {
4261
4424
  shouldSuppressIdle,
4262
4425
  hasPendingSteering: shouldSuppressIdle,
4263
4426
  pendingForSession,
4427
+ setCompacting,
4264
4428
  clearSession
4265
4429
  };
4266
4430
  }
@@ -4288,6 +4452,7 @@ var executorRuntime = createLoopExecutor({
4288
4452
  });
4289
4453
  var {
4290
4454
  clearActiveRun,
4455
+ handleSessionError,
4291
4456
  finalizeActiveRun,
4292
4457
  maybeRunDueJobs: runDueJobs,
4293
4458
  sessionIsIdle,
@@ -4486,10 +4651,10 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4486
4651
  "command.execute.before": async (input, output) => {
4487
4652
  await handleCommand(directory, client, input, undefined, undefined, output);
4488
4653
  },
4489
- "chat.message": async (input) => {
4654
+ "chat.message": async (input, output) => {
4490
4655
  const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
4491
4656
  sessionID: input?.sessionID,
4492
- messageID: input?.messageID
4657
+ messageID: input?.messageID || output?.message?.id
4493
4658
  });
4494
4659
  if (steering?.handled && steering.sessionID)
4495
4660
  rememberSession(directory, client, steering.sessionID);
@@ -4501,9 +4666,13 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4501
4666
  markToolCallFinished(input);
4502
4667
  },
4503
4668
  "experimental.session.compacting": async (input) => {
4669
+ goalSteeringRuntime.setCompacting(input?.sessionID);
4504
4670
  await noteLoopCompactionStarted(directory, input?.sessionID);
4505
4671
  },
4506
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
+ }
4507
4676
  if (event.type === "session.compacted")
4508
4677
  await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID);
4509
4678
  updateSessionRelationshipFromEvent(event);
@@ -4517,6 +4686,11 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4517
4686
  const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
4518
4687
  if (steering?.handled && steering.sessionID)
4519
4688
  rememberSession(directory, client, steering.sessionID);
4689
+ if (event.type === "session.error") {
4690
+ const handledError = await handleSessionError(directory, client, event);
4691
+ if (handledError && event?.properties?.sessionID)
4692
+ rememberSession(directory, client, event.properties.sessionID);
4693
+ }
4520
4694
  const statusUpdate = updateSessionStatusFromEvent(event);
4521
4695
  if (statusUpdate?.sessionID)
4522
4696
  rememberSession(directory, client, statusUpdate.sessionID);