@bybrawe/opencode-loop 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -149,6 +149,7 @@ function parseLoopArgs(raw, defaults = {}) {
149
149
  until: defaults.until,
150
150
  stopFile: defaults.stopFile,
151
151
  progressFile: defaults.progressFile,
152
+ untilChecklistComplete: defaults.untilChecklistComplete ?? false,
152
153
  promptFile: defaults.promptFile,
153
154
  includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
154
155
  watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
@@ -229,6 +230,9 @@ function parseLoopArgs(raw, defaults = {}) {
229
230
  [found, rest] = takeFlag(rest, "--pause-on-verify-fail");
230
231
  if (found)
231
232
  job.pauseOnVerifyFail = true;
233
+ [found, rest] = takeFlag(rest, "--until-checklist-complete");
234
+ if (found)
235
+ job.untilChecklistComplete = true;
232
236
  [found, rest] = takeFlag(rest, "--dry-run");
233
237
  if (found)
234
238
  job.dryRun = true;
@@ -377,6 +381,8 @@ function parseLoopArgs(raw, defaults = {}) {
377
381
  job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
378
382
  }
379
383
  job.lastRunAt = job.immediate ? 0 : now();
384
+ if (job.untilChecklistComplete && !job.progressFile)
385
+ return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" };
380
386
  if (!job.action && !job.promptFile && !job.goalFile)
381
387
  return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" };
382
388
  return { ok: true, job };
@@ -490,6 +496,8 @@ function decoratePrompt(job) {
490
496
  additions.push(continuation);
491
497
  if (job.progressFile)
492
498
  additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
499
+ if (job.untilChecklistComplete)
500
+ additions.push("When all actual Markdown tasks in the configured progress file are checked, stop creating new work and report results. Loop will make the stopping decision itself from the project file at a safe host boundary; do not attempt to invoke /loop-clear as assistant text.");
493
501
  if (job.lastVerifyFailure)
494
502
  additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
495
503
  if (job.askNever)
@@ -612,6 +620,18 @@ async function readStateProcessOwner(file) {
612
620
  await delay(10 * (attempt + 1));
613
621
  }
614
622
  }
623
+ if (raw.length === 0) {
624
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
625
+ await delay(10 * (attempt + 1));
626
+ try {
627
+ raw = await fs.readFile(file, "utf8");
628
+ } catch (error) {
629
+ if (error?.code === "ENOENT")
630
+ return null;
631
+ throw error;
632
+ }
633
+ }
634
+ }
615
635
  let owner;
616
636
  try {
617
637
  owner = JSON.parse(raw);
@@ -652,6 +672,26 @@ async function claimStateProcessLock(candidate, canonical) {
652
672
  } catch (error) {
653
673
  if (error?.code === "EEXIST")
654
674
  return false;
675
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
676
+ const contents = await fs.readFile(candidate);
677
+ let handle;
678
+ try {
679
+ handle = await fs.open(canonical, "wx", 384);
680
+ } catch (failure) {
681
+ if (failure?.code === "EEXIST")
682
+ return false;
683
+ throw failure;
684
+ }
685
+ try {
686
+ await handle.writeFile(contents);
687
+ } catch (failure) {
688
+ await handle.close().catch(() => {});
689
+ await removeStateProcessFile(canonical).catch(() => {});
690
+ throw failure;
691
+ }
692
+ await handle.close();
693
+ return true;
694
+ }
655
695
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
656
696
  throw error;
657
697
  await delay(10 * (attempt + 1));
@@ -1325,7 +1365,7 @@ var init_job_workspace = __esm(() => {
1325
1365
  });
1326
1366
 
1327
1367
  // src/source/opencode2/native-plugin.js
1328
- import path9 from "path";
1368
+ import path10 from "path";
1329
1369
 
1330
1370
  // src/source/opencode2/session-location.js
1331
1371
  import path from "path";
@@ -1742,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1742
1782
  job.askNever ? "ask-never" : undefined,
1743
1783
  job.noOverlap ? "no-overlap" : undefined,
1744
1784
  job.checkpointOnly ? "checkpoint-only" : undefined,
1745
- job.gitCheckpoint ? "git-checkpoint" : undefined
1785
+ job.gitCheckpoint ? "git-checkpoint" : undefined,
1786
+ job.untilChecklistComplete ? "checklist-stop:" + (job.progressFile || "missing-file") : undefined,
1787
+ job.pauseReason ? "reason:" + job.pauseReason : undefined,
1788
+ job.lastChecklistError ? "checklist-error:" + job.lastChecklistError : undefined,
1789
+ !job.maxRuns && !job.maxRuntimeMs && !job.until && !job.stopFile && !job.untilChecklistComplete ? "unlimited" : undefined
1746
1790
  ].filter(Boolean).join(",");
1747
1791
  return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
1748
1792
  }) : ["No active loop jobs."];
