Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal-or-session-id> --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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions scripts/loopd-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading