Skip to content

feat: macOS menu-bar tray (af-tray) with launchd auto-start#725

Open
AbirAbbas wants to merge 4 commits into
mainfrom
feat/desktop-tray
Open

feat: macOS menu-bar tray (af-tray) with launchd auto-start#725
AbirAbbas wants to merge 4 commits into
mainfrom
feat/desktop-tray

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

Adds af-tray, a Docker-Desktop-style menu-bar companion for AgentField on macOS. It puts an AgentField icon in the menu bar that shows control-plane status, opens the dashboard, and manages the control plane's lifecycle — with hands-off install/update via the existing curl | bash flow.

This is a macOS-first slice by design. Windows/Linux trays are deliberately out of scope here (see below); nothing in this PR paints us into a mac-only corner.

What it does

  • Status — polls the public, auth-exempt /health endpoint every 5s; icon switches between active/inactive.
  • Menu — Open Dashboard · Start / Stop / Restart control-plane · Start-at-login toggle · View logs · Quit.
  • Tray-as-controller — the control plane runs as its own launchd agent; the tray loads/kicks/boots it via launchctl. Not a supervisor.
  • Hands-off updatesinstall.sh fetches the tray on macOS and delegates .app-bundle + launchd setup to af-tray install, which is idempotent/convergent (bootstrap + kickstart -k) so a curl | bash update force-restarts a stale tray/server onto the new binary with nothing manual.

Design decisions

  • Separate binary → server stays clean. The systray/CGO dependency lives only in af-tray; the control-plane binary has zero systray/dbus deps (verified via go list -deps ./cmd/af). Containers/headless hosts (Railway/ECS/EC2) never build, fetch, or run the tray — and if af-tray run is somehow invoked without a GUI, it exits 0 (no crash-loop).
  • launchd semantics encode intent. Server: KeepAlive={SuccessfulExit:false} (a graceful Stop sticks; a crash restarts) + --open=false under launchd. Tray: KeepAlive={Crashed:true} (Quit / no-GUI exit never crash-loops). .app uses LSUIElement (menu-bar-only, no dock icon).
  • Testability. Platform-neutral logic (health check, plist generation, launchctl arg construction, atomic writes) is factored into shared.go with contract-based tests that run on the Linux CI; only the systray loop + launchctl exec calls are darwin-tagged. Non-darwin builds compile a graceful no-op stub.

Testing

  • New unit tests (cmd/af-tray/shared_test.go, 12 tests) — run on Linux CI under -tags sqlite_fts5. Contract-based (behavior, not implementation): health = HTTP 200 only; plist contracts (--open=false, SuccessfulExit=false, Crashed=true, LSUIElement, RunAtLoad); kickstart -k only when killing; AGENTFIELD_PORT handling; atomic write replace + no temp leftovers; path layout; serverBinaryPath preference.
  • Verified locally: go build ./...; CGO-off compile-matrix for linux/darwin/windows agentfield-server (confirms the new go.mod dep doesn't break the required cross-compiles); golangci-lint clean; go vet ./...; server-binary systray isolation; and the darwin sources type-check clean against a faithful systray stub (a real macOS build is the final validation of the Cocoa/CGO link).

Scope / follow-ups

  • macOS only. Windows tray = re-enable the goreleaser Windows target + a native .exe (or WSLg); Linux tray = StatusNotifier + systemd-user/XDG autostart. Both are incremental — the systray loop itself is largely shareable.
  • Windows .ico assets are included in the tray assets dir for that upcoming work; they aren't embedded/wired yet.

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits July 6, 2026 19:25
Adds `af-tray`, a small separate binary that puts an AgentField icon in
the macOS menu bar. It polls the control plane's public /health endpoint
to show running/stopped status, opens the dashboard, and drives the
control-plane lifecycle (start/stop/restart/start-at-login) via launchd —
the tray is a controller of an OS service, not a supervisor.

Design/isolation:
- Separate binary so the systray/CGO dependency never enters the server
  binary (verified: ./cmd/af has no systray/dbus deps). Containers/headless
  hosts (Railway/ECS/EC2) never build, install, or run it.
- Platform-neutral logic (health, launchd plist generation, launchctl arg
  construction, atomic writes) lives in shared.go with contract tests that
  run on the Linux CI; the systray loop and launchctl exec calls are
  darwin-tagged. Non-darwin builds compile a graceful no-op stub.
- launchd semantics encode the intended behaviour: server uses
  KeepAlive={SuccessfulExit:false} so a graceful Stop sticks but a crash
  restarts; tray uses KeepAlive={Crashed:true} so Quit / no-GUI exit never
  crash-loops; server runs with --open=false under launchd.
- install is idempotent/convergent (bootstrap + kickstart -k) so a
  `curl | bash` update force-restarts a stale tray/server onto the new
  binary with nothing manual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On macOS (production channel) the installer now fetches the separate
`agentfield-tray-<arch>` binary and delegates .app-bundle + launchd setup
to `af-tray install` (mirroring how the skill install is delegated to the
binary). Best-effort throughout: a missing/failed tray never fails the
overall install, and it is never fetched on Linux/headless/container hosts.
Opt out with --no-tray or TRAY_MODE=none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds goreleaser build targets for agentfield-tray-darwin-{amd64,arm64}
(CGO on, macOS only) and includes them in the release matrix on the
macos-14 runner so the Cocoa/CGO link happens on a real macOS host. The
binaries are named to match the existing agentfield-* asset convention,
so the flatten/checksum/upload steps pick them up automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@AbirAbbas AbirAbbas requested a review from a team as a code owner July 6, 2026 23:26
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 86.90% 87.40% ↓ -0.50 pp 🟡
sdk-go 91.90% 92.00% ↓ -0.10 pp 🟢
sdk-python 93.76% 93.73% ↑ +0.03 pp 🟢
sdk-typescript 90.09% 90.42% ↓ -0.33 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.55% 85.75% ↓ -0.20 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 206 88.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

…helpers

Splits the CLI dispatch out of main() into a testable run([]string) int and
adds unit tests for it plus the non-darwin stubs and the shared helpers
(serverBinaryPath fallbacks, writeFileAtomic error paths). This lifts the
control-plane patch coverage on the af-tray files from 66% to ~85%, above the
80% patch-coverage gate. The darwin-only files are not measured on the Linux
coverage run; the systray loop / launchctl calls remain covered only by the
on-device build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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