●CONNECTORS — Managed MCP connectors gain connector observability in public beta for adoption, errors, latency, and usage●DIRECTORY — Admins can now submit MCP connectors to the directory directly from Claude●SLACK — Team and Enterprise users can tag Claude in Slack to delegate tasks●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●MODEL — Claude Sonnet 5 is the default across all plans at $2/$10 per million tokens through August 31●LIMITS — Claude Code weekly usage limits are up 50% through July 13; Claude Science applications close July 15●CONNECTORS — Managed MCP connectors gain connector observability in public beta for adoption, errors, latency, and usage●DIRECTORY — Admins can now submit MCP connectors to the directory directly from Claude●SLACK — Team and Enterprise users can tag Claude in Slack to delegate tasks●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●MODEL — Claude Sonnet 5 is the default across all plans at $2/$10 per million tokens through August 31●LIMITS — Claude Code weekly usage limits are up 50% through July 13; Claude Science applications close July 15
My background session sat at running all night — adding heartbeats to the agents view
The Claude Code agents view finally lets you see every background session at once, but a running badge is not proof of progress. Here is the external heartbeat layer I built to catch a silently stalled session in minutes instead of the next morning, with real numbers and the traps I hit.
Last Tuesday, one of the background sessions I had running overnight sat at "running" until morning.
When I opened the agents view, the other eleven showed green checkmarks, and that one still had a spinning icon. Its last log line was seven hours old, and nothing had moved since. The task itself was a trivial dependency bump that should have finished in a couple of minutes. What stayed with me was not what had gone wrong, but why I had not noticed until morning.
As an indie developer, I run a handful of wallpaper apps and a few technical blogs, and every night I batch the small chores — dependency bumps, changelog drafts, crash summaries, ad-config checks — as headless Claude Code runs. On a busy night that is twelve sessions. The July 6, 2026 update that improved the agents view and background-session reliability was a genuinely welcome change for this workflow, letting me see every run on one screen. But after a week of leaning on it, one thing became clear: the agents view tells you a session exists, not that it is making progress.
This article shares the external heartbeat layer and reconciler I built to close that gap.
When running lies to you
Let me start from the failure side, because that is where the need becomes obvious.
Background sessions stall for two broad reasons. One is a crash — the process dies, the agents view flips to a failed state, and it is relatively easy to spot. The nastier one is the second: the process is alive but no longer moving forward.
In just the cases I have personally hit, the states looked like this.
Kind of stall
Process
Agents view
Actual progress
Hung on a network response
alive
running
stopped
Tool call that never returns
alive
running
stopped
Silent retry loop
alive
running
effectively stopped
Wedged waiting on input
alive
running
stopped
Every one of them keeps showing "running." Presence is true; liveness is false. In observability terms, what we actually want is a proof of life, not a proof of attendance.
That distinction turns fatal under unattended operation. During the day, when a human is watching, a spinner that lingers too long feels wrong and gets noticed. But a background session is in the background precisely because nobody is watching — so there is no one to feel that friction. The stall simply sits there until the next morning.
There is only one design principle here: add a liveness signal to the agents view from the outside.
Concretely, have each background session leave a small "I am still alive" stamp at a fixed cadence. Then a separate tiny process watches the freshness of those stamps. Fresh stamp, alive; stale stamp, suspected stall. That is the heartbeat idea.
Why external? You cannot — and should not — modify the agents view or the session state itself. The only levers you truly hold are emitting a side effect whenever something happens inside the session, and observing that side effect from outside. Combine the two and you can bolt on a liveness layer without breaking any official behavior.
The stamp can be startlingly small. What I actually write is just three fields: session name, epoch seconds, and current phase.
The important choice is using epoch seconds (ts) rather than a date string. I once wrote log dates with a plain date, UTC and JST drifted apart, and a run overwrote the previous day's file. If a machine is going to compare times, hold them as integers that leave no room for timezone interpretation. Freshness then falls out of a single subtraction: current epoch seconds minus ts.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why the running badge in the agents view cannot reveal a stall, and how separating presence from liveness reshapes the whole design
✦A complete working setup: a PostToolUse hook stamps each session with a heartbeat, and a reconciler judges staleness using plain epoch seconds
✦The measured drop from next-morning discovery to a nine-minute median, plus fixes for the three traps: silent thinking, cloud-sync write contention, and long single tools
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The obvious instinct is to stand up a separate watcher process alongside each session. I avoided that. A watcher introduces a nested problem — now who watches whether the watcher died? Twelve background sessions would mean twelve watchers, doubling what I have to manage.
So I use a PostToolUse hook. Claude Code can fire a hook on every tool call. Put the heartbeat write there, and the stamp refreshes as a side effect every time the session does any work. No extra process. When the session dies, the hook stops with it, so no nested supervision is born.
Here is the small script that writes the stamp.
#!/usr/bin/env bash# heartbeat.sh - refreshes the liveness stamp for the current session on each call# usage: heartbeat.sh <phase>set -euo pipefail# pass the session name in as an env var at launch (keep it aligned with the readable name)SESSION="${CC_SESSION_NAME:-unknown}"PHASE="${1:-tool}"# avoid the cloud-synced folder; use local tmpfs (explained later)HB_DIR="/tmp/cc-heartbeat"mkdir -p "$HB_DIR"# write epoch seconds, never a date string (avoids the timezone accident)NOW=$(date +%s)# atomically replace a single-line JSON (never let a reader see a half-written line)TMP="$(mktemp "${HB_DIR}/.tmp.XXXXXX")"printf '{"session":"%s","ts":%s,"phase":"%s"}\n' "$SESSION" "$NOW" "$PHASE" > "$TMP"mv -f "$TMP" "${HB_DIR}/${SESSION}.json"
Writing to mktemp and then replacing with mv removes any chance the reconciler reads a half-written line. A mv within the same directory is atomic, so the reader always sees exactly one complete line.
Wire the script into the hook. Add this to the project's .claude/settings.json.
The matcher is * so it fires on every tool, and the phase carries the tool name. That way the stamp alone tells you what the session was last doing. If a stalled session's phase reads tool:web_fetch, you immediately suspect a network hang.
The reconciler that judges a stall
Once stamps accumulate, you need the watching side. The reconciler can be a tiny program that sweeps the heartbeat directory and reports stale stamps as stall candidates.
#!/usr/bin/env node// reconcile.mjs - reads heartbeat freshness and reports stall candidatesimport { readdirSync, readFileSync } from "node:fs";const HB_DIR = "/tmp/cc-heartbeat";const NOW = Math.floor(Date.now() / 1000);// thresholds: set well above your longest single tool (see the pitfalls section)const STALE_SEC = 300; // 5 min of silence => "suspected stall"const DEAD_SEC = 900; // 15 min of silence => "likely stopped"const rows = [];for (const f of readdirSync(HB_DIR)) { if (!f.endsWith(".json")) continue; let hb; try { hb = JSON.parse(readFileSync(`${HB_DIR}/${f}`, "utf8")); } catch { continue; // skip a corrupt line (atomic replace should prevent this, but be safe) } const age = NOW - hb.ts; let state = "alive"; if (age >= DEAD_SEC) state = "dead?"; else if (age >= STALE_SEC) state = "stale?"; rows.push({ session: hb.session, ageSec: age, phase: hb.phase, state });}// oldest first; signal via non-zero exit only when something is suspiciousrows.sort((a, b) => b.ageSec - a.ageSec);const suspect = rows.filter((r) => r.state !== "alive");for (const r of rows) { console.log(`${r.state.padEnd(6)} ${String(r.ageSec).padStart(5)}s ${r.session} (${r.phase})`);}process.exit(suspect.length > 0 ? 1 : 0);
Run it every 3 to 5 minutes from cron or launchd. Tie the non-zero exit to a notification and a stall reaches you the moment it happens. I call it from launchd on macOS and surface a notification if even one session looks suspicious. For verifying the completion itself after the fact, I wrote earlier about doubting a silent success with an end-of-run assertion; the heartbeat sits before that, watching whether the run is alive while it is still moving. Keep both and you cover both an in-flight stall and an after-the-fact no-op.
That first line is exactly the session I left stalled for seven hours at the start. Its phase is frozen at tool:web_fetch, so at a glance it was hung waiting on an external fetch.
How much it actually shrank
I recorded how the time-to-notice changed before and after. These are two weeks of real numbers from my own setup — an average of ten to twelve sessions a night, of which roughly one or two stall in a week.
Metric
Before
After
Median time to detect a stall
next morning (~6-7 hours)
~9 minutes
One reconciler pass
-
40-60 ms
Added cost per heartbeat write
-
a few ms each
False positives (alive but flagged)
-
1 in two weeks after tuning
More than the numbers, the real change for me was that I stopped opening the agents view every morning with a knot in my stomach. Because I know a stall will page me within minutes, I hand off the overnight runs more calmly than before. Investment in observability usually pays back as peace of mind before it shows up in a metric.
Where I tripped
Getting to a clean version took three stumbles. None of them were visible until I actually ran it.
Missing silent thinking
A PostToolUse hook, as the name says, only fires after a tool call. So while a session is thinking hard without calling any tool at all, the heartbeat does not refresh. Misjudge that as a stall and you risk killing legitimate deep reasoning.
I settled this by setting the threshold well above the longest legitimate single tool. Keeping the tool: prefix in the phase also helps: it tells you the last activity was a tool, so you can partly distinguish long thinking from a true hang. If you want to be strict, write a phase:thinking stamp once right at session start and overwrite it per tool afterward, which also closes the gap in the first moments after launch.
Write contention on a cloud-synced folder
At first I put the heartbeats directly under my workspace, and that was a mistake. My working folder is cloud-synced, and the small writes every few seconds contended with the sync process, so the reconciler occasionally read stale content.
The fix is simple: keep heartbeats in local /tmp, which is not synced. The stamp is disposable, transient information — there is nothing to persist. The traps around file materialization in cloud-synced folders run deep, and I sorted them out separately in automating file materialization for a cloud-synced workspace. High-frequency writes for the living state belong outside anything that syncs.
False positives from long single tools
Some tools, like code_execution, can take close to 90 seconds in a single call. Set the threshold shorter than that and you will sound "stall" in the middle of a perfectly valid long run.
So set STALE_SECcomfortably longer than the longest single tool your workload can produce. Mine top out around two minutes, so I use a five-minute threshold. This depends entirely on your workload; start wide to kill false positives, then tighten once you trust it. For threshold design in general, the alerting chapter of a solid SRE text treats the tradeoff between false alarms and alert fatigue carefully, and I reread it whenever I set a threshold.
Why I settled on this shape
Let me leave one design rationale.
This heartbeat layer does not replace the agents view. It complements it. The agents view owns presence, commit correlation owns traceability, end-of-run assertions own completion, and the heartbeat owns liveness. Each answers a different question, and any attempt to cover all of them with one mechanism always leaves a hole somewhere. The RSS watchdog design that watches memory growth in long sessions is another node in this same observability mesh.
Reliability under unattended operation rises not through one dramatic move but by layering small nodes like these — something I keep feeling as I run these pipelines day after day at Dolice.
If you want to try it, write a single heartbeat.sh, wire it to the PostToolUse hook, and watch just one of tonight's background sessions. If the stamp keeps refreshing by morning, that is your starting point for a liveness layer.
Thank you for reading, and I hope it lets you hand off a few more of your nightly runs with confidence.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.