Skip to content

Pulse /api/menubar reports four false signals (Hermes "down" on every Omarchy box, permanent "Conduit 0m", "tick stale" on a healthy daemon, "work" counts sessions); diff attached #2064

Description

@0bsolescence

What I saw

Building an Omarchy bar widget over GET /api/menubar (the endpoint the macOS menu bar app reads) and mirroring it honestly made four standing misreports visible. All four come from Pulse, not the client, and the macOS bar has been shown the same things.

Signal Cause (7.40.4) Fix
Hermes sidecar down — gateway down on a machine that never installed the sidecar HERMES/Health.ts:167 treats ~/.hermes existing as installed. Omarchy's own Hermes CLI (omarchy-install-hermes-cli) creates ~/.hermes/skills on every Omarchy box Installed = a sidecar mark exists (gateway_state.json, gateway-starts.log, or cron/)
Conduit 0m creation today, every day, forever PULSE/modules/menubar.ts pushes a feed row whenever Conduit returns a numeric creationMinutes, including 0 No row at 0
Running — tick stale on a daemon that is fine menubar calls the daemon stale when state.json is >120s old, but pulse.ts only writes that file after a job runs. Six jobs, gaps over two minutes are normal. Polled twice on one healthy daemon: "stale", then "Running — 1d 1h" Pulse heartbeats lastTick on its own 30s timer, independent of the run loop (an in-loop write was not enough: the loop blocks on a running job and memory-consolidation is allowed 600s). 120s of silence now means two missed beats. Label reads "loop stalled". DaemonState gains lastTick?: number
counts.work labelled "work" It counts active Claude sessions from work.json (sessions filtered by phase), not the work board Not changed server-side; noting it so clients label it honestly

Reproduce (any Omarchy install of LifeOS ≥ 7.0)

ls ~/.hermes            # skills/ only, created by Omarchy, no sidecar
curl -s localhost:31337/api/menubar | jq '.hermes, [.feed[] | select(.subsystem=="conduit")], .daemon.status'
# → {"status":"down","summary":"gateway down"}, a conduit row with "0m", and "stale" during any quiet stretch

Diff

Against 7.40.4. Four files, 49 insertions, 2 deletions. writeState is tmp+rename, so the timer cannot interleave with the loop's own persist.

