@bybrawe/opencode-loop 0.5.32 → 0.5.34

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 (35) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +197 -283
  3. package/commands/loop.md +1 -1
  4. package/package.json +3 -3
  5. package/scripts/action-dispatch-test.mjs +117 -0
  6. package/scripts/comprehensive-test.mjs +1 -1
  7. package/scripts/host-loop-canary.mjs +8 -3
  8. package/scripts/loop-command-handlers-test.mjs +33 -4
  9. package/scripts/loop-registration-test.mjs +77 -3
  10. package/scripts/run-admission-test.mjs +133 -0
  11. package/scripts/run-finalization-test.mjs +119 -0
  12. package/scripts/schedule-policy-test.mjs +114 -0
  13. package/scripts/schedule-syntax-test.mjs +75 -0
  14. package/scripts/session-status-idle-recovery-test.mjs +44 -0
  15. package/src/index.js +916 -473
  16. package/src/source/core/continuation.js +20 -0
  17. package/src/source/core/jobs.js +3 -0
  18. package/src/source/core/schedule-syntax.js +93 -0
  19. package/src/source/opencode/loop-commands.js +39 -30
  20. package/src/source/opencode/loop-registration.js +40 -3
  21. package/src/source/runtime/action-dispatch.js +108 -0
  22. package/src/source/runtime/companion-goal.js +37 -0
  23. package/src/source/runtime/executor.js +55 -228
  24. package/src/source/runtime/goal-evidence.js +39 -0
  25. package/src/source/runtime/goal-policy.js +1 -1
  26. package/src/source/runtime/goal-prompt.js +53 -0
  27. package/src/source/runtime/goal-report.js +52 -0
  28. package/src/source/runtime/goal-runtime.js +9 -141
  29. package/src/source/runtime/loop-diagnostics.js +45 -0
  30. package/src/source/runtime/run-admission.js +121 -0
  31. package/src/source/runtime/run-finalization.js +100 -0
  32. package/src/source/runtime/schedule-policy.js +80 -0
  33. package/src/source/runtime/scheduler-diagnostics.js +30 -0
  34. package/src/source/runtime/scheduler.js +10 -23
  35. package/src/source/runtime/session-status.js +28 -4
package/src/index.js CHANGED
@@ -1021,6 +1021,27 @@ 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
+ function isContinuationShorthand(value) {
1037
+ return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1038
+ }
1039
+ function continuationProjectInstruction(value) {
1040
+ if (!isContinuationShorthand(value))
1041
+ return "";
1042
+ 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.";
1043
+ }
1044
+
1024
1045
  // src/source/core/jobs.js
1025
1046
  function presetDefaults(name) {
1026
1047
  if (name === "loop-compact")
@@ -1086,6 +1107,9 @@ function actionKind(action, job = {}) {
1086
1107
  }
1087
1108
  function decoratePrompt(job) {
1088
1109
  const additions = [];
1110
+ const continuation = continuationProjectInstruction(job.action);
1111
+ if (continuation)
1112
+ additions.push(continuation);
1089
1113
  if (job.progressFile)
1090
1114
  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
1115
  if (job.lastVerifyFailure)
@@ -1231,91 +1255,10 @@ function createCommandRouter(options = {}) {
1231
1255
  };
1232
1256
  }
1233
1257
 
1234
- // src/source/runtime/goal-runtime.js
1258
+ // src/source/runtime/goal-report.js
1235
1259
  import { promises as fs3 } from "fs";
1236
1260
  import path3 from "path";
1237
1261
  var GOAL_REPORT_DIR = "goals";
1238
- var GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION";
1239
- async function buildGoalPrompt(directory, job) {
1240
- const sections = [];
1241
- sections.push(`Working directory:
1242
- ${path3.resolve(directory)}
1243
- Keep every file operation inside this directory. Prefer workspace-relative paths such as "src/index.js"; never turn a relative path into a root path such as "/src/index.js".`);
1244
- const objective = String(job.action || "").trim();
1245
- if (objective)
1246
- sections.push(`Goal objective:
1247
- ${objective}`);
1248
- if (job.goalFile) {
1249
- const text = await readSmallTextFile(path3.resolve(directory, job.goalFile), 120000);
1250
- if (text.trim())
1251
- sections.push(`Goal file ${job.goalFile}:
1252
- ${text.trim()}`);
1253
- else
1254
- sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`);
1255
- }
1256
- if (job.promptFile) {
1257
- const text = await readSmallTextFile(path3.resolve(directory, job.promptFile), 120000);
1258
- if (text.trim())
1259
- sections.push(`Extra goal instructions from ${job.promptFile}:
1260
- ${text.trim()}`);
1261
- }
1262
- if (job.goalAcceptance?.length)
1263
- sections.push(`Acceptance criteria:
1264
- ` + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join(`
1265
- `));
1266
- if (job.goalChecks?.length)
1267
- sections.push(`Verification commands that define useful evidence:
1268
- ` + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join(`
1269
- `));
1270
- if (job.verifyCommand)
1271
- sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`);
1272
- if (job.lastGoalChecks?.length)
1273
- sections.push(`Latest goal check results:
1274
- ` + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join(`
1275
- `));
1276
- if (job.lastVerifyFailure)
1277
- sections.push(`Previous verify/check failure summary:
1278
- ` + String(job.lastVerifyFailure).slice(0, 1600));
1279
- if (job.goalCompletionRejectedReason)
1280
- sections.push(`Previous completion attempt was rejected:
1281
- ${job.goalCompletionRejectedReason}`);
1282
- if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0)
1283
- sections.push(`No-progress guard:
1284
- ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`);
1285
- if (job.goalProgress?.length)
1286
- sections.push(`Recent goal progress:
1287
- ` + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join(`
1288
- `));
1289
- for (const file of job.includeFiles || []) {
1290
- const text = await readSmallTextFile(path3.resolve(directory, file), 80000);
1291
- if (text.trim())
1292
- sections.push(`Context from ${file}:
1293
- ${text.trim().slice(0, 20000)}`);
1294
- }
1295
- return `${GOAL_PROMPT_PREFIX}.
1296
-
1297
- You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
1298
-
1299
- Rules:
1300
- - Work on the next smallest useful step toward the goal.
1301
- - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
1302
- - Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
1303
- - If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
1304
- - Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
1305
- - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
1306
- - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
1307
- - If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
1308
- - If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
1309
- - Do not call completion tools just to be polite; only call them when the state is real.
1310
- - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
1311
- - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
1312
-
1313
- ${sections.join(`
1314
-
1315
- ---
1316
-
1317
- `)}`;
1318
- }
1319
1262
  function goalReportPath(directory, sessionID, job) {
1320
1263
  return path3.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`);
1321
1264
  }
@@ -1375,21 +1318,8 @@ async function writeGoalReport(directory, sessionID, job) {
1375
1318
  await ensureDir(path3.dirname(target));
1376
1319
  await fs3.writeFile(target, goalReportText(job), "utf8");
1377
1320
  }
1378
- function pickGoalJob(state, target = "") {
1379
- const goals = (state.jobs || []).filter(isGoalJob);
1380
- if (!goals.length)
1381
- return;
1382
- const text = String(target || "").trim();
1383
- if (!text || ["active", "current", "goal"].includes(text.toLowerCase()))
1384
- return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0];
1385
- return goals.find((job, index) => matchJob(job, text, index));
1386
- }
1387
- function parseGoalToolText(args, fields) {
1388
- const result = {};
1389
- for (const field of fields)
1390
- result[field] = String(args?.[field] || "").trim();
1391
- return result;
1392
- }
1321
+
1322
+ // src/source/runtime/goal-evidence.js
1393
1323
  function hasConcreteGoalEvidence(value) {
1394
1324
  const text = String(value || "").trim();
1395
1325
  if (text.length < 24)
@@ -1432,6 +1362,107 @@ function goalMadeMeaningfulProgress(beforeJob, afterJob) {
1432
1362
  return true;
1433
1363
  return false;
1434
1364
  }
1365
+
1366
+ // src/source/runtime/goal-prompt.js
1367
+ import path4 from "path";
1368
+ var GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION";
1369
+ async function buildGoalPrompt(directory, job) {
1370
+ const sections = [];
1371
+ sections.push(`Working directory:
1372
+ ${path4.resolve(directory)}
1373
+ Keep every file operation inside this directory. Prefer workspace-relative paths such as "src/index.js"; never turn a relative path into a root path such as "/src/index.js".`);
1374
+ const objective = String(job.action || "").trim();
1375
+ if (objective)
1376
+ sections.push(`Goal objective:
1377
+ ${objective}`);
1378
+ if (job.goalFile) {
1379
+ const text = await readSmallTextFile(path4.resolve(directory, job.goalFile), 120000);
1380
+ if (text.trim())
1381
+ sections.push(`Goal file ${job.goalFile}:
1382
+ ${text.trim()}`);
1383
+ else
1384
+ sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`);
1385
+ }
1386
+ if (job.promptFile) {
1387
+ const text = await readSmallTextFile(path4.resolve(directory, job.promptFile), 120000);
1388
+ if (text.trim())
1389
+ sections.push(`Extra goal instructions from ${job.promptFile}:
1390
+ ${text.trim()}`);
1391
+ }
1392
+ if (job.goalAcceptance?.length)
1393
+ sections.push(`Acceptance criteria:
1394
+ ` + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join(`
1395
+ `));
1396
+ if (job.goalChecks?.length)
1397
+ sections.push(`Verification commands that define useful evidence:
1398
+ ` + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join(`
1399
+ `));
1400
+ if (job.verifyCommand)
1401
+ sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`);
1402
+ if (job.lastGoalChecks?.length)
1403
+ sections.push(`Latest goal check results:
1404
+ ` + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join(`
1405
+ `));
1406
+ if (job.lastVerifyFailure)
1407
+ sections.push(`Previous verify/check failure summary:
1408
+ ` + String(job.lastVerifyFailure).slice(0, 1600));
1409
+ if (job.goalCompletionRejectedReason)
1410
+ sections.push(`Previous completion attempt was rejected:
1411
+ ${job.goalCompletionRejectedReason}`);
1412
+ if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0)
1413
+ sections.push(`No-progress guard:
1414
+ ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`);
1415
+ if (job.goalProgress?.length)
1416
+ sections.push(`Recent goal progress:
1417
+ ` + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join(`
1418
+ `));
1419
+ for (const file of job.includeFiles || []) {
1420
+ const text = await readSmallTextFile(path4.resolve(directory, file), 80000);
1421
+ if (text.trim())
1422
+ sections.push(`Context from ${file}:
1423
+ ${text.trim().slice(0, 20000)}`);
1424
+ }
1425
+ return `${GOAL_PROMPT_PREFIX}.
1426
+
1427
+ You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
1428
+
1429
+ Rules:
1430
+ - Work on the next smallest useful step toward the goal.
1431
+ - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
1432
+ - Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
1433
+ - If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
1434
+ - Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
1435
+ - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
1436
+ - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
1437
+ - If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
1438
+ - If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
1439
+ - Do not call completion tools just to be polite; only call them when the state is real.
1440
+ - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
1441
+ - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
1442
+
1443
+ ${sections.join(`
1444
+
1445
+ ---
1446
+
1447
+ `)}`;
1448
+ }
1449
+
1450
+ // src/source/runtime/goal-runtime.js
1451
+ function pickGoalJob(state, target = "") {
1452
+ const goals = (state.jobs || []).filter(isGoalJob);
1453
+ if (!goals.length)
1454
+ return;
1455
+ const text = String(target || "").trim();
1456
+ if (!text || ["active", "current", "goal"].includes(text.toLowerCase()))
1457
+ return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0];
1458
+ return goals.find((job, index) => matchJob(job, text, index));
1459
+ }
1460
+ function parseGoalToolText(args, fields) {
1461
+ const result = {};
1462
+ for (const field of fields)
1463
+ result[field] = String(args?.[field] || "").trim();
1464
+ return result;
1465
+ }
1435
1466
  async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
1436
1467
  job.goalCompletionRejectedAt = now();
1437
1468
  job.goalCompletionRejectedReason = reason;
@@ -1616,47 +1647,224 @@ function createGoalCommandHandlers(options = {}) {
1616
1647
  }
1617
1648
 
1618
1649
  // src/source/opencode/loop-commands.js
1619
- import { promises as fs4 } from "fs";
1620
- import path4 from "path";
1621
- var SERVICE2 = "opencode-loop";
1622
- var DEFAULT_PROGRESS_MD = `# Progress
1623
-
1624
- ## Current Goal
1625
- Describe the current project goal here.
1626
-
1627
- ## Agent Rules
1628
- - Do not ask questions unless truly blocked.
1629
- - Make reasonable assumptions and continue.
1630
- - Work on unfinished TODOs in order.
1631
- - Mark completed TODOs with [x].
1632
- - Add new bugs, ideas, and follow-up work as TODOs.
1633
- - Run tests, lint, or build when available.
1634
- - Do not run destructive commands, force pushes, production deploys, or database resets.
1635
-
1636
- ## Active TODO
1637
- - [ ] Review the project structure and pick the next safe improvement.
1650
+ import { promises as fs6 } from "fs";
1651
+ import path7 from "path";
1638
1652
 
