From d7fc47b3fa4bfb2385fe9a615ca48a3ee3f144c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:46:24 +0300 Subject: [PATCH 1/6] Add read-only Goal pulse-check runtime --- scripts/pulse-check.mjs | 244 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 scripts/pulse-check.mjs diff --git a/scripts/pulse-check.mjs b/scripts/pulse-check.mjs new file mode 100644 index 0000000..e7ccae9 --- /dev/null +++ b/scripts/pulse-check.mjs @@ -0,0 +1,244 @@ +import { promises as fs } from "node:fs" +import path from "node:path" +import { dedicatedGoalOwnsContinuation } from "../src/source/runtime/companion-goal.js" + +const LOCK_STALE_MS = 30_000 +const LOCK_RETRY_MS = 25 +const LOCK_RETRIES = 80 + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function parsePulseDurationMs(value) { + const text = String(value ?? "").trim().toLowerCase() + if (text === "0" || text === "0s" || text === "now") return 0 + const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/) + if (!match) throw new Error(`Invalid duration: ${value}`) + const amount = Number(match[1]) + const unit = match[2] + if (unit === "ms") return amount + if (unit.startsWith("s")) return amount * 1000 + if (unit.startsWith("m")) return amount * 60_000 + if (unit.startsWith("h")) return amount * 3_600_000 + if (unit.startsWith("d")) return amount * 86_400_000 + return amount +} + +function pulseDirectory(project) { + return path.join(project, ".opencode", "opencode-loop") +} + +function pulseStateFile(project) { + return path.join(pulseDirectory(project), "pulse-check.json") +} + +function pulseLockDirectory(project) { + return path.join(pulseDirectory(project), "pulse-check.lock") +} + +function goalDirectory(project) { + return path.join(project, ".opencode", "goals") +} + +function timestamp(value) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined +} + +function revision(value) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 +} + +function goalFingerprint(goal) { + return [ + String(goal?.status || "unknown"), + timestamp(goal?.updatedAt) ?? 0, + revision(goal?.progressRevision), + revision(goal?.revision), + revision(goal?.storageGeneration), + ].join(":") +} + +function formatAge(ms) { + if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s` + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m` + if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(ms < 36_000_000 ? 1 : 0)}h` + return `${(ms / 86_400_000).toFixed(ms < 864_000_000 ? 1 : 0)}d` +} + +function iso(value) { + const parsed = timestamp(value) + if (parsed === undefined) return "unknown" + try { return new Date(parsed).toISOString() } catch { return "unknown" } +} + +async function readJson(file, fallback) { + try { + return JSON.parse(await fs.readFile(file, "utf8")) + } catch (error) { + if (error?.code === "ENOENT") return fallback + throw error + } +} + +async function writeJsonAtomic(file, value) { + await fs.mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${process.pid}.${Date.now()}.tmp` + await fs.writeFile(temporary, JSON.stringify(value, null, 2) + "\n", "utf8") + try { + await fs.rename(temporary, file) + } catch (error) { + try { await fs.rm(temporary, { force: true }) } catch {} + throw error + } +} + +async function acquireLock(project) { + const directory = pulseDirectory(project) + const lock = pulseLockDirectory(project) + await fs.mkdir(directory, { recursive: true }) + + for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) { + try { + await fs.mkdir(lock) + return async () => { + try { await fs.rm(lock, { recursive: true, force: true }) } catch {} + } + } catch (error) { + if (error?.code !== "EEXIST") throw error + try { + const stat = await fs.stat(lock) + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + await fs.rm(lock, { recursive: true, force: true }) + continue + } + } catch (statError) { + if (statError?.code !== "ENOENT") throw statError + } + await sleep(LOCK_RETRY_MS) + } + } + + throw new Error("Goal pulse-check state is busy; another checker still holds the lock") +} + +async function readGoalSnapshots(project, target) { + let names + try { + names = await fs.readdir(goalDirectory(project)) + } catch (error) { + if (error?.code === "ENOENT") return [] + throw error + } + + const goals = [] + for (const name of names.sort()) { + if (!name.endsWith(".json")) continue + try { + const goal = JSON.parse(await fs.readFile(path.join(goalDirectory(project), name), "utf8")) + if (!goal?.id || !goal?.sessionID) continue + if (target && goal.id !== target && goal.sessionID !== target) continue + goals.push(goal) + } catch {} + } + return goals +} + +export async function checkGoalPulse(project, options = {}) { + const staleAfterMs = Number(options.staleAfterMs) + if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) throw new Error("staleAfterMs must be a non-negative number") + const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now() + const target = options.target ? String(options.target) : undefined + const sessionActivity = options.sessionActivity && typeof options.sessionActivity === "object" ? options.sessionActivity : {} + const release = await acquireLock(project) + + try { + const goals = await readGoalSnapshots(project, target) + const stateFile = pulseStateFile(project) + const state = await readJson(stateFile, { schemaVersion: 1, goals: {} }) + if (!state.goals || typeof state.goals !== "object") state.goals = {} + + const alerts = [] + for (const goal of goals) { + const id = String(goal.id) + const status = String(goal.status || "unknown") + const progressRevision = revision(goal.progressRevision) + const goalUpdatedAt = timestamp(goal.updatedAt) ?? now + const fingerprint = goalFingerprint(goal) + const previous = state.goals[id] + const changed = !previous || previous.fingerprint !== fingerprint + const statusChanged = Boolean(previous && previous.status !== status) + const progressChanged = Boolean(previous && Number(previous.progressRevision || 0) !== progressRevision) + const updatedAdvanced = Boolean(previous && goalUpdatedAt > Number(previous.goalUpdatedAt || 0)) + + let lastActivityAt + if (!previous) { + lastActivityAt = goalUpdatedAt + } else if (!changed) { + lastActivityAt = Number(previous.lastActivityAt || goalUpdatedAt) + } else if (updatedAdvanced) { + lastActivityAt = goalUpdatedAt + } else if (statusChanged || progressChanged || Number(previous.revision || 0) !== revision(goal.revision)) { + lastActivityAt = now + } else { + lastActivityAt = Math.max(Number(previous.lastActivityAt || 0), goalUpdatedAt) + } + + let alerted = changed ? false : Boolean(previous?.alerted) + const active = dedicatedGoalOwnsContinuation(goal) + const ageMs = Math.max(0, now - lastActivityAt) + const sessionUpdatedAt = timestamp(sessionActivity[goal.sessionID]) + + if (!active) { + alerted = false + } else if (ageMs >= staleAfterMs && !alerted) { + alerted = true + const message = [ + "[opencode-loopd] GOAL PULSE STALLED", + `goal=${id}`, + `session=${goal.sessionID}`, + `stale=${formatAge(ageMs)}`, + `goalUpdated=${iso(goalUpdatedAt)}`, + sessionUpdatedAt === undefined ? undefined : `sessionUpdated=${iso(sessionUpdatedAt)}`, + 'action="/goal resume"', + ].filter(Boolean).join(" ") + alerts.push({ + goalID: id, + sessionID: String(goal.sessionID), + ageMs, + goalUpdatedAt, + sessionUpdatedAt, + progressRevision, + message, + }) + } else if (ageMs < staleAfterMs) { + alerted = false + } + + state.goals[id] = { + fingerprint, + status, + progressRevision, + revision: revision(goal.revision), + goalUpdatedAt, + lastActivityAt, + alerted, + ...(alerted ? { lastAlertAt: alerts.find((item) => item.goalID === id)?.goalID ? now : previous?.lastAlertAt } : {}), + sessionID: String(goal.sessionID), + } + } + + state.schemaVersion = 1 + state.updatedAt = now + await writeJsonAtomic(stateFile, state) + return { + alerts, + checked: goals.length, + stateFile, + } + } finally { + await release() + } +} From ed40bc2470ce3d9c324499251d3b83523f86db72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:46:53 +0300 Subject: [PATCH 2/6] Expose Goal pulse-check through loopd --- scripts/loopd.mjs | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/scripts/loopd.mjs b/scripts/loopd.mjs index 18558de..099e11c 100644 --- a/scripts/loopd.mjs +++ b/scripts/loopd.mjs @@ -5,6 +5,7 @@ import { spawn, spawnSync } from "node:child_process" import { createHash } from "node:crypto" import { homedir } from "node:os" import { fileURLToPath } from "node:url" +import { checkGoalPulse, parsePulseDurationMs } from "./pulse-check.mjs" const args = process.argv.slice(2) const configuredRetryMs = Number(process.env.OPENCODE_LOOPD_FAILED_RUN_RETRY_MS) @@ -51,6 +52,14 @@ function parseMaxRuns(value) { return parsed } +function parseMaxChecks(value) { + const text = String(value ?? "0").trim() + if (!/^\d+$/.test(text)) throw new Error(`Invalid --max-checks value: ${value}`) + const parsed = Number(text) + if (!Number.isSafeInteger(parsed)) throw new Error(`Invalid --max-checks value: ${value}`) + return parsed +} + function validateProject(project) { let stat try { stat = fs.statSync(project) } catch { throw new Error(`Project directory does not exist: ${project}`) } @@ -162,6 +171,21 @@ async function resolveLatestSessionID(opencodeBin, project, preferredTitle) { return typeof match?.id === "string" && match.id ? match.id : undefined } +async function resolveSessionActivity(opencodeBin, project) { + const result = await run(opencodeBin, ["session", "list", "--format", "json", "-n", "100"], project, { capture: true, timeoutMs: 15_000 }) + if (result.code !== 0 || result.timedOut) return {} + let parsed + try { parsed = JSON.parse(result.stdout || "[]") } catch { return {} } + const sessions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.data) ? parsed.data : [] + const activity = {} + for (const session of sessions) { + const id = typeof session?.id === "string" ? session.id : "" + const updated = Number(session?.updated ?? session?.time?.updated) + if (id && Number.isFinite(updated) && updated >= 0) activity[id] = updated + } + return activity +} + function runSync(command, commandArgs) { const options = { shell: false, stdio: "inherit", windowsHide: true } const direct = spawnSync(command, commandArgs, options) @@ -258,6 +282,44 @@ async function daemon(options = {}) { } } +async function pulseCheck(options = {}) { + const project = path.resolve(options.project ?? arg("--project", process.cwd())) + validateProject(project) + const staleAfterText = options.staleAfter ?? arg("--stale-after", "30m") + const staleAfterMs = parsePulseDurationMs(staleAfterText) + const every = options.every ?? arg("--every", "0s") + const delay = parseMs(every) + const maxChecks = parseMaxChecks(options.maxChecks ?? arg("--max-checks", "0")) + const target = options.target ?? arg("--goal") + const opencodeBin = options.opencodeBin || OPENCODE_BIN + + console.log("OpenCode Goal pulse-check") + console.log(`project: ${project}`) + console.log(`stale-after: ${staleAfterText}`) + console.log(`every: ${delay > 0 ? every : "one-shot"}`) + if (target) console.log(`goal: ${target}`) + + let checks = 0 + while (true) { + checks += 1 + const sessionActivity = await resolveSessionActivity(opencodeBin, project) + const result = await checkGoalPulse(project, { + staleAfterMs, + ...(target ? { target } : {}), + sessionActivity, + }) + + for (const alert of result.alerts) console.log(alert.message) + if (checks === 1) console.log(`state: ${result.stateFile}`) + if (target && checks === 1 && result.checked === 0) { + console.log(`[opencode-loopd] no dedicated Goal matched target=${target}`) + } + + if (delay <= 0 || (maxChecks > 0 && checks >= maxChecks)) return 0 + await sleep(delay) + } +} + function taskArtifacts(name) { const id = createHash("sha256").update(String(name || "OpenCodeLoop")).digest("hex").slice(0, 16) return { @@ -354,6 +416,8 @@ OpenCode Loop daemon Usage: opencode-loopd --project . --every 5m --prompt-file loop-prompt.md opencode-loopd --project . --every 0s --prompt "continue from progress.md" + opencode-loopd pulse-check --project . --stale-after 30m + opencode-loopd pulse-check --project . --stale-after 30m --every 1m opencode-loopd install-task --project . --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop opencode-loopd uninstall-task --name OpenCodeLoop @@ -368,6 +432,12 @@ Options: --timeout Max time per OpenCode run (default: 30m, 0s disables) --max-runs Stop after n runs --sleep-first Wait before first run + +Pulse-check options: + --stale-after Alert when an active dedicated Goal has not changed (default: 30m) + --goal Watch one Goal by Goal ID or session ID (default: all dedicated Goals) + --every Repeat checks at this cadence; 0s is one-shot (default: 0s) + --max-checks Stop a repeating pulse-check after n checks `) } @@ -378,6 +448,9 @@ try { if (command === "daemon" || command === "loopd") { args.shift() exitCode = await daemon() + } else if (command === "pulse-check") { + args.shift() + exitCode = await pulseCheck() } else if (command === "install-task") { args.shift() exitCode = installTask() From cbb3c25c068eb5aac08a42c46d148a45d62cca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:47:21 +0300 Subject: [PATCH 3/6] Cover Goal pulse-check stall episodes --- scripts/loopd-test.mjs | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/scripts/loopd-test.mjs b/scripts/loopd-test.mjs index aba8fb6..69bc770 100644 --- a/scripts/loopd-test.mjs +++ b/scripts/loopd-test.mjs @@ -146,6 +146,73 @@ try { }) assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code") + const goalsDir = path.join(project, ".opencode", "goals") + await fs.mkdir(goalsDir, { recursive: true }) + const goalFile = path.join(goalsDir, "goal-stale.json") + const staleGoal = { + schemaVersion: 1, + id: "goal-stale", + sessionID: "ses_goal", + status: "active", + progressRevision: 4, + revision: 1, + storageGeneration: 1, + updatedAt: Date.now() - 60 * 60_000, + } + await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8") + const originalGoal = await fs.readFile(goalFile, "utf8") + const pulseEnv = { + OPENCODE_BIN: fakeOpenCode.command, + FAKE_COMMAND_LOG: path.join(temporaryRoot, "pulse-opencode.jsonl"), + FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_goal", title: "Goal", updated: Date.now() - 1_000 }]), + } + + result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv) + assert.equal(result.code, 0, result.stderr) + assert.match(result.stdout, /GOAL PULSE STALLED/) + assert.match(result.stdout, /goal=goal-stale/) + assert.match(result.stdout, /session=ses_goal/) + assert.match(result.stdout, /sessionUpdated=/) + assert.match(result.stdout, /action="\/goal resume"/) + assert.equal(await fs.readFile(goalFile, "utf8"), originalGoal, "pulse-check must never write dedicated Goal state") + + result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv) + assert.equal(result.code, 0, result.stderr) + assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "one stall episode must alert only once") + + staleGoal.status = "waiting_user" + staleGoal.storageGeneration += 1 + staleGoal.updatedAt = Date.now() - 30 * 60_000 + await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8") + result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv) + assert.equal(result.code, 0, result.stderr) + assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "waiting_user must suppress pulse alerts") + + staleGoal.status = "active" + staleGoal.progressRevision += 1 + staleGoal.storageGeneration += 1 + staleGoal.updatedAt = Date.now() - 11 * 60_000 + await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8") + result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv) + assert.equal(result.code, 0, result.stderr) + assert.match(result.stdout, /GOAL PULSE STALLED/, "new Goal activity/status must re-arm a later stall episode") + + const pulseState = path.join(project, ".opencode", "opencode-loop", "pulse-check.json") + await fs.rm(pulseState, { force: true }) + staleGoal.updatedAt = Date.now() - 60 * 60_000 + staleGoal.storageGeneration += 1 + await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8") + const concurrent = await Promise.all([ + runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv), + runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv), + ]) + assert.ok(concurrent.every((item) => item.code === 0), concurrent.map((item) => item.stderr).join("\n")) + const concurrentAlerts = concurrent.reduce((count, item) => count + (item.stdout.match(/GOAL PULSE STALLED/g) || []).length, 0) + assert.equal(concurrentAlerts, 1, "concurrent one-shot checks must share episode state and emit at most one alert") + + const pulseCalls = await readLog(pulseEnv.FAKE_COMMAND_LOG) + assert.ok(pulseCalls.every((item) => item.args[0] === "session"), "pulse-check may inspect session telemetry but must never dispatch an OpenCode run") + result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], { OPENCODE_BIN: fakeOpenCode.command, FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"), From 3e7728ff8068884a0938d0915b4ddc0e88fa17ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:47:33 +0300 Subject: [PATCH 4/6] Check pulse-check runtime syntax --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f7dbead..453f954 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/index.js && node --check src/server.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", "test": "node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", "install:global": "node scripts/install-with-goals.mjs", From 4cb238e26a5272e64ac7d45992d31cf17b869eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:47:54 +0300 Subject: [PATCH 5/6] Document dedicated Goal pulse-check --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 5d1b827..fe64e11 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,25 @@ Pin a session and limit runs: opencode-loopd --project . --session ses_xxx --every 5m --max-runs 20 --timeout 30m --prompt-file loop-prompt.md ``` +### Dedicated Goal pulse-check + +Use the daemon as a read-only stall detector for dedicated `/goal` state: + +```bash +# one-shot (good for systemd timers / cron-style runners) +opencode-loopd pulse-check --project . --stale-after 30m + +# repeating opt-in watcher +opencode-loopd pulse-check --project . --stale-after 30m --every 1m + +# watch one Goal by Goal ID or session ID +opencode-loopd pulse-check --project . --goal --stale-after 30m --every 1m +``` + +The pulse-check reads `.opencode/goals/*.json` and alerts only while the dedicated Goal is `active`. `paused`, `waiting_user`, `blocked`, budget/usage-limited, and completed Goals do not alert. A stall episode is based on durable Goal activity (`updatedAt`, progress/revision changes); OpenCode session `updated` time is included only as corroborating telemetry because it is not reliable enough to be the stall authority on every host version. + +Episode state is stored separately in `.opencode/opencode-loop/pulse-check.json`. The checker never writes Goal state, never dispatches a prompt, never runs `/goal resume`, and therefore does not participate in the prompt-producing Goal-overlap guard. It emits at most one alert per stall episode and re-arms after observed Goal activity or a status change. The alert explicitly names the human recovery action: run `/goal resume` in the Goal's session. + Windows Task Scheduler: ```powershell From c7f138691fec2d4e499d8fc072b7aeb47e94c84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:48:42 +0300 Subject: [PATCH 6/6] Make pulse episode alert state explicit --- scripts/pulse-check.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/pulse-check.mjs b/scripts/pulse-check.mjs index e7ccae9..7204f7a 100644 --- a/scripts/pulse-check.mjs +++ b/scripts/pulse-check.mjs @@ -187,6 +187,7 @@ export async function checkGoalPulse(project, options = {}) { } let alerted = changed ? false : Boolean(previous?.alerted) + let newAlert = false const active = dedicatedGoalOwnsContinuation(goal) const ageMs = Math.max(0, now - lastActivityAt) const sessionUpdatedAt = timestamp(sessionActivity[goal.sessionID]) @@ -195,6 +196,7 @@ export async function checkGoalPulse(project, options = {}) { alerted = false } else if (ageMs >= staleAfterMs && !alerted) { alerted = true + newAlert = true const message = [ "[opencode-loopd] GOAL PULSE STALLED", `goal=${id}`, @@ -225,7 +227,7 @@ export async function checkGoalPulse(project, options = {}) { goalUpdatedAt, lastActivityAt, alerted, - ...(alerted ? { lastAlertAt: alerts.find((item) => item.goalID === id)?.goalID ? now : previous?.lastAlertAt } : {}), + ...(alerted ? { lastAlertAt: newAlert ? now : previous?.lastAlertAt } : {}), sessionID: String(goal.sessionID), } }