Releases: sandroandric/AgentHandover
v0.3.0 — Skill quality release
The biggest Skill-quality jump since v0.2.0. Eight fixes targeting silent data losses between the VLM, the saved procedure JSON, and the app UI — every one of which left users looking at procedures that were a fraction of what the model had actually produced.
What changed
Step descriptions are now saved AND rendered. Gemma 4 e4b emits a description field on every step (verified 19/19 on the dailynews validation run). Two separate bugs were dropping it: procedure_schema.sop_template_to_procedure() was silently dropping it on the way to JSON, AND SOPDetailView.swift rendered description as a ??-fallback for action so it never appeared on screen even when the JSON was right. Both gaps closed. The Swift step card now shows action (semibold) + description (dim) + → target (monospaced) + Input: + ✓ verify lines, all conditional. Old v0.2.x procedures look the same as before.
Behavioral synthesis fails loud. Empty-insights returns from the VLM now raise EmptyInsightsError, retry once, and only stamp last_synthesized when the extraction was substantive — instead of silently leaving procedures with empty behavioral fields.
Voice profile reads real user-authored text. Style analyzer now reads from clipboard events, step inputs/descriptions, and content samples — not only the late-populated extracted_evidence.content_produced which was empty for most fresh sessions. Min combined text 50 → 30 chars.
Q&A no longer corrupts procedures. Removed _merge_credentials() and _merge_decision() — free-text answers were being auto-merged into structured accounts / branches / decision fields, overwriting clean data with prose. FocusQuestion now carries a step_indexes field; clarifications rewrite the specific steps they cover, in place.
Variables wired into step text. Declared variables with concrete examples are now post-substituted into step text and parameters as {{varname}} templates. Skips generic example values (yes/no/true/false).
Brace double-wrap fix. {{{{var}}suffix}} (Gemma occasionally double-wraps an already-templated reference) is now collapsed to {{var}}suffix. Caught in the wild as target: '{{{{bohemia}}.io}}'.
Schemas tightened. FOCUS_SOP_PROMPT, PASSIVE_SOP_PROMPT, and ENRICHED_PASSIVE_PROMPT now require description and verify per step. Coherence-check rule refined so intermediate actions aren't dropped as "unused later".
Tests
3026/3026 Python tests pass. New regression test test_write_preserves_step_description locks the procedure_schema fix.
End-to-end validation on the dailynews focus session: 19/19 steps with rich descriptions and verifies, 0 brace bugs, 0 Q&A corruption, behavioral synthesis confidence 0.81 with retries=0.
Install
- Direct download:
AgentHandover-0.3.0.pkg(signed + notarized + stapled) - Homebrew:
brew install --cask sandroandric/agenthandover/agenthandover - Sparkle auto-update: v0.2.x users will be prompted on next check.
SHA-256: 20de3341f76d2bf6e2b69048dcf7bbf636370082300df979a502bf5245ce652e
Thanks
To the v0.2.x users who recorded long focus sessions and showed where Skill quality fell short — the dailynews / domain-leads / X-email recordings drove every fix in this release.
v0.2.10 — detach daemon from launching shell via setsid()
Fixes the daemon silently disappearing after the launching shell exits. Isolated by hikoae on v0.2.9 with a precise repro: daemon runs stably for 7+ minutes when the launching shell stays alive, but dies within seconds when the shell exits. macOS unified log showed nothing (no SIGKILL, no jetsam, no TCC) — ruling out external kills.
Root cause
Classic Unix process-group/session issue. When you ran agenthandover restart from a terminal, the daemon was spawned as a detached child that reparented to init (PPID=1) correctly — but its process group ID stayed tied to the launching shell's process group. When the shell closed its controlling TTY, SIGHUP was sent to every process in that group, including the daemon. Default SIGHUP action is immediate termination with no signal handler invocation, no stderr output, no unified log entry, no crash report. The daemon just silently disappeared.
The fix
libc::setsid() at the top of daemon's main() creates a new session with the daemon as its leader. Detaches from the launching shell's session and process group. SIGHUP from shell exit no longer reaches the daemon.
Plus a SIGHUP signal handler as defense in depth — if setsid() ever fails for some reason, or if SIGHUP arrives via another path (explicit kill -HUP, logrotate conventions), the daemon shuts down cleanly with full logs instead of dying silently.
Verification
/bin/ps -j before and after the fix:
- Before: daemon PGID matched launching shell's PGID → SIGHUP propagated
- After: daemon has
PID == PGID, session leader flagsin STAT,TT=??(no controlling terminal), survives subshell exit
Install
brew upgrade --cask agenthandover
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.10/AgentHandover-0.2.10.pkg
sudo installer -pkg AgentHandover-0.2.10.pkg -target /Relates to issue #1 (NOT closing — waiting for reporter's v0.2.10 retest).
v0.2.9 — remove remaining timeout+spawn_blocking crash sites
v0.2.8 fixed the OCR timeout crash but three identical patterns remained. This release removes all of them.
What was still crashing
The tokio::time::timeout + spawn_blocking pattern orphans the blocking thread when the timeout fires — ObjC cleanup runs outside the @try/@catch scope, uncaught exception triggers abort(). v0.2.8 fixed the OCR instance; three more survived:
- Clipboard monitoring (
macos_clipboard.rs) — two timeouts onget_pasteboard_change_countandcapture_clipboard_meta, both calling NSPasteboard ObjC FFI. The clipboard monitor fires immediately on daemon startup — prime suspect for the "daemon dies within seconds" symptom still seen on v0.2.8. - Accessibility checks (
macos_accessibility.rs) — timeout onis_secure_field_focusedAX API call. - AppleScript queries (
applescript_bridge.rs) — timeout onquery_app_state(subprocess, lower risk but fixed for consistency since innerrun_osascript()already has its own timeout).
All four blocking calls now run to completion inside spawn_blocking without outer Tokio timeouts. Verified zero remaining timeout + spawn_blocking combos in the daemon.
Install
brew upgrade --cask agenthandover
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.9/AgentHandover-0.2.9.pkg
sudo installer -pkg AgentHandover-0.2.9.pkg -target /Relates to issue #1 (NOT closing — waiting for reporter's v0.2.9 retest).
v0.2.8 — OCR timeout abort fix + diagnostic infrastructure
Fixes a silent daemon abort caused by a Tokio-level OCR timeout that orphaned an in-flight Vision framework call. Reported by hikoae with a precise diagnostic showing OCR timed out after 500ms as the last log line before silent process death.
Root cause
The OCR wrapper in crates/daemon/src/platform/macos_ocr.rs used tokio::time::timeout(500ms, spawn_blocking(perform_ocr_safe)). When the timeout fired, Tokio dropped the future but the blocking thread kept running the Vision framework call. When Vision eventually finished, ObjC cleanup (request deallocation, autoreleasepool drain) ran outside the @try/@catch/@autoreleasepool scope in perform_ocr_safe() — the Tokio future that owned the context had moved on.
The uncaught ObjC exception triggered abort() → SIGABRT with default handler → immediate process termination, no signal handlers fire, no shutdown logs, no cleanup. This violates the project's own safety rule: all throwing ObjC code must live inside .m files with @try/@catch/@autoreleasepool. The Tokio-level timeout cancellation escaped that boundary.
Why it didn't reproduce everywhere: OCR completes under 500ms on most screen content; only specific content types (complex fonts, image-heavy pages) push Vision past 500ms and trigger the race.
The fix
Three changes:
- Dropped the Tokio-level OCR timeout. The blocking Vision call now runs to completion inside
spawn_blocking. Vision returns in tens of milliseconds in the overwhelming majority of cases; outlier calls taking a second or two are vastly preferable to crashing the daemon. This is the actual fix. - Installed
std::panic::set_hookat daemon startup that logs panics viatracing::error!with location + payload before process exit. Rust's default panic printer writes to stderr, which was being redirected to/dev/null— so any Tokio task panic vanished without a trace. - Redirected daemon stderr to
daemon.stderr.login both the Swift menu bar app'sProcess()spawn and the Rust CLI'sCommand::spawnpath (previously/dev/null). Future ObjCNSExceptionmessages and panic backtraces will leave a diagnostic trail for the next mystery exit.
Verification
- Daemon starts cleanly at v0.2.8
daemon.stderr.logfile created in~/Library/Application Support/agenthandover/logs/(empty — waiting for any future abnormal exit)- 3000/3000 Python tests pass
- Signed + notarized + stapled
Install
brew upgrade --cask agenthandover
# Or direct download
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.8/AgentHandover-0.2.8.pkg
sudo installer -pkg AgentHandover-0.2.8.pkg -target /Relates to issue #1 (NOT closing — waiting for reporter's v0.2.8 retest).
v0.2.7 — clean status reporting after 'Observe Me' toggle
Fixes confusing status output after toggling "Observe Me" off. v0.2.6 stopped the daemon correctly but left daemon-status.json behind with a dead PID, so agenthandover status reported "not responding" while the extension indicator stayed green (because Chrome was spawning transient ah-observer native-messaging bridge instances that wrote their own heartbeat file).
Root cause
The ah-observer binary has two modes:
- Main daemon (launchd/app-spawned): writes
daemon.pid+daemon-status.json, holds SQLite, runs observer loop - NM bridge (Chrome-spawned per message): lightweight stateless stdio relay, writes only
extension-heartbeat.json
When the main daemon was SIGTERM'd, nothing deleted daemon-status.json — it persisted with a dead PID. Meanwhile Chrome kept spawning NM bridges whenever the extension pinged, keeping extension-heartbeat.json fresh. The CLI read the stale daemon status (dead PID → "not responding") and the SwiftUI app read the fresh extension heartbeat (→ "connected"). Two files, two processes, two disagreeing readings.
The fix
Four changes across the daemon, app, and CLI:
- Daemon clears its own
daemon-status.jsonon clean shutdown — matches the worker's existing_remove_worker_status()behavior. ServiceController.stopAll()removes the native messaging host manifest on pause, andstartAll()re-installs it on resume. Chrome can't spawn transient bridge instances while paused → extension indicator matches daemon state.ServiceController.stopDaemon()also deletesdaemon-status.json(belt-and-suspenders for non-clean shutdowns).- CLI
stop_daemon_direct()always clearsdaemon-status.json, even whendaemon.pidis missing (the original implementation returned early before reaching cleanup).
Verification
stopwhen already stopped → clean○ Daemon (not running)start→● Daemon (running) v0.2.7stop→ both status files deleted, clean state — no more stale "● (not responding)"- 3000/3000 Python tests pass
Install
brew upgrade --cask agenthandover
# Or direct download
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.7/AgentHandover-0.2.7.pkg
sudo installer -pkg AgentHandover-0.2.7.pkg -target /Relates to issue #1 (NOT closing — waiting for reporter's v0.2.7 retest).
v0.2.6 — worker state detection + native host manifest + early status
Fixes worker startup state detection, native host manifest reliability on upgrade, and early status file visibility. Three issues reported on v0.2.5.
is_job_running() now checks for an actual running process
The CLI's is_job_running() used launchctl list <label> which returns success when the job is registered in launchd, even if the process isn't running (PID is -). This caused agenthandover start to falsely report "Worker already running" when the worker wasn't actually running. Rewrote to use launchctl print gui/<uid>/<label> and parse for pid = <N> where N > 0 — the same check the Swift menu bar app uses.
Native host manifest written directly by postinstall
v0.2.5 removed stale manifests in postinstall and relied on the app to recreate them on launch. If the app didn't launch cleanly, manifests stayed deleted. v0.2.6 writes the correct manifest directly in the postinstall for every installed Chromium browser. No more agenthandover setup --extension needed after install.
Worker writes an early "starting" status file
Previously worker-status.json was written only after ~700 lines of initialization (DB connect, module imports, knowledge base, vector KB, Ollama checks). If anything failed before that point, no status file existed and the CLI reported "not running" even though the Python process was alive. v0.2.6 writes a minimal status file immediately after PID file creation, then updates it once init completes.
Install
brew tap sandroandric/agenthandover
brew upgrade --cask agenthandover
# Or direct download
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.6/AgentHandover-0.2.6.pkg
sudo installer -pkg AgentHandover-0.2.6.pkg -target /Relates to issue #1 (NOT closing — reporter may have more feedback).
v0.2.5 — fix Chrome extension native messaging connection
Fixes the Chrome extension's native messaging connection. The allowed_origins in the native host manifest was using a stale extension ID (knldjmfmopnpolahpmmgbagdohdnhkik) that didn't match the actual ID derived from the key field in manifest.json (jpemkdcihaijkolbkankcldmiimmmnfo). Chrome correctly rejected the connection with "Access to the specified native messaging host is forbidden."
What was wrong
The extension's manifest.json contains a key field (RSA public key) that determines the stable extension ID for unpacked loads. Chrome derives the ID by SHA-256 hashing the decoded key bytes, taking the first 32 hex chars, and mapping each to a-p. The correct ID is jpemkdcihaijkolbkankcldmiimmmnfo. However, 9 locations across the codebase hardcoded the stale ID knldjmfmopnpolahpmmgbagdohdnhkik — the native host manifest's allowed_origins, Rust config defaults, CLI setup/doctor, Swift ServiceController, shell scripts, and docs.
The fix
Simple global replacement of the stale ID with the correct one in all 9 locations. Zero logic changes. The app's ServiceController.installNativeMessagingHostManifest() runs on every launch when onboarding is complete, so upgrading to v0.2.5 and restarting the app re-writes all native host manifests with the correct ID automatically. The postinstall also deletes any stale manifests before the app launches — no manual editing required.
Verification
agenthandover doctornow showspass Native messaging host(previously FAIL on v0.2.4)- Native host manifest at
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.agenthandover.host.jsoncontains"chrome-extension://jpemkdcihaijkolbkankcldmiimmmnfo/" - 3000/3000 Python tests pass
- 13/13 doctor checks pass, 0 failed
Install
# Homebrew cask
brew tap sandroandric/agenthandover
brew upgrade --cask agenthandover
# Or direct download
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.5/AgentHandover-0.2.5.pkg
sudo installer -pkg AgentHandover-0.2.5.pkg -target /Relates to issue #1 (NOT closing — reporter may have more feedback).
v0.2.4 — postinstall user detection + CLI start cleanup + worker version drift
Hotfix for three follow-up issues reported on the v0.2.3 install. All three are install-time / lifecycle fixes — the observation and SOP pipelines are unchanged. Recommended upgrade for anyone on v0.2.3.
Installer — correct user detection on edge cases
v0.2.3's postinstall relied on stat -f '%Su' /dev/console to find the real logged-in user. On at least one reporter's machine, /dev/console was owned by root at install time, which cascaded into dscl . -read /Users/root NFSHomeDirectory → /var/root — a real directory on macOS, so the [ -d "$USER_HOME" ] safety check passed and the LaunchAgent got copied to /var/root/Library/LaunchAgents/ instead of the user's home. Silent failure: the plist was invisible to the user's session, agenthandover start failed, doctor reported the worker plist missing.
v0.2.4 cascades through four methods:
scutil show State:/Users/ConsoleUser(Apple's canonical GUI-user source)SUDO_USERenv var (forsudo installer -pkgfrom SSH)stat -f '%Su' /dev/console(legacy method, kept as fallback)dscl . list /Users UniqueIDfirst-real-user scan (UID ≥ 500)
Each method is validated via _valid_user() (the UID must exist AND be ≥ 500). The postinstall hard-fails with an explicit error if none of the four methods find a real user, and refuses to install if USER_HOME ever resolves to /var/root or empty. The install diagnostic now prints Installing for user: <user> (UID=<uid>, HOME=<home>, detected via <method>) so future bug reports include the detection path.
CLI — agenthandover start worker no longer prints a spurious error
launchctl load -w (deprecated) returns Load failed: 5: Input/output error when the job is already loaded — even though the worker is running correctly. The CLI wrapper auto-printed stderr before checking the final state, so users saw a scary error message on every call.
v0.2.4 rewrites both start_worker_launchd and stop_worker_launchd to use the modern bootstrap gui/<uid> / kickstart -k gui/<uid>/<label> / bootout gui/<uid>/<label> APIs, adds an idempotent "already running" short-circuit check at the top of start, and switches to a silent-launchctl wrapper that only surfaces stderr on actual failure (a final is_job_running check after 500 ms confirms the outcome).
Before: scary error printed, exit 0.
After: clean success message, no error text in the common case.
Worker — /version and worker-status.json report the installed version
worker_version was hardcoded as the string "0.2.0" in three places (main.py, procedure_schema.py, query_api.py), and __version__ in agenthandover_worker/__init__.py was hardcoded as "0.1.0" — all four had been frozen across v0.2.0, v0.2.1, v0.2.2, and v0.2.3 releases. So the REST /version endpoint and worker-status.json were lying about which worker an agent was talking to, and every saved procedure's generator_version was wrong.
Refactored to a single source of truth: agenthandover_worker/__init__.py now reads the installed version dynamically from importlib.metadata.version("agenthandover-worker"), and the three call sites all read __version__ from the package. No more drift — the worker version tracks pyproject.toml automatically on every release.
Tests
- 3000/3000 Python tests pass (unchanged count — all three fixes are behavioral, no new surface area).
- Swift app, Rust daemon, and Chrome extension unchanged except for version bumps.
- Validated end-to-end on the installed pkg:
/var/log/install.logshowsInstalling for user: sandroandric (UID=501, HOME=/Users/sandroandric, detected via scutil)agenthandover start worker→ clean output, noLoad failed: 5worker-status.jsonreports"version": "0.2.4"- Round-trip
start/stop/startall paths clean
Install
# Homebrew cask
brew tap sandroandric/agenthandover
brew install --cask agenthandover
# Or download the signed + notarized .pkg directly
curl -LO https://github.com/sandroandric/AgentHandover/releases/download/v0.2.4/AgentHandover-0.2.4.pkg
sudo installer -pkg AgentHandover-0.2.4.pkg -target /Relates to issue #1 (NOT closing — reporter may have more feedback after v0.2.4 install).
v0.2.3
Hotfix for a v0.2.2 regression that crashed the worker on first launch, plus four other issues caught during deep dogfooding. Recommended upgrade for everyone on v0.2.2.
Worker startup fix (critical)
Fixed a Python scoping bug where a redundant from agenthandover_worker.focus_processor import FocusProcessor inside main() shadowed the module-level import and caused Python to treat FocusProcessor as local to the entire function, crashing the worker with UnboundLocalError on startup. Proactively removed 3 more latent shadow imports of the same class (datetime, timezone, OpenClawWriter, VLMFallbackQueue) and added a regression test that AST-scans main.py for any module-level import re-imported inside any function body — catches the whole class of bug.
Credit to the reporter in issue #1 for correctly diagnosing the root cause from first principles.
agenthandover doctor refresh
- Daemon binary check now points at
/usr/local/lib/agenthandover/ah-observer(the v0.2.1 rename). - Accessibility + Screen Recording permission checks are now advisory info lines pointing users at System Settings — the previous checks ran
AXIsProcessTrusted()/CGDisplayCreateImage()against the CLI process instead of the app bundle, which always returned false. - Dead
com.agenthandover.daemon.plistlaunchd check removed (daemon hasn't used launchd since v0.2.1). - Required-models check now reads your configured
annotation_model/sop_model/ embedding model fromconfig.tomlinstead of hardcodingqwen3.5— Gemma 4 users (16GB+ recommended tier) stop seeing false failures. Falls back gracefully ifconfig.tomlis missing fields, and skips the local-model check entirely whenvlm.mode = remote.
SOP quality improvements
- SOP generation silently drops declared variables that aren't actually referenced in any step's text. Gemma 4 occasionally hallucinates "might be useful" variables and leaves them unused — these used to clutter the final Skill. Also tightened the SOP generation prompt with a strict variable contract.
- Added a coherence check to the SOP generation prompt that distinguishes workflow steps from incidental distractions (tab-switches, brief unrelated reads, app-flipping during pauses). Frames that are topologically disjoint from the primary task and leave no downstream trace get dropped, even if the user spent multiple frames on them. The rule is written abstractly — no hardcoded app names, no content anchors — so it generalizes across every user's workflow.
- Focus Q&A subprocess now uses your configured SOP model instead of hardcoding
qwen3.5:4b. Previously, Gemma-4-only users (16GB+ tier) had the Q&A silently fall back to a model they never pulled, degrading to zero questions.
Tests
- 3000 / 3000 Python tests pass (+1 new
test_unused_variables_are_dropped, +1 newtest_no_module_imports_are_shadowed_anywhere). - 11 / 11 Rust CLI tests pass.
- Validated end-to-end on the installed pkg with two real focus recordings on Python 3.14.4 + Gemma 4 + Ollama 0.20.5: zero tracebacks, zero
UnboundLocalError, zeroNameError, zeroImportErroracross the full pipeline (capture → annotate → diff → synthesize → SOP generate → Q&A → save).
Verification
- pkg SHA-256:
1ab9d5b6f4242618ab75ab18c515c74f32f0606517e3a965756a30d6c15695c1 - Signed with Developer ID Application: Sandro Andric (444JTK8679)
- Notarized + stapled via Apple notary service
v0.2.2
Maintenance release — finishes the v0.2.0 → v0.2.1 upgrade path and ports the v0.2.1 "rich observations" grounding into the daily re-synthesis loop.
Upgrade safety
- Preinstall now cleans stale
com.agenthandover.daemon.plistfrom every known location (~/Library/LaunchAgents,/usr/local/lib/agenthandover/launchd,/Library/LaunchAgents,/Library/LaunchDaemons). macOS'spkginstaller only replaces files that are in the new Bom, so files the v0.2.1 pkg never heard of were being left in place on in-place upgrades from v0.2.0. Fixes #1 for anyone still sitting on a v0.2.0 install. agenthandover start/stopspawn the daemon directly viaProcess()instead oflaunchctl loadon a plist that no longer ships — mirrors what the menu bar app already does. Worker still routed through launchd.
Behavioral synthesis — rich observations in daily re-synthesis
The daily re-synthesis loop now looks up the real events for each focus-derived observation from SQLite, parses scene_annotation_json, and builds rich per-frame dicts (email_addresses, urls, typed_text, visible_values, active_element, compose) via FocusProcessor._build_pre_analysis_obs — the same grounding focus recordings already used in v0.2.1. The synthesizer prompt's TIMELINE EVIDENCE section now has verbatim text to quote for focus-derived procedures, not just abstract SOP steps. Passive sop_pipeline procedures fall back to abstracted steps (follow-up TODO for v0.3.0).
Homebrew install path
The old homebrew/Formula/agenthandover.rb was built pre-v0.2.0 and was actively incompatible with v0.2.1/v0.2.2 — it installed the daemon as agenthandover-daemon (renamed to ah-observer in v0.2.1), wrote the exact com.agenthandover.daemon.plist file issue #1 is about, and registered the daemon as a brew services job (creating a second TCC principal so Screen Recording silently failed). Replaced with a Homebrew cask at homebrew/Casks/agenthandover.rb that downloads the signed + notarized .pkg from this release and runs Apple's installer — bit-identical behavior to direct-download, so all TCC / launchd / Ollama flows are shared. Install via:
brew tap sandroandric/agenthandover
brew install --cask agenthandoverTests
2997 / 2997 Python tests pass. No schema changes, no breaking changes.
Verification
- pkg SHA-256:
1c84313e86473e96df88b1a7f60eff6af6d992cb4960f3f5c51b25f4889be572 - Signed with Developer ID Application: Sandro Andric (444JTK8679)
- Notarized + stapled via Apple notary service