1639
- ## Completed
1640
- - [x] Created progress.md.
1653
+ // src/source/runtime/companion-goal.js
1654
+ import { promises as fs4 } from "fs";
1655
+ import path5 from "path";
1656
+ function goalRoot(directory) {
1657
+ return path5.join(directory, ".opencode", "goals");
1658
+ }
1659
+ async function findDedicatedGoalForSession(directory, sessionID) {
1660
+ if (!directory || !sessionID)
1661
+ return;
1662
+ let names;
1663
+ try {
1664
+ names = await fs4.readdir(goalRoot(directory));
1665
+ } catch (error) {
1666
+ if (error?.code === "ENOENT")
1667
+ return;
1668
+ return;
1669
+ }
1670
+ for (const name of names) {
1671
+ if (!name.endsWith(".json"))
1672
+ continue;
1673
+ try {
1674
+ const value = JSON.parse(await fs4.readFile(path5.join(goalRoot(directory), name), "utf8"));
1675
+ if (value?.sessionID === sessionID)
1676
+ return value;
1677
+ } catch {}
1678
+ }
1679
+ return;
1680
+ }
1681
+ function dedicatedGoalOwnsContinuation(goal) {
1682
+ return goal?.status === "active";
1683
+ }
1684
+ function dedicatedGoalSummary(goal) {
1685
+ if (!goal)
1686
+ return "not detected";
1687
+ const id = String(goal.id || "unknown").slice(0, 12);
1688
+ const status = String(goal.status || "unknown");
1689
+ return `${status} (${id})`;
1690
+ }
1641
1691
 
1642
- ## Backlog Ideas
1643
- - [ ] Add more project-specific tasks here.
1692
+ // src/source/runtime/loop-diagnostics.js
1693
+ import { promises as fs5 } from "fs";
1694
+ import path6 from "path";
1644
1695
 
1645
- ## Blocked
1646
- - None.
1647
- `;
1648
- function requireFunction3(value, name) {
1649
- if (typeof value !== "function")
1650
- throw new TypeError(`createLoopCommandHandlers requires ${name}`);
1651
- return value;
1696
+ // src/source/runtime/schedule-policy.js
1697
+ var TERMINAL_GOAL_STATUSES = new Set(["completed", "blocked", "cleared"]);
1698
+ function inferredScheduleMode(job) {
1699
+ const explicit = String(job?.scheduleMode || "").toLowerCase();
1700
+ if (["idle", "interval", "once", "watch"].includes(explicit))
1701
+ return explicit;
1702
+ if (job?.watchPaths?.length)
1703
+ return "watch";
1704
+ if (Number(job?.maxRuns || 0) === 1 && job?.immediate === false && Number(job?.intervalMs || 0) > 0)
1705
+ return "once";
1706
+ return Number(job?.intervalMs || 0) === 0 ? "idle" : "interval";
1707
+ }
1708
+ function jobRunnable(job) {
1709
+ if (!job)
1710
+ return false;
1711
+ if (isGoalJob(job) && TERMINAL_GOAL_STATUSES.has(job.goalStatus))
1712
+ return false;
1713
+ if (!job.enabled || job.paused)
1714
+ return false;
1715
+ if (Number(job.maxRuns || 0) > 0 && Number(job.runCount || 0) >= Number(job.maxRuns || 0))
1716
+ return false;
1717
+ return true;
1652
1718
  }
1653
- function createLoopCommandHandlers(options = {}) {
1654
- const clearActiveRun = requireFunction3(options.clearActiveRun, "clearActiveRun");
1655
- const cancelDueWork = requireFunction3(options.cancelDueWork, "cancelDueWork");
1656
- const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
1657
- const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
1658
- const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
1659
- const toast2 = requireFunction3(options.toast, "toast");
1719
+ function jobDueAt(job, current = Date.now()) {
1720
+ if (!jobRunnable(job))
1721
+ return Infinity;
1722
+ if (Number(job.runNowRequestedAt || 0) > 0)
1723
+ return current;
1724
+ const created = Date.parse(job.createdAt || "");
1725
+ if (Number(job.maxRuntimeMs || 0) > 0 && Number.isFinite(created) && current - created >= Number(job.maxRuntimeMs || 0))
1726
+ return current;
1727
+ if (job.watchPaths?.length)
1728
+ return job.watchTriggered === true ? current : Infinity;
1729
+ const intervalMs = Number(job.intervalMs || 0);
1730
+ if (intervalMs === 0)
1731
+ return current;
1732
+ const lastRunAt = Number(job.lastRunAt || 0);
1733
+ if (!lastRunAt) {
1734
+ if (job.immediate === false)
1735
+ return (Number.isFinite(created) ? created : current) + intervalMs;
1736
+ return current;
1737
+ }
1738
+ return lastRunAt + intervalMs;
1739
+ }
1740
+ function jobIsDue(job, current = Date.now(), force = false) {
1741
+ if (!jobRunnable(job))
1742
+ return false;
1743
+ if (force)
1744
+ return true;
1745
+ return jobDueAt(job, current) <= current;
1746
+ }
1747
+ function dueJobs(state, current = Date.now(), force = false) {
1748
+ return (state?.jobs || []).filter((job) => jobIsDue(job, current, force)).sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
1749
+ }
1750
+ function nextDueDelay(state, current = Date.now()) {
1751
+ let soonest = Infinity;
1752
+ for (const job of state?.jobs || [])
1753
+ soonest = Math.min(soonest, jobDueAt(job, current));
1754
+ if (!Number.isFinite(soonest))
1755
+ return Infinity;
1756
+ return Math.max(0, soonest - current);
1757
+ }
1758
+ function scheduleDescription(job) {
1759
+ const mode = inferredScheduleMode(job);
1760
+ const intervalMs = Number(job?.intervalMs || 0);
1761
+ if (mode === "idle")
1762
+ return "every idle";
1763
+ if (mode === "watch")
1764
+ return `on watch: ${(job.watchPaths || []).join(", ")}`;
1765
+ if (mode === "once")
1766
+ return intervalMs > 0 ? `once after ${durationToText(intervalMs)}` : "once on next idle";
1767
+ if (job?.immediate === false)
1768
+ return `every ${durationToText(intervalMs)}, first after ${durationToText(intervalMs)}`;
1769
+ return `every ${durationToText(intervalMs)}, starts on next idle`;
1770
+ }
1771
+ function scheduleState(job, current = Date.now()) {
1772
+ if (!job?.enabled)
1773
+ return "stopped";
1774
+ if (job?.paused)
1775
+ return "paused";
1776
+ if (Number(job?.runNowRequestedAt || 0) > 0)
1777
+ return "due now; waiting for idle";
1778
+ const mode = inferredScheduleMode(job);
1779
+ const dueAt = jobDueAt(job, current);
1780
+ if (!Number.isFinite(dueAt))
1781
+ return mode === "watch" ? "waiting for watched change" : "not scheduled";
1782
+ if (dueAt <= current)
1783
+ return mode === "idle" ? "waiting for idle" : "due; waiting for idle";
1784
+ return `due in ${durationToText(dueAt - current)}`;
1785
+ }
1786
+
1787
+ // src/source/runtime/loop-diagnostics.js
1788
+ function describeJobScheduling(job, current = Date.now()) {
1789
+ return {
1790
+ schedule: scheduleDescription(job),
1791
+ state: scheduleState(job, current)
1792
+ };
1793
+ }
1794
+ async function listPersistedLoopSessions(directory, currentSessionID) {
1795
+ const root = stateDir(directory);
1796
+ let names;
1797
+ try {
1798
+ names = await fs5.readdir(root);
1799
+ } catch (error) {
1800
+ if (error?.code === "ENOENT")
1801
+ return [];
1802
+ return [];
1803
+ }
1804
+ const sessions = [];
1805
+ for (const name of names) {
1806
+ if (!name.endsWith(".json"))
1807
+ continue;
1808
+ const sessionID = name.slice(0, -5);
1809
+ try {
1810
+ const parsed = JSON.parse(await fs5.readFile(path6.join(root, name), "utf8"));
1811
+ const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
1812
+ const enabled = jobs.filter((job) => job?.enabled !== false && !job?.paused).length;
1813
+ const neverRan = jobs.filter((job) => Number(job?.runCount || 0) === 0).length;
1814
+ sessions.push({
1815
+ sessionID,
1816
+ current: sessionID === currentSessionID,
1817
+ jobs: jobs.length,
1818
+ enabled,
1819
+ neverRan
1820
+ });
1821
+ } catch {
1822
+ sessions.push({ sessionID, current: sessionID === currentSessionID, jobs: 0, enabled: 0, neverRan: 0, corrupt: true });
1823
+ }
1824
+ }
1825
+ return sessions.sort((a, b) => Number(b.current) - Number(a.current) || b.enabled - a.enabled || a.sessionID.localeCompare(b.sessionID));
1826
+ }
1827
+
1828
+ // src/source/opencode/loop-commands.js
1829
+ var SERVICE2 = "opencode-loop";
1830
+ var DEFAULT_PROGRESS_MD = `# Progress
1831
+
1832
+ ## Current Goal
1833
+ Describe the current project goal here.
1834
+
1835
+ ## Agent Rules
1836
+ - Do not ask questions unless truly blocked.
1837
+ - Make reasonable assumptions and continue.
1838
+ - Work on unfinished TODOs in order.
1839
+ - Mark completed TODOs with [x].
1840
+ - Add new bugs, ideas, and follow-up work as TODOs.
1841
+ - Run tests, lint, or build when available.
1842
+ - Do not run destructive commands, force pushes, production deploys, or database resets.
1843
+
1844
+ ## Active TODO
1845
+ - [ ] Review the project structure and pick the next safe improvement.
1846
+
1847
+ ## Completed
1848
+ - [x] Created progress.md.
1849
+
1850
+ ## Backlog Ideas
1851
+ - [ ] Add more project-specific tasks here.
1852
+
1853
+ ## Blocked
1854
+ - None.
1855
+ `;
1856
+ function requireFunction3(value, name) {
1857
+ if (typeof value !== "function")
1858
+ throw new TypeError(`createLoopCommandHandlers requires ${name}`);
1859
+ return value;
1860
+ }
1861
+ function createLoopCommandHandlers(options = {}) {
1862
+ const clearActiveRun = requireFunction3(options.clearActiveRun, "clearActiveRun");
1863
+ const cancelDueWork = requireFunction3(options.cancelDueWork, "cancelDueWork");
1864
+ const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
1865
+ const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
1866
+ const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
1867
+ const toast2 = requireFunction3(options.toast, "toast");
1660
1868
  const say2 = requireFunction3(options.say, "say");
1661
1869
  const now2 = typeof options.now === "function" ? options.now : now;
1662
1870
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
@@ -1664,8 +1872,10 @@ function createLoopCommandHandlers(options = {}) {
1664
1872
  const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState;
1665
1873
  const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
1666
1874
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
1667
- const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs4.readFile(...args);
1668
- const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs4.writeFile(...args);
1875
+ const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
1876
+ const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs6.writeFile(...args);
1877
+ const listPersistedLoopSessions2 = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : listPersistedLoopSessions;
1878
+ const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
1669
1879
  const runtimeVersion = options.runtimeVersion || process.version;
1670
1880
  const runtimePlatform = options.runtimePlatform || process.platform;
1671
1881
  async function stopLoop(directory, client, sessionID, args) {
@@ -1697,15 +1907,11 @@ function createLoopCommandHandlers(options = {}) {
1697
1907
  async function statusLoop(directory, client, sessionID) {
1698
1908
  const state = await readState2(directory, sessionID);
1699
1909
  const jobs = state.jobs || [];
1910
+ const current = now2();
1700
1911
  const lines = jobs.length ? jobs.map((job, index) => {
1701
- const current = now2();
1702
- const intervalMs = Number(job.intervalMs || 0);
1703
- const lastRunAt = Number(job.lastRunAt || 0);
1704
- const createdAt = Date.parse(job.createdAt || "");
1705
- const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current;
1706
- const dueIn = Math.max(0, dueAt - current);
1912
+ const scheduling = describeJobScheduling(job, current);
1707
1913
  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(",");
1708
- return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
1914
+ 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}`;
1709
1915
  }) : ["No active loop jobs."];
