@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 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.36`.**
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.36",
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",
@@ -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 [id2, expiresAt] of entry.messageIDs.entries())
929
+ for (const [id, expiresAt] of entry.messageIDs.entries())
930
930
  if (expiresAt < now())
931
- entry.messageIDs.delete(id2);
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 toast2 = requireFunction2(options.toast, "toast");
1621
- const say2 = requireFunction2(options.say, "say");
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 toast2(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
1639
- await say2(client, sessionID, `OpenCode Loop experimental goal status:
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 toast2(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
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 toast2(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
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 toast2(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
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 toast2(client, result.message, result.ok ? "success" : "warning");
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 toast2(client, result.message, "warning");
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 toast2 = requireFunction3(options.toast, "toast");
1934
- const say2 = requireFunction3(options.say, "say");
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 toast2(client, "All loops stopped for this session.", "success");
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 toast2(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
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 toast2(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
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 toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
1983
- await say2(client, sessionID, `OpenCode loop status:
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 say2(client, sessionID, `OpenCode loop logs:
1993
+ await say(client, sessionID, `OpenCode loop logs:
1994
1994
  ` + text);
1995
1995
  }
1996
1996
  async function helpLoop(client, sessionID) {
1997
- await say2(client, sessionID, [
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 toast2(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
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 say2(client, sessionID, lines.join(`
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 toast2(client, `${target} already exists.`, "warning");
2068
+ await toast(client, `${target} already exists.`, "warning");
2069
2069
  return;
2070
2070
  }
2071
2071
  await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
2072
- await toast2(client, `Created ${target}.`, "success");
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 say2(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
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 toast2 = requireFunction4(options.toast, "toast");
2188
- const say2 = requireFunction4(options.say, "say");
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 toast2(client, normalized.error, "warning");
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 toast2(client, parsed.error, "warning");
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 toast2(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");
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 toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
2252
- await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
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 toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
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 now2 = typeof options.now === "function" ? options.now : Date.now;
2404
- const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : async () => {};
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 = now2();
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 appendLoopLog2(directory, "deferred", {
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 toast2 = async (client, message, level) => {
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
- toast2(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
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 delay2 = nextDueDelay(state, clock());
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(delay2)) {
2539
+ if (!hasJobs || !Number.isFinite(delay)) {
2540
2540
  stopWatchdog(sessionID);
2541
2541
  return;
2542
2542
  }
2543
- if (delay2 <= 0)
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 delay2 = nextDueDelay(state, clock());
2561
- if (!Number.isFinite(delay2))
2560
+ const delay = nextDueDelay(state, clock());
2561
+ if (!Number.isFinite(delay))
2562
2562
  return;
2563
- const wait = Math.min(Math.max(delay2, minDelayMs, minDueTimerMs), maxDueTimerMs);
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
- toast2(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
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 runShellCommand2 = requireFunction5(options.runShellCommand, "runShellCommand");
2619
+ const runShellCommand = requireFunction5(options.runShellCommand, "runShellCommand");
2620
2620
  const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
2621
- const toast2 = requireFunction5(options.toast, "toast");
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 toast2(client, job.goalNoProgressReason, "warning");
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 runShellCommand2(command, directory, job.timeoutMs || 300000);
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 toast2(client, "Goal checks passed.", "success");
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 toast2(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
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 toast2 = requireFunction6(options.toast, "toast");
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 toast2(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
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 toast2(client, `Loop checkpoint saved: ${prefix}`, "success");
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, options2 = {}) {
2971
+ async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
2972
2972
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
2973
2973
  return false;
2974
- if (!options2.requireIdle && !options2.forceStale)
2974
+ if (!options.requireIdle && !options.forceStale)
2975
2975
  return true;
2976
- const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2976
+ const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2977
2977
  if (settledAssistantCompletion(completion))
2978
2978
  return true;
2979
- if (!options2.requireIdle)
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" && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2991
+ if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
2992
2992
  return true;
2993
2993
  return false;
2994
2994
  }
2995
- if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
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, options2 = {}) {
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") && options2.recoverStaleActive !== false) {
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, options2 = {}) {
3064
- return await sessionStatusType(client, sessionID, directory, options2) === "idle";
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 dispatch2 = 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: dispatch2 };
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 dueJobs2 = admissionRuntime.dueJobs;
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 = dueJobs2(state, Boolean(runOptions.force));
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: dueJobs2,
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: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
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(context.directory || defaultDirectory, context.sessionID, args);
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: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
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(context.directory || defaultDirectory, context.sessionID, args);
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: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
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(context.directory || defaultDirectory, context.sessionID, args);
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: now2 = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
4511
- if (typeof now2 !== "function")
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(now2());
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 = now2()) {
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, delay2, repeat, api, ref) {
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, delay2) : api.setTimeout(invoke, delay2);
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, delay2, options = {}) {
4665
- return createOwnedTimer(scope, callback, delay2, false, api, options.ref !== false);
4690
+ timeout(callback, delay, options = {}) {
4691
+ return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false);
4666
4692
  },
4667
- interval(callback, delay2, options = {}) {
4668
- return createOwnedTimer(scope, callback, delay2, true, api, options.ref !== false);
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: now2 = Date.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: now2, staleAfterMs });
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 = now2()) {
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 delay2 of [0, 25, 100, 500, 2000, 1e4, 30000]) {
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
- }, delay2);
4962
+ }, delay);
4937
4963
  timer.unref?.();
4938
4964
  }
4939
4965
  }
@@ -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: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
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(context.directory || defaultDirectory, context.sessionID, args)
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: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
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(context.directory || defaultDirectory, context.sessionID, args)
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: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
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(context.directory || defaultDirectory, context.sessionID, args)
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
  }),