The global ⌘' quick-add overlay is a borderless, transparent, always-on-top
accessory window that winit hides with `Visible(false)`. That orders the window
out visually but leaves heph-quickadd the *active* application — so after a
capture (or Esc / toggle) keyboard focus never returns to the app the user was
in, and the lingering overlay can keep intercepting clicks where it used to sit.
Hide at the application level instead via `NSApplication.hide:`, which fully
orders our windows out and activates the next app in line (the previously
focused one). On re-show, `unhide:` clears that hidden flag before the existing
viewport `Focus` command makes the field key again. Both are macOS-only no-ops
elsewhere, wired through new `app_yield_focus`/`app_take_focus` helpers backed by
objc2 / objc2-app-kit (unified to the 0.6/0.3 line global-hotkey already pulls).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Client` connected to the unix socket once and never reconnected, so after an
opt-in self-update or `heph daemon restart` dropped the socket, every later
`call()` failed — `heph-tui` would sit on errors until relaunched (and the work
we just shipped makes restarts more frequent).
`Client` now stores the socket path and reconnects on a dropped connection,
classifying the failure to stay safe:
- write-side failure (request never reached the daemon) → reconnect + retry once;
- reply lost after sending (daemon closed mid-request) → reconnect for next time
but surface this one, so a mutation is never silently double-applied;
- genuine RPC errors are passed through untouched.
heph-tui and the CLI use `Client` unchanged, so the TUI self-heals on its next
refresh tick. Adds an integration test driving a mock daemon that drops the
connection after each request.
Closes the "heph-tui: reconnect on a dropped daemon socket" backlog task.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A double workflow_dispatch produced both v1.3.0 and an empty duplicate v1.4.0
(the version actually deployed via self-update). Move the release notes onto
v1.4.0 to match what shipped; v1.3.0 release+tag are being removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spoke OAuth path funneled every failure into one `AuthError::Provider`
whose Display was hardcoded "identity provider unreachable". So a reachable IdP
returning `400 invalid_grant` on a refresh was reported as "unreachable",
misdirecting incident response toward the network when the fix is re-auth. The
real refresh cause was also swallowed — `bearer()` logged it and returned None,
so sync health only ever showed the downstream 401 on /sync/pull.
Wording fix (auth.rs / oauth.rs):
- Split AuthError into Unreachable (transport), Rejected (IdP returned an HTTP
error — carries the RFC 6749 §5.2 error/error_description), and Other
(keyring / malformed response, previously mislabeled too).
- refresh()/discover()/start()/poll() classify transport vs status; refresh
reads the OAuth error body on a non-2xx.
- Hub-side token verify maps IdP-infra failures → 503, token failures → 401.
Recovery UX (server.rs / heph / heph-tui):
- bearer() returns Result; the sync paths record the real acquisition failure
(with a re-login hint when it's a rejection) instead of a masked 401.
- sync health's last_error carries the exact `heph auth login --hub-url …
--issuer … --client-id …` command (keyed to the configured hub); sync.status
also returns issuer/client_id + the command.
- New `heph auth status` prints auth health and the re-login command.
- heph-tui's auth chip points at it: `⚠ auth · heph auth status`.
Closes the duplicate "misleading identity provider unreachable" tasks and the
"actionable re-auth guidance" task. Also corrects a now-stale set-up-sync-hub
gap note (daemon config baking landed in the prior PR).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`restart` bootstrapped immediately after `bootout`, but `launchctl bootout` is
asynchronous: launchd may still be killing/reaping the job and removing its
label when the command returns. Bootstrapping into that transitional domain
fails with a generic `5: Input/output error`, intermittently — the odds depend
on how fast hephd (sync client + SQLite + a heph-quickadd child) shuts down.
- Wait for the label to actually clear (poll `launchctl print`, bounded) before
re-bootstrapping, and retry the bootstrap to cover the residual settle window.
- When the plist is unchanged (the common binary-upgrade restart), use
`launchctl kickstart -k` to restart the loaded job atomically — no
bootout/bootstrap, no race. The full reload path is reserved for genuine
config changes, where launchd must re-read the plist.
Start's bootstrap shares the same retry helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`heph daemon start`/`restart` previously hardcoded `hephd --mode local` and
only wired the bare `--self-update` bool — the poll interval and all spoke/hub
sync config (`--hub-url`, `--http-addr`, `--oidc-*`) could not be set on the
managed service without hand-editing the plist/unit (which a later
start/restart would clobber).
Generate the hephd arg vector from a DaemonConfig and add the corresponding
`heph daemon start/restart` flags: --mode, --hub-url, --http-addr,
--oidc-issuer, --oidc-audience, --oidc-client-id, and
--self-update-interval-secs. Regenerating now reads the existing service file
and preserves any flags not passed (start as well as restart), so a bare
invocation never silently drops baked config.
Closes the "pass through --self-update-interval-secs" and "bake hub/spoke
config into the generated service" backlog tasks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The background sync loop runs every 30s, so the last-sync age never crossed
the 60s 'just now' threshold — the chip always read 'just now', which also
masked the first missed sync (age 30-60s looked identical to a fresh one).
Show seconds under a minute ('⟳ 26s') so the chip is a visible heartbeat and a
stalled sync surfaces ~30s sooner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A spoke could be silently failing to sync (expired token → 401, or hub
unreachable) with the only signal buried in the daemon log. Now:
- hephd tracks SyncHealth (last attempt/success time, last error, auth-failure
flag) from the background sync loop and sync.now, classifying a 401 as an auth
failure. sync.status returns it plus the pending merge-conflict count.
- heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the
last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub
is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop
polls on a 2s tick so the age advances and failures appear while idle.
- docs: recommended Authentik access/refresh token validity to stop frequent
re-logins (with the iOS PWA localStorage-eviction caveat).
Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health:
surface unhealthy state instead of silent 401 spam' backlog items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side;
no schema or sync change (see hub-spoke-data-evolution).
- humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an
RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back
to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in
the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa
previews instead of the raw FREQ= string.
- project.overview RPC + Store::project_overview: each project's parent (via the
existing 'parent' links) and direct outstanding-task count, a read-only query.
- TUI sidebar: subprojects indented by depth, per-project counts, wider pane,
and ListState + scrollbar so it scrolls instead of clipping on overflow.
Tests: humanize parity (Rust + JS), round-trip through parse_recurrence,
raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle
safety.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document why heph's op-based sync lets most new features (new link types,
read-side queries, optional payload fields) ship without a coordinated
migration across the hub and spokes, and the narrow case — a new required
SQLite column the apply path writes — that does need a hub-first rollout.
Groundwork for the indented/counted project sidebar, which is pure read-side
(existing parent links + a GROUP BY) and needs no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the manual bearer-token paste with a proper browser OIDC sign-in.
- Hub: unauthenticated GET /config -> {issuer, client_id} (added after the auth
layer), sourced from the verifier's new TokenVerifier::oidc_config(). Lets the
PWA self-configure when served from the hub. Tests in web_serve.rs.
- PWA: src/oauth.js implements PKCE (S256), the authorize redirect, the callback
token exchange, and silent refresh (offline_access). Settings gains a "Login
with Authentik" button (manual token kept under a fallback disclosure); rpc.js
retries once on 401 via a refresh hook; app.js completes the callback / refreshes
on load; sw.js skips caching the callback URL and ships oauth.js in the shell.
Requires the PWA origin registered as a redirect URI on the Authentik provider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the PKCE 'Login with Authentik' flow, the hub /config zero-config
discovery, and the redirect-URI prerequisite on the Authentik heph provider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add host-heph-pwa.md: a deployment how-to for serving the PWA from the canonical
hub in the hub/spoke OIDC setup (post-release) — fetch the shell at the hub's
tag, add --web-root, terminate TLS (tailscale serve / reverse proxy), and the
token-paste caveat with the device-code-login follow-up. Cross-linked from
heph-pwa and the how-to index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PWA shares the daemon's store with the TUI/desktop popover but only
re-fetched on a view switch or action — so a task marked done elsewhere left a
stale list on screen. Reload the current view on visibilitychange→visible
(switch back to the phone, unlock, tab re-show), skipping it mid-modal/search.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Popover supervision was gated to Mode::Local, so running the store-owning
daemon in server mode (now needed to host heph-pwa) silently dropped the
desktop quick-capture popover. Server mode is local + an HTTP hub and owns the
same store/socket, so it should drive the popover too; broaden the guard to
Local | Server (client, a thin proxy, still opts out).
Also: when the PWA shell is served from the hub, default the hub URL to its own
origin so the app is zero-config on first open (Settings still overrides). Bump
the service-worker cache to v2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document serving the app from the hub (--web-root), connecting (hub URL +
optional token), quick-add syntax, voice, triage, and the deliberate
design choices (PWA over native iOS; online-only; token paste vs device flow)
with their known limitations to revisit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A buildless, installable PWA that mirrors heph-tui: sidebar of built-in views
(tom/tasks/work/chores/ondeck/inbox) + projects, a task list with attention
flags / project bullets / date chips, tap-to-expand triage (done/drop/skip/
attention/reschedule/move/delete + undo), full-text search, and a read-only
context+log preview. The primary surface is the quick-add modal (FAB or Cmd-'),
which live-parses the TUI syntax into preview chips and supports voice via
on-device dictation / the Web Speech API. rpc.js is the online-only JSON-RPC
client mirroring heph-tui's Backend; settings persist in localStorage. Service
worker caches the app shell for offline launch.
Verified end-to-end against a local server-mode hephd (--web-root): the app
boots, calls the view RPC, and renders RankedTasks in headless Chrome.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Faithful JS ports of hephd's quickadd.rs / datespec.rs so the PWA's quick-add
accepts the identical syntax (p1-4, #Project greedy match, today/+3d/fri/ISO,
'every …' recurrence) and produces the same RRULEs and local-midnight do-dates
as the CLI/TUI. test/parsers.test.mjs replays the Rust unit cases under
`node --test` (13/13 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a permissive CORS middleware (answers the browser OPTIONS preflight and
stamps Access-Control-* on every response) and an optional --web-root static
file handler with an index.html SPA fallback. Together these let a browser
surface — the forthcoming heph-pwa mobile app — call /rpc cross-origin or be
hosted same-origin straight from the hub. No new crate dependencies; file
reads run on the blocking pool. Covered by tests/web_serve.rs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hephd's reqwest client is built default-features=false with no TLS
feature, so the self-update release poll's HTTPS GET always failed
('release check failed: requesting forge releases/latest') — the bug
never surfaced before because nothing in production used reqwest over
HTTPS (hub sync is plain http://). Switch the poll to ureq, which is
already a dependency and ships a rustls/ring TLS stack needing no system
libs (notably no cmake/aws-lc-sys, which would break the rust:bookworm CI
image). Verified end-to-end: a 0.0.0 build now detects v1.1.0, installs,
and restarts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the eight Mikado scaffolding cards (+ goal card) into one
user-facing how-to, docs/how-to/self-update.md: what self-update is and
how to enable it. The per-card breakdown was build-time scaffolding, not
documentation. Keeps the changelog fragment; updates the how-to index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public repo => anonymous HTTPS clone, no credentials (the SSH/canonical
premise was wrong: that was the access-restricted cargo registry, not git
clone). Install URL points at the canonical public host (verified end to
end); the service template bakes cargo onto PATH. Card rewritten to
reflect what actually happened.
The repo is public, so self-update needs no credentials: cargo install
--git is a plain anonymous clone (NOT the access-restricted Forgejo cargo
registry, which is what required forge.ops.eblu.me). Point INSTALL_GIT_URL
and the releases poll at the canonical public host over HTTPS — verified
end-to-end (cargo install --git https://forge.eblu.me/... --tag v1.0.3
builds a working hephd with zero auth).
Make the headless service able to run the apply path: 'heph daemon
start --self-update' (default off) generates a launchd/systemd service
that passes --self-update and bakes a PATH (incl ~/.cargo/bin) + HOME so
the minimal service env can find cargo. restart preserves the setting.
Default (no flag) services are byte-identical to before. Template + URL
behavior covered by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The poller now installs + restarts (not just logs); fix the stale doc and
point at service-env-forge-access as the deployment step that makes the
apply path operational.
Restarter + ProcessRestarter wired: install then exit(0) so the service
manager respawns the new binary; restart only on a successful install.
Unit-tested via injection.
Add a Restarter trait + ProcessRestarter (exit 0 so launchd KeepAlive /
systemd Restart=always respawn the new binary). apply_update now installs
then restarts, and the restart fires only on a successful install. Wired
into the poll loop. Unit-tested with fake installer+restarter: restart on
success, no restart after a failed install. Real process exit is never
run in tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installer trait + CargoInstaller + apply_update landed and unit-tested
via injection. Real cargo execution is gated on the deployment env
(service-env-forge-access).
Add an Installer trait + CargoInstaller (runs cargo install --locked
--git <ssh> --tag <tag> for heph/hephd/heph-tui/heph-quickadd — the
documented install command, via the SSH host that sidesteps the
cargo/forge canonical-name mismatch), and apply_update() which runs the
blocking install on the blocking pool. The poll loop now applies on a
detected update. Apply path is unit-tested with a fake installer (call +
failure paths); the real cargo subprocess is never run in tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock in the base-case guarantee that a self-updating hub (which restarts
under its spokes) relies on. New sync_http test: a spoke whose hub is
unreachable keeps serving + accepting writes, a sync attempt fails fast
(Err, not hang/panic), and when the hub returns the accumulated ops
reconcile with no special recovery.
The verification surfaced one non-graceful path — the daemon's shared
reqwest client had no timeout, so a black-hole hub (connects, never
replies) could stall the sync/self-update loop. Give it a 30s timeout so
'the hub can vanish at any moment' holds even mid-request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-restart works by exiting cleanly and letting the service manager
respawn the new binary. launchd already does this (KeepAlive=true), but
the systemd user unit was Restart=on-failure, which ignores a clean
exit (code 0). Switch to Restart=always + RestartSec=1, update the unit
test, and note in run-the-daemon that existing Linux installs must
`heph daemon restart` once to regenerate the unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>