@bybrawe/opencode-loop 0.5.33 → 0.5.35

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.
package/src/index.js CHANGED
@@ -1021,6 +1021,69 @@ function commandArgsText(args) {
1021
1021
  return String(args);
1022
1022
  }
1023
1023
 
1024
+ // src/source/core/continuation.js
1025
+ var CONTINUATION_SHORTHANDS = new Set([
1026
+ "continue",
1027
+ "continue.",
1028
+ "continue working",
1029
+ "keep going",
1030
+ "go on",
1031
+ "devam",
1032
+ "devam et",
1033
+ "devam et.",
1034
+ "devam et bakal\u0131m"
1035
+ ]);
1036
+ var COMPLETION_BOUNDED_PATTERNS = [
1037
+ /\bbitene kadar\b/i,
1038
+ /\b(?:tamamen|komple) projeyi bitir\b/i,
1039
+ /\bi\u015Fi bitir\b/i,
1040
+ /\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
1041
+ /\bfinish (?:the )?(?:project|task|work)\b/i,
1042
+ /\bkeep going until\b/i
1043
+ ];
1044
+ var TERMINAL_COMPLETION_PATTERNS = [
1045
+ /proje tamamland[\u0131i](?=\s|[.,;:!\u2014-]|$)/i,
1046
+ /\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
1047
+ /\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i
1048
+ ];
1049
+ var TERMINAL_NO_WORK_PATTERNS = [
1050
+ /yap[\u0131i]lacak (?:ba\u015Fka )?i\u015F yok(?=\s|[.,;:!\u2014-]|$)/i,
1051
+ /ba\u015Fka (?:bir )?i\u015F (?:kalmad[\u0131i]|yok)(?=\s|[.,;:!\u2014-]|$)/i,
1052
+ /\bnothing (?:else )?left to do\b/i,
1053
+ /\bno (?:more|remaining) work\b/i,
1054
+ /\bno known (?:bugs|issues)\b/i,
1055
+ /\bzero known (?:bugs|issues)\b/i
1056
+ ];
1057
+ var NEXT_WORK_PATTERNS = [
1058
+ /\bnext(?: step| task)?\b/i,
1059
+ /s[\u0131i]radaki(?=\s|[.,;:!\u2014-]|$)/i,
1060
+ /sonraki(?=\s|[.,;:!\u2014-]|$)/i,
1061
+ /kalan (?:i\u015F|i\u015Fler|todo|ad\u0131m)(?=\s|[.,;:!\u2014-]|$)/i,
1062
+ /\bremaining (?:work|task|todo|step)/i,
1063
+ /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1064
+ ];
1065
+ function isContinuationShorthand(value) {
1066
+ return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1067
+ }
1068
+ function isCompletionBoundedContinuation(value) {
1069
+ const text = String(value || "").trim();
1070
+ return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1071
+ }
1072
+ function isTerminalNoWorkReply(value) {
1073
+ const text = String(value || "").trim();
1074
+ if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1075
+ return false;
1076
+ const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text));
1077
+ const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text));
1078
+ return completed && noWork;
1079
+ }
1080
+ function continuationProjectInstruction(value) {
1081
+ if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value))
1082
+ return "";
1083
+ const finish = isCompletionBoundedContinuation(value) ? " If you believe the project is finished, perform a fresh verification pass before declaring completion; report both that the project is complete and that no work remains only when you have concrete current evidence." : "";
1084
+ return `Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical.${finish}`;
1085
+ }
1086
+
1024
1087
  // src/source/core/jobs.js