@@ -1750,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
1750
1794
  jobs,
1751
1795
  text: `OpenCode loop status:
1752
1796
  ${lines.join(`
1753
- `)}`
1797
+ `)}${jobs.length ? `
1798
+ Host controls: run /loop-stop <name-or-id> or /loop-clear to stop jobs; asking the model to print those commands does not execute them.` : ""}`
1754
1799
  });
1755
1800
  }
1756
1801
 
@@ -1758,7 +1803,128 @@ ${lines.join(`
1758
1803
  init_process();
1759
1804
  init_job_workspace();
1760
1805
  import { stat } from "fs/promises";
1806
+ import path8 from "path";
1807
+
1808
+ // src/source/runtime/checklist-completion.js
1809
+ import { promises as fs4, constants } from "fs";
1761
1810
  import path7 from "path";
1811
+ import { createHash as createHash2 } from "crypto";
1812
+ var MAX_CHECKLIST_BYTES = 256000;
1813
+ var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]);
1814
+ function unavailable(reason) {
1815
+ return { status: "unavailable", reason, total: 0, pending: 0 };
1816
+ }
1817
+ function summarizeTaskChecklist(markdown) {
1818
+ let total = 0;
1819
+ let pending = 0;
1820
+ let fence = null;
1821
+ let comment = false;
1822
+ for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
1823
+ let line = "";
1824
+ let offset = 0;
1825
+ while (offset < original.length) {
1826
+ if (comment) {
1827
+ const end = original.indexOf("-->", offset);
1828
+ if (end < 0)
1829
+ break;
1830
+ comment = false;
1831
+ offset = end + 3;
1832
+ } else {
1833
+ const start = original.indexOf("<!--", offset);
1834
+ if (start < 0) {
1835
+ line += original.slice(offset);
1836
+ break;
1837
+ }
1838
+ line += original.slice(offset, start);
1839
+ comment = true;
1840
+ offset = start + 4;
1841
+ }
1842
+ }
1843
+ const trimmed = line.trimStart();
1844
+ const indentation = line.length - trimmed.length;
1845
+ const marker = trimmed.charAt(0);
1846
+ let markerSize = 0;
1847
+ if (marker === "~" || marker.charCodeAt(0) === 96) {
1848
+ while (trimmed.charAt(markerSize) === marker)
1849
+ markerSize++;
1850
+ }
1851
+ if (fence) {
1852
+ if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim())
1853
+ fence = null;
1854
+ continue;
1855
+ }
1856
+ if (indentation <= 3 && markerSize >= 3) {
1857
+ fence = { marker, length: markerSize };
1858
+ continue;
1859
+ }
1860
+ if (/^\s*>/.test(line))
1861
+ continue;
1862
+ const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/);
1863
+ if (!match)
1864
+ continue;
1865
+ total++;
1866
+ if (match[1] === " ")
1867
+ pending++;
1868
+ }
1869
+ return {
1870
+ status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
1871
+ reason: total === 0 ? "no-task-checkboxes" : "",
1872
+ total,
1873
+ pending
1874
+ };
1875
+ }
1876
+ async function inspectTaskChecklist(directory, progressFile) {
1877
+ const name = String(progressFile || "").trim();
1878
+ if (!name || name.includes("\x00"))
1879
+ return unavailable("missing-progress-file");
1880
+ if (path7.isAbsolute(name) || path7.win32.isAbsolute(name))
1881
+ return unavailable("absolute-path");
1882
+ const root = path7.resolve(directory);
1883
+ const target = path7.resolve(root, name);
1884
+ const relative = path7.relative(root, target);
1885
+ if (!relative || relative === ".." || relative.startsWith(".." + path7.sep) || path7.isAbsolute(relative)) {
1886
+ return unavailable("outside-project");
1887
+ }
1888
+ const parts = relative.split(path7.sep);
1889
+ if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase())))
1890
+ return unavailable("control-plane-path");
1891
+ try {
1892
+ const rootInfo = await fs4.lstat(root);
1893
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
1894
+ return unavailable("unsafe-project-root");
1895
+ let current = root;
1896
+ for (let index = 0;index < parts.length; index++) {
1897
+ current = path7.join(current, parts[index]);
1898
+ const info = await fs4.lstat(current);
1899
+ if (info.isSymbolicLink())
1900
+ return unavailable("symlink-path");
1901
+ if (index < parts.length - 1 && !info.isDirectory())
1902
+ return unavailable("invalid-parent");
1903
+ if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
1904
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
1905
+ }
1906
+ }
1907
+ const handle = await fs4.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
1908
+ try {
1909
+ const info = await handle.stat();
1910
+ if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
1911
+ return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
1912
+ }
1913
+ const content = await handle.readFile({ encoding: "utf8" });
1914
+ const after = await fs4.lstat(target);
1915
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
1916
+ return unavailable("file-changed-during-read");
1917
+ }
1918
+ return { ...summarizeTaskChecklist(content), digest: createHash2("sha256").update(content).digest("hex") };
1919
+ } finally {
1920
+ await handle.close();
1921
+ }
1922
+ } catch (error) {
1923
+ return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable");
1924
+ }
1925
+ }
1926
+
1927
+ // src/source/opencode2/native-policy.js
1762
1928
  function createNativeJobPolicy(options = {}) {
1763
1929
  const now = options.now || Date.now;
1764
1930
  const run = options.runShellCommand || runShellCommand;
@@ -1792,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
1792
1958
  }
1793
1959
  if (job.stopFile) {
1794
1960
  try {
1795
- await stat(path7.resolve(scope.directory, job.stopFile));
1961
+ await stat(path8.resolve(scope.directory, job.stopFile));
1796
1962
  job.enabled = false;
1797
1963
  return pause(job, "stop-file");
1798
1964
  } catch (error) {
@@ -1804,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
1804
1970
  job.enabled = false;
1805
1971
  return pause(job, "until-reached");
1806
1972
  }
1973
+ if (job.untilChecklistComplete) {
1974
+ const checklist = await inspectTaskChecklist(scope.directory, job.progressFile);
1975
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending };
1976
+ if (checklist.status === "unavailable") {
1977
+ job.lastChecklistError = checklist.reason;
1978
+ return pause(job, "checklist-unavailable");
1979
+ }
1980
+ delete job.lastChecklistError;
1981
+ if (checklist.status === "complete" && (!job.verifyCommand || job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest)) {
1982
+ job.enabled = false;
1983
+ job.checklistCompletedAt = now();
1984
+ return pause(job, "checklist-complete");
1985
+ }
1986
+ }
1807
1987
  return true;
1808
1988
  }
1809
1989
  async function prepare(scope, job, isCurrent = () => true) {
@@ -1836,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
1836
2016
  job.lastVerifyOutput = `${result.stdout || ""}
1837
2017
  ${result.stderr || ""}`.trim().slice(-8000);
1838
2018
  if (result.code !== 0) {
2019
+ delete job.lastVerifyChecklistDigest;
1839
2020
  job.failureCount = (job.failureCount || 0) + 1;
1840
2021
  if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
1841
2022
  pause(job, "verification-failed");
1842
2023
  return;
1843
2024
  }
1844
2025
  job.failureCount = 0;
2026
+ if (job.untilChecklistComplete) {
2027
+ const verified = await inspectTaskChecklist(scope.directory, job.progressFile);
2028
+ if (verified.status !== "unavailable")
2029
+ job.lastVerifyChecklistDigest = verified.digest;
2030
+ else
2031
+ delete job.lastVerifyChecklistDigest;
2032
+ }
1845
2033
  }
1846
2034
  if (!isCurrent())
1847
2035
  return;
@@ -1969,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
1969
2157
  continue;
1970
2158
  if (job.watchPaths?.length || job.stopFile || job.until)
1971
2159
  delay = Math.min(delay, 1000);
2160
+ if (job.untilChecklistComplete)
2161
+ delay = Math.min(delay, 5000);
1972
2162
  const due = dueAt(job);
1973
2163
  if (due > now())
1974
2164
  delay = Math.min(delay, due - now());
@@ -3112,8 +3302,8 @@ init_process();
3112
3302
  init_state();
3113
3303
 
3114
3304
  // src/source/core/progress.js
3115
- import { promises as fs4 } from "fs";
3116
- import path8 from "path";
3305
+ import { promises as fs5 } from "fs";
3306
+ import path9 from "path";
3117
3307
  var DEFAULT_PROGRESS_MD = `# Progress
3118
3308
 
3119
3309
  ## Current Goal
@@ -3141,24 +3331,24 @@ Describe the current project goal here.
3141
3331
  - None.
3142
3332
  `;
3143
3333
  function inside(root, candidate) {
3144
- const relative = path8.relative(root, candidate);
3145
- return relative !== ".." && !relative.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relative);
3334
+ const relative = path9.relative(root, candidate);
3335
+ return relative !== ".." && !relative.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relative);
3146
3336
  }
3147
3337
  async function initializeProgressFile(directory, argumentsText = "") {
3148
- const root = await fs4.realpath(directory);
3338
+ const root = await fs5.realpath(directory);
3149
3339
  const target = String(argumentsText || "").trim() || "progress.md";
3150
- const file = path8.resolve(root, target);
3151
- const relative = path8.relative(root, file);
3340
+ const file = path9.resolve(root, target);
3341
+ const relative = path9.relative(root, file);
3152
3342
  if (!relative || !inside(root, file))
3153
3343
  throw new Error("Progress file must be inside the project");
3154
- if ([".git", ".opencode"].includes(relative.split(path8.sep)[0].toLowerCase())) {
3344
+ if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
3155
3345
  throw new Error("Progress file cannot replace a control-plane path");
3156
3346
  }
3157
- const parent = await fs4.realpath(path8.dirname(file));
3347
+ const parent = await fs5.realpath(path9.dirname(file));
3158
3348
  if (!inside(root, parent))
3159
3349
  throw new Error("Progress file must remain inside the project; symlink escape refused");
3160
3350
  try {
3161
- await fs4.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3351
+ await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
3162
3352
  return { created: true, file: relative };
3163
3353
  } catch (error) {
3164
3354
  if (error?.code === "EEXIST")
@@ -3173,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
3173
3363
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
3174
3364
  /loop --watch progress.md <task>
3175
3365
  /loop 0s <task> --prompt-file instructions.md --include-file progress.md
3366
+ /loop 0s <task> --progress-file TASK.md --until-checklist-complete --verify "npm test"
3176
3367
  /loop 0s <task> --compact-every 3
3177
3368
  /loop 5m --shell npm test
3178
3369
  /loop 15m --compact /compact
@@ -3180,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
3180
3371
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
3181
3372
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
3182
3373
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
3374
+ --progress-file alone is context, not a stop condition. --until-checklist-complete is an explicit host-verified stop condition; missing/unsafe/no-task files pause for review, and --verify must have passed before stopping.
3375
+ Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it.
3183
3376
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
3184
3377
  Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
3185
3378
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -3286,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
3286
3479
  return { handled: true, accepted: true, ...output };
3287
3480
  }
3288
3481
  if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
3289
- const text = name === "loop-logs" ? (await readSmallTextFile(path9.join(stateDir(directory), "loop.log"), 2000000)).split(`
3482
+ const text = name === "loop-logs" ? (await readSmallTextFile(path10.join(stateDir(directory), "loop.log"), 2000000)).split(`
3290
3483
  `).slice(-60).join(`
3291
3484
  `) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
3292
3485
  Host: ${ctx.app?.version || "unknown"}
@@ -3378,8 +3571,8 @@ function inspectOpenCode2Context(ctx) {
3378
3571
 
3379
3572
  // src/source/opencode2/diagnostics.js
3380
3573
  init_state();
3381
- import { promises as fs5 } from "fs";
3382
- import path10 from "path";
3574
+ import { promises as fs6 } from "fs";
3575
+ import path11 from "path";
3383
3576
  var OPENCODE_LOOP_V2_HELP_TEXT = [
3384
3577
  "OpenCode Loop V2 experimental help:",
3385
3578
  "/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
@@ -3408,7 +3601,7 @@ function createOpenCode2DiagnosticsRuntime(options = {}) {
3408
3601
  if (typeof options.prompt !== "function")
3409
3602
  throw new TypeError("V2 diagnostics runtime requires prompt()");
3410
3603
  const readState2 = typeof options.readState === "function" ? options.readState : readState;
3411
- const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs5.readFile(...args);
3604
+ const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
3412
3605
  const runtimeVersion = options.runtimeVersion || process.version;
3413
3606
  const runtimePlatform = options.runtimePlatform || process.platform;
3414
3607
  async function exportState(event) {
@@ -3459,7 +3652,7 @@ ${JSON.stringify(state, null, 2)}
3459
3652
  return { handled: false, reason: "missing-scope" };
3460
3653
  let text = "No OpenCode 2 Loop log found.";
3461
3654
  try {
3462
- const raw = await readFile(path10.join(stateDir(scope.directory), "loop.log"), "utf8");
3655
+ const raw = await readFile(path11.join(stateDir(scope.directory), "loop.log"), "utf8");
3463
3656
  const lines = String(raw || "").trim().split(/\r?\n/).filter((line) => line.includes('"v2":true')).slice(-80);
3464
3657
  if (lines.length)
3465
3658
  text = lines.join(`
@@ -135,6 +135,7 @@ export function parseLoopArgs(raw, defaults = {}) {
135
135
  until: defaults.until,
136
136
  stopFile: defaults.stopFile,
137
137
  progressFile: defaults.progressFile,
138
+ untilChecklistComplete: defaults.untilChecklistComplete ?? false,
138
139
  promptFile: defaults.promptFile,
139
140
  includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
140
141
  watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
@@ -197,6 +198,7 @@ export function parseLoopArgs(raw, defaults = {}) {
197
198
  ;[found, rest] = takeFlag(rest, "--git-checkpoint"); if (found) job.gitCheckpoint = true
198
199
  ;[found, rest] = takeFlag(rest, "--checkpoint-only"); if (found) job.checkpointOnly = true
199
200
  ;[found, rest] = takeFlag(rest, "--pause-on-verify-fail"); if (found) job.pauseOnVerifyFail = true
201
+ ;[found, rest] = takeFlag(rest, "--until-checklist-complete"); if (found) job.untilChecklistComplete = true
200
202
  ;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
201
203
  ;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
202
204
  ;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
@@ -276,6 +278,7 @@ export function parseLoopArgs(raw, defaults = {}) {
276
278
  }
277
279
  job.lastRunAt = job.immediate ? 0 : now()
278
280
 
281
+ if (job.untilChecklistComplete && !job.progressFile) return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" }
279
282
  if (!job.action && !job.promptFile && !job.goalFile) return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" }
280
283
  return { ok: true, job }
281
284
  }
@@ -58,6 +58,7 @@ export function decoratePrompt(job) {
58
58
  const continuation = continuationProjectInstruction(job.action)
59
59
  if (continuation) additions.push(continuation)
60
60
  if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
61
+ if (job.untilChecklistComplete) additions.push("When all actual Markdown tasks in the configured progress file are checked, stop creating new work and report results. Loop will make the stopping decision itself from the project file at a safe host boundary; do not attempt to invoke /loop-clear as assistant text.")
61
62
  if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
62
63
  if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
63
64
  if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
@@ -68,6 +68,19 @@ async function readStateProcessOwner(file) {
68
68
  await delay(10 * (attempt + 1))
69
69
  }
70
70
  }
71
+ // An exclusive-file fallback publishes an empty lock briefly before
72
+ // writing its owner. Allow a bounded read retry; stale empty locks still
73
+ // fail closed rather than being taken over.
74
+ if (raw.length === 0) {
75
+ for (let attempt = 0; attempt < 8 && raw.length === 0; attempt++) {
76
+ await delay(10 * (attempt + 1))
77
+ try { raw = await fs.readFile(file, "utf8") }
78
+ catch (error) {
79
+ if (error?.code === "ENOENT") return null
80
+ throw error
81
+ }
82
+ }
83
+ }
71
84
  let owner
72
85
  try { owner = JSON.parse(raw) }
73
86
  catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
@@ -91,11 +104,31 @@ async function removeStateProcessFile(file) {
91
104
  }
92
105
  }
93
106
 
107
+ // Keep atomic hard links on capable volumes. FAT/exFAT and some remote volumes
108
+ // reject them, so fall back to exclusive creation (O_EXCL) without overwriting
109
+ // any existing live lock or stale-owner cleanup election.
94
110
  async function claimStateProcessLock(candidate, canonical) {
95
111
  for (let attempt = 0; ; attempt++) {
96
112
  try { await fs.link(candidate, canonical); return true }
97
113
  catch (error) {
98
114
  if (error?.code === "EEXIST") return false
115
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
116
+ const contents = await fs.readFile(candidate)
117
+ let handle
118
+ try { handle = await fs.open(canonical, "wx", 0o600) }
119
+ catch (failure) {
120
+ if (failure?.code === "EEXIST") return false
121
+ throw failure
122
+ }
123
+ try { await handle.writeFile(contents) }
124
+ catch (failure) {
125
+ await handle.close().catch(() => {})
126
+ await removeStateProcessFile(canonical).catch(() => {})
127
+ throw failure
128
+ }
129
+ await handle.close()
130
+ return true
131
+ }
99
132
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
100
133
  await delay(10 * (attempt + 1))
101
134
  }
@@ -13,6 +13,7 @@ const HELP = `OpenCode Loop: native OpenCode 2 runtime
13
13
  /loop 5m <task> --verify "npm test" --pause-on-verify-fail
14
14
  /loop --watch progress.md <task>
15
15
  /loop 0s <task> --prompt-file instructions.md --include-file progress.md
16
+ /loop 0s <task> --progress-file TASK.md --until-checklist-complete --verify "npm test"
16
17
  /loop 0s <task> --compact-every 3
17
18
  /loop 5m --shell npm test
18
19
  /loop 15m --compact /compact
@@ -20,6 +21,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
20
21
  /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
21
22
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
22
23
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
24
+ --progress-file alone is context, not a stop condition. --until-checklist-complete is an explicit host-verified stop condition; missing/unsafe/no-task files pause for review, and --verify must have passed before stopping.
25
+ Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it.
23
26
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
24
27
  Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
25
28
  An unfinished dedicated Goal reserves its session; Loop will not override it.
@@ -2,6 +2,7 @@ import { stat } from "node:fs/promises"
2
2
  import path from "node:path"
3
3
  import { runShellCommand, appendLoopLog } from "../core/process.js"
4
4
  import { createJobWorkspaceRuntime, dangerousShell } from "../runtime/job-workspace.js"
5
+ import { inspectTaskChecklist } from "../runtime/checklist-completion.js"
5
6
 
6
7
  export function createNativeJobPolicy(options = {}) {
7
8
  const now = options.now || Date.now
@@ -44,6 +45,20 @@ export function createNativeJobPolicy(options = {}) {
44
45
  job.enabled = false
45
46
  return pause(job, "until-reached")
46
47
  }
48
+ if (job.untilChecklistComplete) {
49
+ const checklist = await inspectTaskChecklist(scope.directory, job.progressFile)
50
+ job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending }
51
+ if (checklist.status === "unavailable") {
52
+ job.lastChecklistError = checklist.reason
53
+ return pause(job, "checklist-unavailable")
54
+ }
55
+ delete job.lastChecklistError
56
+ if (checklist.status === "complete" && (!job.verifyCommand || (job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest))) {
57
+ job.enabled = false
58
+ job.checklistCompletedAt = now()
59
+ return pause(job, "checklist-complete")
60
+ }
61
+ }
47
62
  return true
48
63
  }
49
64
 
@@ -71,11 +86,17 @@ export function createNativeJobPolicy(options = {}) {
71
86
  job.lastVerifyAt = now()
72
87
  job.lastVerifyOutput = `${result.stdout || ""}\n${result.stderr || ""}`.trim().slice(-8000)
73
88
  if (result.code !== 0) {
89
+ delete job.lastVerifyChecklistDigest
74
90
  job.failureCount = (job.failureCount || 0) + 1
75
91
  if (result.blocked || job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) pause(job, "verification-failed")
76
92
  return
77
93
  }
78
94
  job.failureCount = 0
95
+ if (job.untilChecklistComplete) {
96
+ const verified = await inspectTaskChecklist(scope.directory, job.progressFile)
97
+ if (verified.status !== "unavailable") job.lastVerifyChecklistDigest = verified.digest
98
+ else delete job.lastVerifyChecklistDigest
99
+ }
79
100
  }
80
101
  if (!isCurrent()) return
81
102
  if (job.postrunCommand) {
@@ -81,6 +81,7 @@ export function createNativeLoopRuntime(options = {}) {
81
81
  for (const job of state.jobs || []) {
82
82
  if (!eligible(job) || job.v2Run || blocked(job).length) continue
83
83
  if (job.watchPaths?.length || job.stopFile || job.until) delay = Math.min(delay, 1000)
84
+ if (job.untilChecklistComplete) delay = Math.min(delay, 5000)
84
85
  const due = dueAt(job)
85
86
  if (due > now()) delay = Math.min(delay, due - now())
86
87
  // A zero-delay deferred preset still needs a first idle-safe wake after
@@ -28,6 +28,10 @@ export function formatOpenCode2LoopStatus(state, current = Date.now()) {
28
28
  job.noOverlap ? "no-overlap" : undefined,
29
29
  job.checkpointOnly ? "checkpoint-only" : undefined,
30
30
  job.gitCheckpoint ? "git-checkpoint" : undefined,
31
+ job.untilChecklistComplete ? "checklist-stop:" + (job.progressFile || "missing-file") : undefined,
32
+ job.pauseReason ? "reason:" + job.pauseReason : undefined,
33
+ job.lastChecklistError ? "checklist-error:" + job.lastChecklistError : undefined,
34
+ !job.maxRuns && !job.maxRuntimeMs && !job.until && !job.stopFile && !job.untilChecklistComplete ? "unlimited" : undefined,
31
35
  ].filter(Boolean).join(",")
32
36
  return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
33
37
  })
@@ -35,6 +39,6 @@ export function formatOpenCode2LoopStatus(state, current = Date.now()) {
35
39
 
36
40
  return Object.freeze({
37
41
  jobs,
38
- text: `OpenCode loop status:\n${lines.join("\n")}`,
42
+ text: `OpenCode loop status:\n${lines.join("\n")}${jobs.length ? "\nHost controls: run /loop-stop <name-or-id> or /loop-clear to stop jobs; asking the model to print those commands does not execute them." : ""}`,
39
43
  })
40
44
  }