@bybrawe/opencode-loop 0.5.36 → 0.5.37
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 +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/smoke-test.mjs +21 -0
- package/src/index.js +117 -91
- package/src/source/legacy-v1.js +29 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.37
|
|
4
|
+
|
|
5
|
+
Dedicated Goal tool-isolation release.
|
|
6
|
+
|
|
7
|
+
- Detect a persisted dedicated @bybrawe/opencode-goal Goal before any experimental `opencode_loop_goal_*` lifecycle tool can mutate Loop Goal state.
|
|
8
|
+
- Fail closed when a dedicated Goal owns the session and explicitly direct the model to `opencode_goal_progress`, `opencode_goal_wait_for_user`, `opencode_goal_blocked`, or `opencode_goal_complete` instead of returning a misleading experimental-Goal result.
|
|
9
|
+
- Mark the Loop lifecycle tools as experimental `/loop-goal`-only in their model-visible descriptions, preventing the exact blocker-tool collision reproduced by the long Loto Diyarı session.
|
|
10
|
+
- Keep modular source and the committed single-file stable bundle byte-for-byte synchronized with the Bun build output.
|
|
11
|
+
- Add coexistence smoke coverage for blocked, progress, and completion collisions; exact feature-head CI, Dedicated Goal Coexistence, and Single-file Bundle Gate pass across the required host/platform lanes.
|
|
12
|
+
|
|
3
13
|
## 0.5.36
|
|
4
14
|
|
|
5
15
|
Empty-turn fail-safe release.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
6
6
|
|
|
7
|
-
> **Current stable release: `0.5.
|
|
7
|
+
> **Current stable release: `0.5.37`.**
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.37",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -86,6 +86,27 @@ try {
|
|
|
86
86
|
"opencode_loop_goal_progress",
|
|
87
87
|
])
|
|
88
88
|
|
|
89
|
+
const dedicatedGoalRoot = path.join(directory, ".opencode", "goals")
|
|
90
|
+
await fs.mkdir(dedicatedGoalRoot, { recursive: true })
|
|
91
|
+
await fs.writeFile(path.join(dedicatedGoalRoot, "dedicated.json"), JSON.stringify({
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
id: "dedicated-goal",
|
|
94
|
+
sessionID,
|
|
95
|
+
status: "active",
|
|
96
|
+
}), "utf8")
|
|
97
|
+
for (const [name, args] of [
|
|
98
|
+
["opencode_loop_goal_blocked", { reason: "Need deploy", needed: "User deploy" }],
|
|
99
|
+
["opencode_loop_goal_progress", { summary: "Checked status", next: "Continue" }],
|
|
100
|
+
["opencode_loop_goal_complete", { summary: "Done", evidence: "tests passed" }],
|
|
101
|
+
]) {
|
|
102
|
+
const guarded = await hooks.tool[name].execute(args, { directory, sessionID })
|
|
103
|
+
assert.equal(guarded.title, "Dedicated Goal owns lifecycle")
|
|
104
|
+
assert.match(guarded.output, /opencode_loop_goal_\*/)
|
|
105
|
+
assert.match(guarded.output, /opencode_goal_wait_for_user/)
|
|
106
|
+
assert.match(guarded.output, /opencode_goal_blocked/)
|
|
107
|
+
}
|
|
108
|
+
await fs.rm(dedicatedGoalRoot, { recursive: true, force: true })
|
|
109
|
+
|
|
89
110
|
const output = { parts: [{ type: "text", text: "original command body" }] }
|
|
90
111
|
await hooks["command.execute.before"]({
|
|
91
112
|
command: "loop-goal",
|
package/src/index.js
CHANGED
|
@@ -926,9 +926,9 @@ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
926
926
|
const entry = loopOwnedUserMessageGuards.get(sessionID);
|
|
927
927
|
if (!entry || typeof entry !== "object")
|
|
928
928
|
return false;
|
|
929
|
-
for (const [
|
|
929
|
+
for (const [id, expiresAt] of entry.messageIDs.entries())
|
|
930
930
|
if (expiresAt < now())
|
|
931
|
-
entry.messageIDs.delete(
|
|
931
|
+
entry.messageIDs.delete(id);
|
|
932
932
|
const id = typeof messageID === "string" ? messageID : "";
|
|
933
933
|
if (id && entry.messageIDs.has(id))
|
|
934
934
|
return true;
|
|
@@ -1617,8 +1617,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1617
1617
|
const addLoop = requireFunction2(options.addLoop, "addLoop");
|
|
1618
1618
|
const scheduleDueWork = requireFunction2(options.scheduleDueWork, "scheduleDueWork");
|
|
1619
1619
|
const scheduleIdleWork = requireFunction2(options.scheduleIdleWork, "scheduleIdleWork");
|
|
1620
|
-
const
|
|
1621
|
-
const
|
|
1620
|
+
const toast = requireFunction2(options.toast, "toast");
|
|
1621
|
+
const say = requireFunction2(options.say, "say");
|
|
1622
1622
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1623
1623
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1624
1624
|
const setGoalComplete2 = typeof options.setGoalComplete === "function" ? options.setGoalComplete : setGoalComplete;
|
|
@@ -1635,8 +1635,8 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1635
1635
|
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
|
|
1636
1636
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`;
|
|
1637
1637
|
}) : ["No experimental goal jobs."];
|
|
1638
|
-
await
|
|
1639
|
-
await
|
|
1638
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
|
|
1639
|
+
await say(client, sessionID, `OpenCode Loop experimental goal status:
|
|
1640
1640
|
` + lines.join(`
|
|
1641
1641
|
`));
|
|
1642
1642
|
}
|
|
@@ -1647,7 +1647,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1647
1647
|
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
|
|
1648
1648
|
await writeState2(directory, sessionID, state);
|
|
1649
1649
|
await scheduleDueWork(directory, client, sessionID);
|
|
1650
|
-
await
|
|
1650
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1651
1651
|
}
|
|
1652
1652
|
async function resumeGoal(directory, client, sessionID, args) {
|
|
1653
1653
|
const target = String(args || "").trim() || "goal";
|
|
@@ -1660,7 +1660,7 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1660
1660
|
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
|
|
1661
1661
|
});
|
|
1662
1662
|
await writeState2(directory, sessionID, state);
|
|
1663
|
-
await
|
|
1663
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1664
1664
|
if (count) {
|
|
1665
1665
|
await scheduleDueWork(directory, client, sessionID);
|
|
1666
1666
|
scheduleIdleWork(directory, client, sessionID);
|
|
@@ -1673,15 +1673,15 @@ function createGoalCommandHandlers(options = {}) {
|
|
|
1673
1673
|
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
|
|
1674
1674
|
await writeState2(directory, sessionID, state);
|
|
1675
1675
|
await scheduleDueWork(directory, client, sessionID);
|
|
1676
|
-
await
|
|
1676
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
|
|
1677
1677
|
}
|
|
1678
1678
|
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1679
1679
|
const result = await setGoalComplete2(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
|
|
1680
|
-
await
|
|
1680
|
+
await toast(client, result.message, result.ok ? "success" : "warning");
|
|
1681
1681
|
}
|
|
1682
1682
|
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1683
1683
|
const result = await setGoalBlocked2(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
|
|
1684
|
-
await
|
|
1684
|
+
await toast(client, result.message, "warning");
|
|
1685
1685
|
}
|
|
1686
1686
|
async function addGoal(directory, client, sessionID, args) {
|
|
1687
1687
|
const text = String(args || "").trim();
|
|
@@ -1930,8 +1930,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1930
1930
|
const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
|
|
1931
1931
|
const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
|
|
1932
1932
|
const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
|
|
1933
|
-
const
|
|
1934
|
-
const
|
|
1933
|
+
const toast = requireFunction3(options.toast, "toast");
|
|
1934
|
+
const say = requireFunction3(options.say, "say");
|
|
1935
1935
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
1936
1936
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1937
1937
|
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
@@ -1951,7 +1951,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1951
1951
|
clearActiveRun(sessionID);
|
|
1952
1952
|
cancelDueWork(sessionID);
|
|
1953
1953
|
stopWatchdog(sessionID);
|
|
1954
|
-
await
|
|
1954
|
+
await toast(client, "All loops stopped for this session.", "success");
|
|
1955
1955
|
return;
|
|
1956
1956
|
}
|
|
1957
1957
|
const state = await readState2(directory, sessionID);
|
|
@@ -1959,7 +1959,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1959
1959
|
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
|
|
1960
1960
|
await writeState2(directory, sessionID, state);
|
|
1961
1961
|
await scheduleDueWork(directory, client, sessionID);
|
|
1962
|
-
await
|
|
1962
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
|
|
1963
1963
|
}
|
|
1964
1964
|
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
1965
1965
|
const target = String(args || "").trim() || "all";
|
|
@@ -1968,7 +1968,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1968
1968
|
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
|
|
1969
1969
|
await writeState2(directory, sessionID, state);
|
|
1970
1970
|
await scheduleDueWork(directory, client, sessionID);
|
|
1971
|
-
await
|
|
1971
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
1972
1972
|
}
|
|
1973
1973
|
async function statusLoop(directory, client, sessionID) {
|
|
1974
1974
|
const state = await readState2(directory, sessionID);
|
|
@@ -1979,8 +1979,8 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1979
1979
|
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",");
|
|
1980
1980
|
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | schedule=${scheduling.schedule} | state=${scheduling.state} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | ${flags}`;
|
|
1981
1981
|
}) : ["No active loop jobs."];
|
|
1982
|
-
await
|
|
1983
|
-
await
|
|
1982
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
1983
|
+
await say(client, sessionID, `OpenCode loop status:
|
|
1984
1984
|
` + lines.join(`
|
|
1985
1985
|
`));
|
|
1986
1986
|
}
|
|
@@ -1990,11 +1990,11 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
1990
1990
|
text = (await readFile(path7.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1991
1991
|
`) || text;
|
|
1992
1992
|
} catch {}
|
|
1993
|
-
await
|
|
1993
|
+
await say(client, sessionID, `OpenCode loop logs:
|
|
1994
1994
|
` + text);
|
|
1995
1995
|
}
|
|
1996
1996
|
async function helpLoop(client, sessionID) {
|
|
1997
|
-
await
|
|
1997
|
+
await say(client, sessionID, [
|
|
1998
1998
|
"OpenCode Loop help:",
|
|
1999
1999
|
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
2000
2000
|
"/loop idle continue the project explicit form of the same idle loop",
|
|
@@ -2028,7 +2028,7 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2028
2028
|
count += 1;
|
|
2029
2029
|
}
|
|
2030
2030
|
await writeState2(directory, sessionID, state);
|
|
2031
|
-
await
|
|
2031
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
|
|
2032
2032
|
if (count)
|
|
2033
2033
|
await scheduleDueWork(directory, client, sessionID);
|
|
2034
2034
|
}
|
|
@@ -2058,23 +2058,23 @@ function createLoopCommandHandlers(options = {}) {
|
|
|
2058
2058
|
}
|
|
2059
2059
|
if (otherSessions.length > 8)
|
|
2060
2060
|
lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`);
|
|
2061
|
-
await
|
|
2061
|
+
await say(client, sessionID, lines.join(`
|
|
2062
2062
|
`));
|
|
2063
2063
|
}
|
|
2064
2064
|
async function initLoop(directory, client, sessionID, args) {
|
|
2065
2065
|
const target = String(args || "").trim() || "progress.md";
|
|
2066
2066
|
const full = path7.resolve(directory, target);
|
|
2067
2067
|
if (await pathExists2(full)) {
|
|
2068
|
-
await
|
|
2068
|
+
await toast(client, `${target} already exists.`, "warning");
|
|
2069
2069
|
return;
|
|
2070
2070
|
}
|
|
2071
2071
|
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
|
|
2072
|
-
await
|
|
2072
|
+
await toast(client, `Created ${target}.`, "success");
|
|
2073
2073
|
await appendLoopLog2(directory, "init", { sessionID, file: target });
|
|
2074
2074
|
}
|
|
2075
2075
|
async function exportLoop(directory, client, sessionID) {
|
|
2076
2076
|
const state = await readState2(directory, sessionID);
|
|
2077
|
-
await
|
|
2077
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
|
|
2078
2078
|
}
|
|
2079
2079
|
return {
|
|
2080
2080
|
stopLoop,
|
|
@@ -2184,8 +2184,8 @@ function createLoopRegistration(options = {}) {
|
|
|
2184
2184
|
const snapshotPaths = requireFunction4(options.snapshotPaths, "snapshotPaths");
|
|
2185
2185
|
const scheduleDueWork = requireFunction4(options.scheduleDueWork, "scheduleDueWork");
|
|
2186
2186
|
const scheduleIdleWork = requireFunction4(options.scheduleIdleWork, "scheduleIdleWork");
|
|
2187
|
-
const
|
|
2188
|
-
const
|
|
2187
|
+
const toast = requireFunction4(options.toast, "toast");
|
|
2188
|
+
const say = requireFunction4(options.say, "say");
|
|
2189
2189
|
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
2190
2190
|
const normalizeLoopScheduleArgs2 = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : normalizeLoopScheduleArgs;
|
|
2191
2191
|
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
@@ -2199,12 +2199,12 @@ function createLoopRegistration(options = {}) {
|
|
|
2199
2199
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
2200
2200
|
const normalized = normalizeLoopScheduleArgs2(args, defaults);
|
|
2201
2201
|
if (!normalized.ok) {
|
|
2202
|
-
await
|
|
2202
|
+
await toast(client, normalized.error, "warning");
|
|
2203
2203
|
return;
|
|
2204
2204
|
}
|
|
2205
2205
|
const parsed = parseLoopArgs2(normalized.args, normalized.defaults);
|
|
2206
2206
|
if (!parsed.ok) {
|
|
2207
|
-
await
|
|
2207
|
+
await toast(client, parsed.error, "warning");
|
|
2208
2208
|
return;
|
|
2209
2209
|
}
|
|
2210
2210
|
parsed.job.scheduleMode = normalized.scheduleMode;
|
|
@@ -2243,13 +2243,13 @@ function createLoopRegistration(options = {}) {
|
|
|
2243
2243
|
job: parsed.job.name || parsed.job.id,
|
|
2244
2244
|
goal: dedicatedGoal.id
|
|
2245
2245
|
});
|
|
2246
|
-
await
|
|
2246
|
+
await toast(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning");
|
|
2247
2247
|
return;
|
|
2248
2248
|
}
|
|
2249
2249
|
}
|
|
2250
2250
|
if (parsed.job.dryRun) {
|
|
2251
|
-
await
|
|
2252
|
-
await
|
|
2251
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
2252
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
2253
2253
|
return;
|
|
2254
2254
|
}
|
|
2255
2255
|
const state = await readState2(directory, sessionID);
|
|
@@ -2273,7 +2273,7 @@ function createLoopRegistration(options = {}) {
|
|
|
2273
2273
|
await scheduleDueWork(directory, client, sessionID);
|
|
2274
2274
|
if (parsed.job.immediate)
|
|
2275
2275
|
scheduleIdleWork(directory, client, sessionID);
|
|
2276
|
-
await
|
|
2276
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
2277
2277
|
await appendLoopLog2(directory, replaced ? "replace" : "add", {
|
|
2278
2278
|
sessionID,
|
|
2279
2279
|
job: parsed.job.name || parsed.job.id,
|
|
@@ -2400,19 +2400,19 @@ function clearSessionActivity(sessionID) {
|
|
|
2400
2400
|
// src/source/runtime/scheduler-diagnostics.js
|
|
2401
2401
|
var DEFAULT_DEFERRAL_LOG_THROTTLE_MS = 30000;
|
|
2402
2402
|
function createSchedulerDiagnostics(options = {}) {
|
|
2403
|
-
const
|
|
2404
|
-
const
|
|
2403
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
2404
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
|
|
2405
2405
|
const configured = Number(options.throttleMs);
|
|
2406
2406
|
const throttleMs = Number.isFinite(configured) && configured >= 0 ? configured : DEFAULT_DEFERRAL_LOG_THROTTLE_MS;
|
|
2407
2407
|
const lastLogged = new Map;
|
|
2408
2408
|
async function logDeferral(directory, sessionID, reason, extra = {}) {
|
|
2409
2409
|
const key = `${sessionID || "unknown"}:${reason || "deferred"}:${extra.source || "runtime"}`;
|
|
2410
|
-
const current =
|
|
2410
|
+
const current = now();
|
|
2411
2411
|
const previous = Number(lastLogged.get(key) || 0);
|
|
2412
2412
|
if (previous > 0 && current - previous < throttleMs)
|
|
2413
2413
|
return false;
|
|
2414
2414
|
lastLogged.set(key, current);
|
|
2415
|
-
await
|
|
2415
|
+
await appendLoopLog(directory, "deferred", {
|
|
2416
2416
|
sessionID,
|
|
2417
2417
|
reason,
|
|
2418
2418
|
...extra
|
|
@@ -2458,7 +2458,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2458
2458
|
if (options.appendLoopLog)
|
|
2459
2459
|
await options.appendLoopLog(directory, event, extra);
|
|
2460
2460
|
};
|
|
2461
|
-
const
|
|
2461
|
+
const toast = async (client, message, level) => {
|
|
2462
2462
|
if (options.toast)
|
|
2463
2463
|
await options.toast(client, message, level);
|
|
2464
2464
|
};
|
|
@@ -2522,7 +2522,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2522
2522
|
}
|
|
2523
2523
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2524
2524
|
}).catch((error) => {
|
|
2525
|
-
|
|
2525
|
+
toast(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2526
2526
|
appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2527
2527
|
});
|
|
2528
2528
|
}, idleDebounceMs);
|
|
@@ -2534,13 +2534,13 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2534
2534
|
const timer = setIntervalFn(() => {
|
|
2535
2535
|
Promise.resolve().then(async () => {
|
|
2536
2536
|
const state = await readStateFn(directory, sessionID);
|
|
2537
|
-
const
|
|
2537
|
+
const delay = nextDueDelay(state, clock());
|
|
2538
2538
|
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
|
|
2539
|
-
if (!hasJobs || !Number.isFinite(
|
|
2539
|
+
if (!hasJobs || !Number.isFinite(delay)) {
|
|
2540
2540
|
stopWatchdog(sessionID);
|
|
2541
2541
|
return;
|
|
2542
2542
|
}
|
|
2543
|
-
if (
|
|
2543
|
+
if (delay <= 0)
|
|
2544
2544
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2545
2545
|
else
|
|
2546
2546
|
await scheduleDueWork(directory, client, sessionID);
|
|
@@ -2557,10 +2557,10 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2557
2557
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
2558
2558
|
cancelDueWork(sessionID);
|
|
2559
2559
|
const state = await readStateFn(directory, sessionID);
|
|
2560
|
-
const
|
|
2561
|
-
if (!Number.isFinite(
|
|
2560
|
+
const delay = nextDueDelay(state, clock());
|
|
2561
|
+
if (!Number.isFinite(delay))
|
|
2562
2562
|
return;
|
|
2563
|
-
const wait = Math.min(Math.max(
|
|
2563
|
+
const wait = Math.min(Math.max(delay, minDelayMs, minDueTimerMs), maxDueTimerMs);
|
|
2564
2564
|
const timer = setTimeoutFn(() => {
|
|
2565
2565
|
dueTimers.delete(sessionID);
|
|
2566
2566
|
Promise.resolve().then(async () => {
|
|
@@ -2577,7 +2577,7 @@ function createSchedulerRuntime(options = {}) {
|
|
|
2577
2577
|
}
|
|
2578
2578
|
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2579
2579
|
}).catch((error) => {
|
|
2580
|
-
|
|
2580
|
+
toast(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2581
2581
|
appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2582
2582
|
});
|
|
2583
2583
|
}, wait);
|
|
@@ -2616,9 +2616,9 @@ function requireFunction5(value, name) {
|
|
|
2616
2616
|
return value;
|
|
2617
2617
|
}
|
|
2618
2618
|
function createGoalExecutionPolicy(options = {}) {
|
|
2619
|
-
const
|
|
2619
|
+
const runShellCommand = requireFunction5(options.runShellCommand, "runShellCommand");
|
|
2620
2620
|
const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
|
|
2621
|
-
const
|
|
2621
|
+
const toast = requireFunction5(options.toast, "toast");
|
|
2622
2622
|
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2623
2623
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2624
2624
|
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
@@ -2639,7 +2639,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2639
2639
|
job.paused = true;
|
|
2640
2640
|
job.goalNoProgressPausedAt = now2();
|
|
2641
2641
|
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
|
|
2642
|
-
await
|
|
2642
|
+
await toast(client, job.goalNoProgressReason, "warning");
|
|
2643
2643
|
await appendLoopLog2(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
2644
2644
|
}
|
|
2645
2645
|
return job;
|
|
@@ -2653,7 +2653,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2653
2653
|
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
|
|
2654
2654
|
continue;
|
|
2655
2655
|
}
|
|
2656
|
-
const result = await
|
|
2656
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300000);
|
|
2657
2657
|
results.push({ command, code: result.code, output: (result.stdout + `
|
|
2658
2658
|
` + result.stderr).slice(0, 1200) });
|
|
2659
2659
|
}
|
|
@@ -2663,7 +2663,7 @@ function createGoalExecutionPolicy(options = {}) {
|
|
|
2663
2663
|
if (allPassed) {
|
|
2664
2664
|
job.goalChecksPassedAt = now2();
|
|
2665
2665
|
job.failureCount = 0;
|
|
2666
|
-
await
|
|
2666
|
+
await toast(client, "Goal checks passed.", "success");
|
|
2667
2667
|
if (job.goalCompleteWhenChecksPass) {
|
|
2668
2668
|
job.goalStatus = "completed";
|
|
2669
2669
|
job.enabled = false;
|
|
@@ -2681,7 +2681,7 @@ exit=${item.code}
|
|
|
2681
2681
|
${item.output}`).join(`
|
|
2682
2682
|
|
|
2683
2683
|
`).slice(0, 4000);
|
|
2684
|
-
await
|
|
2684
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
|
|
2685
2685
|
}
|
|
2686
2686
|
await appendLoopLog2(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
|
|
2687
2687
|
return job;
|
|
@@ -2716,7 +2716,7 @@ function dangerousShell(command) {
|
|
|
2716
2716
|
].some((pattern) => pattern.test(text));
|
|
2717
2717
|
}
|
|
2718
2718
|
function createJobWorkspaceRuntime(options = {}) {
|
|
2719
|
-
const
|
|
2719
|
+
const toast = requireFunction6(options.toast, "toast");
|
|
2720
2720
|
const runProcess2 = typeof options.runProcess === "function" ? options.runProcess : runProcess;
|
|
2721
2721
|
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2722
2722
|
const readSmallTextFile2 = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : readSmallTextFile;
|
|
@@ -2760,7 +2760,7 @@ ${text.trim().slice(0, 20000)}`);
|
|
|
2760
2760
|
if (result.code !== 0)
|
|
2761
2761
|
result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
|
|
2762
2762
|
job.branchDone = true;
|
|
2763
|
-
await
|
|
2763
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
2764
2764
|
await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
|
|
2765
2765
|
return job;
|
|
2766
2766
|
}
|
|
@@ -2854,7 +2854,7 @@ ${staged.stdout}`);
|
|
|
2854
2854
|
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
2855
2855
|
await runProcess2("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
2856
2856
|
}
|
|
2857
|
-
await
|
|
2857
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
2858
2858
|
}
|
|
2859
2859
|
return {
|
|
2860
2860
|
buildPrompt,
|
|
@@ -2968,15 +2968,15 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2968
2968
|
}
|
|
2969
2969
|
return attempted ? { type: "unknown", source: "sdk-error" } : undefined;
|
|
2970
2970
|
}
|
|
2971
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active,
|
|
2971
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
2972
2972
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
2973
2973
|
return false;
|
|
2974
|
-
if (!
|
|
2974
|
+
if (!options.requireIdle && !options.forceStale)
|
|
2975
2975
|
return true;
|
|
2976
|
-
const completion =
|
|
2976
|
+
const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
2977
2977
|
if (settledAssistantCompletion(completion))
|
|
2978
2978
|
return true;
|
|
2979
|
-
if (!
|
|
2979
|
+
if (!options.requireIdle)
|
|
2980
2980
|
return completion === "unknown" && staleActiveRun(sessionID);
|
|
2981
2981
|
const cached = sessionStatuses.get(sessionID);
|
|
2982
2982
|
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
@@ -2988,11 +2988,11 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
2988
2988
|
return true;
|
|
2989
2989
|
}
|
|
2990
2990
|
if (live?.type) {
|
|
2991
|
-
if (live.type === "busy" &&
|
|
2991
|
+
if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2992
2992
|
return true;
|
|
2993
2993
|
return false;
|
|
2994
2994
|
}
|
|
2995
|
-
if (
|
|
2995
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2996
2996
|
return true;
|
|
2997
2997
|
return cachedIdleAfterRun;
|
|
2998
2998
|
}
|
|
@@ -3015,7 +3015,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3015
3015
|
});
|
|
3016
3016
|
return true;
|
|
3017
3017
|
}
|
|
3018
|
-
async function sessionStatusType(client, sessionID, directory,
|
|
3018
|
+
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
3019
3019
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
3020
3020
|
markSessionStatus(sessionID, "busy");
|
|
3021
3021
|
return "busy";
|
|
@@ -3035,7 +3035,7 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3035
3035
|
if (live?.type) {
|
|
3036
3036
|
if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt))
|
|
3037
3037
|
return "idle";
|
|
3038
|
-
if ((live.type === "busy" || live.type === "retry") &&
|
|
3038
|
+
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
3039
3039
|
const active = activeRuns.get(sessionID);
|
|
3040
3040
|
if (active) {
|
|
3041
3041
|
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
@@ -3060,8 +3060,8 @@ function createSessionStatusRuntime(options = {}) {
|
|
|
3060
3060
|
markSessionStatus(sessionID, fallback);
|
|
3061
3061
|
return fallback;
|
|
3062
3062
|
}
|
|
3063
|
-
async function sessionIsIdle(client, sessionID, directory,
|
|
3064
|
-
return await sessionStatusType(client, sessionID, directory,
|
|
3063
|
+
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
3064
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle";
|
|
3065
3065
|
}
|
|
3066
3066
|
return {
|
|
3067
3067
|
markSessionStatus,
|
|
@@ -3247,8 +3247,8 @@ function createActionDispatcher(options = {}) {
|
|
|
3247
3247
|
const shellBody = { command, agent };
|
|
3248
3248
|
if (model)
|
|
3249
3249
|
shellBody.model = model;
|
|
3250
|
-
const
|
|
3251
|
-
return { startsAssistantTurn: true, dispatch
|
|
3250
|
+
const dispatch = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
|
|
3251
|
+
return { startsAssistantTurn: true, dispatch };
|
|
3252
3252
|
}
|
|
3253
3253
|
const prompt = await buildPrompt(directory, job);
|
|
3254
3254
|
const prefix = kind === "goal" ? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real." : "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.";
|
|
@@ -3711,7 +3711,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3711
3711
|
toast: toast2,
|
|
3712
3712
|
dangerousShell: dangerousShell2
|
|
3713
3713
|
});
|
|
3714
|
-
const
|
|
3714
|
+
const dueJobs = admissionRuntime.dueJobs;
|
|
3715
3715
|
function clearActiveRun(sessionID) {
|
|
3716
3716
|
const active = activeRuns.get(sessionID);
|
|
3717
3717
|
if (active?.timer)
|
|
@@ -3934,7 +3934,7 @@ function createLoopExecutor(options = {}) {
|
|
|
3934
3934
|
candidate.watchTriggered = true;
|
|
3935
3935
|
}
|
|
3936
3936
|
}
|
|
3937
|
-
const due =
|
|
3937
|
+
const due = dueJobs(state, Boolean(runOptions.force));
|
|
3938
3938
|
if (!due.length) {
|
|
3939
3939
|
await writeState2(directory, sessionID, state);
|
|
3940
3940
|
await reschedule();
|
|
@@ -4063,7 +4063,7 @@ function createLoopExecutor(options = {}) {
|
|
|
4063
4063
|
}
|
|
4064
4064
|
}
|
|
4065
4065
|
return {
|
|
4066
|
-
dueJobs
|
|
4066
|
+
dueJobs,
|
|
4067
4067
|
clearActiveRun,
|
|
4068
4068
|
disposeSession,
|
|
4069
4069
|
recoverActiveDispatchFailure,
|
|
@@ -4400,47 +4400,73 @@ function steeringToolRejection(sessionID) {
|
|
|
4400
4400
|
output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged."
|
|
4401
4401
|
};
|
|
4402
4402
|
}
|
|
4403
|
+
async function dedicatedGoalToolRejection(directory, sessionID) {
|
|
4404
|
+
const goal = await findDedicatedGoalForSession(directory, sessionID);
|
|
4405
|
+
if (!goal || goal.status === "completed")
|
|
4406
|
+
return;
|
|
4407
|
+
return {
|
|
4408
|
+
title: "Dedicated Goal owns lifecycle",
|
|
4409
|
+
output: [
|
|
4410
|
+
`A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
|
|
4411
|
+
"The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
|
|
4412
|
+
"Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion."
|
|
4413
|
+
].join(`
|
|
4414
|
+
`)
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4403
4417
|
function goalTools(defaultDirectory) {
|
|
4404
4418
|
return {
|
|
4405
4419
|
opencode_loop_goal_complete: tool({
|
|
4406
|
-
description: "
|
|
4420
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
4407
4421
|
args: {
|
|
4408
4422
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
4409
4423
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
4410
4424
|
},
|
|
4411
4425
|
execute: async (args, context) => {
|
|
4426
|
+
const directory = context.directory || defaultDirectory;
|
|
4427
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4428
|
+
if (dedicated)
|
|
4429
|
+
return dedicated;
|
|
4412
4430
|
const steering = steeringToolRejection(context.sessionID);
|
|
4413
4431
|
if (steering)
|
|
4414
4432
|
return steering;
|
|
4415
|
-
const result = await setGoalComplete(
|
|
4433
|
+
const result = await setGoalComplete(directory, context.sessionID, args);
|
|
4416
4434
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
|
|
4417
4435
|
}
|
|
4418
4436
|
}),
|
|
4419
4437
|
opencode_loop_goal_blocked: tool({
|
|
4420
|
-
description: "
|
|
4438
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
|
|
4421
4439
|
args: {
|
|
4422
4440
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
4423
4441
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
4424
4442
|
},
|
|
4425
4443
|
execute: async (args, context) => {
|
|
4444
|
+
const directory = context.directory || defaultDirectory;
|
|
4445
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4446
|
+
if (dedicated)
|
|
4447
|
+
return dedicated;
|
|
4426
4448
|
const steering = steeringToolRejection(context.sessionID);
|
|
4427
4449
|
if (steering)
|
|
4428
4450
|
return steering;
|
|
4429
|
-
const result = await setGoalBlocked(
|
|
4451
|
+
const result = await setGoalBlocked(directory, context.sessionID, args);
|
|
4430
4452
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
|
|
4431
4453
|
}
|
|
4432
4454
|
}),
|
|
4433
4455
|
opencode_loop_goal_progress: tool({
|
|
4434
|
-
description: "
|
|
4456
|
+
description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
|
|
4435
4457
|
args: {
|
|
4436
4458
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
4437
4459
|
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
4438
4460
|
},
|
|
4439
4461
|
execute: async (args, context) => {
|
|
4462
|
+
const directory = context.directory || defaultDirectory;
|
|
4463
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID);
|
|
4464
|
+
if (dedicated)
|
|
4465
|
+
return dedicated;
|
|
4440
4466
|
const steering = steeringToolRejection(context.sessionID);
|
|
4441
4467
|
if (steering)
|
|
4442
4468
|
return steering;
|
|
4443
|
-
const result = await setGoalProgress(
|
|
4469
|
+
const result = await setGoalProgress(directory, context.sessionID, args);
|
|
4444
4470
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
|
|
4445
4471
|
}
|
|
4446
4472
|
})
|
|
@@ -4507,8 +4533,8 @@ var DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
|
|
|
4507
4533
|
function sessionKey(sessionID) {
|
|
4508
4534
|
return String(sessionID || "").trim();
|
|
4509
4535
|
}
|
|
4510
|
-
function createSessionRegistry({ now
|
|
4511
|
-
if (typeof
|
|
4536
|
+
function createSessionRegistry({ now = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
4537
|
+
if (typeof now !== "function")
|
|
4512
4538
|
throw new TypeError("session registry requires a clock function");
|
|
4513
4539
|
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0)
|
|
4514
4540
|
throw new TypeError("session registry requires a non-negative staleAfterMs");
|
|
@@ -4517,7 +4543,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4517
4543
|
const key = sessionKey(sessionID);
|
|
4518
4544
|
if (!key)
|
|
4519
4545
|
throw new TypeError("session registry requires a session ID");
|
|
4520
|
-
const seenAt = Number(
|
|
4546
|
+
const seenAt = Number(now());
|
|
4521
4547
|
if (!Number.isFinite(seenAt))
|
|
4522
4548
|
throw new TypeError("session registry clock must return a finite number");
|
|
4523
4549
|
const entry = Object.freeze({ sessionID: key, runtime, seenAt });
|
|
@@ -4536,7 +4562,7 @@ function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SE
|
|
|
4536
4562
|
return false;
|
|
4537
4563
|
return sessions.delete(key);
|
|
4538
4564
|
}
|
|
4539
|
-
function pruneStale(at =
|
|
4565
|
+
function pruneStale(at = now()) {
|
|
4540
4566
|
const timestamp = Number(at);
|
|
4541
4567
|
if (!Number.isFinite(timestamp))
|
|
4542
4568
|
throw new TypeError("session registry prune time must be finite");
|
|
@@ -4620,7 +4646,7 @@ function createRuntimeScope() {
|
|
|
4620
4646
|
}
|
|
4621
4647
|
|
|
4622
4648
|
// src/source/runtime/timers.js
|
|
4623
|
-
function createOwnedTimer(scope, callback,
|
|
4649
|
+
function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
|
|
4624
4650
|
if (!scope?.isActive?.())
|
|
4625
4651
|
return;
|
|
4626
4652
|
let active = true;
|
|
@@ -4635,7 +4661,7 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4635
4661
|
}
|
|
4636
4662
|
callback(...args);
|
|
4637
4663
|
});
|
|
4638
|
-
handle = repeat ? api.setInterval(invoke,
|
|
4664
|
+
handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay);
|
|
4639
4665
|
release = scope.track(() => {
|
|
4640
4666
|
if (repeat)
|
|
4641
4667
|
api.clearInterval(handle);
|
|
@@ -4661,11 +4687,11 @@ function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
|
4661
4687
|
}
|
|
4662
4688
|
function createRuntimeTimers(scope, api = globalThis) {
|
|
4663
4689
|
return Object.freeze({
|
|
4664
|
-
timeout(callback,
|
|
4665
|
-
return createOwnedTimer(scope, callback,
|
|
4690
|
+
timeout(callback, delay, options = {}) {
|
|
4691
|
+
return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false);
|
|
4666
4692
|
},
|
|
4667
|
-
interval(callback,
|
|
4668
|
-
return createOwnedTimer(scope, callback,
|
|
4693
|
+
interval(callback, delay, options = {}) {
|
|
4694
|
+
return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false);
|
|
4669
4695
|
}
|
|
4670
4696
|
});
|
|
4671
4697
|
}
|
|
@@ -4690,14 +4716,14 @@ function validateRuntime(runtime, sessionID) {
|
|
|
4690
4716
|
return runtime;
|
|
4691
4717
|
}
|
|
4692
4718
|
function createSessionRuntimeManager({
|
|
4693
|
-
now
|
|
4719
|
+
now = Date.now,
|
|
4694
4720
|
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
4695
4721
|
timerAPI = globalThis,
|
|
4696
4722
|
runtimeFactory = defaultRuntimeFactory
|
|
4697
4723
|
} = {}) {
|
|
4698
4724
|
if (typeof runtimeFactory !== "function")
|
|
4699
4725
|
throw new TypeError("session runtime manager requires a runtime factory");
|
|
4700
|
-
const registry = createSessionRegistry({ now
|
|
4726
|
+
const registry = createSessionRegistry({ now, staleAfterMs });
|
|
4701
4727
|
let disposed = false;
|
|
4702
4728
|
function observeExternal(sessionID) {
|
|
4703
4729
|
if (disposed)
|
|
@@ -4727,7 +4753,7 @@ function createSessionRuntimeManager({
|
|
|
4727
4753
|
current.runtime.dispose(reason);
|
|
4728
4754
|
return true;
|
|
4729
4755
|
}
|
|
4730
|
-
function pruneStale(at =
|
|
4756
|
+
function pruneStale(at = now()) {
|
|
4731
4757
|
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]));
|
|
4732
4758
|
const removed = registry.pruneStale(at);
|
|
4733
4759
|
const errors = [];
|
|
@@ -4928,12 +4954,12 @@ function scopedClient(client) {
|
|
|
4928
4954
|
});
|
|
4929
4955
|
}
|
|
4930
4956
|
function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
|
|
4931
|
-
for (const
|
|
4957
|
+
for (const delay of [0, 25, 100, 500, 2000, 1e4, 30000]) {
|
|
4932
4958
|
const timer = setTimeout(() => {
|
|
4933
4959
|
if (!isCurrentGeneration())
|
|
4934
4960
|
return;
|
|
4935
4961
|
Promise.resolve(dispose()).catch(() => {});
|
|
4936
|
-
},
|
|
4962
|
+
}, delay);
|
|
4937
4963
|
timer.unref?.();
|
|
4938
4964
|
}
|
|
4939
4965
|
}
|
package/src/source/legacy-v1.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createGoalExecutionPolicy } from "./runtime/goal-policy.js"
|
|
|
17
17
|
import { createJobWorkspaceRuntime, dangerousShell } from "./runtime/job-workspace.js"
|
|
18
18
|
import { createLoopExecutor } from "./runtime/executor.js"
|
|
19
19
|
import { createGoalSteeringRuntime } from "./runtime/goal-steering.js"
|
|
20
|
+
import { findDedicatedGoalForSession } from "./runtime/companion-goal.js"
|
|
20
21
|
|
|
21
22
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
22
23
|
|
|
@@ -162,44 +163,66 @@ function steeringToolRejection(sessionID) {
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
async function dedicatedGoalToolRejection(directory, sessionID) {
|
|
167
|
+
const goal = await findDedicatedGoalForSession(directory, sessionID)
|
|
168
|
+
if (!goal || goal.status === "completed") return undefined
|
|
169
|
+
return {
|
|
170
|
+
title: "Dedicated Goal owns lifecycle",
|
|
171
|
+
output: [
|
|
172
|
+
`A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
|
|
173
|
+
"The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
|
|
174
|
+
"Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion.",
|
|
175
|
+
].join("\n"),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
165
179
|
function goalTools(defaultDirectory) {
|
|
166
180
|
return {
|
|
167
181
|
opencode_loop_goal_complete: tool({
|
|
168
|
-
description: "
|
|
182
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
169
183
|
args: {
|
|
170
184
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
171
185
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
172
186
|
},
|
|
173
187
|
execute: async (args, context) => {
|
|
188
|
+
const directory = context.directory || defaultDirectory
|
|
189
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
190
|
+
if (dedicated) return dedicated
|
|
174
191
|
const steering = steeringToolRejection(context.sessionID)
|
|
175
192
|
if (steering) return steering
|
|
176
|
-
const result = await setGoalComplete(
|
|
193
|
+
const result = await setGoalComplete(directory, context.sessionID, args)
|
|
177
194
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
178
195
|
},
|
|
179
196
|
}),
|
|
180
197
|
opencode_loop_goal_blocked: tool({
|
|
181
|
-
description: "
|
|
198
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
|
|
182
199
|
args: {
|
|
183
200
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
184
201
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
185
202
|
},
|
|
186
203
|
execute: async (args, context) => {
|
|
204
|
+
const directory = context.directory || defaultDirectory
|
|
205
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
206
|
+
if (dedicated) return dedicated
|
|
187
207
|
const steering = steeringToolRejection(context.sessionID)
|
|
188
208
|
if (steering) return steering
|
|
189
|
-
const result = await setGoalBlocked(
|
|
209
|
+
const result = await setGoalBlocked(directory, context.sessionID, args)
|
|
190
210
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
191
211
|
},
|
|
192
212
|
}),
|
|
193
213
|
opencode_loop_goal_progress: tool({
|
|
194
|
-
description: "
|
|
214
|
+
description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
|
|
195
215
|
args: {
|
|
196
216
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
197
217
|
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
198
218
|
},
|
|
199
219
|
execute: async (args, context) => {
|
|
220
|
+
const directory = context.directory || defaultDirectory
|
|
221
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
222
|
+
if (dedicated) return dedicated
|
|
200
223
|
const steering = steeringToolRejection(context.sessionID)
|
|
201
224
|
if (steering) return steering
|
|
202
|
-
const result = await setGoalProgress(
|
|
225
|
+
const result = await setGoalProgress(directory, context.sessionID, args)
|
|
203
226
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
204
227
|
},
|
|
205
228
|
}),
|