@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/index.js
CHANGED
|
@@ -141,6 +141,7 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
141
141
|
until: defaults.until,
|
|
142
142
|
stopFile: defaults.stopFile,
|
|
143
143
|
progressFile: defaults.progressFile,
|
|
144
|
+
untilChecklistComplete: defaults.untilChecklistComplete ?? false,
|
|
144
145
|
promptFile: defaults.promptFile,
|
|
145
146
|
includeFiles: Array.isArray(defaults.includeFiles) ? [...defaults.includeFiles] : [],
|
|
146
147
|
watchPaths: Array.isArray(defaults.watchPaths) ? [...defaults.watchPaths] : [],
|
|
@@ -221,6 +222,9 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
221
222
|
[found, rest] = takeFlag(rest, "--pause-on-verify-fail");
|
|
222
223
|
if (found)
|
|
223
224
|
job.pauseOnVerifyFail = true;
|
|
225
|
+
[found, rest] = takeFlag(rest, "--until-checklist-complete");
|
|
226
|
+
if (found)
|
|
227
|
+
job.untilChecklistComplete = true;
|
|
224
228
|
[found, rest] = takeFlag(rest, "--dry-run");
|
|
225
229
|
if (found)
|
|
226
230
|
job.dryRun = true;
|
|
@@ -369,6 +373,8 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
369
373
|
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
|
|
370
374
|
}
|
|
371
375
|
job.lastRunAt = job.immediate ? 0 : now();
|
|
376
|
+
if (job.untilChecklistComplete && !job.progressFile)
|
|
377
|
+
return { ok: false, error: "--until-checklist-complete requires --progress-file <project.md>" };
|
|
372
378
|
if (!job.action && !job.promptFile && !job.goalFile)
|
|
373
379
|
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" };
|
|
374
380
|
return { ok: true, job };
|
|
@@ -459,6 +465,18 @@ async function readStateProcessOwner(file) {
|
|
|
459
465
|
await delay(10 * (attempt + 1));
|
|
460
466
|
}
|
|
461
467
|
}
|
|
468
|
+
if (raw.length === 0) {
|
|
469
|
+
for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
|
|
470
|
+
await delay(10 * (attempt + 1));
|
|
471
|
+
try {
|
|
472
|
+
raw = await fs.readFile(file, "utf8");
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error?.code === "ENOENT")
|
|
475
|
+
return null;
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
462
480
|
let owner;
|
|
463
481
|
try {
|
|
464
482
|
owner = JSON.parse(raw);
|
|
@@ -499,6 +517,26 @@ async function claimStateProcessLock(candidate, canonical) {
|
|
|
499
517
|
} catch (error) {
|
|
500
518
|
if (error?.code === "EEXIST")
|
|
501
519
|
return false;
|
|
520
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
521
|
+
const contents = await fs.readFile(candidate);
|
|
522
|
+
let handle;
|
|
523
|
+
try {
|
|
524
|
+
handle = await fs.open(canonical, "wx", 384);
|
|
525
|
+
} catch (failure) {
|
|
526
|
+
if (failure?.code === "EEXIST")
|
|
527
|
+
return false;
|
|
528
|
+
throw failure;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
await handle.writeFile(contents);
|
|
532
|
+
} catch (failure) {
|
|
533
|
+
await handle.close().catch(() => {});
|
|
534
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
535
|
+
throw failure;
|
|
536
|
+
}
|
|
537
|
+
await handle.close();
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
502
540
|
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
503
541
|
throw error;
|
|
504
542
|
await delay(10 * (attempt + 1));
|
|
@@ -1413,6 +1451,8 @@ function decoratePrompt(job) {
|
|
|
1413
1451
|
additions.push(continuation);
|
|
1414
1452
|
if (job.progressFile)
|
|
1415
1453
|
additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
|
|
1454
|
+
if (job.untilChecklistComplete)
|
|
1455
|
+
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.");
|
|
1416
1456
|
if (job.lastVerifyFailure)
|
|
1417
1457
|
additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
|
|
1418
1458
|
if (job.askNever)
|
|
@@ -3593,6 +3633,125 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
|
|
|
3593
3633
|
return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
|
|
3594
3634
|
}
|
|
3595
3635
|
|
|
3636
|
+
// src/source/runtime/checklist-completion.js
|
|
3637
|
+
import { promises as fs8, constants } from "fs";
|
|
3638
|
+
import path9 from "path";
|
|
3639
|
+
import { createHash } from "crypto";
|
|
3640
|
+
var MAX_CHECKLIST_BYTES = 256000;
|
|
3641
|
+
var EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"]);
|
|
3642
|
+
function unavailable(reason) {
|
|
3643
|
+
return { status: "unavailable", reason, total: 0, pending: 0 };
|
|
3644
|
+
}
|
|
3645
|
+
function summarizeTaskChecklist(markdown) {
|
|
3646
|
+
let total = 0;
|
|
3647
|
+
let pending = 0;
|
|
3648
|
+
let fence = null;
|
|
3649
|
+
let comment = false;
|
|
3650
|
+
for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
|
3651
|
+
let line = "";
|
|
3652
|
+
let offset = 0;
|
|
3653
|
+
while (offset < original.length) {
|
|
3654
|
+
if (comment) {
|
|
3655
|
+
const end = original.indexOf("-->", offset);
|
|
3656
|
+
if (end < 0)
|
|
3657
|
+
break;
|
|
3658
|
+
comment = false;
|
|
3659
|
+
offset = end + 3;
|
|
3660
|
+
} else {
|
|
3661
|
+
const start = original.indexOf("<!--", offset);
|
|
3662
|
+
if (start < 0) {
|
|
3663
|
+
line += original.slice(offset);
|
|
3664
|
+
break;
|
|
3665
|
+
}
|
|
3666
|
+
line += original.slice(offset, start);
|
|
3667
|
+
comment = true;
|
|
3668
|
+
offset = start + 4;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const trimmed = line.trimStart();
|
|
3672
|
+
const indentation = line.length - trimmed.length;
|
|
3673
|
+
const marker = trimmed.charAt(0);
|
|
3674
|
+
let markerSize = 0;
|
|
3675
|
+
if (marker === "~" || marker.charCodeAt(0) === 96) {
|
|
3676
|
+
while (trimmed.charAt(markerSize) === marker)
|
|
3677
|
+
markerSize++;
|
|
3678
|
+
}
|
|
3679
|
+
if (fence) {
|
|
3680
|
+
if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim())
|
|
3681
|
+
fence = null;
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
if (indentation <= 3 && markerSize >= 3) {
|
|
3685
|
+
fence = { marker, length: markerSize };
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
if (/^\s*>/.test(line))
|
|
3689
|
+
continue;
|
|
3690
|
+
const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/);
|
|
3691
|
+
if (!match)
|
|
3692
|
+
continue;
|
|
3693
|
+
total++;
|
|
3694
|
+
if (match[1] === " ")
|
|
3695
|
+
pending++;
|
|
3696
|
+
}
|
|
3697
|
+
return {
|
|
3698
|
+
status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
|
|
3699
|
+
reason: total === 0 ? "no-task-checkboxes" : "",
|
|
3700
|
+
total,
|
|
3701
|
+
pending
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
async function inspectTaskChecklist(directory, progressFile) {
|
|
3705
|
+
const name = String(progressFile || "").trim();
|
|
3706
|
+
if (!name || name.includes("\x00"))
|
|
3707
|
+
return unavailable("missing-progress-file");
|
|
3708
|
+
if (path9.isAbsolute(name) || path9.win32.isAbsolute(name))
|
|
3709
|
+
return unavailable("absolute-path");
|
|
3710
|
+
const root = path9.resolve(directory);
|
|
3711
|
+
const target = path9.resolve(root, name);
|
|
3712
|
+
const relative = path9.relative(root, target);
|
|
3713
|
+
if (!relative || relative === ".." || relative.startsWith(".." + path9.sep) || path9.isAbsolute(relative)) {
|
|
3714
|
+
return unavailable("outside-project");
|
|
3715
|
+
}
|
|
3716
|
+
const parts = relative.split(path9.sep);
|
|
3717
|
+
if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase())))
|
|
3718
|
+
return unavailable("control-plane-path");
|
|
3719
|
+
try {
|
|
3720
|
+
const rootInfo = await fs8.lstat(root);
|
|
3721
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
3722
|
+
return unavailable("unsafe-project-root");
|
|
3723
|
+
let current = root;
|
|
3724
|
+
for (let index = 0;index < parts.length; index++) {
|
|
3725
|
+
current = path9.join(current, parts[index]);
|
|
3726
|
+
const info = await fs8.lstat(current);
|
|
3727
|
+
if (info.isSymbolicLink())
|
|
3728
|
+
return unavailable("symlink-path");
|
|
3729
|
+
if (index < parts.length - 1 && !info.isDirectory())
|
|
3730
|
+
return unavailable("invalid-parent");
|
|
3731
|
+
if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
|
|
3732
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
const handle = await fs8.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
3736
|
+
try {
|
|
3737
|
+
const info = await handle.stat();
|
|
3738
|
+
if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
|
|
3739
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large");
|
|
3740
|
+
}
|
|
3741
|
+
const content = await handle.readFile({ encoding: "utf8" });
|
|
3742
|
+
const after = await fs8.lstat(target);
|
|
3743
|
+
if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
|
|
3744
|
+
return unavailable("file-changed-during-read");
|
|
3745
|
+
}
|
|
3746
|
+
return { ...summarizeTaskChecklist(content), digest: createHash("sha256").update(content).digest("hex") };
|
|
3747
|
+
} finally {
|
|
3748
|
+
await handle.close();
|
|
3749
|
+
}
|
|
3750
|
+
} catch (error) {
|
|
3751
|
+
return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable");
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3596
3755
|
// src/source/runtime/run-finalization.js
|
|
3597
3756
|
function requireFunction8(value, label) {
|
|
3598
3757
|
if (typeof value !== "function")
|
|
@@ -3627,8 +3786,16 @@ function createRunFinalizationRuntime(options = {}) {
|
|
|
3627
3786
|
if (verify.code === 0) {
|
|
3628
3787
|
job.failureCount = 0;
|
|
3629
3788
|
job.lastVerifyFailure = "";
|
|
3789
|
+
if (job.untilChecklistComplete) {
|
|
3790
|
+
const inspected = await inspectTaskChecklist(directory, job.progressFile);
|
|
3791
|
+
if (inspected.status !== "unavailable")
|
|
3792
|
+
job.lastVerifyChecklistDigest = inspected.digest;
|
|
3793
|
+
else
|
|
3794
|
+
delete job.lastVerifyChecklistDigest;
|
|
3795
|
+
}
|
|
3630
3796
|
await toast2(client, "Loop verify passed: " + job.verifyCommand, "success");
|
|
3631
3797
|
} else {
|
|
3798
|
+
delete job.lastVerifyChecklistDigest;
|
|
3632
3799
|
job.failureCount = (job.failureCount || 0) + 1;
|
|
3633
3800
|
job.lastVerifyFailure = (job.verifyCommand + `
|
|
3634
3801
|
exit=` + verify.code + `
|
|
@@ -3716,7 +3883,7 @@ exit=` + postrun.code + `
|
|
|
3716
3883
|
}
|
|
3717
3884
|
|
|
3718
3885
|
// src/source/runtime/run-admission.js
|
|
3719
|
-
import
|
|
3886
|
+
import path10 from "path";
|
|
3720
3887
|
function requireFunction9(value, label) {
|
|
3721
3888
|
if (typeof value !== "function")
|
|
3722
3889
|
throw new TypeError(`createRunAdmissionRuntime requires ${label}`);
|
|
@@ -3754,12 +3921,30 @@ function createRunAdmissionRuntime(options = {}) {
|
|
|
3754
3921
|
if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
3755
3922
|
return await stopAndRemove(directory, client, sessionID, state, job, "max_runtime_reached", `Loop stopped by --max-runtime: ${job.name || job.id}`, "max-runtime");
|
|
3756
3923
|
}
|
|
3757
|
-
if (job.stopFile && await pathExists2(
|
|
3924
|
+
if (job.stopFile && await pathExists2(path10.resolve(directory, job.stopFile))) {
|
|
3758
3925
|
return await stopAndRemove(directory, client, sessionID, state, job, "stop_file", "Loop stopped by --stop-file: " + job.stopFile);
|
|
3759
3926
|
}
|
|
3760
3927
|
if (await untilReached(directory, job)) {
|
|
3761
3928
|
return await stopAndRemove(directory, client, sessionID, state, job, "until_reached", `Loop stopped by --until: ${job.until}`);
|
|
3762
3929
|
}
|
|
3930
|
+
if (job.untilChecklistComplete) {
|
|
3931
|
+
const checklist = await inspectTaskChecklist(directory, job.progressFile);
|
|
3932
|
+
job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending };
|
|
3933
|
+
if (checklist.status === "unavailable") {
|
|
3934
|
+
job.paused = true;
|
|
3935
|
+
job.pauseReason = "checklist-unavailable";
|
|
3936
|
+
job.lastChecklistError = checklist.reason;
|
|
3937
|
+
await writeState2(directory, sessionID, state);
|
|
3938
|
+
await notifyJob2(directory, job, "checklist_unavailable");
|
|
3939
|
+
await toast2(client, "Loop paused: checklist " + job.progressFile + " could not be validated (" + checklist.reason + ").", "warning");
|
|
3940
|
+
await reschedule(directory, client, sessionID);
|
|
3941
|
+
return { admitted: false, reason: "checklist_unavailable" };
|
|
3942
|
+
}
|
|
3943
|
+
delete job.lastChecklistError;
|
|
3944
|
+
if (checklist.status === "complete" && (!job.verifyCommand || job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest)) {
|
|
3945
|
+
return await stopAndRemove(directory, client, sessionID, state, job, "checklist_complete", "Loop stopped: " + job.progressFile + " checklist completed.", "checklist-complete");
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3763
3948
|
if (job.preflightCommand) {
|
|
3764
3949
|
if (job.safe && dangerousShell2(job.preflightCommand)) {
|
|
3765
3950
|
if (runNowRequested)
|
package/src/native.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"}
|