Skip to content

fix(conduit): make capture, rollup and scheduling work off macOS - #1788

Open
pkumaschow wants to merge 2 commits into
danielmiessler:mainfrom
pkumaschow:fix/conduit-app-focus-linux-fallback
Open

fix(conduit): make capture, rollup and scheduling work off macOS#1788
pkumaschow wants to merge 2 commits into
danielmiessler:mainfrom
pkumaschow:fix/conduit-app-focus-linux-fallback

Conversation

@pkumaschow

@pkumaschow pkumaschow commented Aug 7, 2026

Copy link
Copy Markdown

Three related macOS assumptions in Conduit that leave it visibly broken on other platforms, each failing silently. Two commits: the capture/rollup fix, and the scheduling fix.

Problem 1 — app-focus fails silently, taking the whole time model with it

Conduit's app-focus adapter shells out to osascript, which only exists on macOS. On any other platform execFileSync throws straight into a bare catch { return [] } — the adapter emits nothing and says nothing.

That's invisible rather than merely degraded, because rollup.ts derives totalMinutes, creationMinutes, consumptionMinutes and every block exclusively from app-focus events. Commits and claude-session events are counted but contribute no time.

On a Linux host the result is a dashboard that looks healthy from every angle you'd normally check:

  • the capture job reports ok
  • events accumulate normally (in my case 304 claude-session + 39 git-commit over 18 days)
  • /api/conduit responds with sensible commits and sessions counts
  • …and the timeline renders empty, totalMinutes: 0, with zero app-focus events ever recorded and no log line anywhere explaining why

Changes

1. adapters/appFocus.ts — make the failure audible

Platform-check before shelling out, and log the reason once per process:

[conduit:appFocus] disabled — app-focus requires macOS `osascript`, host is linux.
No app-focus events will be captured; rollup falls back to session-derived time.

The catch reports now too. On macOS a throw there is a real fault — Automation permission not granted, System Events unresponsive — not an expected platform gap, so it deserves to be audible as well. Once per process rather than per poll, since a frequent cron would drown the log.

2. rollup.ts — derive time from the signal that exists everywhere

When a day has no focus time at all, time is derived from claude-session cadence. Each session event is credited with the gap back to the previous one, capped so an overnight gap isn't counted as work.

The cap self-tunes to 2× the day's median gap rather than a fixed constant: the capture cadence comes from the cron schedule, not pollIntervalSec, so a hardcoded value would silently undercount the moment that schedule changed.

Problem 2 — the insight job is scheduled by launchd only

BuildInsight.ts is scheduled exclusively by InstallConduitInsight.ts, which writes ~/Library/LaunchAgents/com.lifeos.conduit.insight.plist. launchd only exists on macOS, so everywhere else nothing ever runs it — while the Conduit page advertises an "hourly read" and permanently shows "No activity captured yet today".

3. PULSE.toml — schedule it through the scheduler LifeOS already ships

[[job]]
name = "conduit-insight"
schedule = "23 */2 * * *"
type = "script"
command = "bun run Conduit/BuildInsight.ts"
output = "log"
enabled = true

The launchd agent still works where it's installed; this just makes the hourly read real on every other platform. Double-running is harmless — BuildInsight is idempotent against a since watermark and exits without an inference call when no new events have landed. Verified by running it twice in succession; the second printed no new events since <watermark> — skipped (no inference call).

The minute was brute-forced against every enabled job in the shipped config rather than eyeballed. The obvious :15 collides with five of them — cost-aggregation (*/15) and the */5 pollers all land on multiples of 5 — and this job holds an inference call open for ~50s, so it shouldn't share a tick with anything. :23 is clear across all of them.

Design notes

  • macOS behaviour is unchanged. The fallback engages only when there is no measured focus time, so a host with a working osascript never takes this path.
  • Derived time is labelled as derived — the block reads Claude Code (derived from session cadence) rather than being blended into an unqualified number. Inferred keyboard time and measured focus time aren't the same claim and the record shouldn't blur them.
  • N events yield N−1 spans. Nothing is claimed about time after the final event.

Why not just port app-focus to Linux?

There's no drop-in replacement on Wayland — an unprivileged process deliberately cannot ask which window has focus. I tested both routes on Fedora/GNOME:

approach result
org.gnome.Shell.Introspect.GetWindows AccessDenied: GetWindows is not allowed
xprop -root _NET_ACTIVE_WINDOW responds, but sees only XWayland clients — would report confidently wrong apps for native Wayland ones
xdotool / wmctrl X11-only, and not installed by default

