OpenCode plugin that keeps sessions alive: emits liveness heartbeats for external watchdogs and detects (and optionally recovers) stalled agents.
opencode-keepalive is a "watchdog" plugin for OpenCode. It monitors your agent's activity and helps ensure work doesn't grind to a halt without you knowing.
- Liveness Log: Emits continuous JSON telemetry to a background log file.
- Stall Detection: Automatically detects when an agent is "busy" but has stopped making progress.
- Auto-Recovery: Can automatically unstick stalled subagents by aborting them and nudging the parent agent to resume.
npm install opencode-keepaliveEnable it in your opencode.json (or opencode.jsonc):
{
"plugins": [
["opencode-keepalive", {
"childStallAction": "abort-resume-parent"
}]
]
}By default, the plugin works out of the box with zero configuration. It appends NDJSON (Newline Delimited JSON) heartbeats to:
~/.local/state/opencode-keepalive/heartbeat.ndjson
# or $XDG_STATE_HOME/opencode-keepalive/heartbeat.ndjsonYou can view the pulse of your agent in real-time:
tail -f ~/.local/state/opencode-keepalive/heartbeat.ndjson | jq .1. Pipe to an external watchdog If you are using a process manager like Harbor that expects heartbeats on stdout:
["opencode-keepalive", { "output": "stdout" }]2. Enable automatic subagent recovery To have the plugin automatically abort hung subagents and ask the parent to retry:
["opencode-keepalive", { "childStallAction": "abort-resume-parent" }]3. Silence all output To keep the stall detection logic running but stop writing to any log:
["opencode-keepalive", { "output": "none" }]| Option | Type | Default | Description |
|---|---|---|---|
intervalMs |
number |
10000 |
Interval between standard heartbeats (ms). |
includeDetails |
boolean |
true |
Include event types, tool names, and stall details. |
includeSessionID |
boolean |
true |
Include session IDs in heartbeats. |
stallMs |
number |
120000 |
Duration of no-progress before triggering a stall (ms). |
childStallAction |
string |
"report" |
Action for stalled children: "report" or "abort-resume-parent". |
maxStallRecoveries |
number |
3 |
Max recovery attempts per child session. |
abortWaitMs |
number |
30000 |
Wait time after abort before nudging parent (ms). |
toolStallMsByTool |
object |
{...} |
Per-tool stall budgets (e.g., {"glob": 60000}). |
bashQuickStallMs |
number |
60000 |
Stall budget for "quick" bash commands (ls, cat, etc.). |
resumeMessageTemplate |
string |
... |
Template used for parent nudge messages. |
output |
string |
"file" |
Where to write: "file", "stdout", "stderr", or "none". |
logFile |
string |
... |
Custom path when output is "file". |
- No heartbeats appearing? Check the
logFilepath. If you are on a non-standard system, ensureXDG_STATE_HOMEis set or provide an explicitlogFilepath. - False positive stalls? If a long command (like a large
git clone) is stalling, it might be classified incorrectly. You can unmap bash stall budgets by settingtoolStallMsByTool: { "bash": null }. - Too much log noise? Increase
intervalMsto reduce the frequency of keepalive pulses.
The plugin emits envelopes with type: "heartbeat". The behavior is driven by 7 distinct triggers:
| Trigger | When it fires |
|---|---|
event |
Any OpenCode event (except other heartbeats). |
idle |
Periodic pulse when the system is completely idle. |
tool.execute.before |
Immediately before a tool starts running. |
tool.execute.after |
Immediately after a tool finishes. |
tool.running |
Periodic "keepalive" pulse while a tool is in-flight. |
stall |
Fired when the stall budget is exceeded. |
stall.recovery |
Fired during an automatic recovery attempt (or error). |
Example Envelope:
{
"type": "heartbeat",
"properties": {
"source": "opencode-keepalive",
"timestamp": "2026-07-28T12:00:00.000Z",
"unixMs": 1785240000000,
"trigger": "stall",
"sessionID": "sess_123",
"parentID": "sess_000",
"stalledForMs": 120000
}
}- Throttling: Standard events are batched. If an event occurs within
intervalMsof the last heartbeat, it is held in a "pending" slot and flushed later.stallandstall.recoverytriggers bypass this throttle for immediate visibility. - Keepalive Timer: While a tool is active, a dedicated timer re-emits the last
tool.runningsignal at theintervalMsfrequency. Crucially, these keepalives do NOT reset the stall clock. - Backpressure: When writing to
stdoutorstderr, the plugin respects stream drain signals to avoid memory bloat during heavy event volume.
A session is considered stalled if its status is busy but no progress has been noted for the duration of its effective budget.
- Progress is: Message updates (
part.updated,part.delta), tool lifecycle events (before,after), or command events. - Progress is NOT: Internal heartbeats or
tool.runningkeepalives.
The plugin uses a "strictest fuse" rule:
- No tools in flight: Uses global
stallMs. - Mapped tools in flight: If tools like
globorgrepare running, the minimum of their mapped budgets is used (default 60s). - Unmapped tools only: If only unmapped tools (like a long
npm install) are running, the short-fuse timer is disabled entirely to avoid false positives.
The bash tool is special. The plugin inspects the first segment of the command to classify it:
- Quick:
cat,ls,grep,jq,mkdir,rm, etc. These usebashQuickStallMs. - Long:
npm,docker,git,python,tsc,vitest, etc. These are unmapped by default. - Unknown: Fail-safe to Long.
When childStallAction is "abort-resume-parent":
stall.recoveryis emitted.- The plugin calls
session.abort(childID). - It waits for the child to become idle (up to
abortWaitMs). - It sends a synthetic prompt to the parent session with the
task_idof the stalled child, allowing the parent to naturally resume the work.
| Module | Responsibility |
|---|---|
index.ts |
Entry point; wires options to state and hooks. |
hooks.ts |
OpenCode lifecycle hooks; translates events to heartbeats/progress. |
heartbeat.ts |
The core emission engine, throttler, and timers. |
sessions.ts |
State tracking for active sessions and progress timestamps. |
tools.ts |
Tool name normalization and effective budget calculation. |
bash-command.ts |
Logic for classifying bash commands as quick vs long. |
recover.ts |
Implementation of the abort-resume-parent sequence. |
state.ts |
Runtime state and IO abstraction (the test seam). |
- Unmapped Silence: If an agent is running an unmapped tool (like
sleep 300) and the model is silent, the plugin will not stall. It relies on the external worker wall timeout. - Waiting for User: When an agent is blocked on a
permission.askdialog, the stall timer is automatically disarmed to prevent "stalling" while waiting for you.