1710
1916
  await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
1711
1917
  await say2(client, sessionID, `OpenCode loop status:
@@ -1715,7 +1921,7 @@ function createLoopCommandHandlers(options = {}) {
1715
1921
  async function logsLoop(directory, client, sessionID) {
1716
1922
  let text = "No loop log found.";
1717
1923
  try {
1718
- text = (await readFile(path4.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
1924
+ text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
1719
1925
  `) || text;
1720
1926
  } catch {}
1721
1927
  await say2(client, sessionID, `OpenCode loop logs:
@@ -1724,18 +1930,20 @@ function createLoopCommandHandlers(options = {}) {
1724
1930
  async function helpLoop(client, sessionID) {
1725
1931
  await say2(client, sessionID, [
1726
1932
  "OpenCode Loop help:",
1727
- "/loop 0s <prompt> Claude Code style auto-continue",
1728
- "/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
1729
- "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
1730
- "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
1731
- "/loop-shell 10m npm test shell loop, waits for idle",
1732
- "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
1733
- '/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it',
1734
- "/loop-goal status | pause | resume | clear manage experimental goals",
1735
- "/loop 200m --command /compact same as command loop",
1736
- '/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
1737
- "/loop 0s --prompt-file loop-prompt.md load prompt from a file",
1738
- "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
1933
+ "/loop continue the project auto-continue forever whenever the session becomes idle",
1934
+ "/loop idle continue the project explicit form of the same idle loop",
1935
+ "/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
1936
+ "/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
1937
+ "/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
1938
+ "/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
1939
+ "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
1940
+ "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
1941
+ "/loop-shell 10m npm test shell loop, waits for idle",
1942
+ "/loop-goal finish the feature and keep tests green experimental persistent goal mode",
1943
+ '/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
1944
+ "/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
1945
+ "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
1946
+ "Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
1739
1947
  "/loop-doctor | /loop-init | /loop-export"
1740
1948
  ].join(`
1741
1949
  `));
@@ -1760,22 +1968,36 @@ function createLoopCommandHandlers(options = {}) {
1760
1968
  }
1761
1969
  async function doctorLoop(directory, client, sessionID) {
1762
1970
  const state = await readState2(directory, sessionID);
1763
- await say2(client, sessionID, [
1971
+ const persisted = await listPersistedLoopSessions2(directory, sessionID);
1972
+ const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0);
1973
+ const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
1974
+ const lines = [
1764
1975
  "OpenCode Loop doctor:",
1765
1976
  `- plugin: ${SERVICE2}`,
1766
1977
  `- project directory: ${directory}`,
1767
1978
  `- state directory: ${stateDir(directory)}`,
1768
- `- active jobs: ${(state.jobs || []).length}`,
1979
+ `- current session: ${sessionID}`,
1980
+ `- current-session jobs: ${(state.jobs || []).length}`,
1981
+ `- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
1982
+ `- other persisted sessions with enabled jobs: ${otherSessions.length}`,
1769
1983
  `- node: ${runtimeVersion}`,
1770
1984
  `- platform: ${runtimePlatform}`,
1771
- "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
1985
+ "- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
1986
+ "- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
1987
+ "- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
1772
1988
  "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
1773
- ].join(`
1989
+ ];
1990
+ for (const entry of otherSessions.slice(0, 8)) {
1991
+ lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`);
1992
+ }
1993
+ if (otherSessions.length > 8)
1994
+ lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
1995
+ await say2(client, sessionID, lines.join(`
1774
1996
  `));
1775
1997
  }