pulse-menubar-fixes.diff
diff --git a/LifeOS/install/LIFEOS/HERMES/Health.ts b/LifeOS/install/LIFEOS/HERMES/Health.ts
index 9bab5b4..e3d9a6f 100644
--- a/LifeOS/install/LIFEOS/HERMES/Health.ts
+++ b/LifeOS/install/LIFEOS/HERMES/Health.ts
@@ -164,7 +164,14 @@ export function checkHermesHealth(): HermesHealth {
   const checkedAt = new Date().toISOString();
   const home = hermesHome();
 
-  if (!existsSync(home)) {
+  // A bare home directory is not an install. Omarchy's own Hermes CLI
+  // (`omarchy-install-hermes-cli`) creates ~/.hermes/skills on every Omarchy
+  // box, and that alone reported the LifeOS sidecar as "down — gateway down"
+  // on every Linux perch (2026-09-04). Installed means the sidecar has left
+  // one of its own marks: gateway state, a start log, or its cron dir.
+  const sidecarMarks = ["gateway_state.json", "gateway-starts.log", "cron"];
+  const installed = existsSync(home) && sidecarMarks.some((m) => existsSync(join(home, m)));
+  if (!installed) {
     return {
       status: "absent",
       summary: "Hermes sidecar not installed",
diff --git a/LifeOS/install/LIFEOS/PULSE/lib.ts b/LifeOS/install/LIFEOS/PULSE/lib.ts
index ed4650f..9d2776e 100644
--- a/LifeOS/install/LIFEOS/PULSE/lib.ts
+++ b/LifeOS/install/LIFEOS/PULSE/lib.ts
@@ -87,6 +87,8 @@ export interface JobState {
 }
 
 export interface DaemonState {
+  /** Written every run-loop pass (≤ MAX_SLEEP_MS apart); readers treat its age as loop liveness. */
+  lastTick?: number
   version: 1
   jobs: Record<string, JobState>
   startedAt: number
diff --git a/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts b/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts
index 073c62d..a934203 100644
--- a/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts
+++ b/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts
@@ -143,7 +143,9 @@ function daemonBlock(): MenuBarPayload["daemon"] {
     if (!alive && fileAgeSec > 120) return { status: "stopped", label: "Stopped", uptimeSec: 0, failingJobs, jobCount }
     if (failingJobs > 0)
       return { status: "failing", label: `Failing — ${failingJobs} job${failingJobs === 1 ? "" : "s"}`, uptimeSec: 0, failingJobs, jobCount }
-    if (fileAgeSec > 120) return { status: "stale", label: "Running — tick stale", uptimeSec: 0, failingJobs, jobCount }
+    // Pulse heartbeats state.json every loop pass (MAX_SLEEP_MS = 60s), so 120s
+    // here means two consecutive passes were missed: a wedged loop, not a quiet one.
+    if (fileAgeSec > 120) return { status: "stale", label: "Running — loop stalled", uptimeSec: 0, failingJobs, jobCount }
     const uptimeSec = Math.max(0, Math.floor(Date.now() / 1000 - (st.startedAt || 0) / 1000))
     return { status: "running", label: `Running — ${fmtUptime(uptimeSec)}`, uptimeSec, failingJobs, jobCount }
   } catch {
@@ -245,6 +247,12 @@ async function buildPayload(): Promise<MenuBarPayload> {
     const rec = typeof conduit.todayRecordPublic === "function" ? conduit.todayRecordPublic() : null
     if (rec && typeof rec.creationMinutes === "number") {
       conduitMinutes = rec.creationMinutes
+    }
+    // A zero-minute day is not an event. A Conduit that is installed but dark
+    // (config present, nothing captured) must add no row — the same rule the
+    // Hermes block applies to an absent sidecar. Verified 2026-09-04: the row
+    // was showing "Conduit 0m creation today" on every LifeOS bar.
+    if (rec && typeof rec.creationMinutes === "number" && rec.creationMinutes > 0) {
       // Stable "today" stamp — a daily summary row, not a discrete event, so it must not
       // re-badge on every poll (it would never clear once seen).
       feed.push({
diff --git a/LifeOS/install/LIFEOS/PULSE/pulse.ts b/LifeOS/install/LIFEOS/PULSE/pulse.ts
index e1ca0e2..6e676be 100755
--- a/LifeOS/install/LIFEOS/PULSE/pulse.ts
+++ b/LifeOS/install/LIFEOS/PULSE/pulse.ts
@@ -1112,6 +1112,35 @@ async function main() {
   // public PR #1644, @elhoim
   const badScheduleJobs = new Set<string>()
 
+  // Heartbeat, independent of the run loop. state.json used to be written only
+
+  // after a job ran, so any quiet stretch over two minutes read as "tick stale"
+
+  // on every menu bar (2026-09-04). A write inside the loop was not enough: the
+
+  // loop blocks on a running job, and memory-consolidation is allowed 600s
+
+  // (codex review, same day). This timer keeps writing while a job runs, so the
+
+  // file's age means what readers assume: how long since the daemon last proved
+
+  // itself alive. writeState is atomic (tmp + rename), so it cannot interleave
+
+  // with the loop's own persist.
+
+  const heartbeat = setInterval(() => {
+
+    state.lastTick = Date.now()
+
+    writeState(STATE_PATH, state).catch((err) =>
+
+      log("error", "Failed to persist heartbeat", { error: String(err) })
+
+    )
+
+  }, 30_000)
+
+
   while (!shuttingDown) {
     const tickStart = Date.now()
     const now = new Date()
@@ -1205,6 +1234,7 @@ async function main() {
   }
 
   // ── Cleanup ──
+  clearInterval(heartbeat)
   server.stop()
   if (imessageModule) imessageModule.stopIMessage?.()
   if (assistantModule) assistantModule.stopAssistant?.()

The client, if useful

io.github.0bsolescence.lifeos-glance — a read-only Omarchy (Quickshell) bar widget over the same /api/menubar contract: daemon health glyph, session count, feed in the tooltip. kinds: ["bar-widget"], no process spawn, no write path, loopback by default. https://github.com/0bsolescence/omarchy-lifeos-glance — submitted to the Omarchy marketplace as omacom/omarchy-plugin-marketplace#4901. Happy to move it in-tree beside the macOS app if you'd rather own it.

Written with AI assistance; every claim above was reproduced on two installs before filing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions