@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/CHANGELOG.md +17 -0
- package/README.md +21 -9
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/examples/OPENCODE2-0.6.9.md +34 -0
- package/package.json +3 -3
- package/scripts/checklist-completion-test.mjs +104 -0
- package/scripts/comprehensive-test.mjs +9 -1
- package/scripts/desktop-command-diagnostics-test.mjs +97 -2
- package/scripts/desktop-command-diagnostics.mjs +70 -9
- package/scripts/loopd-test.mjs +1 -1
- package/scripts/loopd.mjs +4 -2
- package/scripts/native-cli-io-test.mjs +6 -3
- package/scripts/native-entry-test.mjs +16 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +61 -0
- package/scripts/opencode2-loop-canary.mjs +2 -1
- package/scripts/package-native-smoke.mjs +41 -7
- package/scripts/publish-workflow-test.mjs +3 -2
- package/scripts/run-admission-test.mjs +47 -0
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/src/index.js +187 -2
- package/src/native.js +208 -15
- package/src/server.js +212 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -0
- package/src/source/core/state.js +33 -0
- package/src/source/opencode2/native-plugin.js +3 -0
- package/src/source/opencode2/native-policy.js +21 -0
- package/src/source/opencode2/native-runtime.js +1 -0
- package/src/source/opencode2/status.js +5 -1
- package/src/source/runtime/checklist-completion.js +113 -0
- package/src/source/runtime/run-admission.js +25 -0
- package/src/source/runtime/run-finalization.js +7 -0
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
|
|
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(
|
|
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
|
|
3116
|
-
import
|
|
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 =
|
|
3145
|
-
return relative !== ".." && !relative.startsWith(`..${
|
|
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
|
|
3338
|
+
const root = await fs5.realpath(directory);
|
|
3149
3339
|
const target = String(argumentsText || "").trim() || "progress.md";
|
|
3150
|
-
const file =
|
|
3151
|
-
const relative =
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
3382
|
-
import
|
|
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) =>
|
|
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(
|
|
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(`
|
package/src/source/core/args.js
CHANGED
|
@@ -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
|
}
|
package/src/source/core/jobs.js
CHANGED
|
@@ -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.")
|
package/src/source/core/state.js
CHANGED
|
@@ -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
|
}
|