1025
1088
  function presetDefaults(name) {
1026
1089
  if (name === "loop-compact")
@@ -1086,6 +1149,9 @@ function actionKind(action, job = {}) {
1086
1149
  }
1087
1150
  function decoratePrompt(job) {
1088
1151
  const additions = [];
1152
+ const continuation = continuationProjectInstruction(job.action);
1153
+ if (continuation)
1154
+ additions.push(continuation);
1089
1155
  if (job.progressFile)
1090
1156
  additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
1091
1157
  if (job.lastVerifyFailure)
@@ -1623,8 +1689,185 @@ function createGoalCommandHandlers(options = {}) {
1623
1689
  }
1624
1690
 
1625
1691
  // src/source/opencode/loop-commands.js
1692
+ import { promises as fs6 } from "fs";
1693
+ import path7 from "path";
1694
+
1695
+ // src/source/runtime/companion-goal.js
1626
1696
  import { promises as fs4 } from "fs";
1627
1697
  import path5 from "path";
1698
+ function goalRoot(directory) {
1699
+ return path5.join(directory, ".opencode", "goals");
1700
+ }
1701
+ async function findDedicatedGoalForSession(directory, sessionID) {
1702
+ if (!directory || !sessionID)
1703
+ return;
1704
+ let names;
1705
+ try {
1706
+ names = await fs4.readdir(goalRoot(directory));
1707
+ } catch (error) {
1708
+ if (error?.code === "ENOENT")
1709
+ return;
1710
+ return;
1711
+ }
1712
+ for (const name of names) {
1713
+ if (!name.endsWith(".json"))
1714
+ continue;
1715
+ try {
1716
+ const value = JSON.parse(await fs4.readFile(path5.join(goalRoot(directory), name), "utf8"));
1717
+ if (value?.sessionID === sessionID)
1718
+ return value;
1719
+ } catch {}
1720
+ }
1721
+ return;
1722
+ }
1723
+ function dedicatedGoalOwnsContinuation(goal) {
1724
+ return goal?.status === "active";
1725
+ }
1726
+ function dedicatedGoalSummary(goal) {
1727
+ if (!goal)
1728
+ return "not detected";
1729
+ const id = String(goal.id || "unknown").slice(0, 12);
1730
+ const status = String(goal.status || "unknown");
1731
+ return `${status} (${id})`;
1732
+ }
1733
+
1734
+ // src/source/runtime/loop-diagnostics.js
1735
+ import { promises as fs5 } from "fs";
1736
+ import path6 from "path";
1737
+
1738
+ // src/source/runtime/schedule-policy.js
1739
+ var TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]);
1740
+ function inferredScheduleMode(job) {
1741
+ const explicit = String(job?.scheduleMode || "").toLowerCase();
1742
+ if (["idle", "interval", "once", "watch"].includes(explicit))
1743
+ return explicit;
1744
+ if (job?.watchPaths?.length)
1745
+ return "watch";
1746
+ if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0)
1747
+ return "once";
1748
+ return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval";
1749
+ }
1750
+ function jobRunnable(job) {
1751
+ if (!job)
1752
+ return false;
1753
+ if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus))
1754
+ return false;
1755
+ if (!job.enabled || job.paused)
1756
+ return false;
1757
+ if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0))
1758
+ return false;
1759
+ return true;
1760
+ }
1761
+ function jobDueAt(job, current = Date.now()) {
1762
+ if (!jobRunnable(job))
1763
+ return Infinity;
1764
+ if (Number(job.runNowRequestedAt || 0) > 0)
1765
+ return current;
1766
+ const created = Date.parse(job.createdAt || "");
1767
+ if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0))
1768
+ return current;
1769
+ if (job.watchPaths?.length)
1770
+ return job.watchTriggered === true ? current : Infinity;
1771
+ const intervalMs = Number(job.intervalMs || 0);
1772
+ if (intervalMs === 0)
1773
+ return current;
1774
+ const lastRunAt = Number(job.lastRunAt || 0);
1775
+ if (!lastRunAt) {
1776
+ if (job.immediate === false)
1777
+ return (Number.isFinite(created) ? created : current) + intervalMs;
1778
+ return current;
1779
+ }
1780
+ return lastRunAt + intervalMs;
1781
+ }
1782
+ function jobIsDue(job, current = Date.now(), force = false) {
1783
+ if (!jobRunnable(job))
1784
+ return false;
1785
+ if (force)
1786
+ return true;
1787
+ return jobDueAt(job, current) <= current;
1788
+ }
1789
+ function dueJobs(state, current = Date.now(), force = false) {
1790
+ return (state?.jobs || []).filter((job) => jobIsDue(job, current, force)).sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
1791
+ }
1792
+ function nextDueDelay(state, current = Date.now()) {
1793
+ let soonest = Infinity;
1794
+ for (const job of state?.jobs || [])
1795
+ soonest = Math.min(soonest, jobDueAt(job, current));
1796
+ if (!Number.isFinite(soonest))
1797
+ return Infinity;
1798
+ return Math.max(0, soonest - current);
1799
+ }
1800
+ function scheduleDescription(job) {
1801
+ const mode = inferredScheduleMode(job);
1802
+ const intervalMs = Number(job?.intervalMs || 0);
1803
+ if (mode === "idle")
1804
+ return "every idle";
1805
+ if (mode === "watch")
1806
+ return `on watch: ${(job.watchPaths || []).join(", ")}`;
1807
+ if (mode === "once")
1808
+ return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle";
1809
+ if (job?.immediate === false)
1810
+ return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`;
1811
+ return `every ${durationToText(intervalMs)}, starts on next idle`;
1812
+ }
1813
+ function scheduleState(job, current = Date.now()) {
1814
+ if (!job?.enabled)
1815
+ return "stopped";
1816
+ if (job?.paused)
1817
+ return "paused";
1818
+ if (Number(job?.runNowRequestedAt || 0) > 0)
1819
+ return "due now; waiting for idle";
1820
+ const mode = inferredScheduleMode(job);
1821
+ const dueAt = jobDueAt(job, current);
1822
+ if (!Number.isFinite(dueAt))
1823
+ return mode === "watch" ? "waiting for watched change" : "not scheduled";
1824
+ if (dueAt <= current)
1825
+ return mode === "idle" ? "waiting for idle" : "due; waiting for idle";
1826
+ return `due in ${durationToText(dueAt - current)}`;
1827
+ }
1828
+
1829
+ // src/source/runtime/loop-diagnostics.js
1830
+ function describeJobScheduling(job, current = Date.now()) {
1831
+ return {
1832
+ schedule: scheduleDescription(job),
1833
+ state: scheduleState(job, current)
1834
+ };
1835
+ }
1836
+ async function listPersistedLoopSessions(directory, currentSessionID) {
1837
+ const root = stateDir(directory);
1838
+ let names;
1839
+ try {
1840
+ names = await fs5.readdir(root);
1841
+ } catch (error) {
1842
+ if (error?.code === "ENOENT")
1843
+ return [];
1844
+ return [];
1845
+ }
1846
+ const sessions = [];
1847
+ for (const name of names) {
1848
+ if (!name.endsWith(".json"))
1849
+ continue;
1850
+ const sessionID = name.slice(0, -5);
1851
+ try {
1852
+ const parsed = JSON.parse(await fs5.readFile(path6.join(root, name), "utf8"));
1853
+ const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
1854
+ const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length;
1855
+ const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length;
1856
+ sessions.push({
1857
+ sessionID,
1858
+ current: sessionID === currentSessionID,
1859
+ jobs: jobs.length,
1860
+ enabled,
1861
+ neverRan
1862
+ });
1863
+ } catch {
1864
+ sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true });
1865
+ }
1866
+ }
1867
+ return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID));
1868
+ }
1869
+
1870
+ // src/source/opencode/loop-commands.js
1628
1871
  var SERVICE2 = "opencode-loop";
1629
1872
  var DEFAULT_PROGRESS_MD = `# Progress
1630
1873
 
@@ -1671,8 +1914,10 @@ function createLoopCommandHandlers(options = {}) {
1671
1914
  const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState;
1672
1915
  const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
1673
1916
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
1674
- const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs4.readFile(...args);
1675
- const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs4.writeFile(...args);
1917
+ const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
1918
+ const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs6.writeFile(...args);
1919
+ const listPersistedLoopSessions2 = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : listPersistedLoopSessions;
1920
+ const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
1676
1921
  const runtimeVersion = options.runtimeVersion || process.version;
1677
1922
  const runtimePlatform = options.runtimePlatform || process.platform;
1678
1923
  async function stopLoop(directory, client, sessionID, args) {
@@ -1704,15 +1949,11 @@ function createLoopCommandHandlers(options = {}) {
1704
1949
  async function statusLoop(directory, client, sessionID) {
1705
1950
  const state = await readState2(directory, sessionID);
1706
1951
  const jobs = state.jobs || [];
1952
+ const current = now2();
1707
1953
  const lines = jobs.length ? jobs.map((job, index) => {
1708
- const current = now2();
1709
- const intervalMs = Number(job.intervalMs || 0);
1710
- const lastRunAt = Number(job.lastRunAt || 0);
1711
- const createdAt = Date.parse(job.createdAt || "");
1712
- const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current;
1713
- const dueIn = Math.max(0, dueAt - current);
1954
+ const scheduling = describeJobScheduling(job, current);
1714
1955
  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(",");
1715
- return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
1956
+ 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}`;
1716
1957
  }) : ["No active loop jobs."];
1717
1958
  await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
1718
1959
  await say2(client, sessionID, `OpenCode loop status:
@@ -1722,7 +1963,7 @@ function createLoopCommandHandlers(options = {}) {
1722
1963
  async function logsLoop(directory, client, sessionID) {
1723
1964
  let text = "No loop log found.";
1724
1965
  try {
1725
- text = (await readFile(path5.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
1966
+ text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
1726
1967
  `) || text;
1727
1968
  } catch {}
1728
1969
  await say2(client, sessionID, `OpenCode loop logs:
@@ -1731,18 +1972,20 @@ function createLoopCommandHandlers(options = {}) {
1731
1972
  async function helpLoop(client, sessionID) {
1732
1973
  await say2(client, sessionID, [
1733
1974
  "OpenCode Loop help:",
1734
- "/loop 0s <prompt> Claude Code style auto-continue",
1735
- "/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
1736
- "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
1737
- "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
1738
- "/loop-shell 10m npm test shell loop, waits for idle",
1739
- "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
1740
- '/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it',
1741
- "/loop-goal status | pause | resume | clear manage experimental goals",
1742
- "/loop 200m --command /compact same as command loop",
1743
- '/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
1744
- "/loop 0s --prompt-file loop-prompt.md load prompt from a file",
1745
- "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
1975
+ "/loop continue the project auto-continue forever whenever the session becomes idle",
1976
+ "/loop idle continue the project explicit form of the same idle loop",
1977
+ "/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
1978
+ "/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
1979
+ "/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
1980
+ "/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
1981
+ "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
1982
+ "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
1983
+ "/loop-shell 10m npm test shell loop, waits for idle",
1984
+ "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
1985
+ '/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
1986
+ "/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
1987
+ "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
1988
+ "Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
1746
1989
  "/loop-doctor | /loop-init | /loop-export"
1747
1990
  ].join(`
1748
1991
  `));
@@ -1767,22 +2010,36 @@ function createLoopCommandHandlers(options = {}) {
1767
2010
  }
1768
2011
  async function doctorLoop(directory, client, sessionID) {
1769
2012
  const state = await readState2(directory, sessionID);
1770
- await say2(client, sessionID, [
2013
+ const persisted = await listPersistedLoopSessions2(directory, sessionID);
2014
+ const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0);
2015
+ const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
2016
+ const lines = [
1771
2017
  "OpenCode Loop doctor:",
1772
2018
  `- plugin: ${SERVICE2}`,
1773
2019
  `- project directory: ${directory}`,
1774
2020
  `- state directory: ${stateDir(directory)}`,
1775
- `- active jobs: ${(state.jobs || []).length}`,
2021
+ `- current session: ${sessionID}`,
2022
+ `- current-session jobs: ${(state.jobs || []).length}`,
2023
+ `- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
2024
+ `- other persisted sessions with enabled jobs: ${otherSessions.length}`,
1776
2025
  `- node: ${runtimeVersion}`,
1777
2026
  `- platform: ${runtimePlatform}`,
1778
- "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
2027
+ "- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
2028
+ "- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
2029
+ "- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
1779
2030
  "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
1780
- ].join(`
2031
+ ];
2032
+ for (const entry of otherSessions.slice(0, 8)) {
2033
+ lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`);
2034
+ }
2035
+ if (otherSessions.length > 8)
2036
+ lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
2037
+ await say2(client, sessionID, lines.join(`
1781
2038
  `));
1782
2039
  }
1783
2040
  async function initLoop(directory, client, sessionID, args) {
1784
2041
  const target = String(args || "").trim() || "progress.md";
1785
- const full = path5.resolve(directory, target);
2042
+ const full = path7.resolve(directory, target);
1786
2043
  if (await pathExists2(full)) {
1787
2044
  await toast2(client, `${target} already exists.`, "warning");
1788
2045
  return;
@@ -1808,6 +2065,81 @@ function createLoopCommandHandlers(options = {}) {
1808
2065
  };
1809
2066
  }
1810
2067
 
2068
+ // src/source/core/schedule-syntax.js
2069
+ function removeBooleanFlag(input, flag) {
2070
+ const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i");
2071
+ const found = pattern.test(input);
2072
+ return {
2073
+ found,
2074
+ value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim()
2075
+ };
2076
+ }
2077
+ function firstToken(input) {
2078
+ const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/);
2079
+ return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" };
2080
+ }
2081
+ function inferredMode(intervalMs, maxRuns) {
2082
+ if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0)
2083
+ return "once";
2084
+ return Number(intervalMs || 0) === 0 ? "idle" : "interval";
2085
+ }
2086
+ function normalizeLoopScheduleArgs(raw, defaults = {}) {
2087
+ const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap");
2088
+ let input = overlap.value;
2089
+ const nextDefaults = { ...defaults };
2090
+ let scheduleMode = defaults.scheduleMode;
2091
+ let scheduleSyntax = "legacy";
2092
+ const first = firstToken(input);
2093
+ const keyword = first.token.toLowerCase();
2094
+ if (keyword === "idle") {
2095
+ nextDefaults.intervalMs = 0;
2096
+ nextDefaults.immediate = true;
2097
+ scheduleMode = "idle";
2098
+ scheduleSyntax = "idle";
2099
+ input = first.rest;
2100
+ } else if (keyword === "every" || keyword === "after" || keyword === "in") {
2101
+ const duration = firstToken(first.rest);
2102
+ const intervalMs = parseDuration(duration.token);
2103
+ if (intervalMs === null) {
2104
+ return {
2105
+ ok: false,
2106
+ error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`
2107
+ };
2108
+ }
2109
+ nextDefaults.intervalMs = intervalMs;
2110
+ nextDefaults.immediate = false;
2111
+ input = duration.rest;
2112
+ if (keyword === "every") {
2113
+ scheduleMode = intervalMs === 0 ? "idle" : "interval";
2114
+ scheduleSyntax = "every";
2115
+ } else {
2116
+ nextDefaults.maxRuns = 1;
2117
+ scheduleMode = "once";
2118
+ scheduleSyntax = "after";
2119
+ }
2120
+ } else {
2121
+ const duration = parseDuration(first.token);
2122
+ if (duration !== null) {
2123
+ scheduleMode = duration === 0 ? "idle" : "interval";
2124
+ } else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
2125
+ nextDefaults.intervalMs = 0;
2126
+ nextDefaults.immediate = nextDefaults.immediate ?? true;
2127
+ scheduleMode = "idle";
2128
+ scheduleSyntax = "idle-shorthand";
2129
+ } else {
2130
+ scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns);
2131
+ }
2132
+ }
2133
+ return {
2134
+ ok: true,
2135
+ args: input,
2136
+ defaults: nextDefaults,
2137
+ scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
2138
+ scheduleSyntax,
2139
+ allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true
2140
+ };
2141
+ }
2142
+
1811
2143
  // src/source/opencode/loop-registration.js
1812
2144
  var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
1813
2145
  var FALLBACK_ACTIVE_GUARD_MS = 45000;
@@ -1831,19 +2163,34 @@ function createLoopRegistration(options = {}) {
1831
2163
  const toast2 = requireFunction4(options.toast, "toast");
1832
2164
  const say2 = requireFunction4(options.say, "say");
1833
2165
  const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
2166
+ const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
1834
2167
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
1835
2168
  const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
1836
2169
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
1837
2170
  const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
1838
2171
  const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext;
2172
+ const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
1839
2173
  const configuredGuard = Number(options.defaultActiveGuardMs);
1840
2174
  const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS;
1841
2175
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
1842
- const parsed = parseLoopArgs2(args, defaults);
2176
+ const normalized = normalizeLoopScheduleArgs2(args, defaults);
2177
+ if (!normalized.ok) {
2178
+ await toast2(client, normalized.error, "warning");
2179
+ return;
2180
+ }
2181
+ const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
1843
2182
  if (!parsed.ok) {
1844
2183
  await toast2(client, parsed.error, "warning");
1845
2184
  return;
1846
2185
  }
2186
+ parsed.job.scheduleMode = normalized.scheduleMode;
2187
+ parsed.job.scheduleSyntax = normalized.scheduleSyntax;
2188
+ parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true;
2189
+ if (normalized.scheduleSyntax === "after") {
2190
+ parsed.job.immediate = false;
2191
+ parsed.job.maxRuns = 1;
2192
+ parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now();
2193
+ }
1847
2194
  const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
1848
2195
  parsed.job.agent = defaults.agent || executionContext.agent || "build";
1849
2196
  parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
@@ -1863,6 +2210,19 @@ function createLoopRegistration(options = {}) {
1863
2210
  if (!parsed.job.activeRecoveryMs) {
1864
2211
  parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
1865
2212
  }
2213
+ const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt";
2214
+ if (promptProducing && !parsed.job.allowGoalOverlap) {
2215
+ const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
2216
+ if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
2217
+ await appendLoopLog2(directory, "goal-overlap-blocked", {
2218
+ sessionID,
2219
+ job: parsed.job.name || parsed.job.id,
2220
+ goal: dedicatedGoal.id
2221
+ });
2222
+ 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");
2223
+ return;
2224
+ }
2225
+ }
1866
2226
  if (parsed.job.dryRun) {
1867
2227
  await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
1868
2228
  await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
@@ -1890,7 +2250,13 @@ function createLoopRegistration(options = {}) {
1890
2250
  if (parsed.job.immediate)
1891
2251
  scheduleIdleWork(directory, client, sessionID);
1892
2252
  await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
1893
- await appendLoopLog2(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) });
2253
+ await appendLoopLog2(directory, replaced ? "replace" : "add", {
2254
+ sessionID,
2255
+ job: parsed.job.name || parsed.job.id,
2256
+ label: jobLabel(parsed.job),
2257
+ scheduleMode: parsed.job.scheduleMode,
2258
+ scheduleSyntax: parsed.job.scheduleSyntax
2259
+ });
1894
2260
  }
1895
2261
  return { addLoop };
1896
2262
  }
@@ -2007,6 +2373,37 @@ function clearSessionActivity(sessionID) {
2007
2373
  deleteSessionExecutionContext(sessionID);
2008
2374
  }
2009
2375
 
2376
+ // src/source/runtime/scheduler-diagnostics.js
2377
+ var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
2378
+ function createSchedulerDiagnostics(options = {}) {
2379
+ const now2 = typeof options.now === "function" ? options.now : Date.now;
2380
+ const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
2381
+ const configured = Number(options.throttleMs);
2382
+ const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
2383
+ const lastLogged = new Map;
2384
+ async function logDeferral(directory, sessionID, reason, extra = {}) {
2385
+ const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
2386
+ const current = now2();
2387
+ const previous = Number(lastLogged.get(key) || 0);
2388
+ if (previous > 0 && current - previous < throttleMs)
2389
+ return false;
2390
+ lastLogged.set(key, current);
2391
+ await appendLoopLog2(directory, "deferred", {
2392
+ sessionID,
2393
+ reason,
2394
+ ...extra
2395
+ });
2396
+ return true;
2397
+ }
2398
+ function clearSession(sessionID) {
2399
+ const prefix = `${sessionID || "unknown"}:`;
2400
+ for (const key of lastLogged.keys())
2401
+ if (key.startsWith(prefix))
2402
+ lastLogged.delete(key);
2403
+ }
2404
+ return { logDeferral, clearSession };
2405
+ }
2406
+
2010
2407
  // src/source/runtime/scheduler.js
2011
2408
  var DEFAULT_IDLE_DEBOUNCE_MS = 1200;
2012
2409
  var DEFAULT_BUSY_RETRY_MS = 5000;
@@ -2014,37 +2411,6 @@ var DEFAULT_MIN_DUE_TIMER_MS = 250;
2014
2411
  var DEFAULT_MAX_DUE_TIMER_MS = 2147000000;
2015
2412
  var DEFAULT_HEARTBEAT_MS = 2500;
2016
2413
  var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
2017
- function jobDueAt(job, current = now()) {
2018
- if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
2019
- return Infinity;
2020
- if (!job.enabled || job.paused)
2021
- return Infinity;
2022
- if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
2023
- return Infinity;
2024
- if (Number(job.runNowRequestedAt || 0) > 0)
2025
- return current;
2026
- if (job.watchPaths?.length)
2027
- return Infinity;
2028
- const created = Date.parse(job.createdAt || "");
2029
- if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs)
2030
- return current;
2031
- if (job.intervalMs === 0)
2032
- return current;
2033
- if (!job.lastRunAt) {
2034
- if (job.immediate === false)
2035
- return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0);
2036
- return current;
2037
- }
2038
- return job.lastRunAt + (job.intervalMs || 0);
2039
- }
2040
- function nextDueDelay(state, current = now()) {
2041
- let soonest = Infinity;
2042
- for (const job of state.jobs || [])
2043
- soonest = Math.min(soonest, jobDueAt(job, current));
2044
- if (!Number.isFinite(soonest))
2045
- return Infinity;
2046
- return Math.max(0, soonest - current);
2047
- }
2048
2414
  function createSchedulerRuntime(options = {}) {
2049
2415
  const idleTimers = new Map;
2050
2416
  const dueTimers = new Map;
@@ -2072,6 +2438,7 @@ function createSchedulerRuntime(options = {}) {
2072
2438
  if (options.toast)
2073
2439
  await options.toast(client, message, level);
2074
2440
  };
2441
+ const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs });
2075
2442
  function stopHeartbeatIfIdle() {
2076
2443
  if (!knownSessions.size && heartbeatTimer) {
2077
2444
  clearIntervalFn(heartbeatTimer);
@@ -2119,11 +2486,13 @@ function createSchedulerRuntime(options = {}) {
2119
2486
  idleTimers.delete(sessionID);
2120
2487
  Promise.resolve().then(async () => {
2121
2488
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2489
+ await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs });
2122
2490
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2123
2491
  return;
2124
2492
  }
2125
2493
  await options.finalizeActiveRun?.(directory, client, sessionID);
2126
2494
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2495
+ await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs });
2127
2496
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2128
2497
  return;
2129
2498
  }
@@ -2172,11 +2541,13 @@ function createSchedulerRuntime(options = {}) {
2172
2541
  dueTimers.delete(sessionID);
2173
2542
  Promise.resolve().then(async () => {
2174
2543
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2544
+ await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs });
2175
2545
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2176
2546
  return;
2177
2547
  }
2178
2548
  await options.finalizeActiveRun?.(directory, client, sessionID);
2179
2549
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2550
+ await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs });
2180
2551
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2181
2552
  return;
2182
2553
  }
@@ -2196,6 +2567,7 @@ function createSchedulerRuntime(options = {}) {
2196
2567
  cancelIdleWork(sessionID);
2197
2568
  cancelDueWork(sessionID);
2198
2569
  stopWatchdog(sessionID);
2570
+ diagnostics.clearSession(sessionID);
2199
2571
  knownSessions.delete(sessionID);
2200
2572
  stopHeartbeatIfIdle();
2201
2573
  }
@@ -2294,8 +2666,8 @@ ${item.output}`).join(`
2294
2666
  }
2295
2667
 
2296
2668
  // src/source/runtime/job-workspace.js
2297
- import { promises as fs5 } from "fs";
2298
- import path6 from "path";
2669
+ import { promises as fs7 } from "fs";
2670
+ import path8 from "path";
2299
2671
  var MAX_SCAN_FILES = 200;
2300
2672
  var MAX_SCAN_BYTES = 2000000;
2301
2673
  function requireFunction6(value, name) {
@@ -2330,7 +2702,7 @@ function createJobWorkspaceRuntime(options = {}) {
2330
2702
  return await buildGoalPrompt2(directory, job);
2331
2703
  const sections = [];
2332
2704
  if (job.promptFile) {
2333
- const text = await readSmallTextFile2(path6.resolve(directory, job.promptFile));
2705
+ const text = await readSmallTextFile2(path8.resolve(directory, job.promptFile));
2334
2706
  if (text.trim())
2335
2707
  sections.push(`Instructions from ${job.promptFile}:
2336
2708
  ${text.trim()}`);
@@ -2340,7 +2712,7 @@ ${text.trim()}`);
2340
2712
  if (job.action)
2341
2713
  sections.push(decoratePrompt(job));
2342
2714
  for (const file of job.includeFiles || []) {
2343
- const text = await readSmallTextFile2(path6.resolve(directory, file), 80000);
2715
+ const text = await readSmallTextFile2(path8.resolve(directory, file), 80000);
2344
2716
  if (text.trim())
2345
2717
  sections.push(`Context from ${file}:
2346
2718
  ${text.trim().slice(0, 20000)}`);
@@ -2372,7 +2744,7 @@ ${text.trim().slice(0, 20000)}`);
2372
2744
  const snapshot = {};
2373
2745
  for (const file of files || []) {
2374
2746
  try {
2375
- const stat = await fs5.stat(path6.resolve(directory, file));
2747
+ const stat = await fs7.stat(path8.resolve(directory, file));
2376
2748
  snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
2377
2749
  } catch {
2378
2750
  snapshot[file] = "missing";
@@ -2392,10 +2764,10 @@ ${text.trim().slice(0, 20000)}`);
2392
2764
  }
2393
2765
  async function fileContains(filePath, needle) {
2394
2766
  try {
2395
- const stat = await fs5.stat(filePath);
2767
+ const stat = await fs7.stat(filePath);
2396
2768
  if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
2397
2769
  return false;
2398
- return (await fs5.readFile(filePath, "utf8")).includes(needle);
2770
+ return (await fs7.readFile(filePath, "utf8")).includes(needle);
2399
2771
  } catch {
2400
2772
  return false;
2401
2773
  }
@@ -2403,9 +2775,9 @@ ${text.trim().slice(0, 20000)}`);
2403
2775
  async function untilReached(directory, job) {
2404
2776
  if (!job.until)
2405
2777
  return false;
2406
- const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path6.join(".opencode", "opencode-loop", "until.txt")];
2778
+ const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
2407
2779
  for (const file of files)
2408
- if (await fileContains(path6.resolve(directory, file), job.until))
2780
+ if (await fileContains(path8.resolve(directory, file), job.until))
2409
2781
  return true;
2410
2782
  let scanned = 0;
2411
2783
  async function walk(current) {
@@ -2413,7 +2785,7 @@ ${text.trim().slice(0, 20000)}`);
2413
2785
  return false;
2414
2786
  let entries;
2415
2787
  try {
2416
- entries = await fs5.readdir(current, { withFileTypes: true });
2788
+ entries = await fs7.readdir(current, { withFileTypes: true });
2417
2789
  } catch {
2418
2790
  return false;
2419
2791
  }
@@ -2422,7 +2794,7 @@ ${text.trim().slice(0, 20000)}`);
2422
2794
  return false;
2423
2795
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
2424
2796
  continue;
2425
- const full = path6.join(current, entry.name);
2797
+ const full = path8.join(current, entry.name);
2426
2798
  if (entry.isDirectory()) {
2427
2799
  if (await walk(full))
2428
2800
  return true;
@@ -2446,13 +2818,13 @@ ${text.trim().slice(0, 20000)}`);
2446
2818
  if (!status.stdout.trim())
2447
2819
  return;
2448
2820
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
2449
- const checkpointDir = path6.join(stateDir(directory), "checkpoints", safeID(sessionID));
2821
+ const checkpointDir = path8.join(stateDir(directory), "checkpoints", safeID(sessionID));
2450
2822
  await ensureDir(checkpointDir);
2451
2823
  const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000);
2452
2824
  const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000);
2453
2825
  const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
2454
- await fs5.writeFile(path6.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
2455
- await fs5.writeFile(path6.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
2826
+ await fs7.writeFile(path8.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
2827
+ await fs7.writeFile(path8.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
2456
2828
  ${staged.stdout}`);
2457
2829
  if (job.gitCheckpoint) {
2458
2830
  await runProcess2("git", ["add", "-A"], directory, 120000);
@@ -2535,7 +2907,9 @@ function createSessionStatusRuntime(options = {}) {
2535
2907
  if (directory)
2536
2908
  argsList.push({ query: { directory } }, { directory }, { workspace: directory });
2537
2909
  argsList.push({});
2910
+ let attempted = false;
2538
2911
  for (const args of argsList) {
2912
+ attempted = true;
2539
2913
  try {
2540
2914
  const result = await statusMethod.call(client.session, args);
2541
2915
  const error = sdkError(result);
@@ -2565,7 +2939,7 @@ function createSessionStatusRuntime(options = {}) {
2565
2939
  return { type: "idle", source: "sdk" };
2566
2940
  } catch {}
2567
2941
  }
2568
- return;
2942
+ return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
2569
2943
  }
2570
2944
  async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
2571
2945
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
@@ -2577,19 +2951,42 @@ function createSessionStatusRuntime(options = {}) {
2577
2951
  return true;
2578
2952
  if (!options2.requireIdle)
2579
2953
  return completion === "unknown" && staleActiveRun(sessionID);
2954
+ const cached = sessionStatuses.get(sessionID);
2955
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2956
+ const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0);
2580
2957
  const live = await readLiveSessionStatus(client, sessionID, directory);
2581
2958
  if (live?.type === "idle")
2582
2959
  return true;
2960
+ if (live?.type === "unknown" && cachedIdleAfterRun) {
2961
+ return true;
2962
+ }
2583
2963
  if (live?.type) {
2584
- if ((live.type === "busy" || live.type === "retry") && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2964
+ if (live.type === "busy" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2585
2965
  return true;
2586
2966
  return false;
2587
2967
  }
2588
2968
  if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2589
2969
  return true;
2590
- const cached = sessionStatuses.get(sessionID);
2591
- const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2592
- return cached === "idle" && seenAt > (active.startedAt || 0);
2970
+ return cachedIdleAfterRun;
2971
+ }
2972
+ async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
2973
+ if (activeRuns.has(sessionID))
2974
+ return false;
2975
+ if (liveType !== "busy")
2976
+ return false;
2977
+ if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
2978
+ return false;
2979
+ const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
2980
+ if (completion !== "completed")
2981
+ return false;
2982
+ markSessionStatus(sessionID, "idle");
2983
+ await appendLoopLog2(directory, "status-message-idle-recovery", {
2984
+ sessionID,
2985
+ staleStatus: liveType,
2986
+ statusSeenAt: seenAt,
2987
+ staleForMs: Math.max(0, now2() - seenAt)
2988
+ });
2989
+ return true;
2593
2990
  }
2594
2991
  async function sessionStatusType(client, sessionID, directory, options2 = {}) {
2595
2992
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
@@ -2603,18 +3000,27 @@ function createSessionStatusRuntime(options = {}) {
2603
3000
  if (cached && now2() - seenAt < sessionStatusCacheMs)
2604
3001
  return cached;
2605
3002
  const live = await readLiveSessionStatus(client, sessionID, directory);
3003
+ if (live?.type === "unknown") {
3004
+ const conservative = cached === "retry" ? "retry" : "busy";
3005
+ markSessionStatus(sessionID, conservative);
3006
+ return conservative;
3007
+ }
2606
3008
  if (live?.type) {
3009
+ if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
3010
+ return "idle";
2607
3011
  if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
2608
3012
  const active = activeRuns.get(sessionID);
2609
3013
  if (active) {
2610
3014
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
2611
- if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
3015
+ if (completion === "completed" || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
2612
3016
  markSessionStatus(sessionID, "idle");
2613
- await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
3017
+ const logDetails = {
2614
3018
  sessionID,
2615
3019
  job: active.job?.name || active.jobId,
2616
- startedAt: active.startedAt
2617
- });
3020
+ startedAt: active.startedAt,
3021
+ ...completion === "completed" ? {} : { staleStatus: live.type }
3022
+ };
3023
+ await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", logDetails);
2618
3024
  return "idle";
2619
3025
  }
2620
3026
  }
@@ -2830,6 +3236,58 @@ ${prompt}`;
2830
3236
  return { fireAction };
2831
3237
  }
2832
3238
 
3239
+ // src/source/runtime/terminal-guard.js
3240
+ function messageText(message) {
3241
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
3242
+ const fromParts = parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join(`
3243
+ `).trim();
3244
+ if (fromParts)
3245
+ return fromParts;
3246
+ const info = message?.info || message || {};
3247
+ for (const value of [info.text, info.content, info.summary]) {
3248
+ if (typeof value === "string" && value.trim())
3249
+ return value.trim();
3250
+ }
3251
+ return "";
3252
+ }
3253
+ async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3254
+ if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3255
+ return { job, terminal: false, pausedNow: false };
3256
+ }
3257
+ const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3258
+ if (!messages)
3259
+ return { job, terminal: false, pausedNow: false };
3260
+ const tail = orderedSessionMessages(messages).at(-1);
3261
+ const info = tail?.info || tail || {};
3262
+ if (info.role !== "assistant")
3263
+ return { job, terminal: false, pausedNow: false };
3264
+ const completed = Number(info?.time?.completed || 0);
3265
+ const created = Number(info?.time?.created || 0);
3266
+ const runStarted = Number(job?.lastRunAt || 0);
3267
+ if (!Number.isFinite(completed) || completed <= 0)
3268
+ return { job, terminal: false, pausedNow: false };
3269
+ if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
3270
+ return { job, terminal: false, pausedNow: false };
3271
+ }
3272
+ const text = messageText(tail);
3273
+ const terminal = isTerminalNoWorkReply(text);
3274
+ if (!terminal) {
3275
+ if (job.terminalNoWorkCount)
3276
+ job.terminalNoWorkCount = 0;
3277
+ return { job, terminal: false, pausedNow: false, text };
3278
+ }
3279
+ job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3280
+ job.lastTerminalNoWorkAt = Date.now();
3281
+ job.lastTerminalNoWorkSummary = text.slice(0, 1000);
3282
+ const threshold = Math.max(2, Number(options.threshold) || 2);
3283
+ const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused;
3284
+ if (pausedNow) {
3285
+ job.paused = true;
3286
+ job.lastFailureReason = "terminal_no_work";
3287
+ }
3288
+ return { job, terminal: true, pausedNow, text };
3289
+ }
3290
+
2833
3291
  // src/source/runtime/run-finalization.js
2834
3292
  function requireFunction8(value, label) {
2835
3293
  if (typeof value !== "function")
@@ -2849,7 +3307,14 @@ function createRunFinalizationRuntime(options = {}) {
2849
3307
  const toast2 = typeof options.toast === "function" ? options.toast : toast;
2850
3308
  const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
2851
3309
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3310
+ const applyTerminalContinuationGuard2 = typeof options.applyTerminalContinuationGuard === "function" ? options.applyTerminalContinuationGuard : applyTerminalContinuationGuard;
2852
3311
  async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
3312
+ if (job.infrastructureFailureCount) {
3313
+ job.infrastructureFailureCount = 0;
3314
+ job.lastInfrastructureFailure = "";
3315
+ job.lastInfrastructureError = "";
3316
+ job.lastInfrastructureFailureAt = 0;
3317
+ }
2853
3318
  if (job.verifyCommand) {
2854
3319
  const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
2855
3320
  job.lastVerifyAt = now2();
@@ -2912,6 +3377,18 @@ exit=` + postrun.code + `
2912
3377
  job = await runGoalChecks(directory, sessionID, job, client);
2913
3378
  job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob);
2914
3379
  }
3380
+ const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3381
+ job = terminal.job;
3382
+ if (terminal.pausedNow) {
3383
+ await appendLoopLog2(directory, "terminal-no-work", {
3384
+ sessionID,
3385
+ job: job.name || job.id,
3386
+ count: job.terminalNoWorkCount,
3387
+ summary: String(terminal.text || "").slice(0, 1000)
3388
+ });
3389
+ await notifyJob2(directory, job, "terminal_no_work");
3390
+ await toast2(client, "Loop paused: completion-bounded task reported complete with no work remaining twice.", "success");
3391
+ }
2915
3392
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
2916
3393
  await writeState2(directory, sessionID, state);
2917
3394
  if (isGoalJob(job))
@@ -2924,7 +3401,7 @@ exit=` + postrun.code + `
2924
3401
  }
2925
3402
 
2926
3403
  // src/source/runtime/run-admission.js
2927
- import path7 from "path";
3404
+ import path9 from "path";
2928
3405
  function requireFunction9(value, label) {
2929
3406
  if (typeof value !== "function")
2930
3407
  throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
@@ -2941,26 +3418,8 @@ function createRunAdmissionRuntime(options = {}) {
2941
3418
  const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
2942
3419
  const toast2 = typeof options.toast === "function" ? options.toast : toast;
2943
3420
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
2944
- function dueJobs(state, force = false) {
2945
- const current = now2();
2946
- const due = (state.jobs || []).filter((job) => {
2947
- if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
2948
- return false;
2949
- if (!job.enabled || job.paused)
2950
- return false;
2951
- if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
2952
- return false;
2953
- if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs)
2954
- return true;
2955
- if (Number(job.runNowRequestedAt || 0) > 0)
2956
- return true;
2957
- if (force)
2958
- return true;
2959
- if (job.watchPaths?.length)
2960
- return job.watchTriggered === true;
2961
- return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs;
2962
- });
2963
- return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
3421
+ function dueJobs2(state, force = false) {
3422
+ return dueJobs(state, now2(), force);
2964
3423
  }
2965
3424
  async function reschedule(directory, client, sessionID) {
2966
3425
  await scheduleDueWork(directory, client, sessionID);
@@ -2980,7 +3439,7 @@ function createRunAdmissionRuntime(options = {}) {
2980
3439
  if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
2981
3440
  return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
2982
3441
  }
2983
- if (job.stopFile && await pathExists2(path7.resolve(directory, job.stopFile))) {
3442
+ if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
2984
3443
  return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
2985
3444
  }
2986
3445
  if (await untilReached(directory, job)) {
@@ -3023,12 +3482,61 @@ exit=` + preflight.code + `
3023
3482
  }
3024
3483
  return { admitted: true, job, runNowRequested };
3025
3484
  }
3026
- return { dueJobs, admitJob };
3485
+ return { dueJobs: dueJobs2, admitJob };
3486
+ }
3487
+
3488
+ // src/source/runtime/network-recovery.js
3489
+ var TRANSIENT_NETWORK_PATTERNS = [
3490
+ /\b(?:408|425|429|500|502|503|504|524)\b/i,
3491
+ /rate[\s_-]?limit|too many requests|overloaded|service[\s_-]?unavailable|provider[_ -]?unavailable/i,
3492
+ /terminated|fetch failed|failed to fetch|network[\s_-]?error|network connection lost/i,
3493
+ /connection (?:error|refused|lost)|socket (?:hang up|connection was closed)|reset before headers/i,
3494
+ /\b(?:enotfound|eai_again|econnrefused|econnreset|etimedout|ehostunreach|enetunreach|epipe)\b/i,
3495
+ /\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
3496
+ /\btimeout(?:error)?\b/i
3497
+ ];
3498
+ function errorText(value) {
3499
+ if (value instanceof Error)
3500
+ return `${value.name}: ${value.message}`;
3501
+ if (typeof value === "string")
3502
+ return value;
3503
+ try {
3504
+ return JSON.stringify(value);
3505
+ } catch {
3506
+ return String(value);
3507
+ }
3508
+ }
3509
+ function isTransientNetworkError(value) {
3510
+ const text = errorText(value);
3511
+ return TRANSIENT_NETWORK_PATTERNS.some((pattern) => pattern.test(text));
3512
+ }
3513
+ function networkRetryDelayMs(attempt, baseMs = 5000, maxMs = 60000) {
3514
+ const safeAttempt = Math.max(1, Math.floor(Number(attempt) || 1));
3515
+ const safeBase = Math.max(1, Math.floor(Number(baseMs) || 5000));
3516
+ const safeMax = Math.max(safeBase, Math.floor(Number(maxMs) || 60000));
3517
+ return Math.min(safeMax, safeBase * 2 ** Math.min(8, safeAttempt - 1));
3518
+ }
3519
+ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
3520
+ const chargedCount = Number(snapshot.runCount ?? job.runCount ?? 0);
3521
+ const currentCount = Number(job.runCount || 0);
3522
+ if (chargedCount > 0 && currentCount >= chargedCount)
3523
+ job.runCount = Math.max(0, currentCount - 1);
3524
+ if (Number.isFinite(Number(snapshot.previousLastRunAt)))
3525
+ job.lastRunAt = Number(snapshot.previousLastRunAt);
3526
+ if (snapshot.disabledByMaxRuns && job.maxRuns > 0 && job.runCount < job.maxRuns)
3527
+ job.enabled = true;
3528
+ job.infrastructureFailureCount = (job.infrastructureFailureCount || 0) + 1;
3529
+ job.lastInfrastructureFailure = String(input.reason || "transient_network_failure").slice(0, 120);
3530
+ job.lastInfrastructureError = errorText(input.error).slice(0, 4000);
3531
+ job.lastInfrastructureFailureAt = Number(input.now || Date.now());
3532
+ return job;
3027
3533
  }
3028
3534
 
3029
3535
  // src/source/runtime/executor.js
3030
3536
  var DEFAULT_ACTIVE_GUARD_MS = 45000;
3031
3537
  var DEFAULT_BUSY_RETRY_MS2 = 5000;
3538
+ var DEFAULT_PROVIDER_RETRY_WATCHDOG_MS = 2 * 60000;
3539
+ var DEFAULT_NETWORK_RETRY_MAX_MS = 60000;
3032
3540
  function requireFunction10(value, label) {
3033
3541
  if (typeof value !== "function")
3034
3542
  throw new TypeError(`createLoopExecutor requires ${label}`);
@@ -3060,8 +3568,11 @@ function createLoopExecutor(options = {}) {
3060
3568
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3061
3569
  const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
3062
3570
  const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
3571
+ const providerRetryWatchdogMs = Number.isFinite(Number(options.providerRetryWatchdogMs)) && Number(options.providerRetryWatchdogMs) > 0 ? Number(options.providerRetryWatchdogMs) : DEFAULT_PROVIDER_RETRY_WATCHDOG_MS;
3572
+ const networkRetryMaxMs = Number.isFinite(Number(options.networkRetryMaxMs)) && Number(options.networkRetryMaxMs) > 0 ? Number(options.networkRetryMaxMs) : DEFAULT_NETWORK_RETRY_MAX_MS;
3063
3573
  const activeRuns = new Map;
3064
3574
  const runLocks = new Map;
3575
+ const retryRuns = new Map;
3065
3576
  const statusRuntime = createSessionStatusRuntime({
3066
3577
  activeRuns,
3067
3578
  appendLoopLog: appendLoopLog2,
@@ -3074,6 +3585,7 @@ function createLoopExecutor(options = {}) {
3074
3585
  updateSessionStatusFromEvent,
3075
3586
  staleActiveRun,
3076
3587
  canFinalizeActiveRun,
3588
+ sessionStatusType,
3077
3589
  sessionIsIdle,
3078
3590
  markSessionStatus,
3079
3591
  clearSessionStatus
@@ -3125,13 +3637,14 @@ function createLoopExecutor(options = {}) {
3125
3637
  toast: toast2,
3126
3638
  dangerousShell: dangerousShell2
3127
3639
  });
3128
- const dueJobs = admissionRuntime.dueJobs;
3640
+ const dueJobs2 = admissionRuntime.dueJobs;
3129
3641
  function clearActiveRun(sessionID) {
3130
3642
  const active = activeRuns.get(sessionID);
3131
3643
  if (active?.timer)
3132
3644
  clearTimeout(active.timer);
3133
3645
  compactionRuntime.clearForActiveRun(sessionID, active);
3134
3646
  activeRuns.delete(sessionID);
3647
+ retryRuns.delete(sessionID);
3135
3648
  }
3136
3649
  function disposeSession(sessionID) {
3137
3650
  clearActiveRun(sessionID);
@@ -3139,30 +3652,106 @@ function createLoopExecutor(options = {}) {
3139
3652
  compactionRuntime.clear(sessionID);
3140
3653
  clearSessionStatus(sessionID);
3141
3654
  }
3655
+ async function persistInfrastructureRefund(directory, sessionID, active, input = {}) {
3656
+ const state = await readState2(directory, sessionID);
3657
+ const job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
3658
+ if (!job)
3659
+ return { job: undefined, delayMs: busyRetryMs };
3660
+ refundInfrastructureRun(job, {
3661
+ runCount: active.job?.runCount,
3662
+ previousLastRunAt: active.previousLastRunAt,
3663
+ disabledByMaxRuns: active.disabledByMaxRuns
3664
+ }, {
3665
+ reason: input.reason,
3666
+ error: input.error,
3667
+ now: now2()
3668
+ });
3669
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3670
+ await writeState2(directory, sessionID, state);
3671
+ const delayMs = networkRetryDelayMs(job.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
3672
+ return { job, delayMs };
3673
+ }
3142
3674
  async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
3143
3675
  const active = activeRuns.get(sessionID);
3144
3676
  if (!active || active.jobId !== jobId || active.runToken !== runToken)
3145
3677
  return false;
3678
+ const transient = isTransientNetworkError(error);
3679
+ const snapshot = active;
3146
3680
  clearActiveRun(sessionID);
3147
3681
  clearSessionStatus(sessionID);
3148
3682
  const message = errorMessage(error);
3149
- const state = await readState2(directory, sessionID);
3150
- const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3683
+ let state = await readState2(directory, sessionID);
3684
+ let job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3685
+ let retryDelay = busyRetryMs;
3151
3686
  if (job) {
3152
- job.failureCount = (job.failureCount || 0) + 1;
3153
- job.lastFailureReason = "dispatch_failed";
3154
- job.lastDispatchFailure = message.slice(0, 4000);
3155
- job.lastDispatchFailureAt = now2();
3156
- if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3157
- job.paused = true;
3158
- await notifyJob2(directory, job, "dispatch_failed");
3687
+ if (transient) {
3688
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3689
+ reason: "dispatch_failed_transient",
3690
+ error
3691
+ });
3692
+ job = refunded.job;
3693
+ retryDelay = refunded.delayMs;
3694
+ state = await readState2(directory, sessionID);
3695
+ } else {
3696
+ job.failureCount = (job.failureCount || 0) + 1;
3697
+ job.lastFailureReason = "dispatch_failed";
3698
+ job.lastDispatchFailure = message.slice(0, 4000);
3699
+ job.lastDispatchFailureAt = now2();
3700
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3701
+ job.paused = true;
3702
+ await notifyJob2(directory, job, "dispatch_failed");
3703
+ }
3704
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3705
+ await writeState2(directory, sessionID, state);
3159
3706
  }
3160
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3161
- await writeState2(directory, sessionID, state);
3162
3707
  }
3163
- await appendLoopLog2(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
3164
- await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3165
- await scheduleDueWork(directory, client, sessionID, busyRetryMs);
3708
+ await appendLoopLog2(directory, transient ? "network-dispatch-error" : "dispatch-error", {
3709
+ sessionID,
3710
+ job: job?.name || jobId,
3711
+ error: message,
3712
+ ...transient ? { retryInMs: retryDelay } : {}
3713
+ });
3714
+ if (transient) {
3715
+ await toast2(client, `Loop network dispatch failed; retrying when the session recovers: ${message}`, "warning");
3716
+ } else {
3717
+ await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3718
+ }
3719
+ await scheduleDueWork(directory, client, sessionID, retryDelay);
3720
+ return true;
3721
+ }
3722
+ async function recoverStuckProviderRetry(directory, client, sessionID) {
3723
+ const active = activeRuns.get(sessionID);
3724
+ if (!active || active.compactionOnly) {
3725
+ retryRuns.delete(sessionID);
3726
+ return false;
3727
+ }
3728
+ const current = retryRuns.get(sessionID);
3729
+ if (!current || current.runToken !== active.runToken) {
3730
+ retryRuns.set(sessionID, { runToken: active.runToken, since: now2() });
3731
+ return false;
3732
+ }
3733
+ if (now2() - current.since < providerRetryWatchdogMs)
3734
+ return false;
3735
+ const snapshot = active;
3736
+ try {
3737
+ if (client?.session?.abort) {
3738
+ await fireSdk2(client, "session.abort provider retry watchdog", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
3739
+ }
3740
+ } catch {}
3741
+ clearActiveRun(sessionID);
3742
+ clearSessionStatus(sessionID);
3743
+ const refunded = await persistInfrastructureRefund(directory, sessionID, snapshot, {
3744
+ reason: "provider_retry_watchdog",
3745
+ error: `OpenCode session.status stayed retry for ${providerRetryWatchdogMs}ms`
3746
+ });
3747
+ await appendLoopLog2(directory, "provider-retry-recovery", {
3748
+ sessionID,
3749
+ job: refunded.job?.name || snapshot.jobId,
3750
+ retryForMs: now2() - current.since,
3751
+ retryInMs: refunded.delayMs
3752
+ });
3753
+ await toast2(client, "Loop provider retry exceeded the watchdog; the Loop-owned turn was released and will retry with backoff.", "warning");
3754
+ await scheduleDueWork(directory, client, sessionID, refunded.delayMs);
3166
3755
  return true;
3167
3756
  }
3168
3757
  async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
@@ -3213,14 +3802,24 @@ function createLoopExecutor(options = {}) {
3213
3802
  }
3214
3803
  runLocks.set(sessionID, now2());
3215
3804
  let job;
3805
+ let previousLastRunAt = 0;
3806
+ let disabledByMaxRuns = false;
3216
3807
  try {
3217
3808
  await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
3218
- if (!await sessionIsIdle(client, sessionID, directory)) {
3809
+ const statusType = await sessionStatusType(client, sessionID, directory);
3810
+ if (statusType !== "idle") {
3811
+ if (statusType === "retry") {
3812
+ if (await recoverStuckProviderRetry(directory, client, sessionID))
3813
+ return;
3814
+ } else {
3815
+ retryRuns.delete(sessionID);
3816
+ }
3219
3817
  if (runOptions.force)
3220
3818
  await toast2(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
3221
3819
  await reschedule(busyRetryMs);
3222
3820
  return;
3223
3821
  }
3822
+ retryRuns.delete(sessionID);
3224
3823
  const active = activeRuns.get(sessionID);
3225
3824
  const activeAge = active ? now2() - (active.startedAt || 0) : 0;
3226
3825
  const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs;
@@ -3236,7 +3835,7 @@ function createLoopExecutor(options = {}) {
3236
3835
  candidate.watchTriggered = true;
3237
3836
  }
3238
3837
  }
3239
- const due = dueJobs(state, Boolean(runOptions.force));
3838
+ const due = dueJobs2(state, Boolean(runOptions.force));
3240
3839
  if (!due.length) {
3241
3840
  await writeState2(directory, sessionID, state);
3242
3841
  await reschedule();
@@ -3274,9 +3873,11 @@ function createLoopExecutor(options = {}) {
3274
3873
  if (runNowRequested)
3275
3874
  delete job.runNowRequestedAt;
3276
3875
  job.watchTriggered = false;
3876
+ previousLastRunAt = Number(job.lastRunAt || 0);
3277
3877
  job.lastRunAt = now2();
3278
3878
  job.runCount = (job.runCount || 0) + 1;
3279
- if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
3879
+ disabledByMaxRuns = job.maxRuns > 0 && job.runCount >= job.maxRuns;
3880
+ if (disabledByMaxRuns) {
3280
3881
  job.enabled = false;
3281
3882
  await notifyJob2(directory, job, "max_runs_reached");
3282
3883
  }
@@ -3315,7 +3916,9 @@ function createLoopExecutor(options = {}) {
3315
3916
  startedAt: now2(),
3316
3917
  timer,
3317
3918
  runToken,
3318
- compactionAction: result.compaction === true
3919
+ compactionAction: result.compaction === true,
3920
+ previousLastRunAt,
3921
+ disabledByMaxRuns
3319
3922
  });
3320
3923
  if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
3321
3924
  await compactionRuntime.finalize(directory, client, sessionID);
@@ -3330,6 +3933,24 @@ function createLoopExecutor(options = {}) {
3330
3933
  await reschedule(busyRetryMs);
3331
3934
  } catch (error) {
3332
3935
  clearActiveRun(sessionID);
3936
+ if (isTransientNetworkError(error) && job) {
3937
+ const stateAfterFailure = await readState2(directory, sessionID);
3938
+ const persisted = (stateAfterFailure.jobs || []).find((candidate) => candidate.id === job.id);
3939
+ if (persisted) {
3940
+ refundInfrastructureRun(persisted, {
3941
+ runCount: job.runCount,
3942
+ previousLastRunAt,
3943
+ disabledByMaxRuns
3944
+ }, { reason: "action_dispatch_transient", error, now: now2() });
3945
+ stateAfterFailure.jobs = (stateAfterFailure.jobs || []).map((candidate) => candidate.id === persisted.id ? persisted : candidate);
3946
+ await writeState2(directory, sessionID, stateAfterFailure);
3947
+ const delayMs = networkRetryDelayMs(persisted.infrastructureFailureCount || 1, busyRetryMs, networkRetryMaxMs);
3948
+ await appendLoopLog2(directory, "network-action-error", { sessionID, job: persisted.name || persisted.id, error: errorMessage(error), retryInMs: delayMs });
3949
+ await toast2(client, "Loop action hit a transient network failure; the logical run was refunded and will retry.", "warning");
3950
+ await reschedule(delayMs);
3951
+ return;
3952
+ }
3953
+ }
3333
3954
  await toast2(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
3334
3955
  await appendLoopLog2(directory, "error", {
3335
3956
  sessionID,
@@ -3343,13 +3964,15 @@ function createLoopExecutor(options = {}) {
3343
3964
  }
3344
3965
  }
3345
3966
  return {
3346
- dueJobs,
3967
+ dueJobs: dueJobs2,
3347
3968
  clearActiveRun,
3348
3969
  disposeSession,
3349
3970
  recoverActiveDispatchFailure,
3971
+ recoverStuckProviderRetry,
3350
3972
  finalizeActiveRun,
3351
3973
  fireAction,
3352
3974
  maybeRunDueJobs,
3975
+ sessionStatusType,
3353
3976
  sessionIsIdle,
3354
3977
  updateSessionStatusFromEvent,
3355
3978
  markSessionStatus,