1776
1998
  async function initLoop(directory, client, sessionID, args) {
1777
1999
  const target = String(args || "").trim() || "progress.md";
1778
- const full = path4.resolve(directory, target);
2000
+ const full = path7.resolve(directory, target);
1779
2001
  if (await pathExists2(full)) {
1780
2002
  await toast2(client, `${target} already exists.`, "warning");
1781
2003
  return;
@@ -1801,6 +2023,81 @@ function createLoopCommandHandlers(options = {}) {
1801
2023
  };
1802
2024
  }
1803
2025
 
2026
+ // src/source/core/schedule-syntax.js
2027
+ function removeBooleanFlag(input, flag) {
2028
+ const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i");
2029
+ const found = pattern.test(input);
2030
+ return {
2031
+ found,
2032
+ value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim()
2033
+ };
2034
+ }
2035
+ function firstToken(input) {
2036
+ const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/);
2037
+ return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" };
2038
+ }
2039
+ function inferredMode(intervalMs, maxRuns) {
2040
+ if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0)
2041
+ return "once";
2042
+ return Number(intervalMs || 0) === 0 ? "idle" : "interval";
2043
+ }
2044
+ function normalizeLoopScheduleArgs(raw, defaults = {}) {
2045
+ const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap");
2046
+ let input = overlap.value;
2047
+ const nextDefaults = { ...defaults };
2048
+ let scheduleMode = defaults.scheduleMode;
2049
+ let scheduleSyntax = "legacy";
2050
+ const first = firstToken(input);
2051
+ const keyword = first.token.toLowerCase();
2052
+ if (keyword === "idle") {
2053
+ nextDefaults.intervalMs = 0;
2054
+ nextDefaults.immediate = true;
2055
+ scheduleMode = "idle";
2056
+ scheduleSyntax = "idle";
2057
+ input = first.rest;
2058
+ } else if (keyword === "every" || keyword === "after" || keyword === "in") {
2059
+ const duration = firstToken(first.rest);
2060
+ const intervalMs = parseDuration(duration.token);
2061
+ if (intervalMs === null) {
2062
+ return {
2063
+ ok: false,
2064
+ error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`
2065
+ };
2066
+ }
2067
+ nextDefaults.intervalMs = intervalMs;
2068
+ nextDefaults.immediate = false;
2069
+ input = duration.rest;
2070
+ if (keyword === "every") {
2071
+ scheduleMode = intervalMs === 0 ? "idle" : "interval";
2072
+ scheduleSyntax = "every";
2073
+ } else {
2074
+ nextDefaults.maxRuns = 1;
2075
+ scheduleMode = "once";
2076
+ scheduleSyntax = "after";
2077
+ }
2078
+ } else {
2079
+ const duration = parseDuration(first.token);
2080
+ if (duration !== null) {
2081
+ scheduleMode = duration === 0 ? "idle" : "interval";
2082
+ } else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
2083
+ nextDefaults.intervalMs = 0;
2084
+ nextDefaults.immediate = nextDefaults.immediate ?? true;
2085
+ scheduleMode = "idle";
2086
+ scheduleSyntax = "idle-shorthand";
2087
+ } else {
2088
+ scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns);
2089
+ }
2090
+ }
2091
+ return {
2092
+ ok: true,
2093
+ args: input,
2094
+ defaults: nextDefaults,
2095
+ scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
2096
+ scheduleSyntax,
2097
+ allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true
2098
+ };
2099
+ }
2100
+
1804
2101
  // src/source/opencode/loop-registration.js
1805
2102
  var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
1806
2103
  var FALLBACK_ACTIVE_GUARD_MS = 45000;
@@ -1824,19 +2121,34 @@ function createLoopRegistration(options = {}) {
1824
2121
  const toast2 = requireFunction4(options.toast, "toast");
1825
2122
  const say2 = requireFunction4(options.say, "say");
1826
2123
  const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
2124
+ const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
1827
2125
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
1828
2126
  const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
1829
2127
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
1830
2128
  const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
1831
2129
  const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext;
2130
+ const findDedicatedGoalForSession2 = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : findDedicatedGoalForSession;
1832
2131
  const configuredGuard = Number(options.defaultActiveGuardMs);
1833
2132
  const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS;
1834
2133
  async function addLoop(directory, client, sessionID, args, defaults = {}) {
1835
- const parsed = parseLoopArgs2(args, defaults);
2134
+ const normalized = normalizeLoopScheduleArgs2(args, defaults);
2135
+ if (!normalized.ok) {
2136
+ await toast2(client, normalized.error, "warning");
2137
+ return;
2138
+ }
2139
+ const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
1836
2140
  if (!parsed.ok) {
1837
2141
  await toast2(client, parsed.error, "warning");
1838
2142
  return;
1839
2143
  }
2144
+ parsed.job.scheduleMode = normalized.scheduleMode;
2145
+ parsed.job.scheduleSyntax = normalized.scheduleSyntax;
2146
+ parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true;
2147
+ if (normalized.scheduleSyntax === "after") {
2148
+ parsed.job.immediate = false;
2149
+ parsed.job.maxRuns = 1;
2150
+ parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now();
2151
+ }
1840
2152
  const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
1841
2153
  parsed.job.agent = defaults.agent || executionContext.agent || "build";
1842
2154
  parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
@@ -1856,6 +2168,19 @@ function createLoopRegistration(options = {}) {
1856
2168
  if (!parsed.job.activeRecoveryMs) {
1857
2169
  parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
1858
2170
  }
2171
+ const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt";
2172
+ if (promptProducing && !parsed.job.allowGoalOverlap) {
2173
+ const dedicatedGoal = await findDedicatedGoalForSession2(directory, sessionID);
2174
+ if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
2175
+ await appendLoopLog2(directory, "goal-overlap-blocked", {
2176
+ sessionID,
2177
+ job: parsed.job.name || parsed.job.id,
2178
+ goal: dedicatedGoal.id
2179
+ });
2180
+ 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");
2181
+ return;
2182
+ }
2183
+ }
1859
2184
  if (parsed.job.dryRun) {
1860
2185
  await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
1861
2186
  await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
@@ -1883,7 +2208,13 @@ function createLoopRegistration(options = {}) {
1883
2208
  if (parsed.job.immediate)
1884
2209
  scheduleIdleWork(directory, client, sessionID);
1885
2210
  await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
1886
- await appendLoopLog2(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) });
2211
+ await appendLoopLog2(directory, replaced ? "replace" : "add", {
2212
+ sessionID,
2213
+ job: parsed.job.name || parsed.job.id,
2214
+ label: jobLabel(parsed.job),
2215
+ scheduleMode: parsed.job.scheduleMode,
2216
+ scheduleSyntax: parsed.job.scheduleSyntax
2217
+ });
1887
2218
  }
1888
2219
  return { addLoop };
1889
2220
  }
@@ -2000,6 +2331,37 @@ function clearSessionActivity(sessionID) {
2000
2331
  deleteSessionExecutionContext(sessionID);
2001
2332
  }
2002
2333
 
2334
+ // src/source/runtime/scheduler-diagnostics.js
2335
+ var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
2336
+ function createSchedulerDiagnostics(options = {}) {
2337
+ const now2 = typeof options.now === "function" ? options.now : Date.now;
2338
+ const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
2339
+ const configured = Number(options.throttleMs);
2340
+ const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
2341
+ const lastLogged = new Map;
2342
+ async function logDeferral(directory, sessionID, reason, extra = {}) {
2343
+ const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
2344
+ const current = now2();
2345
+ const previous = Number(lastLogged.get(key) || 0);
2346
+ if (previous > 0 && current - previous < throttleMs)
2347
+ return false;
2348
+ lastLogged.set(key, current);
2349
+ await appendLoopLog2(directory, "deferred", {
2350
+ sessionID,
2351
+ reason,
2352
+ ...extra
2353
+ });
2354
+ return true;
2355
+ }
2356
+ function clearSession(sessionID) {
2357
+ const prefix = `${sessionID || "unknown"}:`;
2358
+ for (const key of lastLogged.keys())
2359
+ if (key.startsWith(prefix))
2360
+ lastLogged.delete(key);
2361
+ }
2362
+ return { logDeferral, clearSession };
2363
+ }
2364
+
2003
2365
  // src/source/runtime/scheduler.js
2004
2366
  var DEFAULT_IDLE_DEBOUNCE_MS = 1200;
2005
2367
  var DEFAULT_BUSY_RETRY_MS = 5000;
@@ -2007,37 +2369,6 @@ var DEFAULT_MIN_DUE_TIMER_MS = 250;
2007
2369
  var DEFAULT_MAX_DUE_TIMER_MS = 2147000000;
2008
2370
  var DEFAULT_HEARTBEAT_MS = 2500;
2009
2371
  var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
2010
- function jobDueAt(job, current = now()) {
2011
- if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
2012
- return Infinity;
2013
- if (!job.enabled || job.paused)
2014
- return Infinity;
2015
- if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
2016
- return Infinity;
2017
- if (Number(job.runNowRequestedAt || 0) > 0)
2018
- return current;
2019
- if (job.watchPaths?.length)
2020
- return Infinity;
2021
- const created = Date.parse(job.createdAt || "");
2022
- if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs)
2023
- return current;
2024
- if (job.intervalMs === 0)
2025
- return current;
2026
- if (!job.lastRunAt) {
2027
- if (job.immediate === false)
2028
- return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0);
2029
- return current;
2030
- }
2031
- return job.lastRunAt + (job.intervalMs || 0);
2032
- }
2033
- function nextDueDelay(state, current = now()) {
2034
- let soonest = Infinity;
2035
- for (const job of state.jobs || [])
2036
- soonest = Math.min(soonest, jobDueAt(job, current));
2037
- if (!Number.isFinite(soonest))
2038
- return Infinity;
2039
- return Math.max(0, soonest - current);
2040
- }
2041
2372
  function createSchedulerRuntime(options = {}) {
2042
2373
  const idleTimers = new Map;
2043
2374
  const dueTimers = new Map;
@@ -2065,6 +2396,7 @@ function createSchedulerRuntime(options = {}) {
2065
2396
  if (options.toast)
2066
2397
  await options.toast(client, message, level);
2067
2398
  };
2399
+ const diagnostics = createSchedulerDiagnostics({ appendLoopLog: appendLog, now: clock, throttleMs: options.deferralLogThrottleMs });
2068
2400
  function stopHeartbeatIfIdle() {
2069
2401
  if (!knownSessions.size && heartbeatTimer) {
2070
2402
  clearIntervalFn(heartbeatTimer);
@@ -2112,11 +2444,13 @@ function createSchedulerRuntime(options = {}) {
2112
2444
  idleTimers.delete(sessionID);
2113
2445
  Promise.resolve().then(async () => {
2114
2446
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2447
+ await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "idle", retryMs: busyRetryMs });
2115
2448
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2116
2449
  return;
2117
2450
  }
2118
2451
  await options.finalizeActiveRun?.(directory, client, sessionID);
2119
2452
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2453
+ await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "idle", retryMs: busyRetryMs });
2120
2454
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2121
2455
  return;
2122
2456
  }
@@ -2165,11 +2499,13 @@ function createSchedulerRuntime(options = {}) {
2165
2499
  dueTimers.delete(sessionID);
2166
2500
  Promise.resolve().then(async () => {
2167
2501
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2502
+ await diagnostics.logDeferral(directory, sessionID, "session-busy", { source: "due", retryMs: busyRetryMs });
2168
2503
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2169
2504
  return;
2170
2505
  }
2171
2506
  await options.finalizeActiveRun?.(directory, client, sessionID);
2172
2507
  if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
2508
+ await diagnostics.logDeferral(directory, sessionID, "session-busy-after-finalize", { source: "due", retryMs: busyRetryMs });
2173
2509
  await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2174
2510
  return;
2175
2511
  }
@@ -2189,6 +2525,7 @@ function createSchedulerRuntime(options = {}) {
2189
2525
  cancelIdleWork(sessionID);
2190
2526
  cancelDueWork(sessionID);
2191
2527
  stopWatchdog(sessionID);
2528
+ diagnostics.clearSession(sessionID);
2192
2529
  knownSessions.delete(sessionID);
2193
2530
  stopHeartbeatIfIdle();
2194
2531
  }
@@ -2287,8 +2624,8 @@ ${item.output}`).join(`
2287
2624
  }
2288
2625
 
2289
2626
  // src/source/runtime/job-workspace.js
2290
- import { promises as fs5 } from "fs";
2291
- import path5 from "path";
2627
+ import { promises as fs7 } from "fs";
2628
+ import path8 from "path";
2292
2629
  var MAX_SCAN_FILES = 200;
2293
2630
  var MAX_SCAN_BYTES = 2000000;
2294
2631
  function requireFunction6(value, name) {
@@ -2323,7 +2660,7 @@ function createJobWorkspaceRuntime(options = {}) {
2323
2660
  return await buildGoalPrompt2(directory, job);
2324
2661
  const sections = [];
2325
2662
  if (job.promptFile) {
2326
- const text = await readSmallTextFile2(path5.resolve(directory, job.promptFile));
2663
+ const text = await readSmallTextFile2(path8.resolve(directory, job.promptFile));
2327
2664
  if (text.trim())
2328
2665
  sections.push(`Instructions from ${job.promptFile}:
2329
2666
  ${text.trim()}`);
@@ -2333,7 +2670,7 @@ ${text.trim()}`);
2333
2670
  if (job.action)
2334
2671
  sections.push(decoratePrompt(job));
2335
2672
  for (const file of job.includeFiles || []) {
2336
- const text = await readSmallTextFile2(path5.resolve(directory, file), 80000);
2673
+ const text = await readSmallTextFile2(path8.resolve(directory, file), 80000);
2337
2674
  if (text.trim())
2338
2675
  sections.push(`Context from ${file}:
2339
2676
  ${text.trim().slice(0, 20000)}`);
@@ -2365,7 +2702,7 @@ ${text.trim().slice(0, 20000)}`);
2365
2702
  const snapshot = {};
2366
2703
  for (const file of files || []) {
2367
2704
  try {
2368
- const stat = await fs5.stat(path5.resolve(directory, file));
2705
+ const stat = await fs7.stat(path8.resolve(directory, file));
2369
2706
  snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
2370
2707
  } catch {
2371
2708
  snapshot[file] = "missing";
@@ -2385,10 +2722,10 @@ ${text.trim().slice(0, 20000)}`);
2385
2722
  }
2386
2723
  async function fileContains(filePath, needle) {
2387
2724
  try {
2388
- const stat = await fs5.stat(filePath);
2725
+ const stat = await fs7.stat(filePath);
2389
2726
  if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
2390
2727
  return false;
2391
- return (await fs5.readFile(filePath, "utf8")).includes(needle);
2728
+ return (await fs7.readFile(filePath, "utf8")).includes(needle);
2392
2729
  } catch {
2393
2730
  return false;
2394
2731
  }
@@ -2396,9 +2733,9 @@ ${text.trim().slice(0, 20000)}`);
2396
2733
  async function untilReached(directory, job) {
2397
2734
  if (!job.until)
2398
2735
  return false;
2399
- const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path5.join(".opencode", "opencode-loop", "until.txt")];
2736
+ const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path8.join(".opencode", "opencode-loop", "until.txt")];
2400
2737
  for (const file of files)
2401
- if (await fileContains(path5.resolve(directory, file), job.until))
2738
+ if (await fileContains(path8.resolve(directory, file), job.until))
2402
2739
  return true;
2403
2740
  let scanned = 0;
2404
2741
  async function walk(current) {
@@ -2406,7 +2743,7 @@ ${text.trim().slice(0, 20000)}`);
2406
2743
  return false;
2407
2744
  let entries;
2408
2745
  try {
2409
- entries = await fs5.readdir(current, { withFileTypes: true });
2746
+ entries = await fs7.readdir(current, { withFileTypes: true });
2410
2747
  } catch {
2411
2748
  return false;
2412
2749
  }
@@ -2415,7 +2752,7 @@ ${text.trim().slice(0, 20000)}`);
2415
2752
  return false;
2416
2753
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
2417
2754
  continue;
2418
- const full = path5.join(current, entry.name);
2755
+ const full = path8.join(current, entry.name);
2419
2756
  if (entry.isDirectory()) {
2420
2757
  if (await walk(full))
2421
2758
  return true;
@@ -2439,13 +2776,13 @@ ${text.trim().slice(0, 20000)}`);
2439
2776
  if (!status.stdout.trim())
2440
2777
  return;
2441
2778
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
2442
- const checkpointDir = path5.join(stateDir(directory), "checkpoints", safeID(sessionID));
2779
+ const checkpointDir = path8.join(stateDir(directory), "checkpoints", safeID(sessionID));
2443
2780
  await ensureDir(checkpointDir);
2444
2781
  const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000);
