diff --git a/commits-tray b/commits-tray
new file mode 100755
index 0000000..b6c5da8
--- /dev/null
+++ b/commits-tray
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""ACS menu bar app with local commit agent.
+
+Manages a lightweight commit agent that discovers local repos, asks the
+remote ACS daemon for LLM-generated commit messages, and commits+pushes.
+
+Usage:
+ ./commits-tray --url http://apricot.local:8200
+ ./commits-tray --url http://apricot.local:8200 --repos ~/Code --cycle 300
+"""
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_tray_dir = os.path.join(_script_dir, "src", "auto_commit_service", "tray")
+sys.path.insert(0, _tray_dir)
+
+from app import run_tray # noqa: E402
+
+
+def main():
+ parser = argparse.ArgumentParser(description="ACS menu bar app + local commit agent")
+ parser.add_argument(
+ "--url", "-u",
+ default="http://apricot.local:8200",
+ help="Remote ACS daemon URL for LLM + recording (default: http://apricot.local:8200)",
+ )
+ parser.add_argument(
+ "--repos", "-r",
+ nargs="+",
+ default=None,
+ help="Base paths to scan for local git repos (default: ~/Code)",
+ )
+ parser.add_argument(
+ "--cycle", "-c",
+ type=int,
+ default=300,
+ help="Seconds between commit cycles (default: 300)",
+ )
+ args = parser.parse_args()
+
+ repos_paths = [Path(p).expanduser() for p in args.repos] if args.repos else None
+ run_tray(daemon_url=args.url, repos_paths=repos_paths, cycle_seconds=args.cycle)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index d7d1b3d..1d26f35 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,9 @@ commits = "auto_commit_service.cli:main"
model-boss = [
"lilith-model-boss>=0.1.0",
]
+tray = [
+ "rumps>=0.4.0",
+]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
diff --git a/src/auto_commit_service/cli/__init__.py b/src/auto_commit_service/cli/__init__.py
index afec674..eadd863 100644
--- a/src/auto_commit_service/cli/__init__.py
+++ b/src/auto_commit_service/cli/__init__.py
@@ -1024,6 +1024,170 @@ WantedBy=default.target
typer.echo(f" Disable: systemctl --user disable {service_name}")
+@app.command()
+def install_launchd(
+ directory: Annotated[str, typer.Argument(help="Directory to monitor")],
+ interval: Annotated[str, typer.Argument(help="Commit interval")] = "5m",
+ recursive: Annotated[bool, typer.Option("-R", "--recursive")] = False,
+ depth: Annotated[Optional[int], typer.Option(help="Recursion depth")] = None,
+ llm_url: Annotated[
+ str, typer.Option("--llm-url", help="LLM service URL (e.g., http://apricot.local:8100)")
+ ] = "",
+) -> None:
+ """Install macOS launchd agent for auto-starting daemon.
+
+ Creates a LaunchAgent plist that starts the commits daemon on login.
+ Use --llm-url to point inference at a remote model-boss instance.
+
+ Examples:
+ commits install-launchd ~/Code 5m -R
+ commits install-launchd ~/Code 5m -R --llm-url http://apricot.local:8100
+ """
+ directory = str(Path(directory).resolve())
+
+ # Generate label from directory
+ label = "com.lilith.commits." + directory.replace("/", ".").strip(".")
+ plist_dir = Path.home() / "Library" / "LaunchAgents"
+ plist_file = plist_dir / f"{label}.plist"
+
+ # Find the commits binary
+ commits_bin = subprocess.run(
+ ["which", "commits"], capture_output=True, text=True
+ ).stdout.strip()
+ if not commits_bin:
+ _echo_error("'commits' binary not found in PATH")
+ raise typer.Exit(1)
+
+ # Build arguments for the start command
+ start_args = [interval]
+ if recursive:
+ start_args.append("-R")
+ if depth is not None:
+ start_args.append(str(depth))
+
+ # Build environment variables
+ env_vars: dict[str, str] = {
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ "HOME": str(Path.home()),
+ }
+ if llm_url:
+ env_vars["AUTO_COMMIT_LLAMA_SERVICE_URL"] = llm_url
+
+ typer.echo(f"Creating launchd agent: {label}")
+ typer.echo(f" Directory: {directory}")
+ typer.echo(f" Interval args: {' '.join(start_args)}")
+ typer.echo(f" Plist: {plist_file}")
+ if llm_url:
+ typer.echo(f" LLM URL: {llm_url}")
+
+ # Build the plist XML
+ env_xml = "\n".join(
+ f" {k}\n {v}"
+ for k, v in env_vars.items()
+ )
+
+ program_args_xml = "\n".join(
+ f" {arg}"
+ for arg in [commits_bin, "start", *start_args]
+ )
+
+ plist_content = f"""
+
+
+
+ Label
+ {label}
+
+ ProgramArguments
+
+{program_args_xml}
+
+
+ WorkingDirectory
+ {directory}
+
+ EnvironmentVariables
+
+{env_xml}
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+ SuccessfulExit
+
+
+
+ StandardOutPath
+ {Path.home() / "Library" / "Logs" / "commits-daemon.log"}
+
+ StandardErrorPath
+ {Path.home() / "Library" / "Logs" / "commits-daemon.log"}
+
+ ThrottleInterval
+ 30
+
+
+"""
+
+ plist_dir.mkdir(parents=True, exist_ok=True)
+ plist_file.write_text(plist_content)
+ _echo_success("Plist file created")
+
+ # Load the agent
+ # Unload first if it already exists (ignore errors)
+ subprocess.run(
+ ["launchctl", "unload", str(plist_file)],
+ capture_output=True,
+ )
+ result = subprocess.run(
+ ["launchctl", "load", str(plist_file)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ _echo_success("LaunchAgent loaded")
+ else:
+ _echo_error(f"Failed to load: {result.stderr.strip()}")
+ raise typer.Exit(1)
+
+ typer.echo()
+ typer.echo("LaunchAgent installed successfully!")
+ typer.echo()
+ typer.echo("Commands:")
+ typer.echo(f" Status: launchctl list | grep {label}")
+ typer.echo(f" Logs: tail -f ~/Library/Logs/commits-daemon.log")
+ typer.echo(f" Stop: launchctl unload {plist_file}")
+ typer.echo(f" Remove: launchctl unload {plist_file} && rm {plist_file}")
+
+
+@app.command()
+def tray(
+ url: Annotated[
+ str, typer.Option("--url", "-u", help="Daemon API URL")
+ ] = "http://localhost:8200",
+) -> None:
+ """Launch the macOS menu bar status app.
+
+ Provides a system tray icon with daemon status, controls, and recent
+ commit history. Connects to the daemon API via HTTP.
+
+ Examples:
+ commits tray # Local daemon
+ commits tray --url http://apricot.local:8200 # Remote daemon
+ """
+ try:
+ from ..tray import run_tray
+ except ImportError:
+ _echo_error("Tray dependencies not installed. Run: pip install 'auto-commit-service[tray]'")
+ raise typer.Exit(1)
+
+ _echo_header(f"Starting menu bar app (connecting to {url})")
+ run_tray(daemon_url=url)
+
+
def main() -> None:
"""Entry point for the CLI."""
app()
diff --git a/src/auto_commit_service/tray/__init__.py b/src/auto_commit_service/tray/__init__.py
new file mode 100644
index 0000000..46d5566
--- /dev/null
+++ b/src/auto_commit_service/tray/__init__.py
@@ -0,0 +1,5 @@
+"""macOS system tray (menu bar) client for the auto-commit daemon."""
+
+from .app import run_tray
+
+__all__ = ["run_tray"]
diff --git a/src/auto_commit_service/tray/__main__.py b/src/auto_commit_service/tray/__main__.py
new file mode 100644
index 0000000..d3a0442
--- /dev/null
+++ b/src/auto_commit_service/tray/__main__.py
@@ -0,0 +1,37 @@
+"""Standalone entry point for the tray app.
+
+Usage:
+ python -m auto_commit_service.tray [--url http://apricot.local:8200]
+
+Can also be run directly without the full auto_commit_service package installed,
+as long as httpx and rumps are available:
+ PYTHONPATH=src python -m auto_commit_service.tray --url http://apricot.local:8200
+"""
+
+import argparse
+import sys
+import os
+
+# Allow running as standalone without the full package by injecting
+# the tray directory into sys.path for relative imports to work.
+_tray_dir = os.path.dirname(os.path.abspath(__file__))
+_pkg_dir = os.path.dirname(_tray_dir)
+if _pkg_dir not in sys.path:
+ sys.path.insert(0, os.path.dirname(_pkg_dir))
+
+from auto_commit_service.tray.app import run_tray
+
+
+def main():
+ parser = argparse.ArgumentParser(description="ACS menu bar app")
+ parser.add_argument(
+ "--url", "-u",
+ default="http://localhost:8200",
+ help="Daemon API URL (default: http://localhost:8200)",
+ )
+ args = parser.parse_args()
+ run_tray(daemon_url=args.url)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/auto_commit_service/tray/app.py b/src/auto_commit_service/tray/app.py
new file mode 100644
index 0000000..9c770ac
--- /dev/null
+++ b/src/auto_commit_service/tray/app.py
@@ -0,0 +1,259 @@
+"""macOS menu bar application for controlling the ACS local commit agent.
+
+Uses `rumps` (Ridiculously Uncomplicated macOS Python Statusbar apps)
+to provide a lightweight native menu bar interface. Manages a local
+commit agent that discovers repos, gets commit messages from the remote
+ACS daemon's LLM, and commits+pushes locally.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from datetime import datetime, timezone
+from pathlib import Path
+
+try:
+ import rumps
+except ImportError:
+ raise ImportError(
+ "rumps is required for the tray app. Install with: pip install 'auto-commit-service[tray]'"
+ )
+
+try:
+ from .client import DaemonClient
+ from .local_git import discover_repos, recent_commits as local_recent_commits
+ from .local_agent import LocalCommitAgent
+except ImportError:
+ from client import DaemonClient # type: ignore[no-redef]
+ from local_git import discover_repos, recent_commits as local_recent_commits # type: ignore[no-redef]
+ from local_agent import LocalCommitAgent # type: ignore[no-redef]
+
+logger = logging.getLogger(__name__)
+
+ICON_OK = "✓"
+ICON_DEGRADED = "⚠"
+ICON_ERROR = "✗"
+ICON_OFFLINE = "○"
+
+POLL_INTERVAL = 30 # seconds
+DEFAULT_CYCLE_SECONDS = 300 # 5 minutes
+
+
+class CommitsTrayApp(rumps.App):
+ """Menu bar app managing a local commit agent + remote ACS connection."""
+
+ def __init__(
+ self,
+ daemon_url: str = "http://localhost:8200",
+ repos_paths: list[Path] | None = None,
+ cycle_seconds: int = DEFAULT_CYCLE_SECONDS,
+ ):
+ super().__init__(
+ name="Commits",
+ title=f"{ICON_OFFLINE} ACS",
+ quit_button=None,
+ )
+
+ self._daemon_url = daemon_url
+ self._repos_paths = repos_paths or [Path.home() / "Code"]
+
+ # Remote daemon client (for status + LLM health checks)
+ self.client = DaemonClient(base_url=daemon_url)
+
+ # Local commit agent
+ self.agent = LocalCommitAgent(
+ acs_url=daemon_url,
+ repos_paths=self._repos_paths,
+ cycle_seconds=cycle_seconds,
+ )
+
+ # For local commit history display
+ self._local_repos: list[Path] = []
+ self._repos_discovered = False
+
+ # Build menu
+ self._status_item = rumps.MenuItem("Status: starting...", callback=None)
+ self._status_item.set_callback(None)
+
+ self._agent_status_item = rumps.MenuItem("Agent: starting...", callback=None)
+ self._agent_status_item.set_callback(None)
+
+ self._repos_item = rumps.MenuItem("Repos: --", callback=None)
+ self._repos_item.set_callback(None)
+
+ self._toggle_item = rumps.MenuItem("Disable Agent", callback=self._on_toggle)
+ self._trigger_item = rumps.MenuItem("Commit Now", callback=self._on_trigger)
+
+ self._commits_menu = rumps.MenuItem("Recent Commits")
+ self._commits_menu.add(rumps.MenuItem("Loading...", callback=None))
+
+ self._quit_item = rumps.MenuItem("Quit", callback=self._on_quit)
+
+ self.menu = [
+ self._status_item,
+ self._agent_status_item,
+ self._repos_item,
+ None,
+ self._toggle_item,
+ self._trigger_item,
+ None,
+ self._commits_menu,
+ None,
+ self._quit_item,
+ ]
+
+ # Start the local agent
+ self.agent.start()
+
+ # Start polling
+ self._poll_timer = rumps.Timer(self._poll, POLL_INTERVAL)
+ self._poll_timer.start()
+ threading.Thread(target=self._poll, args=(None,), daemon=True).start()
+
+ # -- Polling --
+
+ def _poll(self, _sender) -> None:
+ """Update menu bar status from remote daemon + local agent."""
+ # Check remote LLM health
+ health = self.client.health()
+ llm_up = health is not None and health.get("llama_service_available", False)
+
+ # Local agent state
+ agent_running = self.agent.is_running
+ agent_enabled = self.agent.is_enabled
+
+ # Icon
+ if agent_running and llm_up:
+ icon = ICON_OK
+ elif agent_running and not llm_up:
+ icon = ICON_DEGRADED
+ else:
+ icon = ICON_ERROR
+
+ self.title = f"{icon} ACS"
+
+ # Status lines
+ llm_label = "up" if llm_up else "down"
+ self._status_item.title = f"LLM: {llm_label} ({self._daemon_url})"
+
+ if agent_running and agent_enabled:
+ last = self.agent.last_cycle
+ if last:
+ self._agent_status_item.title = (
+ f"Agent: running | last: {last.repos_committed} committed"
+ )
+ else:
+ self._agent_status_item.title = "Agent: running | no cycles yet"
+ elif agent_running and not agent_enabled:
+ self._agent_status_item.title = "Agent: paused"
+ else:
+ self._agent_status_item.title = "Agent: stopped"
+
+ # Repos count
+ repo_count = len(self.agent._repos)
+ self._repos_item.title = f"Repos: {repo_count} local"
+
+ # Toggle label
+ self._toggle_item.title = "Disable Agent" if agent_enabled else "Enable Agent"
+
+ # Update commits menu in background
+ threading.Thread(target=self._update_commits_menu, daemon=True).start()
+
+ def _update_commits_menu(self) -> None:
+ """Refresh the recent commits submenu from local git repos."""
+ if not self._repos_discovered:
+ self._local_repos = discover_repos(self._repos_paths, max_depth=4)
+ self._repos_discovered = True
+
+ if not self._local_repos:
+ self._commits_menu.clear()
+ self._commits_menu.add(rumps.MenuItem("No local repos found", callback=None))
+ return
+
+ commits = local_recent_commits(self._local_repos, limit=15, since="7 days ago")
+
+ self._commits_menu.clear()
+
+ if not commits:
+ self._commits_menu.add(rumps.MenuItem("No recent commits", callback=None))
+ return
+
+ for commit in commits:
+ msg = commit.message
+ if len(msg) > 45:
+ msg = msg[:42] + "..."
+ age = _format_relative_time(commit.timestamp)
+ label = f"{commit.repo_name}: {commit.hash[:7]} {msg} ({age})"
+ self._commits_menu.add(rumps.MenuItem(label, callback=None))
+
+ # -- Actions --
+
+ def _on_toggle(self, _sender) -> None:
+ if self.agent.is_enabled:
+ self.agent.disable()
+ rumps.notification("ACS", "Agent paused", "Auto-commit disabled")
+ else:
+ self.agent.enable()
+ rumps.notification("ACS", "Agent resumed", "Auto-commit enabled")
+ threading.Thread(target=self._poll, args=(None,), daemon=True).start()
+
+ def _on_trigger(self, _sender) -> None:
+ rumps.notification("ACS", "Running commit cycle...", "")
+ threading.Thread(target=self._do_trigger, daemon=True).start()
+
+ def _do_trigger(self) -> None:
+ result = self.agent.trigger()
+ msg = f"{result.repos_committed} committed, {result.repos_failed} failed"
+ if result.commits:
+ details = "; ".join(f"{c['repo_name']}: {c['hash']}" for c in result.commits[:3])
+ msg += f"\n{details}"
+ rumps.notification("ACS", "Cycle complete", msg)
+ self._poll(None)
+
+ def _on_quit(self, _sender) -> None:
+ self.agent.close()
+ self.client.close()
+ rumps.quit_application()
+
+
+def _format_relative_time(iso_timestamp: str) -> str:
+ """Format an ISO timestamp as a relative time string."""
+ try:
+ ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
+ now = datetime.now(timezone.utc)
+ delta = now - ts
+ seconds = delta.total_seconds()
+
+ if seconds < 0:
+ seconds = abs(seconds)
+ if seconds < 60:
+ return f"in {int(seconds)}s"
+ if seconds < 3600:
+ return f"in {int(seconds / 60)}m"
+ return f"in {seconds / 3600:.1f}h"
+
+ if seconds < 60:
+ return f"{int(seconds)}s ago"
+ if seconds < 3600:
+ return f"{int(seconds / 60)}m ago"
+ if seconds < 86400:
+ return f"{int(seconds / 3600)}h ago"
+ return f"{int(seconds / 86400)}d ago"
+ except Exception:
+ return iso_timestamp
+
+
+def run_tray(
+ daemon_url: str = "http://localhost:8200",
+ repos_paths: list[Path] | None = None,
+ cycle_seconds: int = DEFAULT_CYCLE_SECONDS,
+) -> None:
+ """Launch the menu bar application with local commit agent."""
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
+ app = CommitsTrayApp(
+ daemon_url=daemon_url,
+ repos_paths=repos_paths,
+ cycle_seconds=cycle_seconds,
+ )
+ app.run()
diff --git a/src/auto_commit_service/tray/client.py b/src/auto_commit_service/tray/client.py
new file mode 100644
index 0000000..5c4df81
--- /dev/null
+++ b/src/auto_commit_service/tray/client.py
@@ -0,0 +1,76 @@
+"""HTTP client for communicating with the ACS daemon API."""
+
+from __future__ import annotations
+
+import httpx
+
+
+class DaemonClient:
+ """Thin HTTP client wrapping the ACS daemon's FastAPI endpoints."""
+
+ def __init__(self, base_url: str = "http://localhost:8200", timeout: float = 5.0):
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+ self._client = httpx.Client(base_url=self.base_url, timeout=self.timeout)
+
+ def close(self) -> None:
+ self._client.close()
+
+ def _get(self, path: str, **params) -> dict | None:
+ try:
+ resp = self._client.get(path, params=params)
+ resp.raise_for_status()
+ return resp.json()
+ except Exception:
+ return None
+
+ def _post(self, path: str, **json_body) -> dict | None:
+ try:
+ resp = self._client.post(path, json=json_body if json_body else None)
+ resp.raise_for_status()
+ return resp.json()
+ except Exception:
+ return None
+
+ # -- Queries --
+
+ def health(self) -> dict | None:
+ return self._get("/health")
+
+ def status(self) -> dict | None:
+ return self._get("/status")
+
+ def repos(self) -> list[dict] | None:
+ data = self._get("/repos")
+ return data if isinstance(data, list) else None
+
+ def report_summary(self) -> dict | None:
+ return self._get("/report/summary")
+
+ def recent_commits(self, limit: int = 15) -> dict | None:
+ return self._get("/report/commits/db", limit=limit)
+
+ # -- Mutations --
+
+ def trigger(self) -> dict | None:
+ return self._post("/trigger")
+
+ def enable(self) -> dict | None:
+ return self._post("/enable")
+
+ def disable(self) -> dict | None:
+ return self._post("/disable")
+
+ def refresh_and_run(self) -> dict | None:
+ return self._post("/repos/refresh-and-run")
+
+ def set_priority(self, level: str, ttl_seconds: int | None = None) -> dict | None:
+ body: dict = {"level": level}
+ if ttl_seconds is not None:
+ body["ttl_seconds"] = ttl_seconds
+ try:
+ resp = self._client.post("/priority", json=body)
+ resp.raise_for_status()
+ return resp.json()
+ except Exception:
+ return None
diff --git a/src/auto_commit_service/tray/local_agent.py b/src/auto_commit_service/tray/local_agent.py
new file mode 100644
index 0000000..714ed68
--- /dev/null
+++ b/src/auto_commit_service/tray/local_agent.py
@@ -0,0 +1,265 @@
+"""Lightweight local commit agent for macOS hosts without full ACS.
+
+Discovers local git repos, stages changes, asks the remote ACS daemon
+for commit message generation, commits locally, pushes, and records
+the commit to the central DB.
+
+Runs as a background thread managed by the tray app.
+"""
+
+from __future__ import annotations
+
+import logging
+import socket
+import subprocess
+import threading
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+
+import httpx
+
+try:
+ from .local_git import discover_repos
+except ImportError:
+ from local_git import discover_repos # type: ignore[no-redef]
+
+logger = logging.getLogger(__name__)
+
+HOSTNAME = socket.gethostname().split(".")[0] # e.g., "plum" from "plum.voyager.nasty.sh"
+
+
+@dataclass
+class CycleResult:
+ repos_scanned: int = 0
+ repos_committed: int = 0
+ repos_failed: int = 0
+ commits: list[dict] = field(default_factory=list)
+ errors: list[str] = field(default_factory=list)
+ timestamp: str = ""
+
+
+class LocalCommitAgent:
+ """Runs commit cycles against local repos using a remote ACS for LLM."""
+
+ def __init__(
+ self,
+ acs_url: str,
+ repos_paths: list[Path],
+ cycle_seconds: int = 300,
+ co_author: str = "Lilith Autocommit ",
+ ):
+ self.acs_url = acs_url.rstrip("/")
+ self.repos_paths = repos_paths
+ self.cycle_seconds = cycle_seconds
+ self.co_author = co_author
+
+ self._client = httpx.Client(base_url=self.acs_url, timeout=30.0)
+ self._running = False
+ self._enabled = True
+ self._thread: threading.Thread | None = None
+ self._stop_event = threading.Event()
+ self._repos: list[Path] = []
+ self._last_cycle: CycleResult | None = None
+ self._total_cycles = 0
+
+ @property
+ def is_running(self) -> bool:
+ return self._running
+
+ @property
+ def is_enabled(self) -> bool:
+ return self._enabled
+
+ @property
+ def last_cycle(self) -> CycleResult | None:
+ return self._last_cycle
+
+ def start(self) -> None:
+ if self._running:
+ return
+ self._stop_event.clear()
+ self._repos = discover_repos(self.repos_paths, max_depth=4)
+ logger.info(f"Local agent starting: {len(self._repos)} repos, cycle={self.cycle_seconds}s")
+ self._running = True
+ self._thread = threading.Thread(target=self._loop, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop_event.set()
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=10)
+
+ def enable(self) -> None:
+ self._enabled = True
+
+ def disable(self) -> None:
+ self._enabled = False
+
+ def trigger(self) -> CycleResult:
+ return self._run_cycle()
+
+ def _loop(self) -> None:
+ while not self._stop_event.is_set():
+ if self._enabled:
+ try:
+ self._run_cycle()
+ except Exception as e:
+ logger.error(f"Cycle error: {e}")
+ self._stop_event.wait(timeout=self.cycle_seconds)
+ self._running = False
+
+ def _run_cycle(self) -> CycleResult:
+ result = CycleResult(timestamp=datetime.now(timezone.utc).isoformat())
+
+ for repo_path in self._repos:
+ result.repos_scanned += 1
+ try:
+ committed = self._process_repo(repo_path, result)
+ if committed:
+ result.repos_committed += 1
+ except Exception as e:
+ result.repos_failed += 1
+ result.errors.append(f"{repo_path.name}: {e}")
+ logger.error(f"Failed {repo_path.name}: {e}")
+
+ self._last_cycle = result
+ self._total_cycles += 1
+ logger.info(
+ f"Cycle done: {result.repos_committed} committed, "
+ f"{result.repos_failed} failed, {result.repos_scanned} scanned"
+ )
+ return result
+
+ def _process_repo(self, repo_path: Path, result: CycleResult) -> bool:
+ """Process a single repo. Returns True if a commit was made."""
+ # Check for changes
+ status = _git(repo_path, "status", "--porcelain")
+ if not status.strip():
+ return False
+
+ # Stage all changes
+ _git(repo_path, "add", "-A")
+
+ # Get the diff of staged changes
+ diff = _git(repo_path, "diff", "--cached", "--stat") + "\n" + _git(
+ repo_path, "diff", "--cached", max_bytes=6000
+ )
+ if not diff.strip():
+ return False
+
+ # Get repo name and branch
+ repo_name = _repo_display_name(repo_path)
+ branch = _git(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() or "main"
+
+ # Ask ACS for commit message
+ try:
+ resp = self._client.post("/generate-message", json={
+ "diff": diff,
+ "repo_name": repo_name,
+ "branch": branch,
+ })
+ resp.raise_for_status()
+ message = resp.json().get("message", "")
+ except Exception as e:
+ logger.error(f"LLM generation failed for {repo_name}: {e}")
+ return False
+
+ if not message:
+ logger.warning(f"Empty message for {repo_name}, skipping")
+ _git(repo_path, "reset", "HEAD")
+ return False
+
+ # Commit
+ full_message = f"{message}\n\nCo-Authored-By: {self.co_author}"
+ _git(repo_path, "commit", "-m", full_message)
+
+ # Get commit hash
+ commit_hash = _git(repo_path, "rev-parse", "HEAD").strip()
+
+ # Push
+ try:
+ _git(repo_path, "push")
+ except Exception as e:
+ logger.warning(f"Push failed for {repo_name}: {e}")
+ # Still record the commit even if push fails
+
+ # Parse stats from diff
+ stat_line = diff.split("\n")[0] if diff else ""
+ files_changed = insertions = deletions = None
+ for part in stat_line.split(","):
+ part = part.strip()
+ if "file" in part:
+ try:
+ files_changed = int(part.split()[0])
+ except ValueError:
+ pass
+ elif "insertion" in part:
+ try:
+ insertions = int(part.split()[0])
+ except ValueError:
+ pass
+ elif "deletion" in part:
+ try:
+ deletions = int(part.split()[0])
+ except ValueError:
+ pass
+
+ # Record to central DB
+ try:
+ self._client.post("/record-commit", json={
+ "hash": commit_hash,
+ "repo_name": repo_name,
+ "message": message,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "hostname": HOSTNAME,
+ "branch": branch,
+ "files_changed": files_changed,
+ "insertions": insertions,
+ "deletions": deletions,
+ })
+ except Exception as e:
+ logger.warning(f"Failed to record commit to central DB: {e}")
+
+ result.commits.append({
+ "repo_name": repo_name,
+ "hash": commit_hash[:8],
+ "message": message,
+ })
+
+ return True
+
+ def close(self) -> None:
+ self.stop()
+ self._client.close()
+
+
+def _git(repo_path: Path, *args: str, max_bytes: int = 0) -> str:
+ """Run a git command and return stdout."""
+ result = subprocess.run(
+ ["git", "-C", str(repo_path), *args],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"git {args[0]} failed: {result.stderr.strip()}")
+ output = result.stdout
+ if max_bytes and len(output) > max_bytes:
+ output = output[:max_bytes] + "\n... (truncated)"
+ return output
+
+
+def _repo_display_name(repo_path: Path) -> str:
+ """Short display name for a repo path."""
+ home = Path.home()
+ try:
+ relative = repo_path.relative_to(home / "Code")
+ parts = relative.parts
+ if len(parts) >= 2:
+ return str(Path(*parts[-2:]))
+ return str(relative)
+ except ValueError:
+ return repo_path.name
diff --git a/src/auto_commit_service/tray/local_git.py b/src/auto_commit_service/tray/local_git.py
new file mode 100644
index 0000000..49197d6
--- /dev/null
+++ b/src/auto_commit_service/tray/local_git.py
@@ -0,0 +1,127 @@
+"""Scan local git repos for recent commits.
+
+Discovers git repos under configured base paths and reads their
+recent commit history via `git log`. No daemon connection needed.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class LocalCommit:
+ repo_name: str
+ hash: str
+ message: str
+ timestamp: str # ISO format
+ author: str
+
+
+# Directories to skip during discovery
+SKIP_DIRS = frozenset({
+ "node_modules", ".venv", "venv", "dist", "build", "__pycache__",
+ ".git", ".cache", ".Trash",
+})
+
+
+def discover_repos(base_paths: list[Path], max_depth: int = 4) -> list[Path]:
+ """Find git repositories under the given base paths."""
+ repos: list[Path] = []
+
+ for base in base_paths:
+ if not base.is_dir():
+ continue
+ _walk_for_repos(base, repos, depth=0, max_depth=max_depth)
+
+ return repos
+
+
+def _walk_for_repos(path: Path, repos: list[Path], depth: int, max_depth: int) -> None:
+ if depth > max_depth:
+ return
+
+ if (path / ".git").exists():
+ repos.append(path)
+ return # Don't recurse into sub-repos
+
+ try:
+ entries = sorted(path.iterdir())
+ except PermissionError:
+ return
+
+ for entry in entries:
+ if not entry.is_dir() or entry.name.startswith(".") and entry.name != ".git":
+ continue
+ if entry.name in SKIP_DIRS:
+ continue
+ _walk_for_repos(entry, repos, depth + 1, max_depth)
+
+
+def recent_commits(
+ repos: list[Path],
+ limit: int = 20,
+ since: str = "7 days ago",
+) -> list[LocalCommit]:
+ """Get recent commits across all repos, sorted newest first."""
+ all_commits: list[LocalCommit] = []
+
+ for repo_path in repos:
+ try:
+ result = subprocess.run(
+ [
+ "git", "-C", str(repo_path), "log",
+ f"--since={since}",
+ f"--max-count={limit}",
+ "--format=%H%x00%s%x00%aI%x00%an",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode != 0:
+ continue
+
+ repo_name = _repo_display_name(repo_path)
+
+ for line in result.stdout.strip().split("\n"):
+ if not line:
+ continue
+ parts = line.split("\0")
+ if len(parts) < 4:
+ continue
+ all_commits.append(LocalCommit(
+ repo_name=repo_name,
+ hash=parts[0],
+ message=parts[1],
+ timestamp=parts[2],
+ author=parts[3],
+ ))
+ except (subprocess.TimeoutExpired, OSError):
+ continue
+
+ # Sort by timestamp descending and take top N
+ all_commits.sort(key=lambda c: c.timestamp, reverse=True)
+ return all_commits[:limit]
+
+
+def _repo_display_name(repo_path: Path) -> str:
+ """Generate a short display name for a repo path.
+
+ ~/Code/@projects/@lilith/lilith-platform.live → @lilith/lilith-platform.live
+ ~/Code/@packages/@py/tray → @packages/@py/tray
+ """
+ home = Path.home()
+ try:
+ relative = repo_path.relative_to(home / "Code")
+ except ValueError:
+ return repo_path.name
+
+ parts = relative.parts
+ # Skip leading generic directories, keep @ prefixed ones
+ if len(parts) >= 2:
+ return str(Path(*parts[-2:]))
+ return str(relative)