@bybrawe/opencode-loop 0.5.36 → 0.5.38

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 (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -6
  4. package/scripts/comprehensive-test.mjs +7 -4
  5. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  6. package/scripts/install-node.mjs +4 -2
  7. package/scripts/install-test.mjs +5 -0
  8. package/scripts/network-recovery-test.mjs +38 -1
  9. package/scripts/opencode2-loop-canary.mjs +48 -7
  10. package/scripts/publish-workflow-test.mjs +5 -0
  11. package/scripts/run-finalization-test.mjs +33 -0
  12. package/scripts/server-entry-test.mjs +15 -0
  13. package/scripts/session-error-recovery-test.mjs +186 -0
  14. package/scripts/session-status-test.mjs +30 -0
  15. package/scripts/smoke-test.mjs +21 -0
  16. package/scripts/v2-command-adapter-test.mjs +14 -2
  17. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  18. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  19. package/src/index.js +257 -96
  20. package/src/server.js +6451 -0
  21. package/src/source/core/continuation.js +23 -0
  22. package/src/source/legacy-v1.js +34 -6
  23. package/src/source/opencode2/capabilities.js +1 -0
  24. package/src/source/opencode2/commands.js +44 -8
  25. package/src/source/opencode2/events.js +11 -0
  26. package/src/source/opencode2/experimental.js +51 -7
  27. package/src/source/opencode2/prompt-runtime.js +36 -6
  28. package/src/source/opencode2/runtime-adapter.js +15 -2
  29. package/src/source/runtime/executor.js +83 -0
  30. package/src/source/runtime/run-finalization.js +10 -0
  31. package/src/source/runtime/session-status.js +5 -1
  32. package/src/source/runtime/terminal-guard.js +32 -7
  33. package/src/source/server.js +18 -0
package/src/index.js CHANGED
@@ -926,9 +926,9 @@ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
926
926
  const entry = loopOwnedUserMessageGuards.get(sessionID);
927
927
  if (!entry || typeof entry !== "object")
928
928
  return false;
929
- for (const [id2, expiresAt] of entry.messageIDs.entries())
929
+ for (const [id, expiresAt] of entry.messageIDs.entries())
930
930
  if (expiresAt < now())
931
- entry.messageIDs.delete(id2);
931
+ entry.messageIDs.delete(id);
932
932
  const id = typeof messageID === "string" ? messageID : "";
933
933
  if (id && entry.messageIDs.has(id))
934
934
  return true;
@@ -1086,6 +1086,19 @@ var NEXT_WORK_PATTERNS = [
1086
1086
  /\bremaining (?:work|task|todo|step)/i,
1087
1087
  /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1088
1088
  ];
1089
+ var WAITING_USER_PATTERNS = [
1090
+ /(?:^|\n)\s*onay bekleyenler\s*:/i,
1091
+ /(?:^|\n)\s*(?:kullan\u0131c\u0131|user) (?:onay\u0131|onayi|aksiyonu|i\u015Flemi|islemi|eri\u015Fimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1092
+ /\b(?:senin|sizin) (?:onay\u0131n|onay\u0131n\u0131z|onayiniz|aksiyonun|aksiyonunuz|eri\u015Fimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1093
+ /\b(?:onay|eri\u015Fim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|i\u015Flem yapamam|islem yapamam)\b/i,
1094
+ /(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
1095
+ /\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
1096
+ /\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i
1097
+ ];
1098
+ var WAITING_USER_NEGATIONS = [
1099
+ /\b(?:onay|eri\u015Fim|erisim|yetki) (?:gerekmiyor|gerekli de\u011Fil|gerekli degil|beklenmiyor)\b/i,
1100
+ /\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i
1101
+ ];
1089
1102
  function isContinuationShorthand(value) {
1090
1103
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1091
1104
  }
@@ -1093,6 +1106,16 @@ function isCompletionBoundedContinuation(value) {
1093
1106
  const text = String(value || "").trim();
1094
1107
  return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1095
1108
  }
1109
+ function isWaitingUserReply(value) {
1110
+ const text = String(value || "").trim();
1111
+ if (!text)
1112
+ return false;
1113
+ if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text)))
1114
+ return false;
1115
+ if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1116
+ return false;
1117
+ return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text));
1118
+ }
1096
1119
  function isTerminalNoWorkReply(value) {
1097
1120
  const text = String(value || "").trim();
1098
1121
  if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
@@ -1617,8 +1640,8 @@ function createGoalCommandHandlers(options = {}) {
1617
1640
  const addLoop = requireFunction2(options.addLoop, "addLoop");
1618
1641
  const scheduleDueWork = requireFunction2(options.scheduleDueWork, "scheduleDueWork");
1619
1642
  const scheduleIdleWork = requireFunction2(options.scheduleIdleWork, "scheduleIdleWork");
1620
- const toast2 = requireFunction2(options.toast, "toast");
1621
- const say2 = requireFunction2(options.say, "say");
1643
+ const toast = requireFunction2(options.toast, "toast");
1644
+ const say = requireFunction2(options.say, "say");
1622
1645
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
1623
1646
  const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
1624
1647
  const setGoalComplete2 = typeof options.setGoalComplete === "function" ? options.setGoalComplete : setGoalComplete;
@@ -1635,8 +1658,8 @@ function createGoalCommandHandlers(options = {}) {
1635
1658
  const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
1636
1659
  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}`;
1637
1660
  }) : ["No experimental goal jobs."];
1638
- await toast2(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
1639
- await say2(client, sessionID, `OpenCode Loop experimental goal status:
1661
+ await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
1662
+ await say(client, sessionID, `OpenCode Loop experimental goal status:
1640
1663
  ` + lines.join(`
1641
1664
  `));
1642
1665
  }
@@ -1647,7 +1670,7 @@ function createGoalCommandHandlers(options = {}) {
1647
1670
  state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
1648
1671
  await writeState2(directory, sessionID, state);
1649
1672
  await scheduleDueWork(directory, client, sessionID);
1650
- await toast2(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
1673
+ await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
1651
1674
  }
1652
1675
  async function resumeGoal(directory, client, sessionID, args) {
1653
1676
  const target = String(args || "").trim() || "goal";
@@ -1660,7 +1683,7 @@ function createGoalCommandHandlers(options = {}) {
1660
1683
  return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
1661
1684
  });
1662
1685
  await writeState2(directory, sessionID, state);
1663
- await toast2(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
1686
+ await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
1664
1687
  if (count) {
1665
1688
  await scheduleDueWork(directory, client, sessionID);
1666
1689
  scheduleIdleWork(directory, client, sessionID);
@@ -1673,15 +1696,15 @@ function createGoalCommandHandlers(options = {}) {
1673
1696
  state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
1674
1697
  await writeState2(directory, sessionID, state);
1675
1698
  await scheduleDueWork(directory, client, sessionID);
1676
- await toast2(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
1699
+ await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
1677
1700
  }
1678
1701
  async function completeGoalCommand(directory, client, sessionID, args) {
1679
1702
  const result = await setGoalComplete2(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
1680
- await toast2(client, result.message, result.ok ? "success" : "warning");
1703
+ await toast(client, result.message, result.ok ? "success" : "warning");
1681
1704
  }
1682
1705
  async function blockGoalCommand(directory, client, sessionID, args) {
1683
1706
  const result = await setGoalBlocked2(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
1684
- await toast2(client, result.message, "warning");
1707
+ await toast(client, result.message, "warning");
1685
1708
  }
1686
1709
  async function addGoal(directory, client, sessionID, args) {
1687
1710
  const text = String(args || "").trim();
@@ -1930,8 +1953,8 @@ function createLoopCommandHandlers(options = {}) {
1930
1953
  const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
1931
1954
  const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
1932
1955
  const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
1933
- const toast2 = requireFunction3(options.toast, "toast");
1934
- const say2 = requireFunction3(options.say, "say");
1956
+ const toast = requireFunction3(options.toast, "toast");
1957
+ const say = requireFunction3(options.say, "say");
1935
1958
  const now2 = typeof options.now === "function" ? options.now : now;
1936
1959
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
1937
1960
  const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
@@ -1951,7 +1974,7 @@ function createLoopCommandHandlers(options = {}) {
1951
1974
  clearActiveRun(sessionID);
1952
1975
  cancelDueWork(sessionID);
1953
1976
  stopWatchdog(sessionID);
1954
- await toast2(client, "All loops stopped for this session.", "success");
1977
+ await toast(client, "All loops stopped for this session.", "success");
1955
1978
  return;
1956
1979
  }
1957
1980
  const state = await readState2(directory, sessionID);
@@ -1959,7 +1982,7 @@ function createLoopCommandHandlers(options = {}) {
1959
1982
  state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
1960
1983
  await writeState2(directory, sessionID, state);
1961
1984
  await scheduleDueWork(directory, client, sessionID);
1962
- await toast2(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
1985
+ await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
1963
1986
  }
1964
1987
  async function updateJobState(directory, client, sessionID, args, updater, message) {
1965
1988
  const target = String(args || "").trim() || "all";
@@ -1968,7 +1991,7 @@ function createLoopCommandHandlers(options = {}) {
1968
1991
  state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
1969
1992
  await writeState2(directory, sessionID, state);
1970
1993
  await scheduleDueWork(directory, client, sessionID);
1971
- await toast2(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
1994
+ await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
1972
1995
  }
1973
1996
  async function statusLoop(directory, client, sessionID) {
1974
1997
  const state = await readState2(directory, sessionID);
@@ -1979,8 +2002,8 @@ function createLoopCommandHandlers(options = {}) {
1979
2002
  const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, 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(",");
1980
2003
  return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | schedule=${scheduling.schedule} | state=${scheduling.state} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | ${flags}`;
1981
2004
  }) : ["No active loop jobs."];
1982
- await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
1983
- await say2(client, sessionID, `OpenCode loop status:
2005
+ await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
2006
+ await say(client, sessionID, `OpenCode loop status:
1984
2007
  ` + lines.join(`
1985
2008
  `));
1986
2009
  }
@@ -1990,11 +2013,11 @@ function createLoopCommandHandlers(options = {}) {
1990
2013
  text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
1991
2014
  `) || text;
1992
2015
  } catch {}
1993
- await say2(client, sessionID, `OpenCode loop logs:
2016
+ await say(client, sessionID, `OpenCode loop logs:
1994
2017
  ` + text);
1995
2018
  }
1996
2019
  async function helpLoop(client, sessionID) {
1997
- await say2(client, sessionID, [
2020
+ await say(client, sessionID, [
1998
2021
  "OpenCode Loop help:",
1999
2022
  "/loop continue the project auto-continue forever whenever the session becomes idle",
2000
2023
  "/loop idle continue the project explicit form of the same idle loop",
@@ -2028,7 +2051,7 @@ function createLoopCommandHandlers(options = {}) {
2028
2051
  count += 1;
2029
2052
  }
2030
2053
  await writeState2(directory, sessionID, state);
2031
- await toast2(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
2054
+ await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
2032
2055
  if (count)
2033
2056
  await scheduleDueWork(directory, client, sessionID);
2034
2057
  }
@@ -2058,23 +2081,23 @@ function createLoopCommandHandlers(options = {}) {
2058
2081
  }
2059
2082
  if (otherSessions.length > 8)
2060
2083
  lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
2061
- await say2(client, sessionID, lines.join(`
2084
+ await say(client, sessionID, lines.join(`
2062
2085
  `));
2063
2086
  }
2064
2087
  async function initLoop(directory, client, sessionID, args) {
2065
2088
  const target = String(args || "").trim() || "progress.md";
2066
2089
  const full = path7.resolve(directory, target);
2067
2090
  if (await pathExists2(full)) {
2068
- await toast2(client, `${target} already exists.`, "warning");
2091
+ await toast(client, `${target} already exists.`, "warning");
2069
2092
  return;
2070
2093
  }
2071
2094
  await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
2072
- await toast2(client, `Created ${target}.`, "success");
2095
+ await toast(client, `Created ${target}.`, "success");
2073
2096
  await appendLoopLog2(directory, "init", { sessionID, file: target });
2074
2097
  }
2075
2098
  async function exportLoop(directory, client, sessionID) {
2076
2099
  const state = await readState2(directory, sessionID);
2077
- await say2(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
2100
+ await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
2078
2101
  }
2079
2102
  return {
2080
2103
  stopLoop,
@@ -2184,8 +2207,8 @@ function createLoopRegistration(options = {}) {
2184
2207
  const snapshotPaths = requireFunction4(options.snapshotPaths, "snapshotPaths");
2185
2208
  const scheduleDueWork = requireFunction4(options.scheduleDueWork, "scheduleDueWork");
2186
2209
  const scheduleIdleWork = requireFunction4(options.scheduleIdleWork, "scheduleIdleWork");
2187
- const toast2 = requireFunction4(options.toast, "toast");
2188
- const say2 = requireFunction4(options.say, "say");
2210
+ const toast = requireFunction4(options.toast, "toast");
2211
+ const say = requireFunction4(options.say, "say");
2189
2212
  const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
2190
2213
  const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
2191
2214
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
@@ -2199,12 +2222,12 @@ function createLoopRegistration(options = {}) {
2199
2222
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
2200
2223
  const normalized = normalizeLoopScheduleArgs2(args, defaults);
2201
2224
  if (!normalized.ok) {
2202
- await toast2(client, normalized.error, "warning");
2225
+ await toast(client, normalized.error, "warning");
2203
2226
  return;
2204
2227
  }
2205
2228
  const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
2206
2229
  if (!parsed.ok) {
2207
- await toast2(client, parsed.error, "warning");
2230
+ await toast(client, parsed.error, "warning");
2208
2231
  return;
2209
2232
  }
2210
2233
  parsed.job.scheduleMode = normalized.scheduleMode;
@@ -2243,13 +2266,13 @@ function createLoopRegistration(options = {}) {
2243
2266
  job: parsed.job.name || parsed.job.id,
2244
2267
  goal: dedicatedGoal.id
2245
2268
  });
2246
- await toast2(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning");
2269
+ await toast(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning");
2247
2270
  return;
2248
2271
  }
2249
2272
  }
2250
2273
  if (parsed.job.dryRun) {
2251
- await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
2252
- await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
2274
+ await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
2275
+ await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
2253
2276
  return;
2254
2277
  }
2255
2278
  const state = await readState2(directory, sessionID);
@@ -2273,7 +2296,7 @@ function createLoopRegistration(options = {}) {
2273
2296
  await scheduleDueWork(directory, client, sessionID);
2274
2297
  if (parsed.job.immediate)
2275
2298
  scheduleIdleWork(directory, client, sessionID);
2276
- await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
2299
+ await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
2277
2300
  await appendLoopLog2(directory, replaced ? "replace" : "add", {
2278
2301
  sessionID,
2279
2302
  job: parsed.job.name || parsed.job.id,
@@ -2400,19 +2423,19 @@ function clearSessionActivity(sessionID) {
2400
2423
  // src/source/runtime/scheduler-diagnostics.js
2401
2424
  var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
2402
2425
  function createSchedulerDiagnostics(options = {}) {
2403
- const now2 = typeof options.now === "function" ? options.now : Date.now;
2404
- const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
2426
+ const now = typeof options.now === "function" ? options.now : Date.now;
2427
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
2405
2428
  const configured = Number(options.throttleMs);
2406
2429
  const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
2407
2430
  const lastLogged = new Map;
2408
2431
  async function logDeferral(directory, sessionID, reason, extra = {}) {
2409
2432
  const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
2410
- const current = now2();
2433
+ const current = now();
2411
2434
  const previous = Number(lastLogged.get(key) || 0);
2412
2435
  if (previous > 0 && current - previous < throttleMs)
2413
2436
  return false;
2414
2437
  lastLogged.set(key, current);
2415
- await appendLoopLog2(directory, "deferred", {
2438
+ await appendLoopLog(directory, "deferred", {
2416
2439
  sessionID,
2417
2440
  reason,
2418
2441
  ...extra
@@ -2458,7 +2481,7 @@ function createSchedulerRuntime(options = {}) {
2458
2481
  if (options.appendLoopLog)
2459
2482
  await options.appendLoopLog(directory, event, extra);
2460
2483
  };
2461
- const toast2 = async (client, message, level) => {
2484
+ const toast = async (client, message, level) => {
2462
2485
  if (options.toast)
2463
2486
  await options.toast(client, message, level);
2464
2487
  };
@@ -2522,7 +2545,7 @@ function createSchedulerRuntime(options = {}) {
2522
2545
  }
2523
2546
  await options.maybeRunDueJobs?.(directory, client, sessionID);
2524
2547
  }).catch((error) => {
2525
- toast2(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
2548
+ toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
2526
2549
  appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {});
2527
2550
  });
2528
2551
  }, idleDebounceMs);
@@ -2534,13 +2557,13 @@ function createSchedulerRuntime(options = {}) {
2534
2557
  const timer = setIntervalFn(() => {
2535
2558
  Promise.resolve().then(async () => {
2536
2559
  const state = await readStateFn(directory, sessionID);
2537
- const delay2 = nextDueDelay(state, clock());
2560
+ const delay = nextDueDelay(state, clock());
2538
2561
  const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
2539
- if (!hasJobs || !Number.isFinite(delay2)) {
2562
+ if (!hasJobs || !Number.isFinite(delay)) {
2540
2563
  stopWatchdog(sessionID);
2541
2564
  return;
2542
2565
  }
2543
- if (delay2 <= 0)
2566
+ if (delay <= 0)
2544
2567
  await options.maybeRunDueJobs?.(directory, client, sessionID);
2545
2568
  else
2546
2569
  await scheduleDueWork(directory, client, sessionID);
@@ -2557,10 +2580,10 @@ function createSchedulerRuntime(options = {}) {
2557
2580
  async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
2558
2581
  cancelDueWork(sessionID);
2559
2582
  const state = await readStateFn(directory, sessionID);
2560
- const delay2 = nextDueDelay(state, clock());
2561
- if (!Number.isFinite(delay2))
2583
+ const delay = nextDueDelay(state, clock());
2584
+ if (!Number.isFinite(delay))
2562
2585
  return;
2563
- const wait = Math.min(Math.max(delay2, minDelayMs, minDueTimerMs), maxDueTimerMs);
2586
+ const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs);
2564
2587
  const timer = setTimeoutFn(() => {
2565
2588
  dueTimers.delete(sessionID);
2566
2589
  Promise.resolve().then(async () => {
@@ -2577,7 +2600,7 @@ function createSchedulerRuntime(options = {}) {
2577
2600
  }
2578
2601
  await options.maybeRunDueJobs?.(directory, client, sessionID);
2579
2602
  }).catch((error) => {
2580
- toast2(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
2603
+ toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
2581
2604
  appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {});
2582
2605
  });
2583
2606
  }, wait);
@@ -2616,9 +2639,9 @@ function requireFunction5(value, name) {
2616
2639
  return value;
2617
2640
  }
2618
2641
  function createGoalExecutionPolicy(options = {}) {
2619
- const runShellCommand2 = requireFunction5(options.runShellCommand, "runShellCommand");
2642
+ const runShellCommand = requireFunction5(options.runShellCommand, "runShellCommand");
2620
2643
  const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
2621
- const toast2 = requireFunction5(options.toast, "toast");
2644
+ const toast = requireFunction5(options.toast, "toast");
2622
2645
  const now2 = typeof options.now === "function" ? options.now : now;
2623
2646
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
2624
2647
  async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
@@ -2639,7 +2662,7 @@ function createGoalExecutionPolicy(options = {}) {
2639
2662
  job.paused = true;
2640
2663
  job.goalNoProgressPausedAt = now2();
2641
2664
  job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
2642
- await toast2(client, job.goalNoProgressReason, "warning");
2665
+ await toast(client, job.goalNoProgressReason, "warning");
2643
2666
  await appendLoopLog2(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
2644
2667
  }
2645
2668
  return job;
@@ -2653,7 +2676,7 @@ function createGoalExecutionPolicy(options = {}) {
2653
2676
  results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
2654
2677
  continue;
2655
2678
  }
2656
- const result = await runShellCommand2(command, directory, job.timeoutMs || 300000);
2679
+ const result = await runShellCommand(command, directory, job.timeoutMs || 300000);
2657
2680
  results.push({ command, code: result.code, output: (result.stdout + `
2658
2681
  ` + result.stderr).slice(0, 1200) });
2659
2682
  }
@@ -2663,7 +2686,7 @@ function createGoalExecutionPolicy(options = {}) {
2663
2686
  if (allPassed) {
2664
2687
  job.goalChecksPassedAt = now2();
2665
2688
  job.failureCount = 0;
2666
- await toast2(client, "Goal checks passed.", "success");
2689
+ await toast(client, "Goal checks passed.", "success");
2667
2690
  if (job.goalCompleteWhenChecksPass) {
2668
2691
  job.goalStatus = "completed";
2669
2692
  job.enabled = false;
@@ -2681,7 +2704,7 @@ exit=${item.code}
2681
2704
  ${item.output}`).join(`
2682
2705
 
2683
2706
  `).slice(0, 4000);
2684
- await toast2(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
2707
+ await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
2685
2708
  }
2686
2709
  await appendLoopLog2(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
2687
2710
  return job;
@@ -2716,7 +2739,7 @@ function dangerousShell(command) {
2716
2739
  ].some((pattern) => pattern.test(text));
2717
2740
  }
2718
2741
  function createJobWorkspaceRuntime(options = {}) {
2719
- const toast2 = requireFunction6(options.toast, "toast");
2742
+ const toast = requireFunction6(options.toast, "toast");
2720
2743
  const runProcess2 = typeof options.runProcess === "function" ? options.runProcess : runProcess;
2721
2744
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
2722
2745
  const readSmallTextFile2 = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : readSmallTextFile;
@@ -2760,7 +2783,7 @@ ${text.trim().slice(0, 20000)}`);
2760
2783
  if (result.code !== 0)
2761
2784
  result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
2762
2785
  job.branchDone = true;
2763
- await toast2(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
2786
+ await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
2764
2787
  await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
2765
2788
  return job;
2766
2789
  }
@@ -2854,7 +2877,7 @@ ${staged.stdout}`);
2854
2877
  await runProcess2("git", ["add", "-A"], directory, 120000);
2855
2878
  await runProcess2("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
2856
2879
  }
2857
- await toast2(client, `Loop checkpoint saved: ${prefix}`, "success");
2880
+ await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
2858
2881
  }
2859
2882
  return {
2860
2883
  buildPrompt,
@@ -2968,15 +2991,15 @@ function createSessionStatusRuntime(options = {}) {
2968
2991
  }
2969
2992
  return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
2970
2993
  }
2971
- async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
2994
+ async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
2972
2995
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
2973
2996
  return false;
2974
- if (!options2.requireIdle && !options2.forceStale)
2997
+ if (!options.requireIdle && !options.forceStale)
2975
2998
  return true;
2976
- const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2999
+ const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2977
3000
  if (settledAssistantCompletion(completion))
2978
3001
  return true;
2979
- if (!options2.requireIdle)
3002
+ if (!options.requireIdle)
2980
3003
  return completion === "unknown" && staleActiveRun(sessionID);
2981
3004
  const cached = sessionStatuses.get(sessionID);
2982
3005
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
@@ -2988,11 +3011,11 @@ function createSessionStatusRuntime(options = {}) {
2988
3011
  return true;
2989
3012
  }
2990
3013
  if (live?.type) {
2991
- if (live.type === "busy" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
3014
+ if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2992
3015
  return true;
2993
3016
  return false;
2994
3017
  }
2995
- if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
3018
+ if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2996
3019
  return true;
2997
3020
  return cachedIdleAfterRun;
2998
3021
  }
@@ -3015,7 +3038,7 @@ function createSessionStatusRuntime(options = {}) {
3015
3038
  });
3016
3039
  return true;
3017
3040
  }
3018
- async function sessionStatusType(client, sessionID, directory, options2 = {}) {
3041
+ async function sessionStatusType(client, sessionID, directory, options = {}) {
3019
3042
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
3020
3043
  markSessionStatus(sessionID, "busy");
3021
3044
  return "busy";
@@ -3035,7 +3058,7 @@ function createSessionStatusRuntime(options = {}) {
3035
3058
  if (live?.type) {
3036
3059
  if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
3037
3060
  return "idle";
3038
- if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
3061
+ if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
3039
3062
  const active = activeRuns.get(sessionID);
3040
3063
  if (active) {
3041
3064
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
@@ -3048,7 +3071,11 @@ function createSessionStatusRuntime(options = {}) {
3048
3071
  ...completion === "completed" ? {} : { staleStatus: live.type }
3049
3072
  };
3050
3073
  const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
3051
- await appendLoopLog2(directory, recoveryEvent, logDetails);
3074
+ active.statusRecoveryLogEvents ??= new Set;
3075
+ if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
3076
+ active.statusRecoveryLogEvents.add(recoveryEvent);
3077
+ await appendLoopLog2(directory, recoveryEvent, logDetails);
3078
+ }
3052
3079
  return "idle";
3053
3080
  }
3054
3081
  }
@@ -3060,8 +3087,8 @@ function createSessionStatusRuntime(options = {}) {
3060
3087
  markSessionStatus(sessionID, fallback);
3061
3088
  return fallback;
3062
3089
  }
3063
- async function sessionIsIdle(client, sessionID, directory, options2 = {}) {
3064
- return await sessionStatusType(client, sessionID, directory, options2) === "idle";
3090
+ async function sessionIsIdle(client, sessionID, directory, options = {}) {
3091
+ return await sessionStatusType(client, sessionID, directory, options) === "idle";
3065
3092
  }
3066
3093
  return {
3067
3094
  markSessionStatus,
@@ -3247,8 +3274,8 @@ function createActionDispatcher(options = {}) {
3247
3274
  const shellBody = { command, agent };
3248
3275
  if (model)
3249
3276
  shellBody.model = model;
3250
- const dispatch2 = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
3251
- return { startsAssistantTurn: true, dispatch: dispatch2 };
3277
+ const dispatch = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
3278
+ return { startsAssistantTurn: true, dispatch };
3252
3279
  }
3253
3280
  const prompt = await buildPrompt(directory, job);
3254
3281
  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.";
@@ -3280,8 +3307,10 @@ function messageText(message) {
3280
3307
  return "";
3281
3308
  }
3282
3309
  async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3283
- if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3284
- return { job, terminal: false, pausedNow: false };
3310
+ const completionBounded = isCompletionBoundedContinuation(job?.action);
3311
+ const continuation = completionBounded || isContinuationShorthand(job?.action);
3312
+ if (job?.scheduleMode !== "idle" || !continuation) {
3313
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false };
3285
3314
  }
3286
3315
  const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3287
3316
  if (!messages)
@@ -3299,11 +3328,31 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3299
3328
  return { job, terminal: false, pausedNow: false };
3300
3329
  }
3301
3330
  const text = messageText(tail);
3331
+ const waitingUser = isWaitingUserReply(text);
3332
+ if (waitingUser) {
3333
+ if (job.terminalNoWorkCount)
3334
+ job.terminalNoWorkCount = 0;
3335
+ job.waitingUserCount = (job.waitingUserCount || 0) + 1;
3336
+ job.lastWaitingUserAt = Date.now();
3337
+ job.lastWaitingUserSummary = text.slice(0, 1000);
3338
+ const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2);
3339
+ const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused;
3340
+ if (waitingUserPausedNow) {
3341
+ job.paused = true;
3342
+ job.lastFailureReason = "waiting_user";
3343
+ }
3344
+ return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text };
3345
+ }
3346
+ if (job.waitingUserCount)
3347
+ job.waitingUserCount = 0;
3348
+ if (!completionBounded) {
3349
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3350
+ }
3302
3351
  const terminal = isTerminalNoWorkReply(text);
3303
3352
  if (!terminal) {
3304
3353
  if (job.terminalNoWorkCount)
3305
3354
  job.terminalNoWorkCount = 0;
3306
- return { job, terminal: false, pausedNow: false, text };
3355
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3307
3356
  }
3308
3357
  job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3309
3358
  job.lastTerminalNoWorkAt = Date.now();
@@ -3314,7 +3363,7 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3314
3363
  job.paused = true;
3315
3364
  job.lastFailureReason = "terminal_no_work";
3316
3365
  }
3317
- return { job, terminal: true, pausedNow, text };
3366
+ return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
3318
3367
  }
3319
3368
 
3320
3369
  // src/source/runtime/run-finalization.js
@@ -3408,6 +3457,16 @@ exit=` + postrun.code + `
3408
3457
  }
3409
3458
  const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3410
3459
  job = terminal.job;
3460
+ if (terminal.waitingUserPausedNow) {
3461
+ await appendLoopLog2(directory, "waiting-user", {
3462
+ sessionID,
3463
+ job: job.name || job.id,
3464
+ count: job.waitingUserCount,
3465
+ summary: String(terminal.text || "").slice(0, 1000)
3466
+ });
3467
+ await notifyJob2(directory, job, "waiting_user");
3468
+ await toast2(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning");
3469
+ }
3411
3470
  if (terminal.pausedNow) {
3412
3471
  await appendLoopLog2(directory, "terminal-no-work", {
3413
3472
  sessionID,
@@ -3615,6 +3674,25 @@ function requireFunction10(value, label) {
3615
3674
  throw new TypeError(`createLoopExecutor requires ${label}`);
3616
3675
  return value;
3617
3676
  }
3677
+ function sessionErrorDetails(event, fallbackMessage) {
3678
+ if (event?.type !== "session.error")
3679
+ return;
3680
+ const sessionID = event?.properties?.sessionID;
3681
+ if (typeof sessionID !== "string" || !sessionID)
3682
+ return;
3683
+ const error = event?.properties?.error;
3684
+ const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "SessionError";
3685
+ const data = error && typeof error === "object" && error.data && typeof error.data === "object" ? error.data : undefined;
3686
+ const message = typeof data?.message === "string" && data.message.trim() ? data.message.trim() : fallbackMessage(error);
3687
+ return {
3688
+ sessionID,
3689
+ error,
3690
+ name,
3691
+ message,
3692
+ aborted: name === "MessageAbortedError",
3693
+ retryable: name === "APIError" && data?.isRetryable === true
3694
+ };
3695
+ }
3618
3696
  function createLoopExecutor(options = {}) {
3619
3697
  const workspace = options.workspace || {};
3620
3698
  const goalPolicy = options.goalPolicy || {};
@@ -3711,7 +3789,7 @@ function createLoopExecutor(options = {}) {
3711
3789
  toast: toast2,
3712
3790
  dangerousShell: dangerousShell2
3713
3791
  });
3714
- const dueJobs2 = admissionRuntime.dueJobs;
3792
+ const dueJobs = admissionRuntime.dueJobs;
3715
3793
  function clearActiveRun(sessionID) {
3716
3794
  const active = activeRuns.get(sessionID);
3717
3795
  if (active?.timer)
@@ -3793,6 +3871,56 @@ function createLoopExecutor(options = {}) {
3793
3871
  await scheduleDueWork(directory, client, sessionID, retryDelay);
3794
3872
  return true;
3795
3873
  }
3874
+ async function handleSessionError(directory, client, event) {
3875
+ const details = sessionErrorDetails(event, errorMessage);
3876
+ if (!details || details.aborted)
3877
+ return false;
3878
+ const active = activeRuns.get(details.sessionID);
3879
+ if (!active || active.compactionOnly)
3880
+ return false;
3881
+ const snapshot = active;
3882
+ clearActiveRun(details.sessionID);
3883
+ clearSessionStatus(details.sessionID);
3884
+ if (details.retryable) {
3885
+ const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
3886
+ reason: "session_error_retryable",
3887
+ error: details.message
3888
+ });
3889
+ await appendLoopLog2(directory, "provider-session-error-retry", {
3890
+ sessionID: details.sessionID,
3891
+ job: refunded.job?.name || snapshot.jobId,
3892
+ errorName: details.name,
3893
+ error: details.message,
3894
+ retryInMs: refunded.delayMs
3895
+ });
3896
+ await toast2(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning");
3897
+ await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs);
3898
+ return true;
3899
+ }
3900
+ const state = await readState2(directory, details.sessionID);
3901
+ const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId);
3902
+ if (job) {
3903
+ job.failureCount = (job.failureCount || 0) + 1;
3904
+ job.paused = true;
3905
+ job.lastFailureReason = "session_error";
3906
+ job.lastSessionErrorName = details.name;
3907
+ job.lastSessionError = details.message.slice(0, 4000);
3908
+ job.lastSessionErrorAt = now2();
3909
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3910
+ await writeState2(directory, details.sessionID, state);
3911
+ await notifyJob2(directory, job, "session_error");
3912
+ }
3913
+ await appendLoopLog2(directory, "session-error", {
3914
+ sessionID: details.sessionID,
3915
+ job: job?.name || snapshot.jobId,
3916
+ errorName: details.name,
3917
+ error: details.message,
3918
+ paused: Boolean(job?.paused)
3919
+ });
3920
+ await toast2(client, `Loop paused after a terminal OpenCode session error: ${details.message}`, "error");
3921
+ await scheduleDueWork(directory, client, details.sessionID);
3922
+ return true;
3923
+ }
3796
3924
  async function recoverStuckProviderRetry(directory, client, sessionID) {
3797
3925
  const active = activeRuns.get(sessionID);
3798
3926
  if (!active || active.compactionOnly) {
@@ -3934,7 +4062,7 @@ function createLoopExecutor(options = {}) {
3934
4062
  candidate.watchTriggered = true;
3935
4063
  }
3936
4064
  }
3937
- const due = dueJobs2(state, Boolean(runOptions.force));
4065
+ const due = dueJobs(state, Boolean(runOptions.force));
3938
4066
  if (!due.length) {
3939
4067
  await writeState2(directory, sessionID, state);
3940
4068
  await reschedule();
@@ -4063,10 +4191,11 @@ function createLoopExecutor(options = {}) {
4063
4191
  }
4064
4192
  }
4065
4193
  return {
4066
- dueJobs: dueJobs2,
4194
+ dueJobs,
4067
4195
  clearActiveRun,
4068
4196
  disposeSession,
4069
4197
  recoverActiveDispatchFailure,
4198
+ handleSessionError,
4070
4199
  recoverStuckProviderRetry,
4071
4200
  finalizeActiveRun,
4072
4201
  fireAction,
@@ -4288,6 +4417,7 @@ var executorRuntime = createLoopExecutor({
4288
4417
  });
4289
4418
  var {
4290
4419
  clearActiveRun,
4420
+ handleSessionError,
4291
4421
  finalizeActiveRun,
4292
4422
  maybeRunDueJobs: runDueJobs,
4293
4423
  sessionIsIdle,
@@ -4400,47 +4530,73 @@ function steeringToolRejection(sessionID) {
4400
4530
  output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged."
4401
4531
  };
4402
4532
  }
4533
+ async function dedicatedGoalToolRejection(directory, sessionID) {
4534
+ const goal = await findDedicatedGoalForSession(directory, sessionID);
4535
+ if (!goal || goal.status === "completed")
4536
+ return;
4537
+ return {
4538
+ title: "Dedicated Goal owns lifecycle",
4539
+ output: [
4540
+ `A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
4541
+ "The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
4542
+ "Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion."
4543
+ ].join(`
4544
+ `)
4545
+ };
4546
+ }
4403
4547
  function goalTools(defaultDirectory) {
4404
4548
  return {
4405
4549
  opencode_loop_goal_complete: tool({
4406
- 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.",
4550
+ description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
4407
4551
  args: {
4408
4552
  summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
4409
4553
  evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
4410
4554
  },
4411
4555
  execute: async (args, context) => {
4556
+ const directory = context.directory || defaultDirectory;
4557
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
4558
+ if (dedicated)
4559
+ return dedicated;
4412
4560
  const steering = steeringToolRejection(context.sessionID);
4413
4561
  if (steering)
4414
4562
  return steering;
4415
- const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args);
4563
+ const result = await setGoalComplete(directory, context.sessionID, args);
4416
4564
  return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
4417
4565
  }
4418
4566
  }),
4419
4567
  opencode_loop_goal_blocked: tool({
4420
- description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
4568
+ description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
4421
4569
  args: {
4422
4570
  reason: tool.schema.string().describe("Why the goal is blocked."),
4423
4571
  needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
4424
4572
  },
4425
4573
  execute: async (args, context) => {
4574
+ const directory = context.directory || defaultDirectory;
4575
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
4576
+ if (dedicated)
4577
+ return dedicated;
4426
4578
  const steering = steeringToolRejection(context.sessionID);
4427
4579
  if (steering)
4428
4580
  return steering;
4429
- const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args);
4581
+ const result = await setGoalBlocked(directory, context.sessionID, args);
4430
4582
  return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
4431
4583
  }
4432
4584
  }),
4433
4585
  opencode_loop_goal_progress: tool({
4434
- description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
4586
+ description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
4435
4587
  args: {
4436
4588
  summary: tool.schema.string().describe("What useful progress was made."),
4437
4589
  next: tool.schema.string().describe("The next step toward completing the goal.")
4438
4590
  },
4439
4591
  execute: async (args, context) => {
4592
+ const directory = context.directory || defaultDirectory;
4593
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
4594
+ if (dedicated)
4595
+ return dedicated;
4440
4596
  const steering = steeringToolRejection(context.sessionID);
4441
4597
  if (steering)
4442
4598
  return steering;
4443
- const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args);
4599
+ const result = await setGoalProgress(directory, context.sessionID, args);
4444
4600
  return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
4445
4601
  }
4446
4602
  })
@@ -4491,6 +4647,11 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4491
4647
  const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
4492
4648
  if (steering?.handled && steering.sessionID)
4493
4649
  rememberSession(directory, client, steering.sessionID);
4650
+ if (event.type === "session.error") {
4651
+ const handledError = await handleSessionError(directory, client, event);
4652
+ if (handledError && event?.properties?.sessionID)
4653
+ rememberSession(directory, client, event.properties.sessionID);
4654
+ }
4494
4655
  const statusUpdate = updateSessionStatusFromEvent(event);
4495
4656
  if (statusUpdate?.sessionID)
4496
4657
  rememberSession(directory, client, statusUpdate.sessionID);
@@ -4507,8 +4668,8 @@ var DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
4507
4668
  function sessionKey(sessionID) {
4508
4669
  return String(sessionID || "").trim();
4509
4670
  }
4510
- function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
4511
- if (typeof now2 !== "function")
4671
+ function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
4672
+ if (typeof now !== "function")
4512
4673
  throw new TypeError("session registry requires a clock function");
4513
4674
  if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0)
4514
4675
  throw new TypeError("session registry requires a non-negative staleAfterMs");
@@ -4517,7 +4678,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
4517
4678
  const key = sessionKey(sessionID);
4518
4679
  if (!key)
4519
4680
  throw new TypeError("session registry requires a session ID");
4520
- const seenAt = Number(now2());
4681
+ const seenAt = Number(now());
4521
4682
  if (!Number.isFinite(seenAt))
4522
4683
  throw new TypeError("session registry clock must return a finite number");
4523
4684
  const entry = Object.freeze({ sessionID: key, runtime, seenAt });
@@ -4536,7 +4697,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
4536
4697
  return false;
4537
4698
  return sessions.delete(key);
4538
4699
  }
4539
- function pruneStale(at = now2()) {
4700
+ function pruneStale(at = now()) {
4540
4701
  const timestamp = Number(at);
4541
4702
  if (!Number.isFinite(timestamp))
4542
4703
  throw new TypeError("session registry prune time must be finite");
@@ -4620,7 +4781,7 @@ function createRuntimeScope() {
4620
4781
  }
4621
4782
 
4622
4783
  // src/source/runtime/timers.js
4623
- function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
4784
+ function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
4624
4785
  if (!scope?.isActive?.())
4625
4786
  return;
4626
4787
  let active = true;
@@ -4635,7 +4796,7 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
4635
4796
  }
4636
4797
  callback(...args);
4637
4798
  });
4638
- handle = repeat ? api.setInterval(invoke, delay2) : api.setTimeout(invoke, delay2);
4799
+ handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay);
4639
4800
  release = scope.track(() => {
4640
4801
  if (repeat)
4641
4802
  api.clearInterval(handle);
@@ -4661,11 +4822,11 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
4661
4822
  }
4662
4823
  function createRuntimeTimers(scope, api = globalThis) {
4663
4824
  return Object.freeze({
4664
- timeout(callback, delay2, options = {}) {
4665
- return createOwnedTimer(scope, callback, delay2, false, api, options.ref !== false);
4825
+ timeout(callback, delay, options = {}) {
4826
+ return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false);
4666
4827
  },
4667
- interval(callback, delay2, options = {}) {
4668
- return createOwnedTimer(scope, callback, delay2, true, api, options.ref !== false);
4828
+ interval(callback, delay, options = {}) {
4829
+ return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false);
4669
4830
  }
4670
4831
  });
4671
4832
  }
@@ -4690,14 +4851,14 @@ function validateRuntime(runtime, sessionID) {
4690
4851
  return runtime;
4691
4852
  }
4692
4853
  function createSessionRuntimeManager({
4693
- now: now2 = Date.now,
4854
+ now = Date.now,
4694
4855
  staleAfterMs = DEFAULT_SESSION_STALE_MS,
4695
4856
  timerAPI = globalThis,
4696
4857
  runtimeFactory = defaultRuntimeFactory
4697
4858
  } = {}) {
4698
4859
  if (typeof runtimeFactory !== "function")
4699
4860
  throw new TypeError("session runtime manager requires a runtime factory");
4700
- const registry = createSessionRegistry({ now: now2, staleAfterMs });
4861
+ const registry = createSessionRegistry({ now, staleAfterMs });
4701
4862
  let disposed = false;
4702
4863
  function observeExternal(sessionID) {
4703
4864
  if (disposed)
@@ -4727,7 +4888,7 @@ function createSessionRuntimeManager({
4727
4888
  current.runtime.dispose(reason);
4728
4889
  return true;
4729
4890
  }
4730
- function pruneStale(at = now2()) {
4891
+ function pruneStale(at = now()) {
4731
4892
  const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]));
4732
4893
  const removed = registry.pruneStale(at);
4733
4894
  const errors = [];
@@ -4928,12 +5089,12 @@ function scopedClient(client) {
4928
5089
  });
4929
5090
  }
4930
5091
  function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
4931
- for (const delay2 of [0, 25, 100, 500, 2000, 1e4, 30000]) {
5092
+ for (const delay of [0, 25, 100, 500, 2000, 1e4, 30000]) {
4932
5093
  const timer = setTimeout(() => {
4933
5094
  if (!isCurrentGeneration())
4934
5095
  return;
4935
5096
  Promise.resolve(dispose()).catch(() => {});
4936
- }, delay2);
5097
+ }, delay);
4937
5098
  timer.unref?.();
4938
5099
  }
4939
5100
  }