2445
2782
  const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000);
2446
2783
  const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
2447
- await fs5.writeFile(path5.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
2448
- await fs5.writeFile(path5.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
2784
+ await fs7.writeFile(path8.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
2785
+ await fs7.writeFile(path8.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
2449
2786
  ${staged.stdout}`);
2450
2787
  if (job.gitCheckpoint) {
2451
2788
  await runProcess2("git", ["add", "-A"], directory, 120000);
@@ -2463,9 +2800,6 @@ ${staged.stdout}`);
2463
2800
  };
2464
2801
  }
2465
2802
 
2466
- // src/source/runtime/executor.js
2467
- import path6 from "path";
2468
-
2469
2803
  // src/source/runtime/session-status.js
2470
2804
  var DEFAULT_STALE_ACTIVE_RECOVERY_MS = 45000;
2471
2805
  var DEFAULT_SESSION_STATUS_CACHE_MS = 1500;
@@ -2587,6 +2921,25 @@ function createSessionStatusRuntime(options = {}) {
2587
2921
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
2588
2922
  return cached === "idle" && seenAt > (active.startedAt || 0);
2589
2923
  }
2924
+ async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
2925
+ if (activeRuns.has(sessionID))
2926
+ return false;
2927
+ if (liveType !== "busy" && liveType !== "retry")
2928
+ return false;
2929
+ if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
2930
+ return false;
2931
+ const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
2932
+ if (completion !== "completed")
2933
+ return false;
2934
+ markSessionStatus(sessionID, "idle");
2935
+ await appendLoopLog2(directory, "status-message-idle-recovery", {
2936
+ sessionID,
2937
+ staleStatus: liveType,
2938
+ statusSeenAt: seenAt,
2939
+ staleForMs: Math.max(0, now2() - seenAt)
2940
+ });
2941
+ return true;
2942
+ }
2590
2943
  async function sessionStatusType(client, sessionID, directory, options2 = {}) {
2591
2944
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
2592
2945
  markSessionStatus(sessionID, "busy");
@@ -2600,6 +2953,8 @@ function createSessionStatusRuntime(options = {}) {
2600
2953
  return cached;
2601
2954
  const live = await readLiveSessionStatus(client, sessionID, directory);
2602
2955
  if (live?.type) {
2956
+ if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
2957
+ return "idle";
2603
2958
  if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
2604
2959
  const active = activeRuns.get(sessionID);
2605
2960
  if (active) {
@@ -2751,166 +3106,101 @@ function createCompactionRuntime(options = {}) {
2751
3106
  };
2752
3107
  }
2753
3108
 
2754
- // src/source/runtime/executor.js
2755
- var DEFAULT_ACTIVE_GUARD_MS = 45000;
2756
- var DEFAULT_BUSY_RETRY_MS2 = 5000;
3109
+ // src/source/runtime/action-dispatch.js
2757
3110
  function requireFunction7(value, label) {
2758
3111
  if (typeof value !== "function")
2759
- throw new TypeError(`createLoopExecutor requires ${label}`);
3112
+ throw new TypeError(`createActionDispatcher requires ${label}`);
2760
3113
  return value;
2761
3114
  }
2762
- function createLoopExecutor(options = {}) {
2763
- const workspace = options.workspace || {};
2764
- const goalPolicy = options.goalPolicy || {};
2765
- const scheduler = options.scheduler || {};
2766
- const buildPrompt = requireFunction7(workspace.buildPrompt, "workspace.buildPrompt");
2767
- const ensureBranch = requireFunction7(workspace.ensureBranch, "workspace.ensureBranch");
2768
- const watchChanged = requireFunction7(workspace.watchChanged, "workspace.watchChanged");
2769
- const untilReached = requireFunction7(workspace.untilReached, "workspace.untilReached");
2770
- const createCheckpoint = requireFunction7(workspace.createCheckpoint, "workspace.createCheckpoint");
2771
- const runGoalChecks = requireFunction7(goalPolicy.runGoalChecks, "goalPolicy.runGoalChecks");
2772
- const applyGoalNoProgressGuard = requireFunction7(goalPolicy.applyGoalNoProgressGuard, "goalPolicy.applyGoalNoProgressGuard");
2773
- const rememberSession = requireFunction7(scheduler.rememberSession, "scheduler.rememberSession");
2774
- const scheduleDueWork = requireFunction7(scheduler.scheduleDueWork, "scheduler.scheduleDueWork");
2775
- const now2 = typeof options.now === "function" ? options.now : now;
2776
- const readState2 = typeof options.readState === "function" ? options.readState : readState;
2777
- const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
2778
- const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
3115
+ function createActionDispatcher(options = {}) {
3116
+ const buildPrompt = requireFunction7(options.buildPrompt, "buildPrompt");
3117
+ const startCompaction = requireFunction7(options.compactionRuntime?.start, "compactionRuntime.start");
2779
3118
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
2780
- const runShellCommand2 = typeof options.runShellCommand === "function" ? options.runShellCommand : runShellCommand;
2781
- const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
2782
- const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : sdkErrorMessage;
2783
3119
  const sdkCall2 = typeof options.sdkCall === "function" ? options.sdkCall : sdkCall;
2784
3120
  const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
2785
3121
  const fireSdk2 = typeof options.fireSdk === "function" ? options.fireSdk : fireSdk;
2786
3122
  const compactTuiCommandName2 = typeof options.compactTuiCommandName === "function" ? options.compactTuiCommandName : compactTuiCommandName;
2787
- const log2 = typeof options.log === "function" ? options.log : log;
2788
3123
  const toast2 = typeof options.toast === "function" ? options.toast : toast;
2789
3124
  const guardLoopOwnedUserMessage2 = typeof options.guardLoopOwnedUserMessage === "function" ? options.guardLoopOwnedUserMessage : guardLoopOwnedUserMessage;
2790
- const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
2791
3125
  const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
2792
- const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
2793
- const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
2794
- const activeRuns = new Map;
2795
- const runLocks = new Map;
2796
- const statusRuntime = createSessionStatusRuntime({
2797
- activeRuns,
2798
- appendLoopLog: appendLoopLog2,
2799
- now: now2,
2800
- activeRunCompletionFromMessages: options.activeRunCompletionFromMessages,
2801
- staleActiveRecoveryMs: options.staleActiveRecoveryMs,
2802
- sessionStatusCacheMs: options.sessionStatusCacheMs
2803
- });
2804
- const {
2805
- updateSessionStatusFromEvent,
2806
- staleActiveRun,
2807
- canFinalizeActiveRun,
2808
- sessionIsIdle,
2809
- markSessionStatus,
2810
- clearSessionStatus
2811
- } = statusRuntime;
2812
- const compactionRuntime = createCompactionRuntime({
2813
- activeRuns,
2814
- finalizeActiveRun,
2815
- appendLoopLog: appendLoopLog2,
2816
- compactSession: options.compactSession,
2817
- log: log2,
2818
- errorMessage,
2819
- now: now2
2820
- });
2821
- function dueJobs(state, force = false) {
2822
- const current = now2();
2823
- const due = (state.jobs || []).filter((job) => {
2824
- if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
2825
- return false;
2826
- if (!job.enabled || job.paused)
2827
- return false;
2828
- if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
2829
- return false;
2830
- if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs)
2831
- return true;
2832
- if (Number(job.runNowRequestedAt || 0) > 0)
2833
- return true;
2834
- if (force)
2835
- return true;
2836
- if (job.watchPaths?.length)
2837
- return job.watchTriggered === true;
2838
- return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs;
2839
- });
2840
- return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
2841
- }
2842
- function clearActiveRun(sessionID) {
2843
- const active = activeRuns.get(sessionID);
2844
- if (active?.timer)
2845
- clearTimeout(active.timer);
2846
- compactionRuntime.clearForActiveRun(sessionID, active);
2847
- activeRuns.delete(sessionID);
2848
- }
2849
- function disposeSession(sessionID) {
2850
- clearActiveRun(sessionID);
2851
- runLocks.delete(sessionID);
2852
- compactionRuntime.clear(sessionID);
2853
- clearSessionStatus(sessionID);
2854
- }
2855
- async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
2856
- const active = activeRuns.get(sessionID);
2857
- if (!active || active.jobId !== jobId || active.runToken !== runToken)
2858
- return false;
2859
- clearActiveRun(sessionID);
2860
- clearSessionStatus(sessionID);
2861
- const message = errorMessage(error);
2862
- const state = await readState2(directory, sessionID);
2863
- const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
2864
- if (job) {
2865
- job.failureCount = (job.failureCount || 0) + 1;
2866
- job.lastFailureReason = "dispatch_failed";
2867
- job.lastDispatchFailure = message.slice(0, 4000);
2868
- job.lastDispatchFailureAt = now2();
2869
- if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
2870
- job.paused = true;
2871
- await notifyJob2(directory, job, "dispatch_failed");
2872
- }
2873
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
2874
- await writeState2(directory, sessionID, state);
3126
+ async function fireAction(directory, client, sessionID, job) {
3127
+ const action = String(job.action || "").trim();
3128
+ const kind = actionKind(action, job);
3129
+ const agent = job.agent || "build";
3130
+ const model = normalizedModelRef2(job.model);
3131
+ if (kind === "compact") {
3132
+ const ok = await startCompaction(directory, client, sessionID, job.id, model, false);
3133
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
2875
3134
  }
2876
- await appendLoopLog2(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
2877
- await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
2878
- await scheduleDueWork(directory, client, sessionID, busyRetryMs);
2879
- return true;
2880
- }
2881
- async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
2882
- const active = activeRuns.get(sessionID);
2883
- if (!active)
2884
- return;
2885
- if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
2886
- return false;
2887
- const recoveredStale = staleActiveRun(sessionID);
2888
- if (active.compactionOnly) {
2889
- const pending = compactionRuntime.getPending(sessionID);
2890
- clearActiveRun(sessionID);
2891
- clearSessionStatus(sessionID);
2892
- await appendLoopLog2(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
2893
- sessionID,
2894
- job: active.job?.name || active.jobId,
2895
- startedAt: active.startedAt,
2896
- nativeEvent: Boolean(pending?.completedAt)
2897
- });
2898
- await scheduleDueWork(directory, client, sessionID);
2899
- return true;
3135
+ if (kind === "command") {
3136
+ const normalized = action.startsWith("/") ? action.slice(1) : action;
3137
+ const [command, argumentsText] = splitFirst(normalized);
3138
+ if (!command) {
3139
+ await toast2(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning");
3140
+ return { startsAssistantTurn: false, pause: true, reason: "empty_command" };
3141
+ }
3142
+ const tuiCommand = compactTuiCommandName2(command);
3143
+ if (tuiCommand) {
3144
+ guardLoopOwnedUserMessage2(sessionID);
3145
+ const ok = await startCompaction(directory, client, sessionID, job.id, model, false);
3146
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
3147
+ }
3148
+ guardLoopOwnedUserMessage2(sessionID);
3149
+ const commandBody = { command, arguments: argumentsText, agent };
3150
+ if (model)
3151
+ commandBody.model = `${model.providerID}/${model.modelID}`;
3152
+ await sdkCall2(client.session.command.bind(client.session), { path: { id: sessionID }, body: commandBody }, { path: { sessionID }, body: commandBody }, { sessionID, ...commandBody });
3153
+ return { startsAssistantTurn: true };
2900
3154
  }
2901
- clearActiveRun(sessionID);
2902
- const state = await readState2(directory, sessionID);
2903
- let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
2904
- if (!job)
2905
- return;
2906
- job.lastFinishedAt = now2();
2907
- if (recoveredStale) {
2908
- await appendLoopLog2(directory, "active-stale-recovery", {
2909
- sessionID,
2910
- job: job.name || job.id,
2911
- startedAt: active.startedAt
2912
- });
3155
+ if (kind === "shell") {
3156
+ const command = action.replace(/^[!$]\s*/, "").trim();
3157
+ if (job.safe && dangerousShell2(command)) {
3158
+ await toast2(client, `Blocked dangerous shell command in safe mode: ${command}`, "error");
3159
+ await appendLoopLog2(directory, "blocked", { sessionID, job: job.name || job.id, command });
3160
+ return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" };
3161
+ }
3162
+ guardLoopOwnedUserMessage2(sessionID);
3163
+ const shellBody = { command, agent };
3164
+ if (model)
3165
+ shellBody.model = model;
3166
+ const dispatch2 = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
3167
+ return { startsAssistantTurn: true, dispatch: dispatch2 };
2913
3168
  }
3169
+ const prompt = await buildPrompt(directory, job);
3170
+ 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.";
3171
+ const promptText = `${prefix}
3172
+
3173
+ ${prompt}`;
3174
+ guardLoopOwnedUserMessage2(sessionID);
3175
+ const promptBody = { agent, parts: [{ type: "text", text: promptText }] };
3176
+ if (model)
3177
+ promptBody.model = model;
3178
+ const dispatch = fireSdk2(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: promptBody }, { path: { sessionID }, body: promptBody }, { sessionID, ...promptBody });
3179
+ return { startsAssistantTurn: true, dispatch };
3180
+ }
3181
+ return { fireAction };
3182
+ }
3183
+
3184
+ // src/source/runtime/run-finalization.js
3185
+ function requireFunction8(value, label) {
3186
+ if (typeof value !== "function")
3187
+ throw new TypeError(`createRunFinalizationRuntime requires ${label}`);
3188
+ return value;
3189
+ }
3190
+ function createRunFinalizationRuntime(options = {}) {
3191
+ const runGoalChecks = requireFunction8(options.runGoalChecks, "runGoalChecks");
3192
+ const applyGoalNoProgressGuard = requireFunction8(options.applyGoalNoProgressGuard, "applyGoalNoProgressGuard");
3193
+ const createCheckpoint = requireFunction8(options.createCheckpoint, "createCheckpoint");
3194
+ const scheduleDueWork = requireFunction8(options.scheduleDueWork, "scheduleDueWork");
3195
+ const now2 = typeof options.now === "function" ? options.now : now;
3196
+ const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
3197
+ const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
3198
+ const runShellCommand2 = typeof options.runShellCommand === "function" ? options.runShellCommand : runShellCommand;
3199
+ const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
3200
+ const toast2 = typeof options.toast === "function" ? options.toast : toast;
3201
+ const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
3202
+ const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3203
+ async function finalizeJob(directory, client, sessionID, state, job, previousJob) {
2914
3204
  if (job.verifyCommand) {
2915
3205
  const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
2916
3206
  job.lastVerifyAt = now2();
@@ -2971,7 +3261,7 @@ exit=` + postrun.code + `
2971
3261
  }
2972
3262
  if (isGoalJob(job)) {
2973
3263
  job = await runGoalChecks(directory, sessionID, job, client);
2974
- job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job);
3264
+ job = await applyGoalNoProgressGuard(directory, client, sessionID, job, previousJob);
2975
3265
  }
2976
3266
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
2977
3267
  await writeState2(directory, sessionID, state);
@@ -2979,63 +3269,272 @@ exit=` + postrun.code + `
2979
3269
  await writeGoalReport2(directory, sessionID, job);
2980
3270
  await createCheckpoint(directory, sessionID, job, client);
2981
3271
  await scheduleDueWork(directory, client, sessionID);
2982
- return true;
3272
+ return job;
2983
3273
  }
2984
- async function fireAction(directory, client, sessionID, job) {
2985
- const action = String(job.action || "").trim();
2986
- const kind = actionKind(action, job);
2987
- const agent = job.agent || "build";
2988
- const model = normalizedModelRef2(job.model);
2989
- if (kind === "compact") {
2990
- const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false);
2991
- return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
3274
+ return { finalizeJob };
3275
+ }
3276
+
3277
+ // src/source/runtime/run-admission.js
3278
+ import path9 from "path";
3279
+ function requireFunction9(value, label) {
3280
+ if (typeof value !== "function")
3281
+ throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
3282
+ return value;
3283
+ }
3284
+ function createRunAdmissionRuntime(options = {}) {
3285
+ const untilReached = requireFunction9(options.untilReached, "untilReached");
3286
+ const scheduleDueWork = requireFunction9(options.scheduleDueWork, "scheduleDueWork");
3287
+ const now2 = typeof options.now === "function" ? options.now : now;
3288
+ const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
3289
+ const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
3290
+ const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
3291
+ const runShellCommand2 = typeof options.runShellCommand === "function" ? options.runShellCommand : runShellCommand;
3292
+ const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
3293
+ const toast2 = typeof options.toast === "function" ? options.toast : toast;
3294
+ const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3295
+ function dueJobs2(state, force = false) {
3296
+ return dueJobs(state, now2(), force);
3297
+ }
3298
+ async function reschedule(directory, client, sessionID) {
3299
+ await scheduleDueWork(directory, client, sessionID);
3300
+ }
3301
+ async function stopAndRemove(directory, client, sessionID, state, job, reason, message, logEvent) {
3302
+ state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
3303
+ await writeState2(directory, sessionID, state);
3304
+ await notifyJob2(directory, job, reason);
3305
+ await toast2(client, message, "success");
3306
+ if (logEvent)
3307
+ await appendLoopLog2(directory, logEvent, { sessionID, job: job.name || job.id });
3308
+ await reschedule(directory, client, sessionID);
3309
+ return { admitted: false, reason };
3310
+ }
3311
+ async function admitJob(directory, client, sessionID, state, job) {
3312
+ const runNowRequested = Number(job.runNowRequestedAt || 0) > 0;
3313
+ if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
3314
+ return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
2992
3315
  }
2993
- if (kind === "command") {
2994
- const normalized = action.startsWith("/") ? action.slice(1) : action;
2995
- const [command, argumentsText] = splitFirst(normalized);
2996
- if (!command) {
2997
- await toast2(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning");
2998
- return { startsAssistantTurn: false, pause: true, reason: "empty_command" };
3316
+ if (job.stopFile && await pathExists2(path9.resolve(directory, job.stopFile))) {
3317
+ return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
3318
+ }
3319
+ if (await untilReached(directory, job)) {
3320
+ return await stopAndRemove(directory, client, sessionID, state, job, "until_reached", `Loop stopped by --until: ${job.until}`);
3321
+ }
3322
+ if (job.preflightCommand) {
3323
+ if (job.safe && dangerousShell2(job.preflightCommand)) {
3324
+ if (runNowRequested)
3325
+ delete job.runNowRequestedAt;
3326
+ job.paused = true;
3327
+ await writeState2(directory, sessionID, state);
3328
+ await notifyJob2(directory, job, "preflight_blocked");
3329
+ await toast2(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error");
3330
+ await reschedule(directory, client, sessionID);
3331
+ return { admitted: false, reason: "preflight_blocked" };
2999
3332
  }
3000
- const tuiCommand = compactTuiCommandName2(command);
3001
- if (tuiCommand) {
3002
- guardLoopOwnedUserMessage2(sessionID);
3003
- const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false);
3004
- return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
3333
+ const preflight = await runShellCommand2(job.preflightCommand, directory, job.timeoutMs || 300000);
3334
+ await appendLoopLog2(directory, "preflight", {
3335
+ sessionID,
3336
+ job: job.name || job.id,
3337
+ command: job.preflightCommand,
3338
+ code: preflight.code
3339
+ });
3340
+ if (preflight.code !== 0) {
3341
+ if (runNowRequested)
3342
+ delete job.runNowRequestedAt;
3343
+ job.paused = true;
3344
+ job.failureCount = (job.failureCount || 0) + 1;
3345
+ job.lastPreflightFailure = (job.preflightCommand + `
3346
+ exit=` + preflight.code + `
3347
+ ` + preflight.stdout + `
3348
+ ` + preflight.stderr).slice(0, 4000);
3349
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3350
+ await writeState2(directory, sessionID, state);
3351
+ await notifyJob2(directory, job, "preflight_failed");
3352
+ await toast2(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning");
3353
+ await reschedule(directory, client, sessionID);
3354
+ return { admitted: false, reason: "preflight_failed" };
3005
3355
  }
3006
- guardLoopOwnedUserMessage2(sessionID);
3007
- const commandBody = { command, arguments: argumentsText, agent };
3008
- if (model)
3009
- commandBody.model = `${model.providerID}/${model.modelID}`;
3010
- await sdkCall2(client.session.command.bind(client.session), { path: { id: sessionID }, body: commandBody }, { path: { sessionID }, body: commandBody }, { sessionID, ...commandBody });
3011
- return { startsAssistantTurn: true };
3012
3356
  }
3013
- if (kind === "shell") {
3014
- const command = action.replace(/^[!$]\s*/, "").trim();
3015
- if (job.safe && dangerousShell2(command)) {
3016
- await toast2(client, `Blocked dangerous shell command in safe mode: ${command}`, "error");
3017
- await appendLoopLog2(directory, "blocked", { sessionID, job: job.name || job.id, command });
3018
- return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" };
3357
+ return { admitted: true, job, runNowRequested };
3358
+ }
3359
+ return { dueJobs: dueJobs2, admitJob };
3360
+ }
3361
+
3362
+ // src/source/runtime/executor.js
3363
+ var DEFAULT_ACTIVE_GUARD_MS = 45000;
3364
+ var DEFAULT_BUSY_RETRY_MS2 = 5000;
3365
+ function requireFunction10(value, label) {
3366
+ if (typeof value !== "function")
3367
+ throw new TypeError(`createLoopExecutor requires ${label}`);
3368
+ return value;
3369
+ }
3370
+ function createLoopExecutor(options = {}) {
3371
+ const workspace = options.workspace || {};
3372
+ const goalPolicy = options.goalPolicy || {};
3373
+ const scheduler = options.scheduler || {};
3374
+ const buildPrompt = requireFunction10(workspace.buildPrompt, "workspace.buildPrompt");
3375
+ const ensureBranch = requireFunction10(workspace.ensureBranch, "workspace.ensureBranch");
3376
+ const watchChanged = requireFunction10(workspace.watchChanged, "workspace.watchChanged");
3377
+ const untilReached = requireFunction10(workspace.untilReached, "workspace.untilReached");
3378
+ const createCheckpoint = requireFunction10(workspace.createCheckpoint, "workspace.createCheckpoint");
3379
+ const runGoalChecks = requireFunction10(goalPolicy.runGoalChecks, "goalPolicy.runGoalChecks");
3380
+ const applyGoalNoProgressGuard = requireFunction10(goalPolicy.applyGoalNoProgressGuard, "goalPolicy.applyGoalNoProgressGuard");
3381
+ const rememberSession = requireFunction10(scheduler.rememberSession, "scheduler.rememberSession");
3382
+ const scheduleDueWork = requireFunction10(scheduler.scheduleDueWork, "scheduler.scheduleDueWork");
3383
+ const now2 = typeof options.now === "function" ? options.now : now;
3384
+ const readState2 = typeof options.readState === "function" ? options.readState : readState;
3385
+ const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
3386
+ const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
3387
+ const runShellCommand2 = typeof options.runShellCommand === "function" ? options.runShellCommand : runShellCommand;
3388
+ const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
3389
+ const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : sdkErrorMessage;
3390
+ const fireSdk2 = typeof options.fireSdk === "function" ? options.fireSdk : fireSdk;
3391
+ const log2 = typeof options.log === "function" ? options.log : log;
3392
+ const toast2 = typeof options.toast === "function" ? options.toast : toast;
3393
+ const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
3394
+ const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
3395
+ const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
3396
+ const activeRuns = new Map;
3397
+ const runLocks = new Map;
3398
+ const statusRuntime = createSessionStatusRuntime({
3399
+ activeRuns,
3400
+ appendLoopLog: appendLoopLog2,
3401
+ now: now2,
3402
+ activeRunCompletionFromMessages: options.activeRunCompletionFromMessages,
3403
+ staleActiveRecoveryMs: options.staleActiveRecoveryMs,
3404
+ sessionStatusCacheMs: options.sessionStatusCacheMs
3405
+ });
3406
+ const {
3407
+ updateSessionStatusFromEvent,
3408
+ staleActiveRun,
3409
+ canFinalizeActiveRun,
3410
+ sessionIsIdle,
3411
+ markSessionStatus,
3412
+ clearSessionStatus
3413
+ } = statusRuntime;
3414
+ const compactionRuntime = createCompactionRuntime({
3415
+ activeRuns,
3416
+ finalizeActiveRun,
3417
+ appendLoopLog: appendLoopLog2,
3418
+ compactSession: options.compactSession,
3419
+ log: log2,
3420
+ errorMessage,
3421
+ now: now2
3422
+ });
3423
+ const actionDispatcher = createActionDispatcher({
3424
+ buildPrompt,
3425
+ compactionRuntime,
3426
+ appendLoopLog: appendLoopLog2,
3427
+ sdkCall: options.sdkCall,
3428
+ normalizedModelRef: options.normalizedModelRef,
3429
+ fireSdk: options.fireSdk,
3430
+ compactTuiCommandName: options.compactTuiCommandName,
3431
+ toast: toast2,
3432
+ guardLoopOwnedUserMessage: options.guardLoopOwnedUserMessage,
3433
+ dangerousShell: dangerousShell2
3434
+ });
3435
+ const finalizationRuntime = createRunFinalizationRuntime({
3436
+ runGoalChecks,
3437
+ applyGoalNoProgressGuard,
3438
+ createCheckpoint,
3439
+ scheduleDueWork,
3440
+ now: now2,
3441
+ writeState: writeState2,
3442
+ appendLoopLog: appendLoopLog2,
3443
+ runShellCommand: runShellCommand2,
3444
+ notifyJob: notifyJob2,
3445
+ toast: toast2,
3446
+ writeGoalReport: options.writeGoalReport,
3447
+ dangerousShell: dangerousShell2
3448
+ });
3449
+ const admissionRuntime = createRunAdmissionRuntime({
3450
+ untilReached,
3451
+ scheduleDueWork,
3452
+ now: now2,
3453
+ pathExists: options.pathExists,
3454
+ writeState: writeState2,
3455
+ appendLoopLog: appendLoopLog2,
3456
+ runShellCommand: runShellCommand2,
3457
+ notifyJob: notifyJob2,
3458
+ toast: toast2,
3459
+ dangerousShell: dangerousShell2
3460
+ });
3461
+ const dueJobs2 = admissionRuntime.dueJobs;
3462
+ function clearActiveRun(sessionID) {
3463
+ const active = activeRuns.get(sessionID);
3464
+ if (active?.timer)
3465
+ clearTimeout(active.timer);
3466
+ compactionRuntime.clearForActiveRun(sessionID, active);
3467
+ activeRuns.delete(sessionID);
3468
+ }
3469
+ function disposeSession(sessionID) {
3470
+ clearActiveRun(sessionID);
3471
+ runLocks.delete(sessionID);
3472
+ compactionRuntime.clear(sessionID);
3473
+ clearSessionStatus(sessionID);
3474
+ }
3475
+ async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
3476
+ const active = activeRuns.get(sessionID);
3477
+ if (!active || active.jobId !== jobId || active.runToken !== runToken)
3478
+ return false;
3479
+ clearActiveRun(sessionID);
3480
+ clearSessionStatus(sessionID);
3481
+ const message = errorMessage(error);
3482
+ const state = await readState2(directory, sessionID);
3483
+ const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
3484
+ if (job) {
3485
+ job.failureCount = (job.failureCount || 0) + 1;
3486
+ job.lastFailureReason = "dispatch_failed";
3487
+ job.lastDispatchFailure = message.slice(0, 4000);
3488
+ job.lastDispatchFailureAt = now2();
3489
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
3490
+ job.paused = true;
3491
+ await notifyJob2(directory, job, "dispatch_failed");
3019
3492
  }
3020
- guardLoopOwnedUserMessage2(sessionID);
3021
- const shellBody = { command, agent };
3022
- if (model)
3023
- shellBody.model = model;
3024
- const dispatch2 = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
3025
- return { startsAssistantTurn: true, dispatch: dispatch2 };
3493
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3494
+ await writeState2(directory, sessionID, state);
3026
3495
  }
3027
- const prompt = await buildPrompt(directory, job);
3028
- 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.";
3029
- const promptText = `${prefix}
3030
-
3031
- ${prompt}`;
3032
- guardLoopOwnedUserMessage2(sessionID);
3033
- const promptBody = { agent, parts: [{ type: "text", text: promptText }] };
3034
- if (model)
3035
- promptBody.model = model;
3036
- const dispatch = fireSdk2(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: promptBody }, { path: { sessionID }, body: promptBody }, { sessionID, ...promptBody });
3037
- return { startsAssistantTurn: true, dispatch };
3496
+ await appendLoopLog2(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
3497
+ await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
3498
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs);
3499
+ return true;
3500
+ }
3501
+ async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
3502
+ const active = activeRuns.get(sessionID);
3503
+ if (!active)
3504
+ return;
3505
+ if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
3506
+ return false;
3507
+ const recoveredStale = staleActiveRun(sessionID);
3508
+ if (active.compactionOnly) {
3509
+ const pending = compactionRuntime.getPending(sessionID);
3510
+ clearActiveRun(sessionID);
3511
+ clearSessionStatus(sessionID);
3512
+ await appendLoopLog2(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
3513
+ sessionID,
3514
+ job: active.job?.name || active.jobId,
3515
+ startedAt: active.startedAt,
3516
+ nativeEvent: Boolean(pending?.completedAt)
3517
+ });
3518
+ await scheduleDueWork(directory, client, sessionID);
3519
+ return true;
3520
+ }
3521
+ clearActiveRun(sessionID);
3522
+ const state = await readState2(directory, sessionID);
3523
+ let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
3524
+ if (!job)
3525
+ return;
3526
+ job.lastFinishedAt = now2();
3527
+ if (recoveredStale) {
3528
+ await appendLoopLog2(directory, "active-stale-recovery", {
3529
+ sessionID,
3530
+ job: job.name || job.id,
3531
+ startedAt: active.startedAt
3532
+ });
3533
+ }
3534
+ await finalizationRuntime.finalizeJob(directory, client, sessionID, state, job, active.job);
3535
+ return true;
3038
3536
  }
3537
+ const fireAction = actionDispatcher.fireAction;
3039
3538
  async function maybeRunDueJobs(directory, client, sessionID, runOptions = {}) {
3040
3539
  rememberSession(directory, client, sessionID);
3041
3540
  const reschedule = async (minDelayMs = 0) => {
@@ -3070,74 +3569,18 @@ ${prompt}`;
3070
3569
  candidate.watchTriggered = true;
3071
3570
  }
3072
3571
  }
3073
- const due = dueJobs(state, Boolean(runOptions.force));
3572
+ const due = dueJobs2(state, Boolean(runOptions.force));
3074
3573
  if (!due.length) {
3075
3574
  await writeState2(directory, sessionID, state);
3076
3575
  await reschedule();
3077
3576
  return;
3078
3577
  }
3079
3578
  job = due[0];
3080
- const runNowRequested = Number(job.runNowRequestedAt || 0) > 0;
3081
- if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
3082
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
3083
- await writeState2(directory, sessionID, state);
3084
- await notifyJob2(directory, job, "max_runtime_reached");
3085
- await toast2(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success");
3086
- await appendLoopLog2(directory, "max-runtime", { sessionID, job: job.name || job.id });
3087
- await reschedule();
3088
- return;
3089
- }
3090
- if (job.stopFile && await pathExists2(path6.resolve(directory, job.stopFile))) {
3091
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
3092
- await writeState2(directory, sessionID, state);
3093
- await notifyJob2(directory, job, "stop_file");
3094
- await toast2(client, "Loop stopped by --stop-file: " + job.stopFile, "success");
3095
- await reschedule();
3579
+ const admission = await admissionRuntime.admitJob(directory, client, sessionID, state, job);
3580
+ if (!admission.admitted)
3096
3581
  return;
3097
- }
3098
- if (await untilReached(directory, job)) {
3099
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
3100
- await writeState2(directory, sessionID, state);
3101
- await notifyJob2(directory, job, "until_reached");
3102
- await toast2(client, `Loop stopped by --until: ${job.until}`, "success");
3103
- await reschedule();
3104
- return;
3105
- }
3106
- if (job.preflightCommand) {
3107
- if (job.safe && dangerousShell2(job.preflightCommand)) {
3108
- if (runNowRequested)
3109
- delete job.runNowRequestedAt;
3110
- job.paused = true;
3111
- await writeState2(directory, sessionID, state);
3112
- await notifyJob2(directory, job, "preflight_blocked");
3113
- await toast2(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error");
3114
- await reschedule();
3115
- return;
3116
- }
3117
- const preflight = await runShellCommand2(job.preflightCommand, directory, job.timeoutMs || 300000);
3118
- await appendLoopLog2(directory, "preflight", {
3119
- sessionID,
3120
- job: job.name || job.id,
3121
- command: job.preflightCommand,
3122
- code: preflight.code
3123
- });
3124
- if (preflight.code !== 0) {
3125
- if (runNowRequested)
3126
- delete job.runNowRequestedAt;
3127
- job.paused = true;
3128
- job.failureCount = (job.failureCount || 0) + 1;
3129
- job.lastPreflightFailure = (job.preflightCommand + `
3130
- exit=` + preflight.code + `
3131
- ` + preflight.stdout + `
3132
- ` + preflight.stderr).slice(0, 4000);
3133
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3134
- await writeState2(directory, sessionID, state);
3135
- await notifyJob2(directory, job, "preflight_failed");
3136
- await toast2(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning");
3137
- await reschedule();
3138
- return;
3139
- }
3140
- }
3582
+ job = admission.job;
3583
+ const runNowRequested = admission.runNowRequested;
3141
3584
  job = await ensureBranch(directory, job, client, sessionID);
3142
3585
  const compactResult = await compactionRuntime.maybeCompact(directory, client, sessionID, job);
3143
3586
  job = compactResult.job;
@@ -3233,7 +3676,7 @@ exit=` + preflight.code + `
3233
3676
  }
3234
3677
  }
3235
3678
  return {
3236
- dueJobs,
3679
+ dueJobs: dueJobs2,
3237
3680
  clearActiveRun,
3238
3681
  disposeSession,
3239
3682
  recoverActiveDispatchFailure,
@@ -3254,7 +3697,7 @@ exit=` + preflight.code + `
3254
3697
  // src/source/runtime/goal-steering.js
3255
3698
  var DEFAULT_STEERING_SUPPRESSION_MS = 5 * 60000;
3256
3699
  var DEFAULT_SEEN_USER_MESSAGE_MS = 10 * 60000;
3257
- function requireFunction8(value, label) {
3700
+ function requireFunction11(value, label) {
3258
3701
  if (typeof value !== "function")
3259
3702
  throw new TypeError(`createGoalSteeringRuntime requires ${label}`);
3260
3703
  return value;
@@ -3298,8 +3741,8 @@ function activeGoalJobs(state) {
3298
3741
  });
3299
3742
  }
3300
3743
  function createGoalSteeringRuntime(options = {}) {
3301
- const getActiveRun = requireFunction8(options.getActiveRun, "getActiveRun");
3302
- const clearActiveRun = requireFunction8(options.clearActiveRun, "clearActiveRun");
3744
+ const getActiveRun = requireFunction11(options.getActiveRun, "getActiveRun");
3745
+ const clearActiveRun = requireFunction11(options.clearActiveRun, "clearActiveRun");
3303
3746
  const isLoopOwnedUserMessage = typeof options.isLoopOwnedUserMessage === "function" ? options.isLoopOwnedUserMessage : () => false;
3304
3747
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
3305
3748
  const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
@@ -4138,6 +4581,6 @@ var OpenCodeLoopPlugin2 = async (input = {}) => {
4138
4581
  };
4139
4582
  var v1_default = OpenCodeLoopPlugin2;
4140
4583
  export {
4141
- v1_default as default,
4142
- OpenCodeLoopPlugin2 as OpenCodeLoopPlugin
4584
+ OpenCodeLoopPlugin2 as OpenCodeLoopPlugin,
4585
+ v1_default as default
4143
4586
  };