@bybrawe/opencode-loop 0.6.8 → 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 +9 -0
- package/README.md +21 -9
- 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 +2 -2
- 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/src/index.js +155 -2
- package/src/native.js +176 -15
- package/src/server.js +180 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -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/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)
|
|
@@ -1357,7 +1365,7 @@ var init_job_workspace = __esm(() => {
|
|
|
1357
1365
|
});
|
|
1358
1366
|
|
|
1359
1367
|
// src/source/opencode2/native-plugin.js
|
|
1360
|
-
import
|
|
1368
|
+
import path10 from "path";
|
|
1361
1369
|
|
|
1362
1370
|
// src/source/opencode2/session-location.js
|
|
1363
1371
|
import path from "path";
|
|
@@ -1774,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
|
|
|
1774
1782
|
job.askNever ? "ask-never" : undefined,
|
|
1775
1783
|
job.noOverlap ? "no-overlap" : undefined,
|
|
1776
1784
|
job.checkpointOnly ? "checkpoint-only" : undefined,
|
|
1777
|
-
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
|
|
1778
1790
|
].filter(Boolean).join(",");
|
|
1779
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}`;
|
|
1780
1792
|
}) : ["No active loop jobs."];
|
|
@@ -1782,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
|
|
|
1782
1794
|
jobs,
|
|
1783
1795
|
text: `OpenCode loop status:
|
|
1784
1796
|
${lines.join(`
|
|
1785
|
-
`)}`
|
|
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.` : ""}`
|
|
1786
1799
|
});
|
|
1787
1800
|
}
|
|
1788
1801
|
|
|
@@ -1790,7 +1803,128 @@ ${lines.join(`
|
|
|
1790
1803
|
init_process();
|
|
1791
1804
|
init_job_workspace();
|
|
1792
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";
|
|
1793
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
|
|
1794
1928
|
function createNativeJobPolicy(options = {}) {
|
|
1795
1929
|
const now = options.now || Date.now;
|
|
1796
1930
|
const run = options.runShellCommand || runShellCommand;
|
|
@@ -1824,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1824
1958
|
}
|
|
1825
1959
|
if (job.stopFile) {
|
|
1826
1960
|
try {
|
|
1827
|
-
await stat(
|
|
1961
|
+
await stat(path8.resolve(scope.directory, job.stopFile));
|
|
1828
1962
|
job.enabled = false;
|
|
1829
1963
|
return pause(job, "stop-file");
|
|
1830
1964
|
} catch (error) {
|
|
@@ -1836,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1836
1970
|
job.enabled = false;
|
|
1837
1971
|
return pause(job, "until-reached");
|
|
1838
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
|
+
}
|
|
1839
1987
|
return true;
|
|
1840
1988
|
}
|
|
1841
1989
|
async function prepare(scope, job, isCurrent = () => true) {
|
|
@@ -1868,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1868
2016
|
job.lastVerifyOutput = `${result.stdout || ""}
|
|
1869
2017
|
${result.stderr || ""}`.trim().slice(-8000);
|
|
1870
2018
|
if (result.code !== 0) {
|
|
2019
|
+
delete job.lastVerifyChecklistDigest;
|
|
1871
2020
|
job.failureCount = (job.failureCount || 0) + 1;
|
|
1872
2021
|
if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
|
|
1873
2022
|
pause(job, "verification-failed");
|
|
1874
2023
|
return;
|
|
1875
2024
|
}
|
|
1876
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
|
+
}
|
|
1877
2033
|
}
|
|
1878
2034
|
if (!isCurrent())
|
|
1879
2035
|
return;
|
|
@@ -2001,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
|
|
|
2001
2157
|
continue;
|
|
2002
2158
|
if (job.watchPaths?.length || job.stopFile || job.until)
|
|
2003
2159
|
delay = Math.min(delay, 1000);
|
|
2160
|
+
if (job.untilChecklistComplete)
|
|
2161
|
+
delay = Math.min(delay, 5000);
|
|
2004
2162
|
const due = dueAt(job);
|
|
2005
2163
|
if (due > now())
|
|
2006
2164
|
delay = Math.min(delay, due - now());
|
|
@@ -3144,8 +3302,8 @@ init_process();
|
|
|
3144
3302
|
init_state();
|
|
3145
3303
|
|
|
3146
3304
|
// src/source/core/progress.js
|
|
3147
|
-
import { promises as
|
|
3148
|
-
import
|
|
3305
|
+
import { promises as fs5 } from "fs";
|
|
3306
|
+
import path9 from "path";
|
|
3149
3307
|
var DEFAULT_PROGRESS_MD = `# Progress
|
|
3150
3308
|
|
|
3151
3309
|
## Current Goal
|
|
@@ -3173,24 +3331,24 @@ Describe the current project goal here.
|
|
|
3173
3331
|
- None.
|
|
3174
3332
|
`;
|
|
3175
3333
|
function inside(root, candidate) {
|
|
3176
|
-
const relative =
|
|
3177
|
-
return relative !== ".." && !relative.startsWith(`..${
|
|
3334
|
+
const relative = path9.relative(root, candidate);
|
|
3335
|
+
return relative !== ".." && !relative.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relative);
|
|
3178
3336
|
}
|
|
3179
3337
|
async function initializeProgressFile(directory, argumentsText = "") {
|
|
3180
|
-
const root = await
|
|
3338
|
+
const root = await fs5.realpath(directory);
|
|
3181
3339
|
const target = String(argumentsText || "").trim() || "progress.md";
|
|
3182
|
-
const file =
|
|
3183
|
-
const relative =
|
|
3340
|
+
const file = path9.resolve(root, target);
|
|
3341
|
+
const relative = path9.relative(root, file);
|
|
3184
3342
|
if (!relative || !inside(root, file))
|
|
3185
3343
|
throw new Error("Progress file must be inside the project");
|
|
3186
|
-
if ([".git", ".opencode"].includes(relative.split(
|
|
3344
|
+
if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
|
|
3187
3345
|
throw new Error("Progress file cannot replace a control-plane path");
|
|
3188
3346
|
}
|
|
3189
|
-
const parent = await
|
|
3347
|
+
const parent = await fs5.realpath(path9.dirname(file));
|
|
3190
3348
|
if (!inside(root, parent))
|
|
3191
3349
|
throw new Error("Progress file must remain inside the project; symlink escape refused");
|
|
3192
3350
|
try {
|
|
3193
|
-
await
|
|
3351
|
+
await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
|
|
3194
3352
|
return { created: true, file: relative };
|
|
3195
3353
|
} catch (error) {
|
|
3196
3354
|
if (error?.code === "EEXIST")
|
|
@@ -3205,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
|
|
|
3205
3363
|
/loop 5m <task> --verify "npm test" --pause-on-verify-fail
|
|
3206
3364
|
/loop --watch progress.md <task>
|
|
3207
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"
|
|
3208
3367
|
/loop 0s <task> --compact-every 3
|
|
3209
3368
|
/loop 5m --shell npm test
|
|
3210
3369
|
/loop 15m --compact /compact
|
|
@@ -3212,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
3212
3371
|
/loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
|
|
3213
3372
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
3214
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.
|
|
3215
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.
|
|
3216
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.
|
|
3217
3378
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
@@ -3318,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3318
3479
|
return { handled: true, accepted: true, ...output };
|
|
3319
3480
|
}
|
|
3320
3481
|
if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
|
|
3321
|
-
const text = name === "loop-logs" ? (await readSmallTextFile(
|
|
3482
|
+
const text = name === "loop-logs" ? (await readSmallTextFile(path10.join(stateDir(directory), "loop.log"), 2000000)).split(`
|
|
3322
3483
|
`).slice(-60).join(`
|
|
3323
3484
|
`) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
|
|
3324
3485
|
Host: ${ctx.app?.version || "unknown"}
|
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)
|
|
@@ -1357,7 +1365,7 @@ var init_job_workspace = __esm(() => {
|
|
|
1357
1365
|
});
|
|
1358
1366
|
|
|
1359
1367
|
// src/source/opencode2/native-plugin.js
|
|
1360
|
-
import
|
|
1368
|
+
import path10 from "path";
|
|
1361
1369
|
|
|
1362
1370
|
// src/source/opencode2/session-location.js
|
|
1363
1371
|
import path from "path";
|
|
@@ -1774,7 +1782,11 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
|
|
|
1774
1782
|
job.askNever ? "ask-never" : undefined,
|
|
1775
1783
|
job.noOverlap ? "no-overlap" : undefined,
|
|
1776
1784
|
job.checkpointOnly ? "checkpoint-only" : undefined,
|
|
1777
|
-
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
|
|
1778
1790
|
].filter(Boolean).join(",");
|
|
1779
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}`;
|
|
1780
1792
|
}) : ["No active loop jobs."];
|
|
@@ -1782,7 +1794,8 @@ function formatOpenCode2LoopStatus(state, current = Date.now()) {
|
|
|
1782
1794
|
jobs,
|
|
1783
1795
|
text: `OpenCode loop status:
|
|
1784
1796
|
${lines.join(`
|
|
1785
|
-
`)}`
|
|
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.` : ""}`
|
|
1786
1799
|
});
|
|
1787
1800
|
}
|
|
1788
1801
|
|
|
@@ -1790,7 +1803,128 @@ ${lines.join(`
|
|
|
1790
1803
|
init_process();
|
|
1791
1804
|
init_job_workspace();
|
|
1792
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";
|
|
1793
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
|
|
1794
1928
|
function createNativeJobPolicy(options = {}) {
|
|
1795
1929
|
const now = options.now || Date.now;
|
|
1796
1930
|
const run = options.runShellCommand || runShellCommand;
|
|
@@ -1824,7 +1958,7 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1824
1958
|
}
|
|
1825
1959
|
if (job.stopFile) {
|
|
1826
1960
|
try {
|
|
1827
|
-
await stat(
|
|
1961
|
+
await stat(path8.resolve(scope.directory, job.stopFile));
|
|
1828
1962
|
job.enabled = false;
|
|
1829
1963
|
return pause(job, "stop-file");
|
|
1830
1964
|
} catch (error) {
|
|
@@ -1836,6 +1970,20 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1836
1970
|
job.enabled = false;
|
|
1837
1971
|
return pause(job, "until-reached");
|
|
1838
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
|
+
}
|
|
1839
1987
|
return true;
|
|
1840
1988
|
}
|
|
1841
1989
|
async function prepare(scope, job, isCurrent = () => true) {
|
|
@@ -1868,12 +2016,20 @@ function createNativeJobPolicy(options = {}) {
|
|
|
1868
2016
|
job.lastVerifyOutput = `${result.stdout || ""}
|
|
1869
2017
|
${result.stderr || ""}`.trim().slice(-8000);
|
|
1870
2018
|
if (result.code !== 0) {
|
|
2019
|
+
delete job.lastVerifyChecklistDigest;
|
|
1871
2020
|
job.failureCount = (job.failureCount || 0) + 1;
|
|
1872
2021
|
if (result.blocked || job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures)
|
|
1873
2022
|
pause(job, "verification-failed");
|
|
1874
2023
|
return;
|
|
1875
2024
|
}
|
|
1876
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
|
+
}
|
|
1877
2033
|
}
|
|
1878
2034
|
if (!isCurrent())
|
|
1879
2035
|
return;
|
|
@@ -2001,6 +2157,8 @@ function createNativeLoopRuntime(options = {}) {
|
|
|
2001
2157
|
continue;
|
|
2002
2158
|
if (job.watchPaths?.length || job.stopFile || job.until)
|
|
2003
2159
|
delay = Math.min(delay, 1000);
|
|
2160
|
+
if (job.untilChecklistComplete)
|
|
2161
|
+
delay = Math.min(delay, 5000);
|
|
2004
2162
|
const due = dueAt(job);
|
|
2005
2163
|
if (due > now())
|
|
2006
2164
|
delay = Math.min(delay, due - now());
|
|
@@ -3144,8 +3302,8 @@ init_process();
|
|
|
3144
3302
|
init_state();
|
|
3145
3303
|
|
|
3146
3304
|
// src/source/core/progress.js
|
|
3147
|
-
import { promises as
|
|
3148
|
-
import
|
|
3305
|
+
import { promises as fs5 } from "fs";
|
|
3306
|
+
import path9 from "path";
|
|
3149
3307
|
var DEFAULT_PROGRESS_MD = `# Progress
|
|
3150
3308
|
|
|
3151
3309
|
## Current Goal
|
|
@@ -3173,24 +3331,24 @@ Describe the current project goal here.
|
|
|
3173
3331
|
- None.
|
|
3174
3332
|
`;
|
|
3175
3333
|
function inside(root, candidate) {
|
|
3176
|
-
const relative =
|
|
3177
|
-
return relative !== ".." && !relative.startsWith(`..${
|
|
3334
|
+
const relative = path9.relative(root, candidate);
|
|
3335
|
+
return relative !== ".." && !relative.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relative);
|
|
3178
3336
|
}
|
|
3179
3337
|
async function initializeProgressFile(directory, argumentsText = "") {
|
|
3180
|
-
const root = await
|
|
3338
|
+
const root = await fs5.realpath(directory);
|
|
3181
3339
|
const target = String(argumentsText || "").trim() || "progress.md";
|
|
3182
|
-
const file =
|
|
3183
|
-
const relative =
|
|
3340
|
+
const file = path9.resolve(root, target);
|
|
3341
|
+
const relative = path9.relative(root, file);
|
|
3184
3342
|
if (!relative || !inside(root, file))
|
|
3185
3343
|
throw new Error("Progress file must be inside the project");
|
|
3186
|
-
if ([".git", ".opencode"].includes(relative.split(
|
|
3344
|
+
if ([".git", ".opencode"].includes(relative.split(path9.sep)[0].toLowerCase())) {
|
|
3187
3345
|
throw new Error("Progress file cannot replace a control-plane path");
|
|
3188
3346
|
}
|
|
3189
|
-
const parent = await
|
|
3347
|
+
const parent = await fs5.realpath(path9.dirname(file));
|
|
3190
3348
|
if (!inside(root, parent))
|
|
3191
3349
|
throw new Error("Progress file must remain inside the project; symlink escape refused");
|
|
3192
3350
|
try {
|
|
3193
|
-
await
|
|
3351
|
+
await fs5.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" });
|
|
3194
3352
|
return { created: true, file: relative };
|
|
3195
3353
|
} catch (error) {
|
|
3196
3354
|
if (error?.code === "EEXIST")
|
|
@@ -3205,6 +3363,7 @@ var HELP = `OpenCode Loop: native OpenCode 2 runtime
|
|
|
3205
3363
|
/loop 5m <task> --verify "npm test" --pause-on-verify-fail
|
|
3206
3364
|
/loop --watch progress.md <task>
|
|
3207
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"
|
|
3208
3367
|
/loop 0s <task> --compact-every 3
|
|
3209
3368
|
/loop 5m --shell npm test
|
|
3210
3369
|
/loop 15m --compact /compact
|
|
@@ -3212,6 +3371,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
3212
3371
|
/loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
|
|
3213
3372
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
3214
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.
|
|
3215
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.
|
|
3216
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.
|
|
3217
3378
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
@@ -3318,7 +3479,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3318
3479
|
return { handled: true, accepted: true, ...output };
|
|
3319
3480
|
}
|
|
3320
3481
|
if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
|
|
3321
|
-
const text = name === "loop-logs" ? (await readSmallTextFile(
|
|
3482
|
+
const text = name === "loop-logs" ? (await readSmallTextFile(path10.join(stateDir(directory), "loop.log"), 2000000)).split(`
|
|
3322
3483
|
`).slice(-60).join(`
|
|
3323
3484
|
`) || "No Loop log entries." : name === "loop-doctor" ? `Native OpenCode Loop
|
|
3324
3485
|
Host: ${ctx.app?.version || "unknown"}
|
|
@@ -3410,8 +3571,8 @@ function inspectOpenCode2Context(ctx) {
|
|
|
3410
3571
|
|
|
3411
3572
|
// src/source/opencode2/diagnostics.js
|
|
3412
3573
|
init_state();
|
|
3413
|
-
import { promises as
|
|
3414
|
-
import
|
|
3574
|
+
import { promises as fs6 } from "fs";
|
|
3575
|
+
import path11 from "path";
|
|
3415
3576
|
var OPENCODE_LOOP_V2_HELP_TEXT = [
|
|
3416
3577
|
"OpenCode Loop V2 experimental help:",
|
|
3417
3578
|
"/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
|
|
@@ -3440,7 +3601,7 @@ function createOpenCode2DiagnosticsRuntime(options = {}) {
|
|
|
3440
3601
|
if (typeof options.prompt !== "function")
|
|
3441
3602
|
throw new TypeError("V2 diagnostics runtime requires prompt()");
|
|
3442
3603
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
3443
|
-
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) =>
|
|
3604
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs6.readFile(...args);
|
|
3444
3605
|
const runtimeVersion = options.runtimeVersion || process.version;
|
|
3445
3606
|
const runtimePlatform = options.runtimePlatform || process.platform;
|
|
3446
3607
|
async function exportState(event) {
|
|
@@ -3491,7 +3652,7 @@ ${JSON.stringify(state, null, 2)}
|
|
|
3491
3652
|
return { handled: false, reason: "missing-scope" };
|
|
3492
3653
|
let text = "No OpenCode 2 Loop log found.";
|
|
3493
3654
|
try {
|
|
3494
|
-
const raw = await readFile(
|
|
3655
|
+
const raw = await readFile(path11.join(stateDir(scope.directory), "loop.log"), "utf8");
|
|
3495
3656
|
const lines = String(raw || "").trim().split(/\r?\n/).filter((line) => line.includes('"v2":true')).slice(-80);
|
|
3496
3657
|
if (lines.length)
|
|
3497
3658
|
text = lines.join(`
|