A GNOME Shell extension exposing focus over D-Bus is the real port. This PR makes the gap visible and keeps the timeline useful until someone writes it.

Verification

I didn't add a test file — the repo has no test infrastructure (no root package.json, no .test.ts files), and I didn't want to impose a framework in a bugfix PR. Happy to add one if you'd like it. The fallback was verified against a 10-case matrix, all passing:

case expectation result
app-focus present alongside sessions focus wins, derived path does not engage
app-focus minutes with both present unchanged from current behaviour
5 sessions at 5-min cadence, no focus 20 min derived
derived block labelling contains "derived"
derived block classification creation
10-hour gap between sessions capped, not counted
single session event 0 minutes — proves nothing
no events at all 0 minutes, 0 blocks
duplicate commit SHAs de-duped to 1
commits only counted, contribute no time

End-to-end on a Fedora 42 / GNOME / Wayland host: Conduit went from totalMinutes: 0 with an empty timeline to 189.2 minutes across one labelled derived block, with commits and sessions counts unchanged.

For the scheduling commit: Pulse loaded the job (15 → 16 jobs), the loader resolves it as schedule=23 */2 * * * type=script output=log enabled=true, and a full 24-hour minute sweep against all other enabled jobs returns zero collisions. The insight itself produced a correct read from 33 events on first run, and skipped without inference on the second.

…from sessions

Conduit's app-focus adapter shells out to `osascript`, which only exists on
macOS. On every other platform `execFileSync` throws straight into a bare
`catch { return [] }`, so the adapter emits nothing and says nothing.

That is invisible rather than merely degraded, because rollup.ts derives
totalMinutes, creationMinutes, consumptionMinutes and every block exclusively
from app-focus events. Commits and claude-sessions are counted but contribute
no time. The result on a Linux host is a dashboard that reports the capture job
healthy, accumulates events all day, and renders an empty timeline — with no
log line anywhere explaining why.

Two changes:

1. appFocus.ts checks the platform before shelling out and logs the reason
   once per process. The `catch` also reports now: on macOS a throw there means
   a real fault (Automation permission denied, System Events unresponsive)
   rather than an expected platform gap, and that deserves to be audible too.
   Once per process, not per poll — a frequent cron would drown the log.

2. rollup.ts falls back to deriving time from claude-session cadence when a day
   has no focus time at all. Each session event is credited with the gap back to
   the previous one, capped so an overnight gap is not counted as work. The cap
   self-tunes to 2x the day's median gap rather than a fixed constant, because
   the capture cadence comes from the cron schedule rather than pollIntervalSec,
   so a hardcoded value would silently undercount if that schedule changed.

The fallback engages only when there is no measured focus time, so macOS
behaviour is unchanged. Derived time is labelled as derived in the block itself
rather than blended into the same number, because inferred keyboard time and
measured focus time are not the same claim.

There is no drop-in app-focus replacement on Wayland: an unprivileged process
cannot ask which window has focus. GNOME's
org.gnome.Shell.Introspect.GetWindows returns AccessDenied, and xprop sees only
XWayland clients, so it would report confidently wrong apps for native ones. A
Shell extension exposing focus over D-Bus is the real port; this change makes
the gap visible and keeps the timeline useful until then.
… macOS

BuildInsight.ts is scheduled exclusively by InstallConduitInsight.ts, which
writes ~/Library/LaunchAgents/com.lifeos.conduit.insight.plist. launchd only
exists on macOS, so on every other platform nothing ever runs it — while the
Conduit page advertises an "hourly read" and permanently displays "No activity
captured yet today".

Pulse is the cross-platform scheduler LifeOS already ships, so the job belongs
there. The launchd agent still works where it is installed; this just means the
hourly read is real everywhere else.

Double-running is harmless where both are active. BuildInsight is idempotent
against a `since` watermark and exits WITHOUT an inference call when no new
events have landed, so extra firings on idle hours cost nothing. Verified by
running it twice in succession: the second invocation reported
"no new events since <watermark> — skipped (no inference call)".

Schedule is :23 past every second hour. The minute was brute-forced against
every enabled job in the shipped config rather than eyeballed — the obvious :15
collides with five of them, because cost-aggregation (*/15) and the */5 pollers
all land on multiples of 5. This job holds an inference call open for roughly 50
seconds, so it should not share a tick with anything.
@pkumaschow pkumaschow changed the title fix(conduit): stop app-focus failing silently off macOS, derive time from sessions fix(conduit): make capture, rollup and scheduling work off macOS Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant