diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml
index ae16542..41f54dd 100644
--- a/fleetd/fleetd.example.yaml
+++ b/fleetd/fleetd.example.yaml
@@ -77,16 +77,25 @@ bind:
# a lead turn nobody asked for), so upgrading the daemon must never switch it on for you. Absent
# block = feature off, exactly as before.
#
-# Three knobs, each with a default that errs on the side of not burning context:
+# Four knobs, each with a default that errs on the side of not burning context:
# idleAfterSeconds: 300 # how long the lead must stay idle before the FIRST nudge (default 300 —
# # absorbs normal post-turn pauses; re-prompting every pause burns context)
# backoffMs: 60000 # re-check cadence / spacing between nudges past the quiet period (default 60000)
# quietNudgeCap: 3 # cap on consecutive nudges that find NOTHING pending, then it stops
# # until real state appears (default 3 — never nag an empty fleet forever)
+# contextHighNudge: false # fleetd #609 — when true, an idle lead whose OWN Claude Code context
+# # reads HIGH (see LeadContextGauge; fleet_list's context row) gets a text
+# # notice appended to its nudge telling it to consider fleet_handover. Text
+# # only — it never rolls a pane by itself, and only the operator can approve
+# # a roll. Fires once per HIGH stretch (a later OK reading re-arms it), and
+# # never spends the quietNudgeCap budget. Default false/absent = off, same
+# # as every other knob here — an upgraded daemon must not start telling
+# # leads to hand over on its own.
# leadHeartbeat:
# idleAfterSeconds: 300
# backoffMs: 60000
# quietNudgeCap: 3
+# contextHighNudge: false
# Lead rollover (fleetd #480): replace a lead session that has decided it is ready to be replaced,
# without an operator doing it by hand. A lead writes a handover file, then asks fleetd to clear its
diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
index d06c01f..00cf817 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
@@ -4,11 +4,13 @@ import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.config.ConfigRef;
import dev.ltms.fleet.config.ConfigWatcher;
import dev.ltms.fleet.guard.SubscriptionGuard;
+import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.herdr.HerdrRouter;
import dev.ltms.fleet.herdr.LeadTabScanner;
+import dev.ltms.fleet.lead.LeadContextGauge;
import dev.ltms.fleet.lead.LeadLauncher;
import dev.ltms.fleet.lead.LeadRollover;
import dev.ltms.fleet.herdr.PaneLocator;
@@ -563,10 +565,18 @@ public final class Fleetd {
Thread.ofVirtual().name("bridge-heartbeat-").unstarted(r));
if (cfg.leadHeartbeat() != null) {
var hb = cfg.leadHeartbeat();
+ // fleetd #609: own LeadContextGauge instance for the heartbeat loop — separate from the
+ // one FleetMcp builds internally for fleet_list's context row. Each caches independently
+ // (keyed by configDir+sessionId), so this costs at most one extra bounded tail read per
+ // TTL window, never a shared-mutable-state hazard between the two callers.
+ var leadContextGauge = new LeadContextGauge();
heartbeat = new LeadHeartbeatLoop(primaryRegistry, router.leadAgents(), replyInbox, sessions::roster,
pushLoop, heartbeatScheduler, System::nanoTime,
TimeUnit.SECONDS.toNanos(hb.idleAfterSeconds()), hb.backoffMs(), hb.quietNudgeCap(),
- metrics);
+ metrics,
+ leadContextSource(leadContextGauge, router.leadAgents(), leads,
+ leadConfigDirLookup(() -> config.get().profiles(), leaders)),
+ Boolean.TRUE.equals(hb.contextHighNudge()));
heartbeat.start();
} else {
heartbeat = null;
@@ -1632,6 +1642,57 @@ public final class Fleetd {
return new FleetMcp.LeadConfigDirSource(leadConfigDirLookup(profiles, leaders));
}
+ /**
+ * fleetd #609: per-terminal factory for {@link LeadHeartbeatLoop.LeadContextSource} — the lead's
+ * own {@link LeadContextGauge} reading, so the heartbeat loop can tell an idle, HIGH-context lead
+ * to consider a handover.
+ *
+ *
Three hops, each degrading to {@link LeadContextGauge.Reading#unknown()} rather than
+ * throwing, since a herdr hiccup or an unrecognised terminal must never kill the heartbeat's own
+ * tick: {@code liveLeadTerminals} (terminal id → lead name, the same live supplier {@link
+ * #leadSeatLookup} and {@code LeadCoordLoop} already read) → {@code configDirForLeadName} (that
+ * lead's {@code configDir}, normally {@link #leadConfigDirLookup}'s return) → {@code agents.get}
+ * for the live {@link Agent#sessionId()}/{@link Agent#agentType()} the gauge itself needs.
+ *
+ * @param gauge the {@link LeadContextGauge} instance to read through — shares its
+ * cache across every call this factory's function makes
+ * @param agents the {@link AgentControl} instance that reaches the LEAD's pane
+ * (not {@code memberAgents}), normally {@code router.leadAgents()}
+ * @param liveLeadTerminals terminal id → lead name for every CURRENTLY recognised lead
+ * @param configDirForLeadName lead name → {@code configDir}, normally {@link
+ * #leadConfigDirLookup}'s return
+ */
+ static Function leadContextLookup(LeadContextGauge gauge, AgentControl agents,
+ Supplier