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?.()
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.Hermes sidecar down — gateway downon a machine that never installed the sidecarHERMES/Health.ts:167treats~/.hermesexisting as installed. Omarchy's own Hermes CLI (omarchy-install-hermes-cli) creates~/.hermes/skillson every Omarchy boxgateway_state.json,gateway-starts.log, orcron/)Conduit 0m creation today, every day, foreverPULSE/modules/menubar.tspushes a feed row whenever Conduit returns a numericcreationMinutes, including 0Running — tick staleon a daemon that is finestate.jsonis >120s old, butpulse.tsonly 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"lastTickon its own 30s timer, independent of the run loop (an in-loop write was not enough: the loop blocks on a running job andmemory-consolidationis allowed 600s). 120s of silence now means two missed beats. Label reads "loop stalled".DaemonStategainslastTick?: numbercounts.worklabelled "work"work.json(sessions filtered by phase), not the work boardReproduce (any Omarchy install of LifeOS ≥ 7.0)
Diff
Against 7.40.4. Four files, 49 insertions, 2 deletions.
writeStateis tmp+rename, so the timer cannot interleave with the loop's own persist.pulse-menubar-fixes.diff
The client, if useful
io.github.0bsolescence.lifeos-glance— a read-only Omarchy (Quickshell) bar widget over the same/api/menubarcontract: 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.