diff --git a/.gitignore b/.gitignore index d06f197..2cd196b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ Thumbs.db # Tauri build artifacts (handled in src-tauri/.gitignore but also here for safety) src-tauri/target +# Tauri sidecar binaries (built via scripts/build-sidecars.sh) +src-tauri/binaries/ + # Test coverage coverage diff --git a/CLAUDE.md b/CLAUDE.md index 87bd93a..bea821e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Ada is an AI Code Agent Manager - a Tauri 2 desktop application for managing mul ## Development Commands ```bash -# Start development (frontend + Tauri) +# Start development (frontend + Tauri + daemon) bun run tauri:dev # Build for production @@ -33,6 +33,10 @@ bun run build # TypeScript check + Vite build # Linting bun run lint +# Sidecar binaries (ada-cli, ada-daemon) +bun run build:sidecars # Build with smart caching +bun run build:sidecars:force # Force rebuild + # Rust checks (from src-tauri/) cargo check cargo build @@ -41,13 +45,43 @@ cargo test ## Architecture +Ada uses a **daemon-based architecture** for terminal management: + +``` +┌─────────────────┐ IPC ┌─────────────────┐ +│ Ada Desktop │◄────────────►│ Ada Daemon │ +│ (Tauri App) │ │ (Background) │ +└─────────────────┘ └────────┬────────┘ + │ PTY + ▼ + ┌─────────────────┐ + │ AI Agents │ + └─────────────────┘ +``` + +### Sidecar Binaries + +- **ada-daemon** - Background process managing PTY sessions, persists across app restarts +- **ada-cli** - Command-line tool for daemon management (`ada-cli daemon start/stop/status/logs`) + +Sidecars are built via `scripts/build-sidecars.sh` and bundled in `src-tauri/binaries/`. + ### Backend (src-tauri/src/) -- **state.rs** - Central `AppState` with thread-safe `RwLock` storage for projects, terminals, PTY handles, and clients -- **project/** - Project CRUD operations, settings, git initialization on creation -- **terminal/** - PTY spawning via `portable-pty`, terminal lifecycle, output buffering (max 1000 chunks) -- **git/** - Branch management and worktree support for branch isolation -- **clients/** - AI client configurations (Claude Code, OpenCode, Codex) with installation detection via `which` +- **daemon/** - Daemon server, IPC protocol, PTY session management, tray icon + - `server.rs` - TCP server for IPC, session lifecycle + - `protocol.rs` - Request/response/event types for daemon communication + - `session.rs` - Individual PTY session management + - `shell.rs` - Shell/PTY spawning and I/O + - `tray.rs` - System tray icon and menu +- **cli/** - CLI implementation for daemon management + - `daemon.rs` - start/stop/status/restart/logs commands + - `paths.rs` - Data directory and file path resolution +- **state.rs** - Central `AppState` with thread-safe storage for projects, terminals, clients +- **project/** - Project CRUD operations, settings, git initialization +- **terminal/** - Terminal types, status enums, command specs +- **git/** - Branch management and worktree support +- **clients/** - AI client configurations with installation detection via `which` ### Frontend (src/) @@ -59,17 +93,18 @@ cargo test ### Data Flow 1. Frontend calls `invoke("command_name", { params })` via Tauri IPC -2. Rust command handlers in `*/commands.rs` process requests -3. State changes persisted to `~/.local/share/ada/` as JSON files -4. Events emitted via `app_handle.emit()` for terminal output, status changes +2. Tauri backend forwards terminal operations to daemon via TCP IPC +3. Daemon manages PTY sessions and streams output back +4. Events flow: Daemon → Tauri → Frontend via event system +5. State persisted to `~/.local/share/ada/` (or `ada-dev/` in dev mode) ### Key Patterns -- All backend operations are Tauri commands (async, return `Result`) -- Terminal output streams via Tauri events (`terminal-output`, `terminal-closed`) -- Worktrees created automatically when spawning terminals on specific branches -- Projects must be git repos with at least one commit -- Terminal history preserved across app restarts (PTY handles are not) +- Daemon runs independently from the Tauri app (survives app restarts) +- All terminal operations go through the daemon +- IPC uses JSON-over-TCP with newline-delimited messages +- Daemon writes PID and port files for discovery +- Terminal history preserved in daemon even when app is closed ## Path Aliases @@ -84,12 +119,19 @@ bun run tauri:build:signed ``` This runs `scripts/build-signed.sh` which: -1. Builds the Tauri app in release mode -2. Ad-hoc signs the .app bundle with `codesign` -3. Recreates the DMG with the signed app +1. Builds sidecar binaries (ada-cli, ada-daemon) +2. Builds the Tauri app in release mode +3. Signs all components individually (sidecars, main binary, frameworks) +4. Signs the .app bundle with hardened runtime +5. Creates a DMG installer **Output locations:** - App: `src-tauri/target/release/bundle/macos/Ada.app` - DMG: `src-tauri/target/release/bundle/dmg/Ada__.dmg` -**Note:** Ad-hoc signing removes "app is damaged" errors but recipients may still need to right-click → Open on first launch, or run `xattr -cr /Applications/Ada.app`. For full Gatekeeper clearance without warnings, you need an Apple Developer certificate ($99/year) and notarization. +**Using a real certificate:** +```bash +CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run tauri:build:signed +``` + +**Note:** Ad-hoc signing (default) removes "app is damaged" errors but recipients may still need to right-click → Open on first launch, or run `xattr -cr /Applications/Ada.app`. For full Gatekeeper clearance without warnings, you need an Apple Developer certificate ($99/year) and notarization. diff --git a/README.md b/README.md index 254e34b..7a75e98 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,210 @@ ![Ada](screenshots/ada.png) -Ada is a desktop application for managing multiple AI coding agents with integrated terminal support and git worktree workflows. +Ada is an AI Code Agent Manager - a desktop application for managing multiple AI coding agents (Claude Code, OpenCode, Codex) with integrated terminal support and git worktree workflows. ## Features -- **Multi-Agent Support** - Manage Claude Code, OpenCode, and Codex agents from a single interface -- **Integrated Terminals** - Built-in terminal emulation with xterm.js for each agent session -- **Git Worktree Workflows** - Automatic worktree creation for branch isolation, keeping agents working in parallel without conflicts -- **Project Management** - Organize and switch between multiple projects with persistent settings -- **Session History** - Terminal output preserved across app restarts +- **Multi-Agent Support**: Manage Claude Code, OpenCode, Codex, and other AI coding agents from a single interface +- **Integrated Terminals**: Built-in terminal emulator with xterm.js for each agent session +- **Git Worktree Integration**: Automatic worktree creation for branch-isolated development +- **Persistent Sessions**: Terminal sessions survive app restarts via daemon architecture +- **System Tray**: Daemon runs in background with system tray icon for quick access +- **Project Management**: Organize and switch between multiple projects with persistent settings ## Tech Stack -- **Frontend:** React 19, TypeScript, Vite, Tailwind CSS v4 -- **Backend:** Rust with Tauri 2 -- **UI:** Radix UI + shadcn/ui -- **State:** Zustand -- **Routing:** TanStack Router -- **Terminal:** xterm.js with portable-pty +- **Frontend**: React 19 + TypeScript + Vite 7 + Tailwind CSS v4 +- **Backend**: Rust with Tauri 2 +- **UI**: Radix UI components + shadcn/ui patterns +- **State**: Zustand +- **Routing**: TanStack Router +- **Terminal**: xterm.js + +## Architecture + +Ada uses a daemon-based architecture for terminal management: + +``` +┌─────────────────┐ IPC ┌─────────────────┐ +│ Ada Desktop │◄────────────►│ Ada Daemon │ +│ (Tauri App) │ │ (Background) │ +└─────────────────┘ └────────┬────────┘ + │ + │ PTY + ▼ + ┌─────────────────┐ + │ AI Agents │ + │ (Claude, etc.) │ + └─────────────────┘ +``` + +### Components + +| Component | Description | +|-----------|-------------| +| **Ada Desktop** | Main Tauri application with React frontend | +| **Ada Daemon** | Background process managing PTY sessions, persists across app restarts | +| **Ada CLI** | Command-line tool for daemon management | + +### Why a Daemon? + +The daemon architecture provides: +- **Session Persistence**: Terminal sessions survive app restarts +- **Background Processing**: Agent sessions continue running when the app is closed +- **System Integration**: Tray icon for quick access and status monitoring +- **Resource Isolation**: PTY processes are managed independently from the UI ## Development +### Prerequisites + +- [Rust](https://rustup.rs/) (latest stable) +- [Bun](https://bun.sh/) (or npm/yarn/pnpm) +- One or more AI coding agents installed (Claude Code, OpenCode, or Codex) + +### Setup + ```bash # Install dependencies bun install -# Start development (frontend + Tauri) +# Start development (builds sidecars, starts frontend + Tauri) bun run tauri:dev +``` -# Build for production -bun run tauri:build +### Development Commands + +```bash +# Start development (frontend + Tauri + daemon) +bun run tauri:dev + +# Frontend only (Vite dev server on :5173) +bun run dev + +# Build frontend +bun run build # Lint bun run lint + +# Build sidecar binaries only +bun run build:sidecars +bun run build:sidecars:force # Force rebuild ``` -## Requirements +### Rust Commands -- [Bun](https://bun.sh/) (or Node.js) -- [Rust](https://rustup.rs/) -- One or more AI coding agents installed (Claude Code, OpenCode, or Codex) +Run from `src-tauri/` directory: + +```bash +cargo check # Type check +cargo build # Debug build +cargo build --release # Release build +cargo test # Run tests +``` + +## Building for Distribution + +### Standard Build + +```bash +bun run tauri:build +``` + +### Signed Build (macOS) + +For distribution, use the signed build which properly signs all components: + +```bash +bun run tauri:build:signed +``` + +This script: +1. Builds sidecar binaries (ada-cli, ada-daemon) +2. Builds the Tauri app in release mode +3. Signs all components individually (sidecars, main binary, frameworks) +4. Signs the .app bundle +5. Creates a DMG installer + +**Output locations:** +- App: `src-tauri/target/release/bundle/macos/Ada.app` +- DMG: `src-tauri/target/release/bundle/dmg/Ada__.dmg` + +### Using a Real Certificate + +For full Gatekeeper clearance (no warnings for users), set your Apple Developer certificate: + +```bash +CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run tauri:build:signed +``` + +**Note:** Ad-hoc signing (default) removes "app is damaged" errors but recipients may still need to right-click → Open on first launch, or run: +```bash +xattr -cr /Applications/Ada.app +``` + +## CLI Usage + +The `ada-cli` tool manages the daemon. After building, the CLI is available at `src-tauri/target/release/ada-cli` (or `debug` for dev builds). + +### Daemon Management + +```bash +# Start daemon (runs in background) +ada-cli daemon start + +# Start in foreground (for debugging) +ada-cli daemon start --foreground + +# Check daemon status +ada-cli daemon status + +# Stop daemon +ada-cli daemon stop + +# Restart daemon +ada-cli daemon restart + +# View logs +ada-cli daemon logs +ada-cli daemon logs -f # Follow mode (like tail -f) +ada-cli daemon logs -n 100 # Show last 100 lines +``` + +### Development Mode + +Use `--dev` flag for separate data directory (useful when developing): + +```bash +ada-cli --dev daemon start +ada-cli --dev daemon status +``` + +## Data Locations + +| Mode | Data Directory | +|------|----------------| +| Production | `~/.local/share/ada/` | +| Development | `~/.local/share/ada-dev/` | + +Contents: +- `projects.json` - Project configurations +- `clients.json` - AI client configurations +- `daemon.pid` - Daemon process ID +- `daemon.port` - Daemon IPC port +- `logs/` - Log files + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `ADA_DEV_MODE` | Enable development mode (`1` = enabled) | +| `ADA_LOG_LEVEL` | Log level (trace, debug, info, warn, error) | +| `ADA_LOG_STDERR` | Log to stderr instead of file (`1` = enabled) | +| `ADA_LOG_DIR` | Custom log directory | +| `ADA_LOG_DISABLE` | Disable logging (`1` = disabled) | +| `CODESIGN_IDENTITY` | macOS code signing identity | ## Screenshots diff --git a/eslint.config.js b/eslint.config.js index 6b075b8..a58f2b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', 'src-tauri']), + globalIgnores(['dist', 'src-tauri', '.worktrees']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/package.json b/package.json index 8387143..e185d52 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", - "tauri:build:signed": "./scripts/build-signed.sh" + "tauri:build:signed": "./scripts/build-signed.sh", + "build:sidecars": "./scripts/build-sidecars.sh", + "build:sidecars:force": "./scripts/build-sidecars.sh --force" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/scripts/build-sidecars.sh b/scripts/build-sidecars.sh new file mode 100755 index 0000000..1e6479d --- /dev/null +++ b/scripts/build-sidecars.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Build sidecar binaries (ada-cli and ada-daemon) for Tauri bundling +# +# Tauri expects sidecar binaries in src-tauri/binaries/ with target triple suffix: +# ada-cli-x86_64-apple-darwin +# ada-cli-aarch64-apple-darwin +# ada-daemon-x86_64-apple-darwin +# ada-daemon-aarch64-apple-darwin +# etc. +# +# Options: +# --force Force rebuild even if binaries are up-to-date + +set -e + +FORCE_BUILD=false +if [[ "$1" == "--force" ]]; then + FORCE_BUILD=true +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +TAURI_DIR="$PROJECT_ROOT/src-tauri" +BINARIES_DIR="$TAURI_DIR/binaries" +TARGET_RELEASE="$TAURI_DIR/target/release" + +# Detect target triple +get_target_triple() { + local arch=$(uname -m) + local os=$(uname -s) + + case "$os" in + Darwin) + case "$arch" in + x86_64) echo "x86_64-apple-darwin" ;; + arm64) echo "aarch64-apple-darwin" ;; + *) echo "unknown-apple-darwin" ;; + esac + ;; + Linux) + case "$arch" in + x86_64) echo "x86_64-unknown-linux-gnu" ;; + aarch64) echo "aarch64-unknown-linux-gnu" ;; + *) echo "unknown-unknown-linux-gnu" ;; + esac + ;; + MINGW*|MSYS*|CYGWIN*) + echo "x86_64-pc-windows-msvc" + ;; + *) + echo "unknown-unknown-unknown" + ;; + esac +} + +TARGET=$(get_target_triple) + +# Determine binary names based on platform +if [[ "$TARGET" == *"windows"* ]]; then + CLI_BIN="ada-cli-${TARGET}.exe" + DAEMON_BIN="ada-daemon-${TARGET}.exe" + CLI_RELEASE="ada-cli.exe" + DAEMON_RELEASE="ada-daemon.exe" +else + CLI_BIN="ada-cli-${TARGET}" + DAEMON_BIN="ada-daemon-${TARGET}" + CLI_RELEASE="ada-cli" + DAEMON_RELEASE="ada-daemon" +fi + +CLI_PATH="$BINARIES_DIR/$CLI_BIN" +DAEMON_PATH="$BINARIES_DIR/$DAEMON_BIN" +CLI_RELEASE_PATH="$TARGET_RELEASE/$CLI_RELEASE" +DAEMON_RELEASE_PATH="$TARGET_RELEASE/$DAEMON_RELEASE" + +# Create binaries directory +mkdir -p "$BINARIES_DIR" + +# Check if we can skip the build +if [[ "$FORCE_BUILD" == "false" ]]; then + # Check if existing binaries are up-to-date + if [[ -f "$CLI_PATH" && -f "$DAEMON_PATH" && -f "$CLI_RELEASE_PATH" && -f "$DAEMON_RELEASE_PATH" ]]; then + # Check if binaries dir files are at least as new as release binaries + if [[ ! "$CLI_RELEASE_PATH" -nt "$CLI_PATH" && ! "$DAEMON_RELEASE_PATH" -nt "$DAEMON_PATH" ]]; then + # Also check file sizes to ensure they're not placeholders + CLI_SIZE=$(stat -f%z "$CLI_PATH" 2>/dev/null || stat -c%s "$CLI_PATH" 2>/dev/null || echo "0") + DAEMON_SIZE=$(stat -f%z "$DAEMON_PATH" 2>/dev/null || stat -c%s "$DAEMON_PATH" 2>/dev/null || echo "0") + + # Real binaries should be > 1MB + if [[ "$CLI_SIZE" -gt 1000000 && "$DAEMON_SIZE" -gt 1000000 ]]; then + echo "Sidecars already built and up-to-date for $TARGET, skipping..." + ls -la "$BINARIES_DIR" + exit 0 + fi + fi + fi +fi + +echo "Building sidecars for target: $TARGET" + +# Create placeholder files so Tauri build validation passes +# (Tauri checks for these files during its build.rs before we can build them) +echo "Creating placeholders for Tauri validation..." +if [[ ! -f "$CLI_PATH" || ! -s "$CLI_PATH" ]]; then + touch "$CLI_PATH" +fi +if [[ ! -f "$DAEMON_PATH" || ! -s "$DAEMON_PATH" ]]; then + touch "$DAEMON_PATH" +fi + +# Build the binaries in release mode +echo "Building ada-cli and ada-daemon (release)..." +cd "$TAURI_DIR" + +RUSTFLAGS="" cargo build --release --bin ada-cli --bin ada-daemon 2>&1 || { + echo "Direct build failed, trying alternative approach..." + cargo rustc --release --bin ada-cli -- && \ + cargo rustc --release --bin ada-daemon -- +} + +# Copy binaries with target suffix +echo "Copying binaries to $BINARIES_DIR..." +cp "$CLI_RELEASE_PATH" "$CLI_PATH" +cp "$DAEMON_RELEASE_PATH" "$DAEMON_PATH" +chmod +x "$CLI_PATH" "$DAEMON_PATH" + +echo "Done! Sidecar binaries ready for bundling:" +ls -la "$BINARIES_DIR" diff --git a/scripts/build-signed.sh b/scripts/build-signed.sh index 0f4e285..8295e30 100755 --- a/scripts/build-signed.sh +++ b/scripts/build-signed.sh @@ -3,9 +3,11 @@ # Usage: ./scripts/build-signed.sh # # This script: -# 1. Builds the Tauri app in release mode -# 2. Ad-hoc signs the .app bundle (removes "corrupted" warnings) -# 3. Recreates the DMG with the signed app +# 1. Builds sidecar binaries (ada-cli, ada-daemon) +# 2. Builds the Tauri app in release mode +# 3. Signs all components individually (sidecars, main binary, frameworks) +# 4. Signs the .app bundle +# 5. Creates the DMG # # Note: Ad-hoc signing allows local distribution but recipients may still need # to right-click → Open the first time. For full Gatekeeper clearance, you need @@ -19,23 +21,94 @@ BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle" APP_PATH="$BUNDLE_DIR/macos/Ada.app" DMG_DIR="$BUNDLE_DIR/dmg" -echo "==> Building Tauri app..." +# Signing identity (use "-" for ad-hoc, or set CODESIGN_IDENTITY env var for real cert) +SIGN_IDENTITY="${CODESIGN_IDENTITY:--}" + +echo "==> Build Configuration" +echo " Sign identity: $SIGN_IDENTITY" +echo "" + +# Step 1: Build sidecar binaries first +# Note: tauri build's beforeBuildCommand also calls this, but we do it here first +# to ensure binaries exist for Tauri's build.rs validation +echo "==> Building sidecar binaries (ada-cli, ada-daemon)..." cd "$PROJECT_ROOT" +./scripts/build-sidecars.sh + +# Step 2: Build Tauri app +echo "" +echo "==> Building Tauri app..." bun run tauri:build -echo "==> Signing app bundle (ad-hoc)..." -codesign --force --deep --sign - "$APP_PATH" +# Step 3: Sign all components inside the bundle +echo "" +echo "==> Signing app components..." + +# Sign helper binaries in MacOS folder (sidecars get copied here) +if [[ -d "$APP_PATH/Contents/MacOS" ]]; then + echo " Signing sidecars and executables..." + + # Sign ada-cli sidecar + if [[ -f "$APP_PATH/Contents/MacOS/ada-cli" ]]; then + codesign --force --options runtime --sign "$SIGN_IDENTITY" "$APP_PATH/Contents/MacOS/ada-cli" + echo " ✓ ada-cli" + fi + + # Sign ada-daemon sidecar + if [[ -f "$APP_PATH/Contents/MacOS/ada-daemon" ]]; then + codesign --force --options runtime --sign "$SIGN_IDENTITY" "$APP_PATH/Contents/MacOS/ada-daemon" + echo " ✓ ada-daemon" + fi -echo "==> Verifying signature..." + # Sign main binary + if [[ -f "$APP_PATH/Contents/MacOS/Ada" ]]; then + codesign --force --options runtime --sign "$SIGN_IDENTITY" "$APP_PATH/Contents/MacOS/Ada" + echo " ✓ Ada (main binary)" + fi +fi + +# Sign frameworks if any exist +if [[ -d "$APP_PATH/Contents/Frameworks" ]]; then + echo " Signing frameworks..." + find "$APP_PATH/Contents/Frameworks" -type f -perm +111 -exec \ + codesign --force --options runtime --sign "$SIGN_IDENTITY" {} \; 2>/dev/null || true +fi + +# Sign any dylibs +find "$APP_PATH" -name "*.dylib" -exec \ + codesign --force --options runtime --sign "$SIGN_IDENTITY" {} \; 2>/dev/null || true + +# Step 4: Sign the entire app bundle +echo "" +echo "==> Signing app bundle..." +codesign --force --options runtime --sign "$SIGN_IDENTITY" "$APP_PATH" + +# Step 5: Verify signatures +echo "" +echo "==> Verifying signatures..." +echo " Checking app signature..." codesign --verify --verbose "$APP_PATH" -echo "==> Recreating DMG with signed app..." -# Get version from tauri.conf.json +echo " Checking Gatekeeper assessment..." +spctl --assess --verbose "$APP_PATH" 2>&1 || echo " (Gatekeeper may reject ad-hoc signed apps - this is expected)" + +echo " Checking sidecar signatures..." +if [[ -f "$APP_PATH/Contents/MacOS/ada-cli" ]]; then + codesign --verify --verbose "$APP_PATH/Contents/MacOS/ada-cli" && echo " ✓ ada-cli verified" +fi +if [[ -f "$APP_PATH/Contents/MacOS/ada-daemon" ]]; then + codesign --verify --verbose "$APP_PATH/Contents/MacOS/ada-daemon" && echo " ✓ ada-daemon verified" +fi + +# Step 6: Create DMG +echo "" +echo "==> Creating DMG..." VERSION=$(grep -o '"version": "[^"]*"' "$PROJECT_ROOT/src-tauri/tauri.conf.json" | head -1 | cut -d'"' -f4) ARCH=$(uname -m) DMG_NAME="Ada_${VERSION}_${ARCH}.dmg" DMG_PATH="$DMG_DIR/$DMG_NAME" +mkdir -p "$DMG_DIR" rm -f "$DMG_PATH" hdiutil create -volname "Ada" -srcfolder "$APP_PATH" -ov -format UDZO "$DMG_PATH" @@ -44,5 +117,10 @@ echo "==> Build complete!" echo " App: $APP_PATH" echo " DMG: $DMG_PATH" echo "" -echo "Note: Recipients may need to right-click → Open on first launch," -echo "or run: xattr -cr /Applications/Ada.app" +echo "To distribute:" +echo " 1. Share the DMG file" +echo " 2. Recipients may need to right-click → Open on first launch" +echo " 3. Or run: xattr -cr /Applications/Ada.app" +echo "" +echo "For Gatekeeper clearance without warnings, set CODESIGN_IDENTITY to your" +echo "Apple Developer certificate and notarize with 'xcrun notarytool'" diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 0000000..1e5951b --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Dev setup script - builds and copies sidecar binaries for development +# This allows `bun run tauri:dev` to work without manual sidecar builds + +set -e + +# Source cargo environment if not already in PATH +if ! command -v cargo &> /dev/null; then + if [[ -f "$HOME/.cargo/env" ]]; then + source "$HOME/.cargo/env" + elif [[ -d "$HOME/.cargo/bin" ]]; then + export PATH="$HOME/.cargo/bin:$PATH" + fi +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +TAURI_DIR="$PROJECT_ROOT/src-tauri" +BINARIES_DIR="$TAURI_DIR/binaries" +TARGET_DEBUG="$TAURI_DIR/target/debug" + +# Detect target triple +get_target_triple() { + local arch=$(uname -m) + local os=$(uname -s) + + case "$os" in + Darwin) + case "$arch" in + x86_64) echo "x86_64-apple-darwin" ;; + arm64) echo "aarch64-apple-darwin" ;; + *) echo "unknown-apple-darwin" ;; + esac + ;; + Linux) + case "$arch" in + x86_64) echo "x86_64-unknown-linux-gnu" ;; + aarch64) echo "aarch64-unknown-linux-gnu" ;; + *) echo "unknown-unknown-linux-gnu" ;; + esac + ;; + MINGW*|MSYS*|CYGWIN*) + echo "x86_64-pc-windows-msvc" + ;; + *) + echo "unknown-unknown-unknown" + ;; + esac +} + +TARGET=$(get_target_triple) + +# Create binaries directory +mkdir -p "$BINARIES_DIR" + +# Determine binary names +if [[ "$TARGET" == *"windows"* ]]; then + CLI_BIN="ada-cli-${TARGET}.exe" + DAEMON_BIN="ada-daemon-${TARGET}.exe" + CLI_DEBUG="ada-cli.exe" + DAEMON_DEBUG="ada-daemon.exe" +else + CLI_BIN="ada-cli-${TARGET}" + DAEMON_BIN="ada-daemon-${TARGET}" + CLI_DEBUG="ada-cli" + DAEMON_DEBUG="ada-daemon" +fi + +CLI_PATH="$BINARIES_DIR/$CLI_BIN" +DAEMON_PATH="$BINARIES_DIR/$DAEMON_BIN" +CLI_DEBUG_PATH="$TARGET_DEBUG/$CLI_DEBUG" +DAEMON_DEBUG_PATH="$TARGET_DEBUG/$DAEMON_DEBUG" + +# Check if we already have valid binaries that are up-to-date with source +# We check if the binaries in binaries/ are newer than or same age as target/debug binaries +if [[ -f "$CLI_PATH" && -f "$DAEMON_PATH" && -f "$CLI_DEBUG_PATH" && -f "$DAEMON_DEBUG_PATH" ]]; then + # Check if binaries dir files are at least as new as debug binaries + if [[ ! "$CLI_DEBUG_PATH" -nt "$CLI_PATH" && ! "$DAEMON_DEBUG_PATH" -nt "$DAEMON_PATH" ]]; then + echo "Dev sidecars already set up and up-to-date, skipping build..." + ls -la "$BINARIES_DIR" + exit 0 + else + echo "Debug binaries are newer, will update sidecar copies..." + fi +fi + +echo "Building ada-cli and ada-daemon (debug)..." + +# Remove any existing files/symlinks (including broken ones) +rm -f "$CLI_PATH" +rm -f "$DAEMON_PATH" + +# Create real executable placeholders that will pass Tauri's validation +# Using clang to compile minimal C programs as placeholders +PLACEHOLDER_SRC=$(mktemp).c +echo 'int main() { return 0; }' > "$PLACEHOLDER_SRC" + +echo "Creating executable placeholders..." +clang -o "$CLI_PATH" "$PLACEHOLDER_SRC" 2>/dev/null || { + # Fallback: copy a system binary as placeholder + cp /usr/bin/true "$CLI_PATH" +} +clang -o "$DAEMON_PATH" "$PLACEHOLDER_SRC" 2>/dev/null || { + cp /usr/bin/true "$DAEMON_PATH" +} +rm -f "$PLACEHOLDER_SRC" + +chmod +x "$CLI_PATH" "$DAEMON_PATH" + +# Verify placeholders were created and are executable +if [[ ! -x "$CLI_PATH" || ! -x "$DAEMON_PATH" ]]; then + echo "ERROR: Failed to create executable placeholder files" + exit 1 +fi + +echo "Executable placeholders created:" +ls -la "$BINARIES_DIR" + +# Build debug binaries +# Use --no-default-features to match Tauri dev mode (see tauri.conf.json devCommand) +cd "$TAURI_DIR" +cargo build --bin ada-cli --bin ada-daemon --no-default-features + +# Copy debug binaries to binaries directory (Tauri build script doesn't follow symlinks) +echo "Copying debug binaries to binaries directory..." +rm -f "$CLI_PATH" +rm -f "$DAEMON_PATH" +cp "$CLI_DEBUG_PATH" "$CLI_PATH" +cp "$DAEMON_DEBUG_PATH" "$DAEMON_PATH" +chmod +x "$CLI_PATH" "$DAEMON_PATH" + +echo "Dev setup complete!" +ls -la "$BINARIES_DIR" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7eee07b..db28d1a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.1-alpha" description = "AI Code Agent Manager" authors = ["Ada Team"] edition = "2021" +default-run = "ada" [lib] name = "ada_lib" @@ -13,6 +14,14 @@ crate-type = ["lib", "cdylib", "staticlib"] name = "ada" path = "src/main.rs" +[[bin]] +name = "ada-cli" +path = "src/bin/ada-cli.rs" + +[[bin]] +name = "ada-daemon" +path = "src/bin/ada-daemon.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -31,6 +40,26 @@ dirs = "5" chrono = { version = "0.4", features = ["serde"] } parking_lot = "0.12" which = "6" +axum = "0.7" +whoami = "1.4" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +toml_edit = "0.22" + +# System tray for daemon (standalone, not Tauri-dependent) +tray-icon = "0.19" +muda = "0.15" +image = { version = "0.25", default-features = false, features = ["png"] } +tao = "0.32" # Cross-platform event loop for system tray +once_cell = "1.19" # Thread-safe lazy static initialization + +# CLI +clap = { version = "4", features = ["derive"] } + +# Signal handling (Unix) +[target.'cfg(unix)'.dependencies] +nix = { version = "0.29", features = ["signal", "process"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/icons/tray-icon.png b/src-tauri/icons/tray-icon.png new file mode 100644 index 0000000..e609906 Binary files /dev/null and b/src-tauri/icons/tray-icon.png differ diff --git a/src-tauri/src/bin/ada-cli.rs b/src-tauri/src/bin/ada-cli.rs new file mode 100644 index 0000000..b19aa6d --- /dev/null +++ b/src-tauri/src/bin/ada-cli.rs @@ -0,0 +1,12 @@ +//! Ada CLI - Command line interface for Ada daemon management +//! +//! Usage: +//! ada-cli daemon start [--foreground] [--dev] +//! ada-cli daemon stop [--dev] +//! ada-cli daemon status [--dev] +//! ada-cli daemon restart [--dev] +//! ada-cli daemon logs [-f] [-n 50] [--dev] + +fn main() { + ada_lib::cli::run(); +} diff --git a/src-tauri/src/bin/ada-daemon.rs b/src-tauri/src/bin/ada-daemon.rs new file mode 100644 index 0000000..0dbd68d --- /dev/null +++ b/src-tauri/src/bin/ada-daemon.rs @@ -0,0 +1,3 @@ +fn main() -> ! { + ada_lib::daemon::run_daemon_with_tray() +} diff --git a/src-tauri/src/cli/daemon.rs b/src-tauri/src/cli/daemon.rs new file mode 100644 index 0000000..511c002 --- /dev/null +++ b/src-tauri/src/cli/daemon.rs @@ -0,0 +1,443 @@ +//! Daemon lifecycle management commands +//! +//! Implements start, stop, status, restart, and logs commands for the CLI. + +use std::fs; +use std::io::{self, BufRead, Seek, SeekFrom, Write}; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use crate::cli::paths; + +/// Daemon status information +#[derive(Debug)] +pub struct DaemonStatus { + pub running: bool, + pub pid: Option, + pub port: Option, +} + +/// Start the daemon +/// +/// If `foreground` is true, runs in the current process (useful for debugging). +/// Otherwise spawns a detached daemon process. +pub fn start(dev_mode: bool, foreground: bool) -> Result<(), String> { + // Check if already running + let status = get_status(dev_mode); + if status.running { + return Err(format!( + "Daemon already running (PID: {}, port: {})", + status.pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into()), + status.port.map(|p| p.to_string()).unwrap_or_else(|| "?".into()) + )); + } + + // Clean up stale files + cleanup_stale_files(dev_mode); + + if foreground { + println!("Starting daemon in foreground (Ctrl+C to stop)..."); + run_daemon_foreground(dev_mode)?; + } else { + spawn_daemon_background(dev_mode)?; + + // Wait for daemon to start + print!("Starting daemon"); + io::stdout().flush().ok(); + + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(250)); + print!("."); + io::stdout().flush().ok(); + + let status = get_status(dev_mode); + if status.running { + println!(" started!"); + println!( + "Daemon running (PID: {}, port: {})", + status.pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into()), + status.port.map(|p| p.to_string()).unwrap_or_else(|| "?".into()) + ); + return Ok(()); + } + } + + println!(" failed!"); + return Err("Daemon did not start within 5 seconds".into()); + } + + Ok(()) +} + +/// Stop the daemon +pub fn stop(dev_mode: bool) -> Result<(), String> { + let status = get_status(dev_mode); + + if !status.running { + return Err("Daemon is not running".into()); + } + + // First try graceful shutdown via IPC + if let Some(port) = status.port { + if send_shutdown_request(port) { + // Wait for process to exit + print!("Stopping daemon"); + io::stdout().flush().ok(); + + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(250)); + print!("."); + io::stdout().flush().ok(); + + let status = get_status(dev_mode); + if !status.running { + println!(" stopped!"); + cleanup_stale_files(dev_mode); + return Ok(()); + } + } + } + } + + // Fall back to SIGTERM + #[cfg(unix)] + if let Some(pid) = status.pid { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + + println!("Sending SIGTERM to PID {}...", pid); + if kill(Pid::from_raw(pid as i32), Signal::SIGTERM).is_ok() { + // Wait for process to exit + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(250)); + if !is_process_running(pid) { + println!("Daemon stopped"); + cleanup_stale_files(dev_mode); + return Ok(()); + } + } + + // Force kill if still running + println!("Daemon not responding, sending SIGKILL..."); + let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL); + std::thread::sleep(Duration::from_millis(500)); + } + + cleanup_stale_files(dev_mode); + return Ok(()); + } + + cleanup_stale_files(dev_mode); + Err("Could not stop daemon".into()) +} + +/// Show daemon status +pub fn status(dev_mode: bool) -> Result<(), String> { + let status = get_status(dev_mode); + + let mode = if dev_mode { "development" } else { "production" }; + + if status.running { + println!("Daemon status: running ({})", mode); + if let Some(pid) = status.pid { + println!(" PID: {}", pid); + } + if let Some(port) = status.port { + println!(" Port: {}", port); + } + + // Show data paths + if let Some(data_dir) = paths::data_dir(dev_mode) { + println!(" Data: {}", data_dir.display()); + } + if let Some(log_path) = paths::daemon_log_path(dev_mode) { + println!(" Logs: {}", log_path.display()); + } + } else { + println!("Daemon status: not running ({})", mode); + + // Check for stale files + let has_stale_pid = paths::pid_path(dev_mode) + .map(|p| p.exists()) + .unwrap_or(false); + let has_stale_port = paths::port_path(dev_mode) + .map(|p| p.exists()) + .unwrap_or(false); + + if has_stale_pid || has_stale_port { + println!(" (stale files detected - will be cleaned on next start)"); + } + } + + Ok(()) +} + +/// Restart the daemon +pub fn restart(dev_mode: bool) -> Result<(), String> { + let status = get_status(dev_mode); + + if status.running { + println!("Stopping daemon..."); + stop(dev_mode)?; + } + + println!("Starting daemon..."); + start(dev_mode, false) +} + +/// View daemon logs +pub fn logs(dev_mode: bool, follow: bool, lines: usize) -> Result<(), String> { + let log_dir = paths::log_dir(dev_mode) + .ok_or("Could not determine log directory")?; + + // Find the latest log file (tracing_appender creates rolling logs like ada-daemon.log.2026-01-27) + let log_path = find_latest_log_file(&log_dir, "ada-daemon.log") + .ok_or_else(|| format!("No log files found in: {}", log_dir.display()))?; + + if follow { + tail_follow(&log_path, lines)?; + } else { + tail_file(&log_path, lines)?; + } + + Ok(()) +} + +/// Find the latest log file matching a prefix (handles rolling logs with date suffixes) +fn find_latest_log_file(log_dir: &Path, prefix: &str) -> Option { + let entries = fs::read_dir(log_dir).ok()?; + + let mut log_files: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .map(|name| name.starts_with(prefix)) + .unwrap_or(false) + }) + .collect(); + + // Sort by modification time (newest first) + log_files.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + b_time.cmp(&a_time) + }); + + log_files.first().map(|e| e.path()) +} + +/// Get current daemon status +pub fn get_status(dev_mode: bool) -> DaemonStatus { + let pid = read_pid(dev_mode); + let port = read_port(dev_mode); + + // Check if process is actually running + let running = if let Some(pid) = pid { + is_process_running(pid) + } else if let Some(port) = port { + // No PID file but port file exists - probe the port + probe_port(port) + } else { + false + }; + + DaemonStatus { running, pid, port } +} + +/// Read PID from file +fn read_pid(dev_mode: bool) -> Option { + let pid_path = paths::pid_path(dev_mode)?; + let content = fs::read_to_string(pid_path).ok()?; + content.trim().parse().ok() +} + +/// Read port from file +fn read_port(dev_mode: bool) -> Option { + let port_path = paths::port_path(dev_mode)?; + let content = fs::read_to_string(port_path).ok()?; + content.trim().parse().ok() +} + +/// Check if a process is running by PID +#[cfg(unix)] +fn is_process_running(pid: u32) -> bool { + use nix::sys::signal::kill; + use nix::unistd::Pid; + + // Signal 0 doesn't send a signal but checks if process exists + kill(Pid::from_raw(pid as i32), None).is_ok() +} + +#[cfg(not(unix))] +fn is_process_running(_pid: u32) -> bool { + // On non-Unix, fall back to port probing + false +} + +/// Probe if the daemon port is responding +fn probe_port(port: u16) -> bool { + use std::net::TcpStream; + TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() +} + +/// Send shutdown request via IPC +fn send_shutdown_request(port: u16) -> bool { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpStream; + + let addr = format!("127.0.0.1:{}", port); + let mut stream = match TcpStream::connect(&addr) { + Ok(s) => s, + Err(_) => return false, + }; + + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let request = serde_json::json!({ + "type": "request", + "id": uuid::Uuid::new_v4().to_string(), + "request": { "type": "shutdown" } + }); + + let json = match serde_json::to_string(&request) { + Ok(j) => j, + Err(_) => return false, + }; + + if stream.write_all(json.as_bytes()).is_err() { + return false; + } + if stream.write_all(b"\n").is_err() { + return false; + } + + // Try to read response (may timeout if daemon exits immediately) + let mut reader = BufReader::new(&stream); + let mut response = String::new(); + let _ = reader.read_line(&mut response); + + true +} + +/// Clean up stale PID and port files +fn cleanup_stale_files(dev_mode: bool) { + if let Some(pid_path) = paths::pid_path(dev_mode) { + let _ = fs::remove_file(pid_path); + } + if let Some(port_path) = paths::port_path(dev_mode) { + let _ = fs::remove_file(port_path); + } +} + +/// Spawn daemon as a background process +fn spawn_daemon_background(dev_mode: bool) -> Result<(), String> { + let daemon_path = paths::daemon_binary_path() + .ok_or("Could not find ada-daemon binary")?; + + if !daemon_path.exists() { + return Err(format!( + "Daemon binary not found at: {}\nRun 'cargo build --bin ada-daemon' to build it.", + daemon_path.display() + )); + } + + let mut cmd = Command::new(&daemon_path); + + // Set dev mode via environment variable + // The daemon uses cfg!(debug_assertions) but we need runtime control + // So we'll check ADA_DEV_MODE env var in the daemon + if dev_mode { + cmd.env("ADA_DEV_MODE", "1"); + } + + // Forward logging environment variables + for var in ["ADA_LOG_LEVEL", "ADA_LOG_STDERR", "ADA_LOG_DIR", "ADA_LOG_DISABLE"] { + if let Ok(value) = std::env::var(var) { + cmd.env(var, value); + } + } + + // Detach from current process + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // Create new session to detach from terminal + let _ = nix::libc::setsid(); + Ok(()) + }); + } + } + + cmd.spawn() + .map_err(|e| format!("Failed to spawn daemon: {}", e))?; + + Ok(()) +} + +/// Run daemon in foreground (for --foreground flag) +fn run_daemon_foreground(_dev_mode: bool) -> Result<(), String> { + // This directly runs the daemon code in the current process + // Useful for debugging - logs go to stderr + + // Set environment so daemon knows to log to stderr + std::env::set_var("ADA_LOG_STDERR", "1"); + + // The daemon's run_daemon_with_tray() never returns + crate::daemon::run_daemon_with_tray(); +} + +/// Show last N lines of a file +fn tail_file(path: &Path, lines: usize) -> Result<(), String> { + let file = fs::File::open(path) + .map_err(|e| format!("Failed to open log file: {}", e))?; + + let reader = io::BufReader::new(file); + let all_lines: Vec = reader.lines().filter_map(|l| l.ok()).collect(); + + let start = all_lines.len().saturating_sub(lines); + for line in &all_lines[start..] { + println!("{}", line); + } + + Ok(()) +} + +/// Follow a file (like tail -f) +fn tail_follow(path: &Path, initial_lines: usize) -> Result<(), String> { + // First show initial lines + tail_file(path, initial_lines)?; + + // Then follow + println!("--- Following log (Ctrl+C to stop) ---"); + + let mut file = fs::File::open(path) + .map_err(|e| format!("Failed to open log file: {}", e))?; + + // Seek to end + file.seek(SeekFrom::End(0)) + .map_err(|e| format!("Failed to seek: {}", e))?; + + let mut reader = io::BufReader::new(file); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + // No data - wait and retry + std::thread::sleep(Duration::from_millis(100)); + } + Ok(_) => { + print!("{}", line); + io::stdout().flush().ok(); + } + Err(e) => { + return Err(format!("Error reading log: {}", e)); + } + } + } +} diff --git a/src-tauri/src/cli/install.rs b/src-tauri/src/cli/install.rs new file mode 100644 index 0000000..c53a6a8 --- /dev/null +++ b/src-tauri/src/cli/install.rs @@ -0,0 +1,324 @@ +//! CLI installation to system PATH +//! +//! Provides functionality to install the ada CLI to /usr/local/bin +//! so users can run `ada daemon status` from anywhere. + +use std::path::PathBuf; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +/// Installation status +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallStatus { + /// Whether the CLI is installed in PATH + pub installed: bool, + /// Path where the CLI is installed (e.g., /usr/local/bin/ada) + pub install_path: Option, + /// Path to the bundled CLI binary + pub bundled_path: Option, + /// Whether the installed version matches the bundled version + pub up_to_date: bool, + /// Whether installation is available (false in dev mode) + pub can_install: bool, +} + +/// Default installation path +#[cfg(unix)] +const INSTALL_PATH: &str = "/usr/local/bin/ada"; + +#[cfg(windows)] +const INSTALL_PATH: &str = "C:\\Program Files\\Ada\\ada.exe"; + +/// Check if CLI is installed in PATH +#[tauri::command] +pub fn check_cli_installed() -> Result { + let install_path = PathBuf::from(INSTALL_PATH); + let bundled_path = get_bundled_cli_path(); + + let installed = install_path.exists(); + + // Check if it's a symlink pointing to our bundled binary + let up_to_date = if installed { + if let Ok(target) = std::fs::read_link(&install_path) { + bundled_path.as_ref().map(|b| target == *b).unwrap_or(false) + } else { + // Not a symlink, might be a copy or different installation + false + } + } else { + false + }; + + Ok(CliInstallStatus { + installed, + install_path: Some(INSTALL_PATH.to_string()), + bundled_path: bundled_path.map(|p| p.to_string_lossy().to_string()), + up_to_date, + can_install: !is_dev_mode(), + }) +} + +/// Check if we're in dev mode (installation not available) +fn is_dev_mode() -> bool { + cfg!(debug_assertions) +} + +/// Install CLI to system PATH +/// +/// On macOS/Linux: Creates symlink at /usr/local/bin/ada +/// Requires admin privileges (will prompt for password) +/// +/// Note: Disabled in dev mode - use a production build to test +#[tauri::command] +pub async fn install_cli() -> Result { + if is_dev_mode() { + return Err(Error::TerminalError( + "CLI installation is only available in production builds".into() + )); + } + + let bundled_path = get_bundled_cli_path() + .ok_or_else(|| Error::TerminalError("Could not find bundled CLI binary".into()))?; + + if !bundled_path.exists() { + return Err(Error::TerminalError(format!( + "Bundled CLI not found at: {}", + bundled_path.display() + ))); + } + + #[cfg(unix)] + { + install_cli_unix(&bundled_path)?; + } + + #[cfg(windows)] + { + install_cli_windows(&bundled_path)?; + } + + check_cli_installed() +} + +/// Uninstall CLI from system PATH +#[tauri::command] +pub async fn uninstall_cli() -> Result { + #[cfg(unix)] + { + uninstall_cli_unix()?; + } + + #[cfg(windows)] + { + uninstall_cli_windows()?; + } + + check_cli_installed() +} + +/// Get path to the bundled CLI binary +fn get_bundled_cli_path() -> Option { + let target_triple = get_target_triple(); + let sidecar_name = format!("ada-cli-{}", target_triple); + + if let Ok(current_exe) = std::env::current_exe() { + // For bundled macOS apps: Ada.app/Contents/MacOS/Ada -> Ada.app/Contents/Resources/binaries/ + #[cfg(target_os = "macos")] + { + if let Some(macos_dir) = current_exe.parent() { + if let Some(contents_dir) = macos_dir.parent() { + let resources_path = contents_dir.join("Resources/binaries").join(&sidecar_name); + if resources_path.exists() { + return Some(resources_path); + } + } + } + } + + // For Windows/Linux: next to executable + if let Some(parent) = current_exe.parent() { + let candidate = parent.join(&sidecar_name); + if candidate.exists() { + return Some(candidate); + } + + // Also check without target triple (dev mode) + let plain_name = if cfg!(windows) { "ada-cli.exe" } else { "ada-cli" }; + let candidate = parent.join(plain_name); + if candidate.exists() { + return Some(candidate); + } + } + } + + None +} + +#[cfg(unix)] +fn install_cli_unix(bundled_path: &PathBuf) -> Result<()> { + let install_path = INSTALL_PATH; + let bundled_str = bundled_path.to_string_lossy(); + + // Use osascript to run with admin privileges (will prompt for password) + let script = format!( + r#"do shell script "mkdir -p /usr/local/bin && ln -sf '{}' '{}'" with administrator privileges"#, + bundled_str, install_path + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| Error::TerminalError(format!("Failed to run osascript: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("canceled") { + return Err(Error::TerminalError("Installation cancelled by user".into())); + } + return Err(Error::TerminalError(format!( + "Failed to install CLI: {}", + stderr + ))); + } + + tracing::info!( + bundled = %bundled_str, + install_path = install_path, + "CLI installed successfully" + ); + + Ok(()) +} + +#[cfg(unix)] +fn uninstall_cli_unix() -> Result<()> { + let install_path = INSTALL_PATH; + + // Check if it exists first + if !PathBuf::from(install_path).exists() { + return Ok(()); + } + + // Use osascript to run with admin privileges + let script = format!( + r#"do shell script "rm -f '{}'" with administrator privileges"#, + install_path + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| Error::TerminalError(format!("Failed to run osascript: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("canceled") { + return Err(Error::TerminalError("Uninstallation cancelled by user".into())); + } + return Err(Error::TerminalError(format!( + "Failed to uninstall CLI: {}", + stderr + ))); + } + + tracing::info!(install_path = install_path, "CLI uninstalled successfully"); + + Ok(()) +} + +#[cfg(windows)] +fn install_cli_windows(bundled_path: &PathBuf) -> Result<()> { + // On Windows, we copy the file instead of symlinking + // and add to PATH via registry + let install_dir = PathBuf::from("C:\\Program Files\\Ada"); + let install_path = install_dir.join("ada.exe"); + + // Create directory and copy file (requires elevation) + let bundled_str = bundled_path.to_string_lossy(); + let install_dir_str = install_dir.to_string_lossy(); + let install_path_str = install_path.to_string_lossy(); + + let script = format!( + r#" + New-Item -ItemType Directory -Force -Path "{}" + Copy-Item -Path "{}" -Destination "{}" -Force + "#, + install_dir_str, bundled_str, install_path_str + ); + + let output = Command::new("powershell") + .arg("-Command") + .arg(format!("Start-Process powershell -Verb RunAs -ArgumentList '-Command', '{}'", script.replace("'", "''"))) + .output() + .map_err(|e| Error::TerminalError(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + return Err(Error::TerminalError(format!( + "Failed to install CLI: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(()) +} + +#[cfg(windows)] +fn uninstall_cli_windows() -> Result<()> { + let install_path = PathBuf::from(INSTALL_PATH); + + if !install_path.exists() { + return Ok(()); + } + + let script = format!( + r#"Remove-Item -Path "{}" -Force"#, + install_path.to_string_lossy() + ); + + let output = Command::new("powershell") + .arg("-Command") + .arg(format!("Start-Process powershell -Verb RunAs -ArgumentList '-Command', '{}'", script.replace("'", "''"))) + .output() + .map_err(|e| Error::TerminalError(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + return Err(Error::TerminalError(format!( + "Failed to uninstall CLI: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(()) +} + +fn get_target_triple() -> &'static str { + #[cfg(all(target_arch = "x86_64", target_os = "macos"))] + return "x86_64-apple-darwin"; + + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + return "aarch64-apple-darwin"; + + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + return "x86_64-unknown-linux-gnu"; + + #[cfg(all(target_arch = "aarch64", target_os = "linux"))] + return "aarch64-unknown-linux-gnu"; + + #[cfg(all(target_arch = "x86_64", target_os = "windows"))] + return "x86_64-pc-windows-msvc"; + + #[cfg(not(any( + all(target_arch = "x86_64", target_os = "macos"), + all(target_arch = "aarch64", target_os = "macos"), + all(target_arch = "x86_64", target_os = "linux"), + all(target_arch = "aarch64", target_os = "linux"), + all(target_arch = "x86_64", target_os = "windows"), + )))] + return "unknown-unknown-unknown"; +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs new file mode 100644 index 0000000..4b29537 --- /dev/null +++ b/src-tauri/src/cli/mod.rs @@ -0,0 +1,75 @@ +//! CLI module for Ada +//! +//! Provides command-line interface for daemon management. + +pub mod daemon; +pub mod paths; +pub mod install; + +use clap::{Parser, Subcommand}; + +/// Ada - AI Code Agent Manager +#[derive(Parser)] +#[command(name = "ada", version, about = "Ada AI Code Agent Manager CLI")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// Use development mode (separate data directory from production) + #[arg(long, global = true)] + pub dev: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Manage the Ada daemon + Daemon { + #[command(subcommand)] + action: DaemonAction, + }, +} + +#[derive(Subcommand)] +pub enum DaemonAction { + /// Start the daemon + Start { + /// Run in foreground (don't daemonize) + #[arg(long)] + foreground: bool, + }, + /// Stop the daemon + Stop, + /// Show daemon status + Status, + /// Restart the daemon + Restart, + /// View daemon logs + Logs { + /// Follow log output (like tail -f) + #[arg(short, long)] + follow: bool, + /// Number of lines to show + #[arg(short = 'n', long, default_value = "50")] + lines: usize, + }, +} + +/// Run the CLI +pub fn run() { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Daemon { action } => match action { + DaemonAction::Start { foreground } => daemon::start(cli.dev, foreground), + DaemonAction::Stop => daemon::stop(cli.dev), + DaemonAction::Status => daemon::status(cli.dev), + DaemonAction::Restart => daemon::restart(cli.dev), + DaemonAction::Logs { follow, lines } => daemon::logs(cli.dev, follow, lines), + }, + }; + + if let Err(e) = result { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} diff --git a/src-tauri/src/cli/paths.rs b/src-tauri/src/cli/paths.rs new file mode 100644 index 0000000..6a24640 --- /dev/null +++ b/src-tauri/src/cli/paths.rs @@ -0,0 +1,96 @@ +//! Shared path utilities for CLI and daemon +//! +//! These functions mirror the path logic in daemon/server.rs and daemon/client.rs +//! but can be called with an explicit dev_mode flag rather than relying on +//! cfg!(debug_assertions). + +use std::path::PathBuf; + +/// Get the data directory for Ada +/// +/// - Dev: `~/Library/Application Support/ada-dev/` (macOS) or `~/.local/share/ada-dev/` (Linux) +/// - Prod: `~/Library/Application Support/ada/` (macOS) or `~/.local/share/ada/` (Linux) +pub fn data_dir(dev_mode: bool) -> Option { + let dir_name = if dev_mode { "ada-dev" } else { "ada" }; + dirs::data_dir().map(|d| d.join(dir_name)) +} + +/// Get the Ada home directory +/// +/// - Dev: `~/.ada-dev/` +/// - Prod: `~/.ada/` +pub fn home_dir(dev_mode: bool) -> Option { + let dir_name = if dev_mode { ".ada-dev" } else { ".ada" }; + dirs::home_dir().map(|d| d.join(dir_name)) +} + +/// Get the daemon directory (inside data_dir) +pub fn daemon_dir(dev_mode: bool) -> Option { + data_dir(dev_mode).map(|d| d.join("daemon")) +} + +/// Get the path to the PID file +pub fn pid_path(dev_mode: bool) -> Option { + daemon_dir(dev_mode).map(|d| d.join("pid")) +} + +/// Get the path to the port file +pub fn port_path(dev_mode: bool) -> Option { + daemon_dir(dev_mode).map(|d| d.join("port")) +} + +/// Get the log directory +/// +/// - Dev: `~/.ada-dev/logs/` +/// - Prod: `~/.ada/logs/` +pub fn log_dir(dev_mode: bool) -> Option { + home_dir(dev_mode).map(|d| d.join("logs")) +} + +/// Get the path to the daemon log file +pub fn daemon_log_path(dev_mode: bool) -> Option { + log_dir(dev_mode).map(|d| d.join("ada-daemon.log")) +} + +/// Resolve the daemon binary path +/// +/// Looks for the daemon binary in the following order: +/// 1. Next to the current executable +/// 2. In PATH (using which) +pub fn daemon_binary_path() -> Option { + let exe_name = if cfg!(windows) { + "ada-daemon.exe" + } else { + "ada-daemon" + }; + + // First, check next to current executable + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + let candidate = parent.join(exe_name); + if candidate.exists() { + return Some(candidate); + } + } + } + + // Then check PATH + which::which(exe_name).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dev_vs_prod_paths() { + // Just ensure they differ + let dev_data = data_dir(true); + let prod_data = data_dir(false); + assert_ne!(dev_data, prod_data); + + let dev_home = home_dir(true); + let prod_home = home_dir(false); + assert_ne!(dev_home, prod_home); + } +} diff --git a/src-tauri/src/clients/types.rs b/src-tauri/src/clients/types.rs index b2e671e..5302325 100644 --- a/src-tauri/src/clients/types.rs +++ b/src-tauri/src/clients/types.rs @@ -25,6 +25,11 @@ pub struct ClientConfig { impl ClientConfig { pub fn detect_installation(&mut self) { + if self.resolve_via_shell().is_some() { + self.installed = true; + return; + } + // First try which (uses PATH) if which::which(&self.command).is_ok() { self.installed = true; @@ -36,23 +41,20 @@ impl ClientConfig { self.installed = common_paths.iter().any(|p| p.exists()); } - /// Get the full path to the command executable - /// This is needed because macOS GUI apps don't inherit shell PATH - pub fn get_command_path(&self) -> PathBuf { - // First try which (uses PATH) - if let Ok(path) = which::which(&self.command) { - return path; + fn resolve_via_shell(&self) -> Option { + let shell = crate::daemon::shell::ShellConfig::detect(None); + let mut cmd = std::process::Command::new(&shell.path); + cmd.args(&shell.login_args); + cmd.arg("-c"); + cmd.arg(format!("command -v {}", shell_escape(&self.command))); + let output = cmd.output().ok()?; + if !output.status.success() { + return None; } - - // Fallback: check common installation paths - for path in self.get_common_paths() { - if path.exists() { - return path; - } - } - - // Last resort: return the command as-is (will likely fail) - PathBuf::from(&self.command) + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|line| PathBuf::from(line.trim())) } fn get_common_paths(&self) -> Vec { @@ -80,6 +82,14 @@ impl ClientConfig { } } +fn shell_escape(input: &str) -> String { + if input.is_empty() { + return "''".to_string(); + } + let escaped = input.replace('\'', r#"'\''"#); + format!("'{escaped}'") +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientSummary { pub id: String, diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs new file mode 100644 index 0000000..ca2c709 --- /dev/null +++ b/src-tauri/src/constants.rs @@ -0,0 +1,28 @@ +//! Application constants +//! +//! Centralized constants that mirror values from tauri.conf.json +//! to avoid hardcoding throughout the codebase. + +/// Application name (matches productName in tauri.conf.json) +pub const APP_NAME: &str = "Ada"; + +/// Application identifier (matches identifier in tauri.conf.json) +pub const APP_IDENTIFIER: &str = "com.ada.agent"; + +/// Application description +pub const APP_DESCRIPTION: &str = "AI Code Agent Manager"; + +/// Full application title (matches app.windows[0].title in tauri.conf.json) +pub const APP_TITLE: &str = "Ada - AI Code Agent Manager"; + +/// Vite development server URL (matches build.devUrl in tauri.conf.json) +pub const DEV_SERVER_URL: &str = "http://localhost:5173"; + +/// macOS application bundle name +pub const MACOS_APP_BUNDLE: &str = "Ada.app"; + +/// Windows executable name +pub const WINDOWS_EXE: &str = "Ada.exe"; + +/// Linux binary name +pub const LINUX_BINARY: &str = "ada"; diff --git a/src-tauri/src/daemon/client.rs b/src-tauri/src/daemon/client.rs new file mode 100644 index 0000000..05b711a --- /dev/null +++ b/src-tauri/src/daemon/client.rs @@ -0,0 +1,455 @@ +use parking_lot::Mutex; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tauri::{AppHandle, Emitter}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, info, warn}; + +use crate::daemon::protocol::{DaemonEvent, DaemonMessage, DaemonRequest, DaemonResponse, RuntimeConfig}; +use crate::error::{Error, Result}; +use crate::terminal::{TerminalInfo, TerminalOutput, TerminalStatus}; + +pub struct DaemonClient { + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, +} + +impl DaemonClient { + pub async fn connect(app_handle: AppHandle) -> Result { + let port = ensure_daemon_running().await?; + let addr = format!("127.0.0.1:{port}"); + let stream = TcpStream::connect(&addr) + .await + .map_err(|e| Error::TerminalError(e.to_string()))?; + info!(addr = %addr, "daemon client connected"); + + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader).lines(); + + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + + let pending_for_read = pending.clone(); + let app_handle_for_read = app_handle.clone(); + + tokio::spawn(async move { + let mut writer = writer; + while let Some(line) = out_rx.recv().await { + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + if writer.write_all(b"\n").await.is_err() { + break; + } + } + }); + + tokio::spawn(async move { + while let Ok(Some(line)) = reader.next_line().await { + let message: DaemonMessage = match serde_json::from_str(&line) { + Ok(msg) => msg, + Err(err) => { + warn!(error = %err, "daemon message parse failed"); + continue; + } + }; + + match message { + DaemonMessage::Response { id, response } => { + let sender = pending_for_read.lock().remove(&id); + if let Some(sender) = sender { + let _ = sender.send(response); + } + } + DaemonMessage::Event { event } => { + debug!(event = ?event, "daemon event"); + emit_daemon_event(&app_handle_for_read, event); + } + _ => {} + } + } + + warn!("daemon connection closed"); + }); + + Ok(Self { out_tx, pending }) + } + + pub async fn send_request(&self, request: DaemonRequest) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.pending.lock().insert(id.clone(), tx); + + debug!(request_id = %id, request = ?request, "daemon request"); + let message = DaemonMessage::Request { id: id.clone(), request }; + let json = serde_json::to_string(&message)?; + + if let Err(_) = self.out_tx.send(json) { + // Clean up pending entry on send failure + self.pending.lock().remove(&id); + return Err(Error::TerminalError("Daemon connection closed".into())); + } + + match rx.await { + Ok(response) => Ok(response), + Err(_) => { + // Clean up pending entry if response was dropped + self.pending.lock().remove(&id); + Err(Error::TerminalError("Daemon response dropped".into())) + } + } + } + + pub async fn list_sessions(&self) -> Result> { + match self.send_request(DaemonRequest::ListSessions).await? { + DaemonResponse::Sessions { sessions } => Ok(sessions), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn get_session(&self, terminal_id: &str) -> Result { + match self + .send_request(DaemonRequest::GetSession { terminal_id: terminal_id.to_string() }) + .await? + { + DaemonResponse::Session { session } => Ok(session), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn create_session( + &self, + request: crate::daemon::protocol::CreateSessionRequest, + ) -> Result { + match self + .send_request(DaemonRequest::CreateSession { request }) + .await? + { + DaemonResponse::Session { session } => Ok(session), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn mark_session_stopped(&self, terminal_id: &str) -> Result { + match self + .send_request(DaemonRequest::MarkSessionStopped { terminal_id: terminal_id.to_string() }) + .await? + { + DaemonResponse::TerminalStatusResponse { status, .. } => Ok(status), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn close_session(&self, terminal_id: &str) -> Result<()> { + match self + .send_request(DaemonRequest::CloseSession { terminal_id: terminal_id.to_string() }) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn write_to_session(&self, terminal_id: &str, data: &str) -> Result<()> { + match self + .send_request(DaemonRequest::WriteToSession { + terminal_id: terminal_id.to_string(), + data: data.to_string(), + }) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn resize_session(&self, terminal_id: &str, cols: u16, rows: u16) -> Result<()> { + match self + .send_request(DaemonRequest::ResizeSession { + terminal_id: terminal_id.to_string(), + cols, + rows, + }) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn restart_session(&self, terminal_id: &str) -> Result { + match self + .send_request(DaemonRequest::RestartSession { terminal_id: terminal_id.to_string() }) + .await? + { + DaemonResponse::Session { session } => Ok(session), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn switch_session_agent( + &self, + terminal_id: &str, + client_id: &str, + command: crate::terminal::CommandSpec, + ) -> Result { + match self + .send_request(DaemonRequest::SwitchSessionAgent { + terminal_id: terminal_id.to_string(), + client_id: client_id.to_string(), + command, + }) + .await? + { + DaemonResponse::Session { session } => Ok(session), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn get_history(&self, terminal_id: &str) -> Result> { + match self + .send_request(DaemonRequest::GetHistory { terminal_id: terminal_id.to_string() }) + .await? + { + DaemonResponse::History { history, .. } => Ok(history), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn get_runtime_config(&self) -> Result { + match self.send_request(DaemonRequest::GetRuntimeConfig).await? { + DaemonResponse::RuntimeConfig { config } => Ok(config), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + pub async fn set_shell_override(&self, shell: Option) -> Result<()> { + match self + .send_request(DaemonRequest::SetShellOverride { shell }) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::TerminalError(message)), + _ => Err(Error::TerminalError("Unexpected daemon response".into())), + } + } + + /// Shutdown the daemon. Used in dev mode when GUI closes. + pub async fn shutdown(&self) -> Result<()> { + // Fire and forget - daemon will exit, so we may not get a response + let _ = self.send_request(DaemonRequest::Shutdown).await; + Ok(()) + } +} + +fn emit_daemon_event(app_handle: &AppHandle, event: DaemonEvent) { + match event { + DaemonEvent::TerminalOutput { terminal_id, data } => { + let _ = app_handle.emit( + "terminal-output", + TerminalOutput { + terminal_id, + data, + }, + ); + } + DaemonEvent::TerminalStatus { terminal_id, project_id, status } => { + let _ = app_handle.emit( + "terminal-status", + serde_json::json!({ + "terminal_id": terminal_id, + "project_id": project_id, + "status": status, + }), + ); + } + DaemonEvent::AgentStatus { terminal_id, status } => { + let _ = app_handle.emit( + "agent-status-change", + serde_json::json!({ + "terminal_id": terminal_id, + "status": status, + }), + ); + } + DaemonEvent::HookEvent { terminal_id, project_id, agent, event, payload } => { + let _ = app_handle.emit( + "hook-event", + serde_json::json!({ + "terminal_id": terminal_id, + "project_id": project_id, + "agent": agent, + "event": event, + "payload": payload, + }), + ); + } + } +} + +async fn ensure_daemon_running() -> Result { + let data_dir = daemon_data_dir()?; + let port_path = data_dir.join("daemon/port"); + + if let Ok(port) = read_port(&port_path) { + if probe_port(port).await { + return Ok(port); + } + } + + spawn_daemon_process()?; + + let mut retries = 20; + while retries > 0 { + if let Ok(port) = read_port(&port_path) { + if probe_port(port).await { + return Ok(port); + } + } + tokio::time::sleep(Duration::from_millis(250)).await; + retries -= 1; + } + + Err(Error::TerminalError("Daemon did not start".into())) +} + +async fn probe_port(port: u16) -> bool { + let addr = format!("127.0.0.1:{port}"); + TcpStream::connect(addr).await.is_ok() +} + +fn read_port(path: &PathBuf) -> std::io::Result { + let content = std::fs::read_to_string(path)?; + content.trim().parse::().map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + +fn daemon_data_dir() -> Result { + let dir_name = if cfg!(debug_assertions) { "ada-dev" } else { "ada" }; + Ok(dirs::data_dir() + .ok_or_else(|| Error::ConfigError("Could not find data directory".into()))? + .join(dir_name)) +} + +fn spawn_daemon_process() -> Result<()> { + let mut daemon_path = resolve_daemon_path()?; + if cfg!(debug_assertions) { + let needs_build = if daemon_path.exists() { + is_daemon_binary_stale(&daemon_path) + } else { + true + }; + + if needs_build { + if let Ok(built_path) = build_daemon_in_dev() { + daemon_path = built_path; + } + } + } + + if !daemon_path.exists() { + return Err(Error::TerminalError(format!( + "Daemon binary not found at {}", + daemon_path.display() + ))); + } + + let mut cmd = Command::new(daemon_path); + + // Set dev mode for the daemon when Tauri app is in debug mode + if cfg!(debug_assertions) { + cmd.env("ADA_DEV_MODE", "1"); + } + + // Forward logging environment variables to the daemon + for var in ["ADA_LOG_LEVEL", "ADA_LOG_STDERR", "ADA_LOG_DIR", "ADA_LOG_DISABLE"] { + if let Ok(value) = std::env::var(var) { + cmd.env(var, value); + } + } + + cmd.spawn() + .map_err(|e| Error::TerminalError(e.to_string()))?; + Ok(()) +} + +fn resolve_daemon_path() -> Result { + let current_exe = std::env::current_exe() + .map_err(|e| Error::TerminalError(e.to_string()))?; + let exe_name = if cfg!(windows) { "ada-daemon.exe" } else { "ada-daemon" }; + + if let Some(parent) = current_exe.parent() { + let candidate = parent.join(exe_name); + if candidate.exists() { + return Ok(candidate); + } + } + + Ok(PathBuf::from(exe_name)) +} + +fn is_daemon_binary_stale(binary_path: &PathBuf) -> bool { + let binary_time = std::fs::metadata(binary_path) + .and_then(|meta| meta.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + let source_time = latest_daemon_source_mtime().unwrap_or(SystemTime::UNIX_EPOCH); + source_time > binary_time +} + +fn latest_daemon_source_mtime() -> Option { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let daemon_dir = manifest_dir.join("src/daemon"); + let entries = std::fs::read_dir(daemon_dir).ok()?; + let mut latest = SystemTime::UNIX_EPOCH; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + if let Ok(modified) = std::fs::metadata(&path).and_then(|meta| meta.modified()) { + if modified > latest { + latest = modified; + } + } + } + + Some(latest) +} + +fn build_daemon_in_dev() -> Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let status = Command::new("cargo") + .arg("build") + .arg("--bin") + .arg("ada-daemon") + .current_dir(&manifest_dir) + .status() + .map_err(|e| Error::TerminalError(e.to_string()))?; + + if !status.success() { + return Err(Error::TerminalError("Failed to build ada-daemon".into())); + } + + let exe_name = if cfg!(windows) { "ada-daemon.exe" } else { "ada-daemon" }; + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| manifest_dir.join("target")); + Ok(target_dir.join("debug").join(exe_name)) +} diff --git a/src-tauri/src/daemon/env.rs b/src-tauri/src/daemon/env.rs new file mode 100644 index 0000000..de1c2cc --- /dev/null +++ b/src-tauri/src/daemon/env.rs @@ -0,0 +1,81 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::daemon::shell::ShellConfig; + +const ALLOWED_ENV_VARS: &[&str] = &[ + "PATH", "HOME", "USER", "SHELL", "TERM", "TMPDIR", "LANG", + "SSH_AUTH_SOCK", "SSH_AGENT_PID", + "NVM_DIR", "NVM_BIN", "NVM_INC", + "PYENV_ROOT", "PYENV_SHELL", + "RBENV_ROOT", "RBENV_SHELL", + "CARGO_HOME", "RUSTUP_HOME", + "GOPATH", "GOROOT", "GOBIN", + "BUN_INSTALL", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "no_proxy", + "__CF_USER_TEXT_ENCODING", + "Apple_PubSub_Socket_Render", + "LC_ALL", "LC_CTYPE", "LC_MESSAGES", +]; + +const ALLOWED_PREFIXES: &[&str] = &[ + "ADA_", + "LC_", +]; + +pub fn build_terminal_env( + shell: &ShellConfig, + wrapper_dir: &Path, + ada_home: &Path, + ada_bin_dir: &Path, + terminal_id: &str, + project_id: &str, + notification_port: u16, +) -> HashMap { + let mut env = HashMap::new(); + let allowed: HashSet<&str> = ALLOWED_ENV_VARS.iter().copied().collect(); + + for (key, value) in std::env::vars() { + let included = allowed.contains(key.as_str()) + || ALLOWED_PREFIXES.iter().any(|prefix| key.starts_with(prefix)); + + if included { + env.insert(key, value); + } + } + + match shell.name.as_str() { + "zsh" => { + let orig_zdotdir = std::env::var("ZDOTDIR") + .unwrap_or_else(|_| { + dirs::home_dir() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_default() + }); + env.insert("ADA_ORIG_ZDOTDIR".into(), orig_zdotdir); + env.insert( + "ZDOTDIR".into(), + wrapper_dir.join("zsh").to_string_lossy().to_string(), + ); + } + _ => {} + } + + env.insert("ADA_HOME".into(), ada_home.to_string_lossy().to_string()); + env.insert("ADA_BIN_DIR".into(), ada_bin_dir.to_string_lossy().to_string()); + env.insert("ADA_TERMINAL_ID".into(), terminal_id.to_string()); + env.insert("ADA_PROJECT_ID".into(), project_id.to_string()); + env.insert("ADA_NOTIFICATION_PORT".into(), notification_port.to_string()); + env.insert("TERM".into(), "xterm-256color".into()); + env.insert("SHELL".into(), shell.path.to_string_lossy().to_string()); + + let ada_bin = ada_bin_dir.to_string_lossy().to_string(); + let path_value = env + .get("PATH") + .map(|path| format!("{ada_bin}:{path}")) + .unwrap_or_else(|| ada_bin.clone()); + env.insert("PATH".into(), path_value); + + env +} diff --git a/src-tauri/src/daemon/logging.rs b/src-tauri/src/daemon/logging.rs new file mode 100644 index 0000000..afc1f2d --- /dev/null +++ b/src-tauri/src/daemon/logging.rs @@ -0,0 +1,63 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, Registry}; + +pub fn init_daemon_logging(ada_home: &Path) -> Option { + if env_flag("ADA_LOG_DISABLE") { + return None; + } + + let log_dir = env::var_os("ADA_LOG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| ada_home.join("logs")); + + if let Err(err) = fs::create_dir_all(&log_dir) { + eprintln!( + "Warning: failed to create daemon log directory {}: {}", + log_dir.display(), + err + ); + return None; + } + + let filter = match env::var("ADA_LOG_LEVEL") { + Ok(level) if !level.trim().is_empty() => EnvFilter::new(level), + _ => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + }; + + let file_appender = tracing_appender::rolling::daily(log_dir, "ada-daemon.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_target(true); + + if env_flag("ADA_LOG_STDERR") { + let stderr_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_ansi(true) + .with_target(true); + Registry::default() + .with(filter) + .with(file_layer) + .with(stderr_layer) + .init(); + } else { + Registry::default().with(filter).with(file_layer).init(); + } + Some(guard) +} + +fn env_flag(name: &str) -> bool { + match env::var(name) { + Ok(value) => matches!( + value.as_str(), + "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON" + ), + Err(_) => false, + } +} diff --git a/src-tauri/src/daemon/mod.rs b/src-tauri/src/daemon/mod.rs new file mode 100644 index 0000000..e151727 --- /dev/null +++ b/src-tauri/src/daemon/mod.rs @@ -0,0 +1,16 @@ +pub mod protocol; +pub mod server; +pub mod session; +pub mod env; +pub mod shell; +pub mod shell_wrapper; +pub mod persistence; +pub mod wrappers; +pub mod notification; +pub mod client; +pub mod logging; +pub mod tray; +pub mod pid; +pub mod tauri_commands; + +pub use server::{run_daemon, run_daemon_with_tray}; diff --git a/src-tauri/src/daemon/notification.rs b/src-tauri/src/daemon/notification.rs new file mode 100644 index 0000000..2db624a --- /dev/null +++ b/src-tauri/src/daemon/notification.rs @@ -0,0 +1,95 @@ +use axum::{ + extract::{Query, State}, + response::Json, + routing::get, + Router, +}; +use serde::Deserialize; +use tokio::net::TcpListener; +use tokio::sync::broadcast; +use tracing::{debug, info}; + +use crate::daemon::protocol::DaemonEvent; +use crate::terminal::AgentStatus; + +#[derive(Deserialize)] +pub struct AgentEventQuery { + terminal_id: String, + event: String, + /// Agent name (claude, codex, opencode, gemini, cursor) + #[serde(default)] + agent: Option, + /// Project ID for context + #[serde(default)] + project_id: Option, + /// Raw JSON payload from the hook (URL-encoded) + #[serde(default)] + payload: Option, +} + +pub async fn start_notification_server( + event_tx: broadcast::Sender, +) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + let app = Router::new() + .route("/hook/agent-event", get(handle_agent_event)) + .with_state(event_tx); + + let server = axum::serve(listener, app); + tokio::spawn(async move { + if let Err(err) = server.await { + eprintln!("[Ada Daemon] Notification server error: {err}"); + } + }); + + Ok(port) +} + +async fn handle_agent_event( + Query(params): Query, + State(event_tx): State>, +) -> Json<&'static str> { + let agent = params.agent.clone().unwrap_or_else(|| "unknown".to_string()); + + info!( + agent = %agent, + event = %params.event, + terminal_id = %params.terminal_id, + project_id = ?params.project_id, + has_payload = params.payload.is_some(), + "Received hook event" + ); + + // Always send raw HookEvent for all events (for frontend logging) + let _ = event_tx.send(DaemonEvent::HookEvent { + terminal_id: params.terminal_id.clone(), + project_id: params.project_id.clone(), + agent: agent.clone(), + event: params.event.clone(), + payload: params.payload, + }); + + // Also map known events to AgentStatus for UI state management + let status = match params.event.as_str() { + "Start" => Some(AgentStatus::Working), + "Stop" => Some(AgentStatus::Idle), + "Permission" => Some(AgentStatus::Permission), + _ => None, + }; + + if let Some(status) = status { + debug!( + terminal_id = %params.terminal_id, + status = ?status, + "Mapped to AgentStatus" + ); + let _ = event_tx.send(DaemonEvent::AgentStatus { + terminal_id: params.terminal_id, + status, + }); + } + + Json("ok") +} diff --git a/src-tauri/src/daemon/persistence.rs b/src-tauri/src/daemon/persistence.rs new file mode 100644 index 0000000..7406968 --- /dev/null +++ b/src-tauri/src/daemon/persistence.rs @@ -0,0 +1,184 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use crate::terminal::{CommandSpec, TerminalMode}; + +const MAX_SCROLLBACK_BYTES: usize = 5 * 1024 * 1024; // 5MB + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMeta { + pub terminal_id: String, + pub project_id: String, + pub name: String, + pub client_id: String, + pub working_dir: PathBuf, + pub branch: Option, + pub worktree_path: Option, + pub folder_path: Option, + pub is_main: bool, + pub mode: TerminalMode, + pub command: CommandSpec, + pub shell: Option, + pub cols: u16, + pub rows: u16, + pub created_at: DateTime, + pub last_activity: DateTime, + pub ended_at: Option>, + pub scrollback_bytes: usize, +} + +pub struct SessionPersistence { + session_dir: PathBuf, + scrollback_writer: BufWriter, + bytes_written: usize, + bytes_since_flush: usize, + pub meta: SessionMeta, +} + +impl SessionPersistence { + pub fn new(base_dir: &Path, meta: SessionMeta) -> std::io::Result { + let session_dir = base_dir.join(&meta.terminal_id); + fs::create_dir_all(&session_dir)?; + + let scrollback_file = open_scrollback(&session_dir, true)?; + + let persistence = Self { + session_dir, + scrollback_writer: BufWriter::new(scrollback_file), + bytes_written: 0, + bytes_since_flush: 0, + meta, + }; + + persistence.save_meta()?; + Ok(persistence) + } + + pub fn open_existing(base_dir: &Path, meta: SessionMeta) -> std::io::Result { + let session_dir = base_dir.join(&meta.terminal_id); + fs::create_dir_all(&session_dir)?; + + let scrollback_file = open_scrollback(&session_dir, false)?; + + let persistence = Self { + session_dir, + scrollback_writer: BufWriter::new(scrollback_file), + bytes_written: meta.scrollback_bytes, + bytes_since_flush: 0, + meta, + }; + + persistence.save_meta()?; + Ok(persistence) + } + + pub fn load_meta(session_dir: &Path) -> Option { + let meta_path = session_dir.join("meta.json"); + let content = fs::read_to_string(&meta_path).ok()?; + match serde_json::from_str(&content) { + Ok(meta) => Some(meta), + Err(e) => { + tracing::warn!("Corrupt session metadata {}: {}", meta_path.display(), e); + None + } + } + } + + pub fn read_scrollback(session_dir: &Path) -> std::io::Result { + let scrollback_path = session_dir.join("scrollback.bin"); + let bytes = fs::read(scrollback_path).unwrap_or_default(); + Ok(String::from_utf8_lossy(&bytes).to_string()) + } + + pub fn session_dir(&self) -> &Path { + &self.session_dir + } + + pub fn write_output(&mut self, data: &[u8]) -> std::io::Result<()> { + if self.bytes_written + data.len() > MAX_SCROLLBACK_BYTES { + self.rotate_scrollback()?; + } + + self.scrollback_writer.write_all(data)?; + self.bytes_written += data.len(); + self.bytes_since_flush += data.len(); + self.meta.scrollback_bytes = self.bytes_written; + self.meta.last_activity = Utc::now(); + + if self.bytes_since_flush >= 4096 { + self.scrollback_writer.flush()?; + self.save_meta()?; + self.bytes_since_flush = 0; + } + + Ok(()) + } + + pub fn mark_ended(&mut self) -> std::io::Result<()> { + self.meta.ended_at = Some(Utc::now()); + self.scrollback_writer.flush()?; + self.save_meta() + } + + pub fn reset(&mut self, meta: SessionMeta) -> std::io::Result<()> { + let scrollback_file = open_scrollback(&self.session_dir, true)?; + + self.scrollback_writer = BufWriter::new(scrollback_file); + self.bytes_written = 0; + self.bytes_since_flush = 0; + self.meta = meta; + self.save_meta() + } + + fn rotate_scrollback(&mut self) -> std::io::Result<()> { + self.scrollback_writer.flush()?; + let scrollback_path = self.session_dir.join("scrollback.bin"); + let content = fs::read(&scrollback_path)?; + + let keep_from = content.len().saturating_sub(4 * 1024 * 1024); + let truncated = truncate_utf8_safe(&content[keep_from..]); + + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&scrollback_path)?; + + self.scrollback_writer = BufWriter::new(file); + self.scrollback_writer.write_all(truncated)?; + self.bytes_written = truncated.len(); + + Ok(()) + } + + fn save_meta(&self) -> std::io::Result<()> { + let meta_path = self.session_dir.join("meta.json"); + let json = serde_json::to_string_pretty(&self.meta)?; + crate::util::atomic_write(&meta_path, json.as_bytes())?; + Ok(()) + } +} + +fn open_scrollback(session_dir: &Path, truncate: bool) -> std::io::Result { + let scrollback_path = session_dir.join("scrollback.bin"); + let mut options = OpenOptions::new(); + options.create(true).write(true); + if truncate { + options.truncate(true); + } else { + options.append(true); + } + options.open(scrollback_path) +} + +fn truncate_utf8_safe(bytes: &[u8]) -> &[u8] { + for i in 0..4.min(bytes.len()) { + if std::str::from_utf8(&bytes[i..]).is_ok() { + return &bytes[i..]; + } + } + bytes +} diff --git a/src-tauri/src/daemon/pid.rs b/src-tauri/src/daemon/pid.rs new file mode 100644 index 0000000..c0d094f --- /dev/null +++ b/src-tauri/src/daemon/pid.rs @@ -0,0 +1,66 @@ +//! PID file management for the daemon +//! +//! Provides functions to write, read, and cleanup PID files for daemon lifecycle management. + +use std::fs; +use std::io; +use std::path::Path; + +/// Write the current process PID to the PID file +pub fn write_pid(daemon_dir: &Path) -> io::Result<()> { + fs::create_dir_all(daemon_dir)?; + let pid_path = daemon_dir.join("pid"); + let pid = std::process::id(); + crate::util::atomic_write(&pid_path, pid.to_string().as_bytes())?; + tracing::info!(pid = pid, path = %pid_path.display(), "wrote PID file"); + Ok(()) +} + +/// Read the PID from the PID file +pub fn read_pid(daemon_dir: &Path) -> Option { + let pid_path = daemon_dir.join("pid"); + let content = fs::read_to_string(&pid_path).ok()?; + content.trim().parse().ok() +} + +/// Remove the PID file +pub fn remove_pid(daemon_dir: &Path) -> io::Result<()> { + let pid_path = daemon_dir.join("pid"); + if pid_path.exists() { + fs::remove_file(&pid_path)?; + tracing::info!(path = %pid_path.display(), "removed PID file"); + } + Ok(()) +} + +/// Check if a process with the given PID is running +#[cfg(unix)] +pub fn is_process_running(pid: u32) -> bool { + use nix::sys::signal::kill; + use nix::unistd::Pid; + + // Signal 0 doesn't send a signal but checks if process exists + kill(Pid::from_raw(pid as i32), None).is_ok() +} + +#[cfg(not(unix))] +pub fn is_process_running(_pid: u32) -> bool { + // On non-Unix systems, we can't easily check + // Fall back to assuming it's not running + false +} + +/// Cleanup stale PID file if the process is no longer running +pub fn cleanup_stale_pid(daemon_dir: &Path) { + if let Some(pid) = read_pid(daemon_dir) { + if !is_process_running(pid) { + let _ = remove_pid(daemon_dir); + // Also remove stale port file + let port_path = daemon_dir.join("port"); + if port_path.exists() { + let _ = fs::remove_file(&port_path); + } + tracing::info!(pid = pid, "cleaned up stale PID file"); + } + } +} diff --git a/src-tauri/src/daemon/protocol.rs b/src-tauri/src/daemon/protocol.rs new file mode 100644 index 0000000..0c9965e --- /dev/null +++ b/src-tauri/src/daemon/protocol.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +use crate::terminal::{AgentStatus, CommandSpec, TerminalInfo, TerminalMode, TerminalStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeConfig { + pub ada_home: String, + pub data_dir: String, + pub daemon_port: u16, + pub notification_port: u16, + pub shell_override: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSessionRequest { + pub terminal_id: String, + pub project_id: String, + pub name: String, + pub client_id: String, + pub working_dir: String, + pub branch: Option, + pub worktree_path: Option, + pub folder_path: Option, + pub is_main: bool, + pub mode: TerminalMode, + pub command: CommandSpec, + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonRequest { + /// Health check - daemon responds with Pong + Ping, + /// Get daemon status information + Status, + ListSessions, + GetSession { terminal_id: String }, + CreateSession { request: CreateSessionRequest }, + MarkSessionStopped { terminal_id: String }, + CloseSession { terminal_id: String }, + WriteToSession { terminal_id: String, data: String }, + ResizeSession { terminal_id: String, cols: u16, rows: u16 }, + RestartSession { terminal_id: String }, + SwitchSessionAgent { terminal_id: String, client_id: String, command: CommandSpec }, + GetHistory { terminal_id: String }, + GetRuntimeConfig, + SetShellOverride { shell: Option }, + /// Shutdown the daemon (used in dev mode when GUI closes) + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonResponse { + Ok, + /// Response to Ping request + Pong, + Error { message: String }, + Sessions { sessions: Vec }, + Session { session: TerminalInfo }, + History { terminal_id: String, history: Vec }, + RuntimeConfig { config: RuntimeConfig }, + /// Terminal status response (renamed from Status to avoid confusion with DaemonStatus) + TerminalStatusResponse { terminal_id: String, status: TerminalStatus }, + /// Daemon status information + DaemonStatus { + pid: u32, + port: u16, + uptime_secs: u64, + session_count: usize, + version: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonEvent { + TerminalOutput { terminal_id: String, data: String }, + TerminalStatus { terminal_id: String, project_id: String, status: TerminalStatus }, + AgentStatus { terminal_id: String, status: AgentStatus }, + /// Raw hook event from any agent - forwarded to frontend for logging/debugging + HookEvent { + terminal_id: String, + project_id: Option, + agent: String, + event: String, + payload: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DaemonMessage { + Request { id: String, request: DaemonRequest }, + Response { id: String, response: DaemonResponse }, + Event { event: DaemonEvent }, +} diff --git a/src-tauri/src/daemon/server.rs b/src-tauri/src/daemon/server.rs new file mode 100644 index 0000000..e1c6118 --- /dev/null +++ b/src-tauri/src/daemon/server.rs @@ -0,0 +1,559 @@ +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::mpsc as std_mpsc; +use std::time::Instant; + +use parking_lot::RwLock as ParkingRwLock; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::{broadcast, mpsc, RwLock}; + +use crate::daemon::logging::init_daemon_logging; +use crate::daemon::notification::start_notification_server; +use crate::daemon::pid; +use crate::daemon::protocol::{DaemonEvent, DaemonMessage, DaemonRequest, DaemonResponse, RuntimeConfig}; +use crate::daemon::session::SessionManager; +use crate::daemon::tray::{self, TrayCommand}; +use crate::error::Result as AdaResult; +use tracing::{debug, error, info, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct RuntimeSettings { + pub shell_override: Option, +} + +/// Buffer size for the event broadcast channel +/// Large enough to handle high-throughput terminal output without losing events +const EVENT_BUFFER_SIZE: usize = 4096; + +/// Runs the daemon with system tray support. +/// +/// On macOS, this must be called from the main thread because Cocoa requires +/// UI operations (like the system tray) to run on the main thread. +/// +/// The function: +/// 1. Spawns the tokio runtime and IPC server on a background thread +/// 2. Runs the system tray event loop on the current (main) thread +/// +/// This function never returns - it runs the event loop forever. +pub fn run_daemon_with_tray() -> ! { + // Initialize logging on main thread + let ada_home = ada_home_dir(); + let _log_guard = init_daemon_logging(&ada_home); + info!("daemon starting with tray support"); + + // Channel for tray commands + let (tray_cmd_tx, tray_cmd_rx) = std_mpsc::channel(); + let (sessions_tx, sessions_rx) = std_mpsc::channel::>(); + + // Spawn the async daemon on a background thread + let tray_cmd_rx_clone = tray_cmd_rx; + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); + rt.block_on(async { + if let Err(e) = run_daemon_async(tray_cmd_rx_clone, sessions_tx).await { + error!("daemon error: {}", e); + } + }); + }); + + // Wait for initial sessions from the daemon + let initial_sessions = sessions_rx.recv().unwrap_or_default(); + + // Run tray on main thread (required by macOS) - never returns + tray::run_tray_on_main_thread(tray_cmd_tx, initial_sessions) +} + +/// Internal async daemon implementation +async fn run_daemon_async( + tray_cmd_rx: std_mpsc::Receiver, + sessions_tx: std_mpsc::Sender>, +) -> std::result::Result<(), Box> { + let start_time = Instant::now(); + let data_dir = daemon_data_dir()?; + let ada_home = ada_home_dir(); + + info!( + dev_mode = is_dev_mode(), + data_dir = %data_dir.display(), + ada_home = %ada_home.display(), + "daemon async starting" + ); + + fs::create_dir_all(&data_dir)?; + fs::create_dir_all(&ada_home)?; + + let settings = load_runtime_settings(&ada_home); + let shell_override = Arc::new(ParkingRwLock::new(settings.shell_override.clone())); + + // Single broadcast channel for all events with large buffer + let (event_tx, _) = broadcast::channel(EVENT_BUFFER_SIZE); + let notification_port = start_notification_server(event_tx.clone()).await?; + info!(notification_port, "notification server started"); + + let manager = SessionManager::new( + &data_dir, + &ada_home, + event_tx.clone(), + notification_port, + shell_override.clone(), + )?; + + // Send initial sessions to tray + let initial_sessions = manager.list_sessions(); + let _ = sessions_tx.send(initial_sessions); + + // Task to update agent status in session manager and notify tray when events arrive + { + let manager_for_events = manager.clone(); + let mut agent_rx = event_tx.subscribe(); + tokio::spawn(async move { + while let Ok(event) = agent_rx.recv().await { + match event { + DaemonEvent::AgentStatus { terminal_id, status } => { + manager_for_events.update_agent_status(&terminal_id, status); + // Notify tray of session changes + let sessions = manager_for_events.list_sessions(); + tray::notify_sessions_changed(sessions); + } + DaemonEvent::TerminalStatus { .. } => { + // Notify tray when terminal status changes + let sessions = manager_for_events.list_sessions(); + tray::notify_sessions_changed(sessions); + } + _ => {} + } + } + }); + } + + let runtime = RuntimeConfig { + ada_home: ada_home.to_string_lossy().to_string(), + data_dir: data_dir.to_string_lossy().to_string(), + daemon_port: 0, + notification_port, + shell_override: settings.shell_override, + }; + let runtime = Arc::new(RwLock::new(runtime)); + + serve_ipc(data_dir, manager, runtime, event_tx, shell_override, tray_cmd_rx, start_time).await?; + Ok(()) +} + +/// Original daemon entry point (without tray - for backwards compatibility) +pub async fn run_daemon() -> std::result::Result<(), Box> { + let start_time = Instant::now(); + let data_dir = daemon_data_dir()?; + let ada_home = ada_home_dir(); + + let _log_guard = init_daemon_logging(&ada_home); + info!( + data_dir = %data_dir.display(), + ada_home = %ada_home.display(), + "daemon starting" + ); + + fs::create_dir_all(&data_dir)?; + fs::create_dir_all(&ada_home)?; + + let settings = load_runtime_settings(&ada_home); + let shell_override = Arc::new(ParkingRwLock::new(settings.shell_override.clone())); + + // Single broadcast channel for all events with large buffer + let (event_tx, _) = broadcast::channel(EVENT_BUFFER_SIZE); + let notification_port = start_notification_server(event_tx.clone()).await?; + info!(notification_port, "notification server started"); + + let manager = SessionManager::new( + &data_dir, + &ada_home, + event_tx.clone(), + notification_port, + shell_override.clone(), + )?; + + // Task to update agent status in session manager when status events arrive + { + let manager_for_events = manager.clone(); + let mut agent_rx = event_tx.subscribe(); + tokio::spawn(async move { + while let Ok(event) = agent_rx.recv().await { + if let DaemonEvent::AgentStatus { terminal_id, status } = event { + manager_for_events.update_agent_status(&terminal_id, status); + } + } + }); + } + + let runtime = RuntimeConfig { + ada_home: ada_home.to_string_lossy().to_string(), + data_dir: data_dir.to_string_lossy().to_string(), + daemon_port: 0, + notification_port, + shell_override: settings.shell_override, + }; + let runtime = Arc::new(RwLock::new(runtime)); + + // No tray - create dummy receiver that never receives + let (_tray_cmd_tx, tray_cmd_rx) = std_mpsc::channel(); + + serve_ipc(data_dir, manager, runtime, event_tx, shell_override, tray_cmd_rx, start_time).await?; + Ok(()) +} + +async fn serve_ipc( + data_dir: PathBuf, + manager: SessionManager, + runtime: Arc>, + event_tx: broadcast::Sender, + shell_override: Arc>>, + tray_cmd_rx: std_mpsc::Receiver, + start_time: Instant, +) -> AdaResult<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + info!(daemon_port = addr.port(), "daemon listening"); + + runtime.write().await.daemon_port = addr.port(); + + // Write PID and port files + let daemon_dir = data_dir.join("daemon"); + if let Err(e) = pid::write_pid(&daemon_dir) { + warn!(error = %e, "failed to write PID file"); + } + write_port_file(&data_dir, addr)?; + + // Spawn a blocking task to handle tray commands (std::sync::mpsc is blocking) + let _tray_task = std::thread::spawn(move || { + loop { + match tray_cmd_rx.recv() { + Ok(TrayCommand::Quit) => { + info!("quit command received from tray"); + // Exit the daemon + std::process::exit(0); + } + Ok(TrayCommand::OpenApp) => { + tray::open_main_app(); + } + Ok(TrayCommand::SelectSession(terminal_id)) => { + info!(terminal_id = %terminal_id, "session selected from tray"); + // For now, just open the app - in the future could focus the terminal + tray::open_main_app(); + } + Err(_) => { + // Channel closed (tray exited) + warn!("tray command channel closed"); + break; + } + } + } + }); + + // Accept connections in the main loop + let daemon_port = addr.port(); + loop { + let (stream, _) = listener.accept().await?; + let peer = stream.peer_addr().ok(); + let manager = manager.clone(); + let runtime = runtime.clone(); + let event_tx = event_tx.clone(); + let shell_override = shell_override.clone(); + + tokio::spawn(async move { + info!(peer = ?peer, "ipc connection accepted"); + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader).lines(); + + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + let write_task = tokio::spawn(async move { + let mut writer = writer; + while let Some(line) = out_rx.recv().await { + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + if writer.write_all(b"\n").await.is_err() { + break; + } + } + }); + + // Forward all events to IPC + let mut event_rx = event_tx.subscribe(); + let out_tx_events = out_tx.clone(); + let event_task = tokio::spawn(async move { + loop { + match event_rx.recv().await { + Ok(event) => { + let message = DaemonMessage::Event { event }; + if let Ok(json) = serde_json::to_string(&message) { + if out_tx_events.send(json).is_err() { + break; + } + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(lagged = n, "IPC client lagged, some events were dropped"); + // Continue receiving + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + }); + + while let Ok(Some(line)) = reader.next_line().await { + let message: DaemonMessage = match serde_json::from_str(&line) { + Ok(msg) => msg, + Err(err) => { + warn!(error = %err, "ipc message parse failed"); + continue; + } + }; + + let (id, request) = match message { + DaemonMessage::Request { id, request } => (id, request), + _ => continue, + }; + + debug!(request = request_kind(&request), request_id = %id, "ipc request"); + let response = handle_request(&manager, &runtime, request, &shell_override, start_time, daemon_port).await; + let message = DaemonMessage::Response { id, response }; + if let Ok(json) = serde_json::to_string(&message) { + let _ = out_tx.send(json); + } + } + + event_task.abort(); + write_task.abort(); + warn!(peer = ?peer, "ipc connection closed"); + }); + } +} + +async fn handle_request( + manager: &SessionManager, + runtime: &Arc>, + request: DaemonRequest, + shell_override: &Arc>>, + start_time: Instant, + daemon_port: u16, +) -> DaemonResponse { + let kind = request_kind(&request); + let response = match request { + DaemonRequest::Ping => DaemonResponse::Pong, + DaemonRequest::Status => { + let uptime = start_time.elapsed().as_secs(); + let session_count = manager.list_sessions().len(); + DaemonResponse::DaemonStatus { + pid: std::process::id(), + port: daemon_port, + uptime_secs: uptime, + session_count, + version: env!("CARGO_PKG_VERSION").to_string(), + } + } + DaemonRequest::ListSessions => { + let sessions = manager.list_sessions(); + DaemonResponse::Sessions { sessions } + } + DaemonRequest::GetSession { terminal_id } => { + match manager.get_session(&terminal_id) { + Ok(session) => DaemonResponse::Session { session }, + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::CreateSession { request } => { + match manager.create_session(request) { + Ok(session) => { + // Notify tray of new session + tray::notify_sessions_changed(manager.list_sessions()); + DaemonResponse::Session { session } + } + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::MarkSessionStopped { terminal_id } => { + match manager.mark_session_stopped(&terminal_id) { + Ok(status) => { + // Notify tray of status change + tray::notify_sessions_changed(manager.list_sessions()); + DaemonResponse::TerminalStatusResponse { terminal_id, status } + } + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::CloseSession { terminal_id } => { + match manager.close_session(&terminal_id) { + Ok(()) => { + // Notify tray of closed session + tray::notify_sessions_changed(manager.list_sessions()); + DaemonResponse::Ok + } + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::WriteToSession { terminal_id, data } => { + match manager.write_to_session(&terminal_id, &data) { + Ok(()) => DaemonResponse::Ok, + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::ResizeSession { terminal_id, cols, rows } => { + match manager.resize_session(&terminal_id, cols, rows) { + Ok(()) => DaemonResponse::Ok, + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::RestartSession { terminal_id } => { + info!(terminal_id = %terminal_id, "restart_session request received"); + match manager.restart_session(&terminal_id) { + Ok(session) => { + info!( + terminal_id = %terminal_id, + status = ?session.status, + "restart_session completed successfully" + ); + // Notify tray of restarted session + tray::notify_sessions_changed(manager.list_sessions()); + DaemonResponse::Session { session } + } + Err(err) => { + error!( + terminal_id = %terminal_id, + error = %err, + "restart_session failed" + ); + DaemonResponse::Error { message: err.to_string() } + } + } + } + DaemonRequest::SwitchSessionAgent { terminal_id, client_id, command } => { + match manager.switch_session_agent(&terminal_id, &client_id, command) { + Ok(session) => { + // Notify tray of agent switch + tray::notify_sessions_changed(manager.list_sessions()); + DaemonResponse::Session { session } + } + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::GetHistory { terminal_id } => { + match manager.get_history(&terminal_id) { + Ok(history) => DaemonResponse::History { terminal_id, history }, + Err(err) => DaemonResponse::Error { message: err.to_string() }, + } + } + DaemonRequest::GetRuntimeConfig => { + let config = runtime.read().await.clone(); + DaemonResponse::RuntimeConfig { config } + } + DaemonRequest::SetShellOverride { shell } => { + { + let mut override_value = shell_override.write(); + *override_value = shell.clone(); + } + { + let mut config = runtime.write().await; + config.shell_override = shell.clone(); + let _ = save_runtime_settings(&config, Path::new(&config.ada_home)); + } + DaemonResponse::Ok + } + DaemonRequest::Shutdown => { + info!("Shutdown request received, exiting daemon"); + // Spawn a task to exit after a short delay to allow response to be sent + tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + std::process::exit(0); + }); + DaemonResponse::Ok + } + }; + + if let DaemonResponse::Error { ref message } = response { + warn!(request = kind, error = %message, "ipc request failed"); + } + + response +} + +fn request_kind(request: &DaemonRequest) -> &'static str { + match request { + DaemonRequest::Ping => "ping", + DaemonRequest::Status => "status", + DaemonRequest::ListSessions => "list_sessions", + DaemonRequest::GetSession { .. } => "get_session", + DaemonRequest::CreateSession { .. } => "create_session", + DaemonRequest::MarkSessionStopped { .. } => "mark_session_stopped", + DaemonRequest::CloseSession { .. } => "close_session", + DaemonRequest::WriteToSession { .. } => "write_to_session", + DaemonRequest::ResizeSession { .. } => "resize_session", + DaemonRequest::RestartSession { .. } => "restart_session", + DaemonRequest::SwitchSessionAgent { .. } => "switch_session_agent", + DaemonRequest::GetHistory { .. } => "get_history", + DaemonRequest::GetRuntimeConfig => "get_runtime_config", + DaemonRequest::SetShellOverride { .. } => "set_shell_override", + DaemonRequest::Shutdown => "shutdown", + } +} + +/// Check if we're running in dev mode (via env var or debug build) +fn is_dev_mode() -> bool { + std::env::var("ADA_DEV_MODE").map(|v| v == "1").unwrap_or(false) + || cfg!(debug_assertions) +} + +fn daemon_data_dir() -> AdaResult { + let dir_name = if is_dev_mode() { "ada-dev" } else { "ada" }; + Ok(dirs::data_dir() + .ok_or_else(|| crate::error::Error::ConfigError("Could not find data directory".into()))? + .join(dir_name)) +} + +fn ada_home_dir() -> PathBuf { + let dir_name = if is_dev_mode() { ".ada-dev" } else { ".ada" }; + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(dir_name) +} + +fn runtime_settings_path(ada_home: &Path) -> PathBuf { + ada_home.join("config/runtime.json") +} + +fn load_runtime_settings(ada_home: &Path) -> RuntimeSettings { + let path = runtime_settings_path(ada_home); + if let Ok(content) = fs::read_to_string(&path) { + match serde_json::from_str::(&content) { + Ok(settings) => return settings, + Err(e) => { + tracing::warn!("Corrupt runtime settings {}: {}", path.display(), e); + } + } + } + RuntimeSettings::default() +} + +fn save_runtime_settings(config: &RuntimeConfig, ada_home: &Path) -> std::io::Result<()> { + let settings = RuntimeSettings { + shell_override: config.shell_override.clone(), + }; + let path = runtime_settings_path(ada_home); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&settings)?; + crate::util::atomic_write(&path, json.as_bytes())?; + Ok(()) +} + +fn write_port_file(data_dir: &Path, addr: SocketAddr) -> std::io::Result<()> { + let daemon_dir = data_dir.join("daemon"); + fs::create_dir_all(&daemon_dir)?; + fs::write(daemon_dir.join("port"), addr.port().to_string()) +} diff --git a/src-tauri/src/daemon/session.rs b/src-tauri/src/daemon/session.rs new file mode 100644 index 0000000..10e1586 --- /dev/null +++ b/src-tauri/src/daemon/session.rs @@ -0,0 +1,843 @@ +use chrono::Utc; +use parking_lot::{Mutex, RwLock}; +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use tokio::sync::broadcast; +use tracing::{error, info, warn}; + +use crate::daemon::env::build_terminal_env; +use crate::daemon::persistence::{SessionMeta, SessionPersistence}; +use crate::daemon::protocol::{CreateSessionRequest, DaemonEvent}; +use crate::daemon::shell::ShellConfig; +use crate::daemon::shell_wrapper::setup_shell_wrappers; +use crate::daemon::wrappers::{ensure_claude_settings, setup_agent_wrappers}; +use crate::error::{Error, Result}; +use crate::terminal::{AgentStatus, CommandSpec, PtyHandle, Terminal, TerminalInfo, TerminalStatus}; + +pub struct SessionEntry { + pub terminal: Terminal, + pub pty: Option, + pub persistence: Arc>, + pub cols: u16, + pub rows: u16, + /// Shutdown signal for the PTY reader thread + pub shutdown: Arc, + /// Handle to the PTY reader thread for cleanup + pub reader_handle: Option>, +} + +#[derive(Clone)] +pub struct SessionManager { + sessions: Arc>>, + /// Broadcast channel for all events (output + status) + /// Buffer is large enough for high-throughput terminals + event_tx: broadcast::Sender, + sessions_dir: PathBuf, + wrapper_dir: PathBuf, + ada_bin_dir: PathBuf, + ada_home: PathBuf, + notification_port: u16, + shell_override: Arc>>, +} + +impl SessionManager { + pub fn new( + data_dir: &Path, + ada_home: &Path, + event_tx: broadcast::Sender, + notification_port: u16, + shell_override: Arc>>, + ) -> Result { + let sessions_dir = data_dir.join("sessions"); + std::fs::create_dir_all(&sessions_dir)?; + + let ada_home = ada_home.to_path_buf(); + let wrapper_dir = setup_shell_wrappers(&ada_home)?; + let wrappers = setup_agent_wrappers(&ada_home)?; + + let manager = Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + event_tx, + sessions_dir, + wrapper_dir, + ada_bin_dir: wrappers.bin_dir, + ada_home, + notification_port, + shell_override, + }; + + manager.load_from_disk()?; + Ok(manager) + } + + pub fn list_sessions(&self) -> Vec { + self.sessions + .read() + .values() + .map(|entry| TerminalInfo::from(&entry.terminal)) + .collect() + } + + pub fn get_session(&self, terminal_id: &str) -> Result { + let sessions = self.sessions.read(); + let entry = sessions + .get(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + Ok(TerminalInfo::from(&entry.terminal)) + } + + pub fn create_session(&self, request: CreateSessionRequest) -> Result { + let working_dir = PathBuf::from(&request.working_dir); + if !working_dir.exists() { + return Err(Error::InvalidRequest(format!( + "Working directory does not exist: {}", + request.working_dir + ))); + } + + info!( + terminal_id = %request.terminal_id, + project_id = %request.project_id, + client_id = %request.client_id, + "creating terminal session" + ); + + let shell = ShellConfig::detect(self.shell_override.read().clone()); + let mut terminal = Terminal { + id: request.terminal_id.clone(), + project_id: request.project_id.clone(), + name: request.name, + client_id: request.client_id, + working_dir: working_dir.clone(), + branch: request.branch, + worktree_path: request.worktree_path.map(PathBuf::from), + status: TerminalStatus::Starting, + created_at: Utc::now(), + command: request.command, + shell: Some(shell.path.to_string_lossy().to_string()), + agent_status: AgentStatus::Idle, + mode: request.mode, + is_main: request.is_main, + folder_path: request.folder_path.map(PathBuf::from), + }; + + let meta = SessionMeta { + terminal_id: terminal.id.clone(), + project_id: terminal.project_id.clone(), + name: terminal.name.clone(), + client_id: terminal.client_id.clone(), + working_dir: terminal.working_dir.clone(), + branch: terminal.branch.clone(), + worktree_path: terminal.worktree_path.clone(), + folder_path: terminal.folder_path.clone(), + is_main: terminal.is_main, + mode: terminal.mode, + command: terminal.command.clone(), + shell: terminal.shell.clone(), + cols: request.cols, + rows: request.rows, + created_at: terminal.created_at, + last_activity: terminal.created_at, + ended_at: None, + scrollback_bytes: 0, + }; + + let persistence = SessionPersistence::new(&self.sessions_dir, meta)?; + let persistence = Arc::new(Mutex::new(persistence)); + + let shutdown = Arc::new(AtomicBool::new(false)); + + // Spawn PTY + let (pty, reader_handle) = self.spawn_pty( + &mut terminal, + request.cols, + request.rows, + persistence.clone(), + shutdown.clone(), + )?; + terminal.status = TerminalStatus::Running; + + let entry = SessionEntry { + terminal: terminal.clone(), + pty: Some(pty), + persistence, + cols: request.cols, + rows: request.rows, + shutdown, + reader_handle: Some(reader_handle), + }; + + self.sessions.write().insert(terminal.id.clone(), entry); + + self.emit_status(&terminal)?; + Ok(TerminalInfo::from(&terminal)) + } + + pub fn write_to_session(&self, terminal_id: &str, data: &str) -> Result<()> { + // Clone the PTY handle under a short read lock, then release before I/O + let pty_handle = { + let sessions = self.sessions.read(); + let entry = sessions + .get(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + let pty = entry + .pty + .as_ref() + .ok_or_else(|| Error::TerminalError("Terminal PTY is not running".into()))?; + + // Clone the Arc> handles - this is cheap + PtyHandle { + master: pty.master.clone(), + writer: pty.writer.clone(), + } + }; + // Lock released here, now perform I/O without blocking other sessions + crate::terminal::pty::write_to_pty(&pty_handle, data.as_bytes()) + } + + pub fn resize_session(&self, terminal_id: &str, cols: u16, rows: u16) -> Result<()> { + // Clone PTY handle and persistence under short lock, then perform I/O + let (pty_handle, persistence) = { + let sessions = self.sessions.read(); + let entry = sessions + .get(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + let pty = entry + .pty + .as_ref() + .ok_or_else(|| Error::TerminalError("Terminal PTY is not running".into()))?; + + ( + PtyHandle { + master: pty.master.clone(), + writer: pty.writer.clone(), + }, + entry.persistence.clone(), + ) + }; + + // Perform resize I/O without holding session lock + crate::terminal::pty::resize_pty(&pty_handle, cols, rows)?; + + // Update metadata under write lock + { + let mut sessions = self.sessions.write(); + if let Some(entry) = sessions.get_mut(terminal_id) { + entry.cols = cols; + entry.rows = rows; + } + } + { + let mut persistence = persistence.lock(); + persistence.meta.cols = cols; + persistence.meta.rows = rows; + } + + Ok(()) + } + + pub fn close_session(&self, terminal_id: &str) -> Result<()> { + let entry = { + let mut sessions = self.sessions.write(); + sessions + .remove(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))? + }; + + // Signal the reader thread to stop + entry.shutdown.store(true, Ordering::SeqCst); + + // Don't wait for reader thread - it will exit on its own when: + // 1. The PTY master is dropped and read returns EOF/error, or + // 2. It checks the shutdown flag after the next read completes + // Joining here can block indefinitely if the read is blocked. + drop(entry.reader_handle); + + { + let mut persistence = entry.persistence.lock(); + let _ = persistence.mark_ended(); + } + + let _ = std::fs::remove_dir_all( + self.sessions_dir.join(terminal_id) + ); + + self.emit_status(&Terminal { + status: TerminalStatus::Stopped, + ..entry.terminal + })?; + + Ok(()) + } + + pub fn mark_session_stopped(&self, terminal_id: &str) -> Result { + let mut sessions = self.sessions.write(); + let entry = sessions + .get_mut(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + entry.pty = None; + entry.terminal.status = TerminalStatus::Stopped; + + { + let mut persistence = entry.persistence.lock(); + let _ = persistence.mark_ended(); + } + + self.emit_status(&entry.terminal)?; + Ok(entry.terminal.status) + } + + pub fn update_agent_status(&self, terminal_id: &str, status: AgentStatus) { + let mut sessions = self.sessions.write(); + if let Some(entry) = sessions.get_mut(terminal_id) { + entry.terminal.agent_status = status; + } + } + + pub fn restart_session(&self, terminal_id: &str) -> Result { + info!(terminal_id = %terminal_id, "restart_session: starting"); + + // First, signal the old reader thread to stop and get necessary data + let (cols, rows, persistence, old_shutdown, old_handle) = { + info!(terminal_id = %terminal_id, "restart_session: acquiring write lock"); + let mut sessions = self.sessions.write(); + info!(terminal_id = %terminal_id, "restart_session: write lock acquired"); + + let entry = sessions + .get_mut(terminal_id) + .ok_or_else(|| { + error!(terminal_id = %terminal_id, "restart_session: terminal not found"); + Error::TerminalNotFound(terminal_id.to_string()) + })?; + + info!( + terminal_id = %terminal_id, + current_status = ?entry.terminal.status, + has_pty = entry.pty.is_some(), + "restart_session: found terminal" + ); + + // Signal old reader to stop + entry.shutdown.store(true, Ordering::SeqCst); + entry.pty = None; + + let shell = ShellConfig::detect(self.shell_override.read().clone()); + info!( + terminal_id = %terminal_id, + shell = %shell.path.display(), + "restart_session: detected shell" + ); + + entry.terminal.shell = Some(shell.path.to_string_lossy().to_string()); + entry.terminal.status = TerminalStatus::Starting; + entry.terminal.created_at = Utc::now(); + + let meta = SessionMeta { + terminal_id: entry.terminal.id.clone(), + project_id: entry.terminal.project_id.clone(), + name: entry.terminal.name.clone(), + client_id: entry.terminal.client_id.clone(), + working_dir: entry.terminal.working_dir.clone(), + branch: entry.terminal.branch.clone(), + worktree_path: entry.terminal.worktree_path.clone(), + folder_path: entry.terminal.folder_path.clone(), + is_main: entry.terminal.is_main, + mode: entry.terminal.mode, + command: entry.terminal.command.clone(), + shell: entry.terminal.shell.clone(), + cols: entry.cols, + rows: entry.rows, + created_at: entry.terminal.created_at, + last_activity: entry.terminal.created_at, + ended_at: None, + scrollback_bytes: 0, + }; + + info!(terminal_id = %terminal_id, "restart_session: resetting persistence"); + entry.persistence.lock().reset(meta)?; + + info!(terminal_id = %terminal_id, "restart_session: releasing write lock (phase 1)"); + ( + entry.cols, + entry.rows, + entry.persistence.clone(), + std::mem::replace(&mut entry.shutdown, Arc::new(AtomicBool::new(false))), + entry.reader_handle.take(), + ) + }; + + // Don't wait for old reader thread - it will exit on its own when: + // 1. The PTY master is dropped and read returns EOF/error, or + // 2. It checks the shutdown flag after the next read completes + // Joining here can block indefinitely if the read is blocked. + info!(terminal_id = %terminal_id, "restart_session: dropping old handles"); + drop(old_handle); + drop(old_shutdown); + + let shutdown = Arc::new(AtomicBool::new(false)); + + // Spawn new PTY + info!(terminal_id = %terminal_id, "restart_session: acquiring write lock (phase 2)"); + let mut sessions = self.sessions.write(); + info!(terminal_id = %terminal_id, "restart_session: write lock acquired (phase 2)"); + + let entry = sessions + .get_mut(terminal_id) + .ok_or_else(|| { + error!(terminal_id = %terminal_id, "restart_session: terminal not found (phase 2)"); + Error::TerminalNotFound(terminal_id.to_string()) + })?; + + info!( + terminal_id = %terminal_id, + working_dir = %entry.terminal.working_dir.display(), + command = %entry.terminal.command.command, + "restart_session: spawning new PTY" + ); + + let (pty, reader_handle) = self.spawn_pty( + &mut entry.terminal, + cols, + rows, + persistence, + shutdown.clone(), + )?; + + info!(terminal_id = %terminal_id, "restart_session: PTY spawned successfully"); + + entry.pty = Some(pty); + entry.terminal.status = TerminalStatus::Running; + entry.shutdown = shutdown; + entry.reader_handle = Some(reader_handle); + + info!(terminal_id = %terminal_id, "restart_session: emitting status"); + self.emit_status(&entry.terminal)?; + + info!(terminal_id = %terminal_id, "restart_session: completed successfully"); + Ok(TerminalInfo::from(&entry.terminal)) + } + + pub fn switch_session_agent( + &self, + terminal_id: &str, + client_id: &str, + command: CommandSpec, + ) -> Result { + // First, signal the old reader thread to stop and get necessary data + let (cols, rows, persistence, old_shutdown, old_handle) = { + let mut sessions = self.sessions.write(); + let entry = sessions + .get_mut(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + // Signal old reader to stop + entry.shutdown.store(true, Ordering::SeqCst); + entry.pty = None; + + entry.terminal.client_id = client_id.to_string(); + entry.terminal.command = command; + entry.terminal.status = TerminalStatus::Starting; + entry.terminal.created_at = Utc::now(); + + let meta = SessionMeta { + terminal_id: entry.terminal.id.clone(), + project_id: entry.terminal.project_id.clone(), + name: entry.terminal.name.clone(), + client_id: entry.terminal.client_id.clone(), + working_dir: entry.terminal.working_dir.clone(), + branch: entry.terminal.branch.clone(), + worktree_path: entry.terminal.worktree_path.clone(), + folder_path: entry.terminal.folder_path.clone(), + is_main: entry.terminal.is_main, + mode: entry.terminal.mode, + command: entry.terminal.command.clone(), + shell: entry.terminal.shell.clone(), + cols: entry.cols, + rows: entry.rows, + created_at: entry.terminal.created_at, + last_activity: entry.terminal.created_at, + ended_at: None, + scrollback_bytes: 0, + }; + + entry.persistence.lock().reset(meta)?; + + ( + entry.cols, + entry.rows, + entry.persistence.clone(), + std::mem::replace(&mut entry.shutdown, Arc::new(AtomicBool::new(false))), + entry.reader_handle.take(), + ) + }; + + // Don't wait for old reader thread - it will exit on its own when: + // 1. The PTY master is dropped and read returns EOF/error, or + // 2. It checks the shutdown flag after the next read completes + // Joining here can block indefinitely if the read is blocked. + drop(old_handle); + drop(old_shutdown); + + let shutdown = Arc::new(AtomicBool::new(false)); + + // Spawn new PTY + let mut sessions = self.sessions.write(); + let entry = sessions + .get_mut(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + let (pty, reader_handle) = self.spawn_pty( + &mut entry.terminal, + cols, + rows, + persistence, + shutdown.clone(), + )?; + + entry.pty = Some(pty); + entry.terminal.status = TerminalStatus::Running; + entry.terminal.agent_status = AgentStatus::Idle; + entry.shutdown = shutdown; + entry.reader_handle = Some(reader_handle); + + self.emit_status(&entry.terminal)?; + Ok(TerminalInfo::from(&entry.terminal)) + } + + pub fn get_history(&self, terminal_id: &str) -> Result> { + let sessions = self.sessions.read(); + let entry = sessions + .get(terminal_id) + .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + + let scrollback = SessionPersistence::read_scrollback(entry.persistence.lock().session_dir())?; + if scrollback.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![scrollback]) + } + } + + fn spawn_pty( + &self, + terminal: &mut Terminal, + cols: u16, + rows: u16, + persistence: Arc>, + shutdown: Arc, + ) -> Result<(PtyHandle, JoinHandle<()>)> { + info!(terminal_id = %terminal.id, "spawn_pty: starting"); + + self.ensure_claude_settings_file(); + + let shell = ShellConfig::detect(self.shell_override.read().clone()); + terminal.shell = Some(shell.path.to_string_lossy().to_string()); + + info!( + terminal_id = %terminal.id, + shell = %shell.path.display(), + cols = cols, + rows = rows, + "spawn_pty: opening PTY" + ); + + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| { + error!(terminal_id = %terminal.id, error = %e, "spawn_pty: failed to open PTY"); + Error::TerminalError(e.to_string()) + })?; + + info!(terminal_id = %terminal.id, "spawn_pty: PTY opened"); + + let mut cmd = CommandBuilder::new(&shell.path); + cmd.args(&shell.login_args); + + if shell.name == "bash" { + cmd.arg("--rcfile"); + cmd.arg(self.wrapper_dir.join("bash/.bashrc")); + } + + let command_line = format_command_line(&terminal.command); + cmd.arg("-c"); + cmd.arg(&command_line); + + info!( + terminal_id = %terminal.id, + working_dir = %terminal.working_dir.display(), + command_line = %command_line, + "spawn_pty: preparing command" + ); + + cmd.cwd(&terminal.working_dir); + + let env = build_terminal_env( + &shell, + &self.wrapper_dir, + &self.ada_home, + &self.ada_bin_dir, + &terminal.id, + &terminal.project_id, + self.notification_port, + ); + + for (key, value) in &env { + cmd.env(key, value); + } + + for (key, value) in &terminal.command.env { + cmd.env(key, value); + } + + info!(terminal_id = %terminal.id, "spawn_pty: spawning command"); + + let _child = pair + .slave + .spawn_command(cmd) + .map_err(|e| { + error!(terminal_id = %terminal.id, error = %e, "spawn_pty: failed to spawn command"); + Error::TerminalError(e.to_string()) + })?; + + info!(terminal_id = %terminal.id, "spawn_pty: command spawned successfully"); + + drop(pair.slave); + + info!(terminal_id = %terminal.id, "spawn_pty: cloning reader"); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| { + error!(terminal_id = %terminal.id, error = %e, "spawn_pty: failed to clone reader"); + Error::TerminalError(e.to_string()) + })?; + + info!(terminal_id = %terminal.id, "spawn_pty: reader cloned, spawning reader thread"); + + let terminal_id = terminal.id.clone(); + let project_id = terminal.project_id.clone(); + let event_tx = self.event_tx.clone(); + let sessions = self.sessions.clone(); + + let reader_handle = thread::Builder::new() + .name(format!("pty-reader-{}", terminal_id)) + .spawn(move || { + info!(terminal_id = %terminal_id, "pty-reader: thread started"); + let mut buffer = [0u8; 4096]; + loop { + // Check shutdown flag before blocking on read + if shutdown.load(Ordering::SeqCst) { + info!(terminal_id = %terminal_id, "pty-reader: shutdown flag set, exiting"); + break; + } + + match reader.read(&mut buffer) { + Ok(0) => { + info!(terminal_id = %terminal_id, "pty-reader: EOF received, exiting"); + break; + } + Ok(n) => { + let output = String::from_utf8_lossy(&buffer[..n]).to_string(); + { + let mut persistence = persistence.lock(); + if let Err(e) = persistence.write_output(output.as_bytes()) { + warn!(terminal_id = %terminal_id, error = %e, "failed to write output to persistence"); + } + } + // Send to broadcast channel - use send() which won't block + // If channel is full, old messages are dropped (lagged) + if let Err(e) = event_tx.send(DaemonEvent::TerminalOutput { + terminal_id: terminal_id.clone(), + data: output, + }) { + // No receivers or lagged - that's okay, output is persisted + if event_tx.receiver_count() == 0 { + // No receivers at all, that's fine + } else { + warn!(terminal_id = %terminal_id, error = %e, "failed to send terminal output event"); + } + } + } + Err(e) => { + // Check if this is a normal shutdown + if !shutdown.load(Ordering::SeqCst) { + warn!(terminal_id = %terminal_id, error = %e, "PTY read error"); + } + break; + } + } + } + + // Update session status on exit + info!(terminal_id = %terminal_id, "pty-reader: updating session status to Stopped"); + { + let mut sessions = sessions.write(); + if let Some(entry) = sessions.get_mut(&terminal_id) { + entry.terminal.status = TerminalStatus::Stopped; + entry.pty = None; + let mut persistence = entry.persistence.lock(); + let _ = persistence.mark_ended(); + // Send status event + info!(terminal_id = %terminal_id, "pty-reader: sending TerminalStatus::Stopped event"); + if let Err(e) = event_tx.send(DaemonEvent::TerminalStatus { + terminal_id: terminal_id.clone(), + project_id: project_id.clone(), + status: TerminalStatus::Stopped, + }) { + warn!(terminal_id = %terminal_id, error = %e, "failed to send terminal status event"); + } + } else { + warn!(terminal_id = %terminal_id, "pty-reader: session not found on exit"); + } + } + info!(terminal_id = %terminal_id, "pty-reader: thread exiting"); + }) + .map_err(|e| { + error!(error = %e, "spawn_pty: failed to spawn reader thread"); + Error::TerminalError(format!("failed to spawn PTY reader thread: {}", e)) + })?; + + info!(terminal_id = %terminal.id, "spawn_pty: reader thread spawned, taking writer"); + + let writer = pair + .master + .take_writer() + .map_err(|e| { + error!(terminal_id = %terminal.id, error = %e, "spawn_pty: failed to take writer"); + Error::TerminalError(e.to_string()) + })?; + + info!(terminal_id = %terminal.id, "spawn_pty: completed successfully"); + + Ok(( + PtyHandle { + master: Arc::new(Mutex::new(pair.master)), + writer: Arc::new(Mutex::new(writer)), + }, + reader_handle, + )) + } + + fn ensure_claude_settings_file(&self) { + if let Err(err) = ensure_claude_settings(&self.ada_home) { + eprintln!("Warning: failed to ensure Claude settings: {err}"); + } + } + + fn emit_status(&self, terminal: &Terminal) -> Result<()> { + if let Err(e) = self.event_tx.send(DaemonEvent::TerminalStatus { + terminal_id: terminal.id.clone(), + project_id: terminal.project_id.clone(), + status: terminal.status, + }) { + warn!(terminal_id = %terminal.id, error = %e, "failed to emit terminal status"); + } + Ok(()) + } + + fn load_from_disk(&self) -> Result<()> { + if !self.sessions_dir.exists() { + return Ok(()); + } + + for dir_entry in std::fs::read_dir(&self.sessions_dir)? { + let dir_entry = dir_entry?; + let path = dir_entry.path(); + if !path.is_dir() { + continue; + } + + let meta = match SessionPersistence::load_meta(&path) { + Some(meta) => meta, + None => continue, + }; + + let mut terminal = Terminal { + id: meta.terminal_id.clone(), + project_id: meta.project_id.clone(), + name: meta.name.clone(), + client_id: meta.client_id.clone(), + working_dir: meta.working_dir.clone(), + branch: meta.branch.clone(), + worktree_path: meta.worktree_path.clone(), + status: TerminalStatus::Stopped, + created_at: meta.created_at, + command: meta.command.clone(), + shell: meta.shell.clone(), + agent_status: AgentStatus::Idle, + mode: meta.mode, + is_main: meta.is_main, + folder_path: meta.folder_path.clone(), + }; + + let persistence = SessionPersistence::open_existing(&self.sessions_dir, meta.clone())?; + let persistence = Arc::new(Mutex::new(persistence)); + + let shutdown = Arc::new(AtomicBool::new(false)); + + // Try to restart if session wasn't ended + let (pty, reader_handle) = if meta.ended_at.is_none() { + match self.spawn_pty( + &mut terminal, + meta.cols, + meta.rows, + persistence.clone(), + shutdown.clone(), + ) { + Ok((pty, handle)) => { + terminal.status = TerminalStatus::Running; + (Some(pty), Some(handle)) + } + Err(e) => { + warn!(terminal_id = %terminal.id, error = %e, "failed to restart session from disk"); + (None, None) + } + } + } else { + (None, None) + }; + + let entry = SessionEntry { + terminal: terminal.clone(), + pty, + persistence, + cols: meta.cols, + rows: meta.rows, + shutdown, + reader_handle, + }; + + self.sessions.write().insert(terminal.id.clone(), entry); + } + + Ok(()) + } +} + +fn format_command_line(command: &CommandSpec) -> String { + let mut parts = Vec::with_capacity(command.args.len() + 1); + parts.push(shell_escape(&command.command)); + for arg in &command.args { + parts.push(shell_escape(arg)); + } + parts.join(" ") +} + +fn shell_escape(input: &str) -> String { + if input.is_empty() { + return "''".to_string(); + } + let escaped = input.replace('\'', r#"'\''"#); + format!("'{escaped}'") +} diff --git a/src-tauri/src/daemon/shell.rs b/src-tauri/src/daemon/shell.rs new file mode 100644 index 0000000..0e7c307 --- /dev/null +++ b/src-tauri/src/daemon/shell.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct ShellConfig { + pub path: PathBuf, + pub name: String, + pub login_args: Vec, +} + +impl ShellConfig { + pub fn detect(shell_override: Option) -> Self { + let shell_path = shell_override + .map(PathBuf::from) + .or_else(Self::get_user_shell) + .unwrap_or_else(|| PathBuf::from("/bin/bash")); + + let name = shell_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("bash") + .to_string(); + + let login_args = match name.as_str() { + "fish" => vec!["--login".to_string()], + _ => vec!["-l".to_string()], + }; + + Self { + path: shell_path, + name, + login_args, + } + } + + #[cfg(target_os = "macos")] + fn get_user_shell() -> Option { + let username = whoami::username(); + let output = Command::new("dscl") + .args([".", "-read", &format!("/Users/{username}"), "UserShell"]) + .output() + .ok()?; + + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| line.starts_with("UserShell:")) + .map(|line| PathBuf::from(line.trim_start_matches("UserShell:").trim())) + } + + #[cfg(target_os = "linux")] + fn get_user_shell() -> Option { + let username = whoami::username(); + std::fs::read_to_string("/etc/passwd") + .ok()? + .lines() + .find(|line| line.starts_with(&format!("{username}:"))) + .and_then(|line| line.split(':').last()) + .map(PathBuf::from) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn get_user_shell() -> Option { + None + } +} diff --git a/src-tauri/src/daemon/shell_wrapper.rs b/src-tauri/src/daemon/shell_wrapper.rs new file mode 100644 index 0000000..90862a5 --- /dev/null +++ b/src-tauri/src/daemon/shell_wrapper.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const ZSH_ZPROFILE: &str = r#" +# Ada shell wrapper - sources user config then adds Ada modifications + +if [[ -f "${ADA_ORIG_ZDOTDIR}/.zprofile" ]]; then + source "${ADA_ORIG_ZDOTDIR}/.zprofile" +fi + +export PATH="${ADA_BIN_DIR}:${PATH}" +"#; + +const ZSH_ZSHRC: &str = r#" +export ZDOTDIR="${ADA_ORIG_ZDOTDIR}" + +if [[ -f "${ZDOTDIR}/.zshrc" ]]; then + source "${ZDOTDIR}/.zshrc" +fi +"#; + +const BASH_RC: &str = r#" +if [[ -f /etc/profile ]]; then + source /etc/profile +fi + +if [[ -f ~/.bash_profile ]]; then + source ~/.bash_profile +elif [[ -f ~/.bash_login ]]; then + source ~/.bash_login +elif [[ -f ~/.profile ]]; then + source ~/.profile +fi + +if [[ -f ~/.bashrc ]]; then + source ~/.bashrc +fi + +export PATH="${ADA_BIN_DIR}:${PATH}" +"#; + +pub fn setup_shell_wrappers(ada_home: &Path) -> std::io::Result { + let wrapper_dir = ada_home.join("shell-wrapper"); + + let zsh_dir = wrapper_dir.join("zsh"); + fs::create_dir_all(&zsh_dir)?; + fs::write(zsh_dir.join(".zprofile"), ZSH_ZPROFILE.trim_start())?; + fs::write(zsh_dir.join(".zshrc"), ZSH_ZSHRC.trim_start())?; + + let bash_dir = wrapper_dir.join("bash"); + fs::create_dir_all(&bash_dir)?; + fs::write(bash_dir.join(".bashrc"), BASH_RC.trim_start())?; + + Ok(wrapper_dir) +} diff --git a/src-tauri/src/daemon/tauri_commands.rs b/src-tauri/src/daemon/tauri_commands.rs new file mode 100644 index 0000000..6e3a63a --- /dev/null +++ b/src-tauri/src/daemon/tauri_commands.rs @@ -0,0 +1,248 @@ +//! Tauri commands for daemon management +//! +//! These commands allow the GUI to check daemon status, connect to it, +//! and optionally start it with user consent. + +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use crate::error::{Error, Result}; +use crate::state::AppState; + +/// Daemon status information returned to the frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonStatusInfo { + pub running: bool, + pub connected: bool, + pub pid: Option, + pub port: Option, + pub uptime_secs: Option, + pub session_count: Option, + pub version: Option, +} + +/// Connection state for the daemon +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ConnectionState { + /// Daemon is connected and working + Connected, + /// Daemon is running but not connected + Disconnected, + /// Daemon is not running + NotRunning, + /// Connecting to daemon + Connecting, +} + +/// Check daemon status without connecting +#[tauri::command] +pub async fn check_daemon_status() -> Result { + let dev_mode = cfg!(debug_assertions); + let port = read_port(dev_mode); + let pid = read_pid(dev_mode); + + // Check if port is responding + let running = port.map(probe_port).unwrap_or(false); + + if running { + // Try to get detailed status via IPC + if let Some(port) = port { + if let Ok(status) = query_daemon_status(port) { + return Ok(status); + } + } + } + + Ok(DaemonStatusInfo { + running, + connected: false, + pid, + port, + uptime_secs: None, + session_count: None, + version: None, + }) +} + +/// Connect to the daemon (state will hold the connection) +#[tauri::command] +pub async fn connect_to_daemon( + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Try to connect + state.connect_daemon(app_handle).await?; + + // Return status + check_daemon_status().await +} + +/// Start the daemon process +#[tauri::command] +pub async fn start_daemon() -> Result<()> { + let daemon_path = resolve_daemon_path()?; + + if !daemon_path.exists() { + return Err(Error::TerminalError(format!( + "Daemon binary not found at {}", + daemon_path.display() + ))); + } + + let mut cmd = Command::new(&daemon_path); + + // Set dev mode via environment variable if needed + if cfg!(debug_assertions) { + cmd.env("ADA_DEV_MODE", "1"); + } + + // Forward logging environment variables + for var in ["ADA_LOG_LEVEL", "ADA_LOG_STDERR", "ADA_LOG_DIR", "ADA_LOG_DISABLE"] { + if let Ok(value) = std::env::var(var) { + cmd.env(var, value); + } + } + + // Detach from current process + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // Create new session to detach from terminal + let _ = nix::libc::setsid(); + Ok(()) + }); + } + } + + cmd.spawn() + .map_err(|e| Error::TerminalError(format!("Failed to spawn daemon: {}", e)))?; + + // Wait for daemon to start + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(250)); + + let port = read_port(cfg!(debug_assertions)); + if let Some(port) = port { + if probe_port(port) { + return Ok(()); + } + } + } + + Err(Error::TerminalError("Daemon did not start within 5 seconds".into())) +} + +/// Get connection state +#[tauri::command] +pub fn get_connection_state(state: State<'_, AppState>) -> ConnectionState { + state.get_connection_state() +} + +// Helper functions + +fn daemon_data_dir(dev_mode: bool) -> Option { + let dir_name = if dev_mode { "ada-dev" } else { "ada" }; + dirs::data_dir().map(|d| d.join(dir_name)) +} + +fn read_port(dev_mode: bool) -> Option { + let port_path = daemon_data_dir(dev_mode)?.join("daemon/port"); + let content = std::fs::read_to_string(port_path).ok()?; + content.trim().parse().ok() +} + +fn read_pid(dev_mode: bool) -> Option { + let pid_path = daemon_data_dir(dev_mode)?.join("daemon/pid"); + let content = std::fs::read_to_string(pid_path).ok()?; + content.trim().parse().ok() +} + +fn probe_port(port: u16) -> bool { + TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() +} + +fn resolve_daemon_path() -> Result { + let exe_name = if cfg!(windows) { + "ada-daemon.exe" + } else { + "ada-daemon" + }; + + // First, check next to current executable + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + let candidate = parent.join(exe_name); + if candidate.exists() { + return Ok(candidate); + } + } + } + + // Then check PATH + which::which(exe_name) + .map_err(|_| Error::TerminalError(format!("Could not find {} in PATH", exe_name))) +} + +fn query_daemon_status(port: u16) -> Result { + use std::io::{BufRead, BufReader, Write}; + + let addr = format!("127.0.0.1:{}", port); + let mut stream = TcpStream::connect(&addr) + .map_err(|e| Error::TerminalError(e.to_string()))?; + + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let request = serde_json::json!({ + "type": "request", + "id": uuid::Uuid::new_v4().to_string(), + "request": { "type": "status" } + }); + + let json = serde_json::to_string(&request)?; + stream.write_all(json.as_bytes()) + .map_err(|e| Error::TerminalError(e.to_string()))?; + stream.write_all(b"\n") + .map_err(|e| Error::TerminalError(e.to_string()))?; + + let mut reader = BufReader::new(&stream); + let mut response = String::new(); + reader.read_line(&mut response) + .map_err(|e| Error::TerminalError(e.to_string()))?; + + // Parse response + let parsed: serde_json::Value = serde_json::from_str(&response)?; + + if let Some(resp) = parsed.get("response") { + if resp.get("type").and_then(|t| t.as_str()) == Some("daemon_status") { + return Ok(DaemonStatusInfo { + running: true, + connected: false, // Will be updated by caller + pid: resp.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32), + port: Some(port), + uptime_secs: resp.get("uptime_secs").and_then(|v| v.as_u64()), + session_count: resp.get("session_count").and_then(|v| v.as_u64()).map(|v| v as usize), + version: resp.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()), + }); + } + } + + Ok(DaemonStatusInfo { + running: true, + connected: false, + pid: None, + port: Some(port), + uptime_secs: None, + session_count: None, + version: None, + }) +} diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs new file mode 100644 index 0000000..3cc110e --- /dev/null +++ b/src-tauri/src/daemon/tray.rs @@ -0,0 +1,532 @@ +//! System tray icon for the Ada daemon +//! +//! Provides a menu bar icon with quick access to: +//! - Open the main Ada app +//! - View active sessions (grouped by project) +//! - Quit the daemon +//! +//! Features: +//! - Live updates when sessions change +//! - Sessions grouped by project + +use std::collections::HashMap; +use std::sync::mpsc; +#[allow(unused_imports)] +use std::thread::{self, JoinHandle}; +use std::sync::Arc; + +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use parking_lot::{Mutex, RwLock}; +use once_cell::sync::Lazy; +use tao::event_loop::{ControlFlow, EventLoopBuilder}; +use tray_icon::{menu::MenuId, TrayIconBuilder}; +use tracing::{debug, info, warn}; + +use crate::constants::{APP_DESCRIPTION, APP_NAME}; +#[cfg(target_os = "macos")] +use crate::constants::{DEV_SERVER_URL, MACOS_APP_BUNDLE}; +#[cfg(target_os = "windows")] +use crate::constants::WINDOWS_EXE; +#[cfg(target_os = "linux")] +use crate::constants::LINUX_BINARY; +use crate::terminal::{TerminalInfo, TerminalStatus}; +// AgentStatus is tracked by the daemon but not currently displayed in the tray + +/// Commands that can be sent from the tray to the daemon +#[derive(Debug, Clone)] +pub enum TrayCommand { + /// Open the main Ada application + OpenApp, + /// User selected a specific session + SelectSession(String), + /// Quit the daemon + Quit, +} + +/// Updates that can be sent to the tray to refresh the menu +#[derive(Debug, Clone)] +pub enum TrayUpdate { + /// Sessions have changed, rebuild menu + SessionsChanged(Vec), +} + +/// Menu item IDs +const ID_OPEN_APP: &str = "open_app"; +const ID_QUIT: &str = "quit"; +const ID_NO_SESSIONS: &str = "no_sessions"; + +/// Shared state for tray updates +pub struct TrayState { + sessions: Arc>>, + update_tx: mpsc::Sender, +} + +impl TrayState { + /// Update the sessions and notify the tray to rebuild its menu + pub fn update_sessions(&self, sessions: Vec) { + *self.sessions.write() = sessions.clone(); + let _ = self.update_tx.send(TrayUpdate::SessionsChanged(sessions)); + } +} + +/// Runs the tray event loop on the current thread. +/// +/// On macOS, this MUST be called from the main thread. +/// This function never returns - it runs the event loop forever. +/// +/// Returns a TrayState that can be used to send updates to the tray. +pub fn run_tray_on_main_thread( + cmd_tx: mpsc::Sender, + initial_sessions: Vec, +) -> ! { + info!("tray starting on main thread"); + run_tray_loop(cmd_tx, initial_sessions) +} + +/// Create tray state for sending updates +pub fn create_tray_state( + initial_sessions: Vec, +) -> (TrayState, mpsc::Receiver) { + let (update_tx, update_rx) = mpsc::channel(); + let state = TrayState { + sessions: Arc::new(RwLock::new(initial_sessions)), + update_tx, + }; + (state, update_rx) +} + +/// Main tray event loop using tao for proper cross-platform support +fn run_tray_loop( + cmd_tx: mpsc::Sender, + initial_sessions: Vec, +) -> ! { + // Create the event loop - this handles platform-specific initialization + let event_loop = EventLoopBuilder::new().build(); + + // Create update channel for live updates + let (update_tx, update_rx) = mpsc::channel::(); + + // Store sessions in shared state + let sessions = Arc::new(RwLock::new(initial_sessions.clone())); + + // Make update_tx available globally for the daemon to send updates + info!("setting up global tray update channel"); + *TRAY_UPDATE_TX.lock() = Some(update_tx); + info!("tray update channel ready for cross-thread notifications"); + + // Build initial menu + let menu = build_menu(&initial_sessions).expect("failed to build tray menu"); + + // Load icon - use embedded icon data + let icon = load_tray_icon().expect("failed to load tray icon"); + + // Build tray with dynamic title showing session count + let title = format_tray_title(&initial_sessions); + let tooltip = format!("{} - {}", APP_NAME, APP_DESCRIPTION); + + let mut builder = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_tooltip(&tooltip) + .with_icon(icon) + .with_menu_on_left_click(true) + .with_title(&title); + + // On macOS, set icon as template for proper menu bar display + #[cfg(target_os = "macos")] + { + builder = builder.with_icon_as_template(true); + } + + let tray = builder.build().expect("failed to build tray icon"); + + info!("tray icon created"); + + // Get menu event receiver + let menu_channel = MenuEvent::receiver(); + + // Run the event loop (never returns) + event_loop.run(move |_event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + // Check for session updates (non-blocking) + if let Ok(update) = update_rx.try_recv() { + match update { + TrayUpdate::SessionsChanged(new_sessions) => { + let running = new_sessions.iter() + .filter(|s| s.status == TerminalStatus::Running) + .count(); + let projects: std::collections::HashSet<_> = new_sessions.iter() + .map(|s| &s.project_id) + .collect(); + + info!( + total_sessions = new_sessions.len(), + running_sessions = running, + unique_projects = projects.len(), + "tray received session update, rebuilding menu" + ); + + *sessions.write() = new_sessions.clone(); + + // Rebuild and update menu + match build_menu(&new_sessions) { + Ok(new_menu) => { + tray.set_menu(Some(Box::new(new_menu))); + debug!("tray menu rebuilt successfully"); + } + Err(e) => { + warn!(error = %e, "failed to rebuild tray menu"); + } + } + + // Update title with session count + let new_title = format_tray_title(&new_sessions); + tray.set_title(Some(&new_title)); + debug!(title = %new_title, "tray title updated"); + } + } + } + + // Check for menu events (non-blocking) + if let Ok(event) = menu_channel.try_recv() { + let id = event.id.0.as_str(); + debug!(menu_id = id, "tray menu event"); + + match id { + ID_OPEN_APP => { + if cmd_tx.send(TrayCommand::OpenApp).is_err() { + warn!("failed to send OpenApp command"); + *control_flow = ControlFlow::Exit; + } + } + ID_QUIT => { + info!("quit requested from tray"); + let _ = cmd_tx.send(TrayCommand::Quit); + *control_flow = ControlFlow::Exit; + } + ID_NO_SESSIONS => { + // Disabled item, do nothing + } + id if id.starts_with("session:") => { + let terminal_id = id.strip_prefix("session:").unwrap_or(id); + if cmd_tx.send(TrayCommand::SelectSession(terminal_id.to_string())).is_err() { + warn!("failed to send SelectSession command"); + *control_flow = ControlFlow::Exit; + } + } + _ => { + debug!(id, "unknown menu item"); + } + } + } + }) +} + +/// Global sender for tray updates (thread-safe, accessible from any thread) +static TRAY_UPDATE_TX: Lazy>>> = Lazy::new(|| { + debug!("initializing global tray update channel"); + Mutex::new(None) +}); + +/// Send a session update to the tray (call from daemon when sessions change) +pub fn notify_sessions_changed(sessions: Vec) { + let session_count = sessions.len(); + let running_count = sessions.iter() + .filter(|s| s.status == TerminalStatus::Running) + .count(); + + if let Some(sender) = TRAY_UPDATE_TX.lock().as_ref() { + match sender.send(TrayUpdate::SessionsChanged(sessions)) { + Ok(()) => { + info!( + session_count, + running_count, + "tray notification queued" + ); + } + Err(e) => { + warn!(error = %e, "failed to send tray notification"); + } + } + } else { + warn!( + session_count, + "tray update channel not initialized yet, skipping notification" + ); + } +} + +/// Format the tray title with session count +/// Note: Agent status tracking is preserved for future use but not displayed +fn format_tray_title(sessions: &[TerminalInfo]) -> String { + let running_count = sessions.iter() + .filter(|s| s.status == TerminalStatus::Running) + .count(); + + // Agent attention tracking preserved for future use: + // let _needs_attention = sessions.iter() + // .any(|s| s.agent_status == AgentStatus::Permission); + + if running_count == 0 { + APP_NAME.to_string() + } else { + format!("{} ({})", APP_NAME, running_count) + } +} + +/// Builds the tray menu with sessions grouped by project +fn build_menu(sessions: &[TerminalInfo]) -> Result> { + let menu = Menu::new(); + + // Open App + let open_label = format!("Open {}", APP_NAME); + let open_item = MenuItem::with_id(ID_OPEN_APP, &open_label, true, None); + menu.append(&open_item)?; + + // Separator + menu.append(&PredefinedMenuItem::separator())?; + + // Group sessions by project + let mut projects: HashMap> = HashMap::new(); + for session in sessions { + projects + .entry(session.project_id.clone()) + .or_default() + .push(session); + } + + if projects.is_empty() { + // No sessions - show placeholder + let no_sessions = MenuItem::with_id(ID_NO_SESSIONS, "No active sessions", false, None); + menu.append(&no_sessions)?; + } else { + // Always group by project in submenus + let mut project_list: Vec<_> = projects.into_iter().collect(); + project_list.sort_by(|a, b| a.0.cmp(&b.0)); + + for (project_id, project_sessions) in project_list { + // Use a non-worktree session's working dir to get the project name + // Worktree sessions have different working_dir (the worktree path, not project root) + let project_name = project_sessions.iter() + .find(|s| s.worktree_path.is_none()) + .or_else(|| project_sessions.first()) + .and_then(|s| { + std::path::Path::new(&s.working_dir) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| short_id(&project_id)); + + // Count sessions + // Note: Agent attention tracking preserved for future use: + // let _attention = project_sessions.iter() + // .any(|s| s.agent_status == AgentStatus::Permission); + let session_count = project_sessions.len(); + let label = format!("{} ({})", project_name, session_count); + + let project_menu = Submenu::new(&label, true); + + for session in project_sessions { + append_session_item_to_submenu(&project_menu, session)?; + } + + menu.append(&project_menu)?; + } + } + + // Separator + menu.append(&PredefinedMenuItem::separator())?; + + // Quit + let quit_item = MenuItem::with_id(ID_QUIT, "Quit Daemon", true, None); + menu.append(&quit_item)?; + + Ok(menu) +} + +/// Append a session item to a submenu +fn append_session_item_to_submenu(submenu: &Submenu, session: &TerminalInfo) -> Result<(), Box> { + let status_indicator = format_session_status(session); + let label = format!("{} {}", session.name, status_indicator); + let id = format!("session:{}", session.id); + let item = MenuItem::with_id(MenuId::new(&id), &label, true, None); + submenu.append(&item)?; + Ok(()) +} + +/// Get a short version of an ID for display +fn short_id(id: &str) -> String { + if id.len() > 8 { + format!("{}...", &id[..8]) + } else { + id.to_string() + } +} + +/// Format session status as an indicator string (terminal status only) +/// Note: Agent status is tracked but not displayed here. For future use: +/// - AgentStatus::Working => "⏳" +/// - AgentStatus::Permission => "⚠️" +/// - AgentStatus::Review => "👀" +/// - AgentStatus::Idle => "✓" +fn format_session_status(session: &TerminalInfo) -> &'static str { + match session.status { + TerminalStatus::Running => "●", + TerminalStatus::Starting => "...", + TerminalStatus::Stopped => "■", + TerminalStatus::Error => "✗", + } +} + +/// Load the tray icon +fn load_tray_icon() -> Result> { + // Use embedded icon bytes for portability + let icon_data = include_bytes!("../../icons/tray-icon.png"); + + let img = image::load_from_memory(icon_data)?; + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + + let icon = tray_icon::Icon::from_rgba(rgba.into_raw(), width, height)?; + Ok(icon) +} + +/// Opens the main application +#[cfg(target_os = "macos")] +pub fn open_main_app() { + use std::process::Command; + + // In dev mode, try to activate existing dev window via AppleScript + // since the dev app runs differently than the production build + let dev_mode = std::env::var("ADA_DEV_MODE").map(|v| v == "1").unwrap_or(false) + || cfg!(debug_assertions); + if dev_mode { + info!("debug mode: trying to activate existing {} dev window", APP_NAME); + + // Try to activate any window with the app name (dev build) + let app_name_lower = APP_NAME.to_lowercase(); + let script = format!( + r#"tell application "System Events" + set frontmost of first process whose name contains "{}" to true + end tell"#, + app_name_lower + ); + + let result = Command::new("osascript") + .args(["-e", &script]) + .output(); + + match result { + Ok(output) if output.status.success() => { + info!("activated {} dev window", APP_NAME); + return; + } + _ => { + // Fall back to opening dev server URL in browser + info!("no dev window found, opening dev server URL"); + let _ = Command::new("open") + .arg(DEV_SERVER_URL) + .spawn(); + return; + } + } + } + + info!("opening {} app", APP_NAME); + + // Production: Try to open by app name first + let result = Command::new("open") + .args(["-a", APP_NAME]) + .spawn(); + + if result.is_err() { + // Fallback: try to find the app in common locations + let mut paths: Vec = vec![ + std::path::PathBuf::from("/Applications").join(MACOS_APP_BUNDLE), + ]; + + // Add user Applications folder + if let Some(home) = dirs::home_dir() { + paths.push(home.join("Applications").join(MACOS_APP_BUNDLE)); + } + + for path in paths { + if path.exists() { + let _ = Command::new("open").arg(&path).spawn(); + return; + } + } + + warn!("could not find {} to open", MACOS_APP_BUNDLE); + } +} + +#[cfg(target_os = "windows")] +pub fn open_main_app() { + use std::process::Command; + + info!("opening {} app", APP_NAME); + + // Windows: try to start the executable + let mut paths: Vec = vec![ + std::path::PathBuf::from("C:\\Program Files") + .join(APP_NAME) + .join(WINDOWS_EXE), + ]; + + // Try relative to current exe + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + paths.insert(0, parent.join(WINDOWS_EXE)); + } + } + + for path in paths { + if path.exists() { + let _ = Command::new(&path).spawn(); + return; + } + } + + warn!("could not find {} to open", WINDOWS_EXE); +} + +#[cfg(target_os = "linux")] +pub fn open_main_app() { + use std::process::Command; + + info!("opening {} app", APP_NAME); + + // Linux: try common approaches + // 1. Try desktop entry + let result = Command::new("gtk-launch") + .arg(LINUX_BINARY) + .spawn(); + + if result.is_err() { + // 2. Try direct execution + let mut paths: Vec = vec![ + std::path::PathBuf::from("/usr/bin").join(LINUX_BINARY), + std::path::PathBuf::from("/usr/local/bin").join(LINUX_BINARY), + ]; + + // Add ~/.local/bin + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".local/bin").join(LINUX_BINARY)); + } + + for path in paths { + if path.exists() { + let _ = Command::new(&path).spawn(); + return; + } + } + } + + warn!("could not find {} to open", LINUX_BINARY); +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +pub fn open_main_app() { + warn!("open_main_app not implemented for this platform"); +} diff --git a/src-tauri/src/daemon/wrappers.rs b/src-tauri/src/daemon/wrappers.rs new file mode 100644 index 0000000..0fcc38e --- /dev/null +++ b/src-tauri/src/daemon/wrappers.rs @@ -0,0 +1,1223 @@ +use std::fs::{self, Permissions}; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Map, Value}; +use toml_edit::{Array, DocumentMut, value}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +pub struct AgentWrapperPaths { + pub bin_dir: PathBuf, + pub hooks_dir: PathBuf, +} + +pub fn setup_agent_wrappers(ada_home: &Path) -> std::io::Result { + let bin_dir = ada_home.join("bin"); + let hooks_dir = ada_home.join("hooks"); + let plugins_dir = ada_home.join("plugins"); + + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&hooks_dir)?; + fs::create_dir_all(&plugins_dir)?; + + // Create hook scripts for different agents + create_claude_notify_hook(&hooks_dir)?; + create_codex_notify_hook(&hooks_dir)?; + create_gemini_notify_hook(&hooks_dir)?; + create_cursor_notify_hook(&hooks_dir)?; + create_opencode_plugin(&plugins_dir)?; + + // Ensure agent-specific configurations + if let Err(err) = ensure_claude_settings(ada_home) { + eprintln!("Warning: failed to ensure Claude settings: {err}"); + } + if let Err(err) = ensure_codex_config(&hooks_dir) { + eprintln!("Warning: failed to ensure Codex config: {err}"); + } + if let Err(err) = ensure_gemini_settings(ada_home) { + eprintln!("Warning: failed to ensure Gemini settings: {err}"); + } + if let Err(err) = ensure_cursor_hooks(ada_home) { + eprintln!("Warning: failed to ensure Cursor hooks: {err}"); + } + if let Err(err) = ensure_opencode_plugin(&plugins_dir) { + eprintln!("Warning: failed to ensure OpenCode plugin: {err}"); + } + + // Create wrappers for all supported agents + create_agent_wrapper(&bin_dir, ada_home, "claude", AgentType::Claude)?; + create_agent_wrapper(&bin_dir, ada_home, "codex", AgentType::Codex)?; + create_agent_wrapper(&bin_dir, ada_home, "gemini", AgentType::Gemini)?; + create_agent_wrapper(&bin_dir, ada_home, "cursor", AgentType::Cursor)?; + create_opencode_wrapper(&bin_dir, ada_home, &plugins_dir)?; + + Ok(AgentWrapperPaths { bin_dir, hooks_dir }) +} + +#[derive(Clone, Copy)] +enum AgentType { + Claude, + Codex, + Gemini, + Cursor, +} + +fn create_agent_wrapper( + bin_dir: &Path, + ada_home: &Path, + command: &str, + agent_type: AgentType, +) -> std::io::Result<()> { + let ada_home_str = ada_home.to_string_lossy(); + let settings_block = match agent_type { + AgentType::Claude => format!(r#" +SETTINGS_PATH="{ada_home_str}/claude-settings.json" +SETTINGS_ARGS=() +if [[ -f "$SETTINGS_PATH" ]]; then + PYTHON_BIN="" + if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" + elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" + fi + + if [[ -n "$PYTHON_BIN" ]]; then + if "$PYTHON_BIN" - "$SETTINGS_PATH" <<'PY' +import json +import sys +try: + with open(sys.argv[1], "r", encoding="utf-8") as handle: + json.load(handle) +except Exception: + sys.exit(1) +PY + then + SETTINGS_ARGS=("--settings" "$SETTINGS_PATH") + else + TS=$(date +%s) + mv "$SETTINGS_PATH" "$SETTINGS_PATH.bak.$TS" 2>/dev/null || true + echo "Warning: invalid Claude settings JSON, running without hooks." >&2 + fi + else + SETTINGS_ARGS=("--settings" "$SETTINGS_PATH") + fi +fi +"#), + AgentType::Codex => r#" +SETTINGS_ARGS=() +"#.to_string(), // Codex uses config.toml, no wrapper injection needed + AgentType::Gemini => r#" +# Gemini CLI uses .gemini/settings.json in the project directory +# We set up global settings at ~/.gemini/settings.json +SETTINGS_ARGS=() +"#.to_string(), + AgentType::Cursor => r#" +# Cursor uses .cursor/hooks.json in the project directory +# We set up global hooks at ~/.cursor/hooks.json +SETTINGS_ARGS=() +"#.to_string(), + }; + + let wrapper = format!( + r#"#!/bin/bash +# Ada wrapper for {command} + +REAL_CMD=$(which -a {command} 2>/dev/null | grep -v "{ada_home_str}/bin" | head -1) + +if [[ -z "$REAL_CMD" ]]; then + for path in "$HOME/.local/bin/{command}" "/usr/local/bin/{command}" "/opt/homebrew/bin/{command}"; do + if [[ -x "$path" ]]; then + REAL_CMD="$path" + break + fi + done +fi + +if [[ -z "$REAL_CMD" ]]; then + echo "Error: {command} not found" >&2 + exit 1 +fi +{settings_block} +exec "$REAL_CMD" "${{SETTINGS_ARGS[@]}}" "$@" +"# + ); + + let path = bin_dir.join(command); + fs::write(&path, wrapper)?; + set_executable(&path)?; + Ok(()) +} + +/// Create OpenCode wrapper +/// Note: OpenCode plugin is installed to ~/.config/opencode/plugins/ by ensure_opencode_plugin() +fn create_opencode_wrapper(bin_dir: &Path, ada_home: &Path, _plugins_dir: &Path) -> std::io::Result<()> { + let ada_home_str = ada_home.to_string_lossy(); + let wrapper = format!(r#"#!/bin/bash +# Ada wrapper for opencode +# Plugin is installed to ~/.config/opencode/plugins/ada-notify.js + +REAL_CMD=$(which -a opencode 2>/dev/null | grep -v "{ada_home_str}/bin" | head -1) + +if [[ -z "$REAL_CMD" ]]; then + for path in "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "/opt/homebrew/bin/opencode"; do + if [[ -x "$path" ]]; then + REAL_CMD="$path" + break + fi + done +fi + +if [[ -z "$REAL_CMD" ]]; then + echo "Error: opencode not found" >&2 + exit 1 +fi + +exec "$REAL_CMD" "$@" +"#); + + let path = bin_dir.join("opencode"); + fs::write(&path, wrapper)?; + set_executable(&path)?; + Ok(()) +} + +/// Create notification hook for Claude Code (receives JSON on stdin) +/// Claude Code Hook Events (from https://code.claude.com/docs/en/hooks): +/// - SessionStart: Session begins or resumes +/// - UserPromptSubmit: User submits a prompt +/// - PreToolUse: Before tool execution +/// - PermissionRequest: When permission dialog appears +/// - PostToolUse: After tool succeeds +/// - PostToolUseFailure: After tool fails +/// - SubagentStart: When spawning a subagent +/// - SubagentStop: When subagent finishes +/// - Stop: Claude finishes responding +/// - PreCompact: Before context compaction +/// - SessionEnd: Session terminates +/// - Notification: Claude Code sends notifications +/// - Setup: When invoked with --init, --init-only, or --maintenance +fn create_claude_notify_hook(hooks_dir: &Path) -> std::io::Result<()> { + let hook = r#"#!/bin/bash +# Ada agent notification hook for Claude Code +# Claude passes JSON on stdin +# Tracks ALL Claude Code hook events for debugging and status tracking + +LOG_FILE="${ADA_HOME:-$HOME/.ada}/logs/hooks.log" +mkdir -p "$(dirname "$LOG_FILE")" + +read -r INPUT + +# Log the raw input for debugging (truncate if too long) +INPUT_LOG=$(echo "$INPUT" | head -c 2000) +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] RAW: $INPUT_LOG" >> "$LOG_FILE" + +# Extract event type +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"\s*:\s*"[^"]*"' | cut -d'"' -f4) + +# Extract additional context based on event type +TOOL_NAME=$(echo "$INPUT" | grep -oE '"tool_name"\s*:\s*"[^"]*"' | cut -d'"' -f4) +NOTIFICATION_TYPE=$(echo "$INPUT" | grep -oE '"notification_type"\s*:\s*"[^"]*"' | cut -d'"' -f4) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | grep -oE '"stop_hook_active"\s*:\s*(true|false)' | cut -d':' -f2 | tr -d ' ') +SESSION_SOURCE=$(echo "$INPUT" | grep -oE '"source"\s*:\s*"[^"]*"' | cut -d'"' -f4) +AGENT_TYPE=$(echo "$INPUT" | grep -oE '"agent_type"\s*:\s*"[^"]*"' | cut -d'"' -f4) + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] EVENT_TYPE: $EVENT_TYPE | tool: $TOOL_NAME | notification: $NOTIFICATION_TYPE | stop_active: $STOP_HOOK_ACTIVE | source: $SESSION_SOURCE | agent: $AGENT_TYPE" >> "$LOG_FILE" + +# Map Claude events to Ada status events +# Ada events: Start (working), Stop (idle), Permission (needs input) +case "$EVENT_TYPE" in + # Session lifecycle + "SessionStart") + EVENT="Start" + ;; + "SessionEnd") + EVENT="Stop" + ;; + + # User interaction + "UserPromptSubmit") + EVENT="Start" + ;; + + # Tool execution + "PreToolUse") + EVENT="Start" + ;; + "PostToolUse") + # Tool completed - still working unless Stop follows + EVENT="" + ;; + "PostToolUseFailure") + # Tool failed - still working + EVENT="" + ;; + + # Permission + "PermissionRequest") + EVENT="Permission" + ;; + + # Notifications (permission_prompt, idle_prompt, auth_success, elicitation_dialog) + "Notification") + case "$NOTIFICATION_TYPE" in + "permission_prompt") + EVENT="Permission" + ;; + "idle_prompt") + EVENT="Stop" + ;; + *) + EVENT="" + ;; + esac + ;; + + # Agent completion + "Stop") + EVENT="Stop" + ;; + "SubagentStart") + EVENT="Start" + ;; + "SubagentStop") + # Subagent stopped, but main agent may continue + EVENT="" + ;; + + # Context management + "PreCompact") + EVENT="" + ;; + + # Setup + "Setup") + EVENT="" + ;; + + *) + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] UNHANDLED EVENT: $EVENT_TYPE" >> "$LOG_FILE" + EVENT="" + ;; +esac + +# Always send hook event to frontend (for logging), with optional mapped event for UI state +if [[ -n "$ADA_TERMINAL_ID" ]]; then + PORT="${ADA_NOTIFICATION_PORT:-9876}" + # URL-encode the JSON payload for transmission + ENCODED_PAYLOAD=$(printf '%s' "$JSON" | jq -sRr @uri 2>/dev/null || printf '%s' "$JSON" | sed 's/ /%20/g; s/"/%22/g; s/{/%7B/g; s/}/%7D/g; s/:/%3A/g; s/,/%2C/g') + + # Build URL with agent name, project_id, and payload + URL="http://127.0.0.1:${PORT}/hook/agent-event?terminal_id=${ADA_TERMINAL_ID}&project_id=${ADA_PROJECT_ID}&event=${EVENT:-raw}&agent=claude&payload=${ENCODED_PAYLOAD}" + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] Sending: terminal_id=${ADA_TERMINAL_ID} event=${EVENT:-raw} port=${PORT}" >> "$LOG_FILE" + + RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" --max-time 2 --connect-timeout 1 "$URL" 2>&1) + CURL_EXIT=$? + HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) + + if [[ $CURL_EXIT -ne 0 ]]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] NOTIFY_ERROR: curl failed with exit code $CURL_EXIT" >> "$LOG_FILE" + elif [[ "$HTTP_CODE" != "200" ]]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] NOTIFY_ERROR: HTTP $HTTP_CODE" >> "$LOG_FILE" + fi +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [claude] SKIP_NOTIFY: No ADA_TERMINAL_ID set" >> "$LOG_FILE" +fi + +exit 0 +"#; + + let path = hooks_dir.join("notify.sh"); + fs::write(&path, hook)?; + set_executable(&path)?; + Ok(()) +} + +/// Create notification hook for Codex (receives JSON as command-line argument) +/// Codex Event Types (from https://developers.openai.com/codex/config-advanced/): +/// - agent-turn-complete: Agent finished a turn (includes thread-id, turn-id, cwd, input-messages, last-assistant-message) +/// - approval-requested: User approval is needed (for TUI notifications) +/// Note: Codex has limited hook support compared to Claude. Only "notify" config is available. +fn create_codex_notify_hook(hooks_dir: &Path) -> std::io::Result<()> { + let hook = r#"#!/bin/bash +# Ada agent notification hook for Codex +# Codex passes JSON as first argument (not stdin) +# Logs ALL events for debugging and future use +# Docs: https://developers.openai.com/codex/config-advanced/ + +LOG_FILE="${ADA_HOME:-$HOME/.ada}/logs/hooks.log" +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [codex] $1" >> "$LOG_FILE" +} + +JSON="$1" + +# Log raw input (truncate if too long) +JSON_LOG=$(echo "$JSON" | head -c 3000) +log "RAW: $JSON_LOG" + +if [[ -z "$JSON" ]]; then + log "ERROR: Empty JSON received" + exit 0 +fi + +# Check environment variables +if [[ -z "$ADA_TERMINAL_ID" ]]; then + log "WARNING: ADA_TERMINAL_ID not set" +fi +if [[ -z "$ADA_NOTIFICATION_PORT" ]]; then + log "WARNING: ADA_NOTIFICATION_PORT not set, using default 9876" +fi + +# Extract fields using jq if available, fallback to grep +if command -v jq &>/dev/null; then + EVENT_TYPE=$(echo "$JSON" | jq -r '.type // empty' 2>/dev/null) + THREAD_ID=$(echo "$JSON" | jq -r '.["thread-id"] // empty' 2>/dev/null) + TURN_ID=$(echo "$JSON" | jq -r '.["turn-id"] // empty' 2>/dev/null) + CWD=$(echo "$JSON" | jq -r '.cwd // empty' 2>/dev/null) + ERROR_MSG=$(echo "$JSON" | jq -r '.error // .message // empty' 2>/dev/null) + LAST_MSG=$(echo "$JSON" | jq -r '.["last-assistant-message"] // empty' 2>/dev/null | head -c 200) +else + EVENT_TYPE=$(echo "$JSON" | grep -oE '"type"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + THREAD_ID=$(echo "$JSON" | grep -oE '"thread-id"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + TURN_ID=$(echo "$JSON" | grep -oE '"turn-id"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + CWD=$(echo "$JSON" | grep -oE '"cwd"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + ERROR_MSG=$(echo "$JSON" | grep -oE '"error"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + LAST_MSG=$(echo "$JSON" | grep -oE '"last-assistant-message"\s*:\s*"[^"]{0,200}' | head -1 | cut -d'"' -f4) +fi + +# Log parsed event details +log "EVENT: type=$EVENT_TYPE thread=$THREAD_ID turn=$TURN_ID cwd=$CWD" + +# Log error if present +if [[ -n "$ERROR_MSG" ]]; then + log "ERROR_MSG: $ERROR_MSG" +fi + +# Log last message if present (truncated) +if [[ -n "$LAST_MSG" ]]; then + log "LAST_MSG: ${LAST_MSG:0:200}..." +fi + +# Map Codex events to Ada status events +case "$EVENT_TYPE" in + "agent-turn-complete") + EVENT="Stop" + ;; + "approval-requested") + EVENT="Permission" + ;; + *) + # Log unknown events but don't send - capture everything for future use + log "UNKNOWN_EVENT: $EVENT_TYPE (full payload logged above)" + EVENT="" + ;; +esac + +# Always send hook event to frontend (for logging), with optional mapped event for UI state +if [[ -n "$ADA_TERMINAL_ID" ]]; then + PORT="${ADA_NOTIFICATION_PORT:-9876}" + # URL-encode the JSON payload for transmission + ENCODED_PAYLOAD=$(printf '%s' "$JSON" | jq -sRr @uri 2>/dev/null || printf '%s' "$JSON" | sed 's/ /%20/g; s/"/%22/g; s/{/%7B/g; s/}/%7D/g; s/:/%3A/g; s/,/%2C/g') + + # Build URL with agent name, project_id, and payload + URL="http://127.0.0.1:${PORT}/hook/agent-event?terminal_id=${ADA_TERMINAL_ID}&project_id=${ADA_PROJECT_ID}&event=${EVENT:-raw}&agent=codex&payload=${ENCODED_PAYLOAD}" + + log "NOTIFY: event=${EVENT:-raw} terminal_id=$ADA_TERMINAL_ID port=$PORT" + + # Capture curl response and errors + RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" --max-time 2 --connect-timeout 1 "$URL" 2>&1) + CURL_EXIT=$? + HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) + BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE:") + + if [[ $CURL_EXIT -ne 0 ]]; then + log "NOTIFY_ERROR: curl failed with exit code $CURL_EXIT" + elif [[ "$HTTP_CODE" != "200" ]]; then + log "NOTIFY_ERROR: HTTP $HTTP_CODE - $BODY" + else + log "NOTIFY_OK: HTTP $HTTP_CODE" + fi +else + log "SKIP_NOTIFY: No ADA_TERMINAL_ID set" +fi + +exit 0 +"#; + + let path = hooks_dir.join("codex-notify.sh"); + fs::write(&path, hook)?; + set_executable(&path)?; + Ok(()) +} + +/// Create notification hook for Gemini CLI (receives JSON on stdin, similar to Claude) +fn create_gemini_notify_hook(hooks_dir: &Path) -> std::io::Result<()> { + let hook = r#"#!/bin/bash +# Ada agent notification hook for Gemini CLI +# Gemini passes JSON on stdin + +LOG_FILE="${ADA_HOME:-$HOME/.ada}/logs/hooks.log" +mkdir -p "$(dirname "$LOG_FILE")" + +read -r INPUT + +# Log the raw input for debugging +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gemini] RAW: $INPUT" >> "$LOG_FILE" + +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"\s*:\s*"[^"]*"' | cut -d'"' -f4) + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gemini] EVENT_TYPE: $EVENT_TYPE" >> "$LOG_FILE" + +case "$EVENT_TYPE" in + "BeforeAgent") EVENT="Start" ;; + "AfterAgent") EVENT="Stop" ;; + "Notification") EVENT="Permission" ;; + *) + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gemini] UNKNOWN EVENT, skipping" >> "$LOG_FILE" + exit 0 + ;; +esac + +PORT="${ADA_NOTIFICATION_PORT:-9876}" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [gemini] Sending: terminal_id=${ADA_TERMINAL_ID} event=${EVENT} port=${PORT}" >> "$LOG_FILE" + +curl -s --max-time 2 --connect-timeout 1 \ + "http://127.0.0.1:${PORT}/hook/agent-event?terminal_id=${ADA_TERMINAL_ID}&event=${EVENT}" \ + &>/dev/null || true + +exit 0 +"#; + + let path = hooks_dir.join("gemini-notify.sh"); + fs::write(&path, hook)?; + set_executable(&path)?; + Ok(()) +} + +/// Create notification hook for Cursor Agent (receives JSON on stdin) +fn create_cursor_notify_hook(hooks_dir: &Path) -> std::io::Result<()> { + let hook = r#"#!/bin/bash +# Ada agent notification hook for Cursor Agent +# Cursor passes JSON on stdin + +LOG_FILE="${ADA_HOME:-$HOME/.ada}/logs/hooks.log" +mkdir -p "$(dirname "$LOG_FILE")" + +read -r INPUT + +# Log the raw input for debugging +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [cursor] RAW: $INPUT" >> "$LOG_FILE" + +# Cursor uses different event names +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"\s*:\s*"[^"]*"' | cut -d'"' -f4) + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [cursor] EVENT_TYPE: $EVENT_TYPE" >> "$LOG_FILE" + +case "$EVENT_TYPE" in + "sessionStart") EVENT="Start" ;; + "stop") EVENT="Stop" ;; + "preToolUse") EVENT="Permission" ;; + *) + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [cursor] UNKNOWN EVENT, skipping" >> "$LOG_FILE" + exit 0 + ;; +esac + +PORT="${ADA_NOTIFICATION_PORT:-9876}" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] [cursor] Sending: terminal_id=${ADA_TERMINAL_ID} event=${EVENT} port=${PORT}" >> "$LOG_FILE" + +curl -s --max-time 2 --connect-timeout 1 \ + "http://127.0.0.1:${PORT}/hook/agent-event?terminal_id=${ADA_TERMINAL_ID}&event=${EVENT}" \ + &>/dev/null || true + +# Output JSON response for Cursor (it expects JSON output) +echo '{"status": "ok"}' + +exit 0 +"#; + + let path = hooks_dir.join("cursor-notify.sh"); + fs::write(&path, hook)?; + set_executable(&path)?; + Ok(()) +} + +/// Create OpenCode JavaScript plugin for notifications +/// OpenCode plugins are ES modules that export async functions returning hook objects +/// Placed in ~/.config/opencode/plugin/ for global loading +fn create_opencode_plugin(plugins_dir: &Path) -> std::io::Result<()> { + let plugin = r#"// Ada notification plugin for OpenCode v2 +// Uses event handler pattern like other OpenCode plugins +// Docs: https://opencode.ai/docs/plugins/ + +import { appendFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +const ADA_HOME = process.env.ADA_HOME || join(homedir(), '.ada'); +const LOG_FILE = join(ADA_HOME, 'logs', 'hooks.log'); + +function log(message) { + try { + const dir = dirname(LOG_FILE); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + appendFileSync(LOG_FILE, `[${timestamp}] [opencode] ${message}\n`); + } catch (e) { + // Silently ignore logging errors + } +} + +export const AdaNotifyPlugin = async ({ client }) => { + // Only run inside an Ada terminal session + if (!process?.env?.ADA_TERMINAL_ID) { + log('Plugin loaded but no ADA_TERMINAL_ID - skipping'); + return {}; + } + + // Prevent duplicate registration + if (globalThis.__adaOpencodeNotifyPlugin) return {}; + globalThis.__adaOpencodeNotifyPlugin = true; + + const port = process.env.ADA_NOTIFICATION_PORT || "9876"; + const terminalId = process.env.ADA_TERMINAL_ID; + + log(`Plugin initialized: terminal_id=${terminalId}, port=${port}`); + + // State tracking for deduplication + let currentState = 'idle'; // 'idle' | 'busy' + let rootSessionID = null; + let stopSent = false; + + // Cache for child session checks + const childSessionCache = new Map(); + + const isChildSession = async (sessionID) => { + if (!sessionID) return true; + if (!client?.session?.list) return true; + + if (childSessionCache.has(sessionID)) { + return childSessionCache.get(sessionID); + } + + try { + const sessions = await client.session.list(); + const session = sessions.data?.find((s) => s.id === sessionID); + const isChild = !!session?.parentID; + childSessionCache.set(sessionID, isChild); + log(`Session lookup: ${sessionID} isChild=${isChild}`); + return isChild; + } catch (err) { + log(`Session lookup failed: ${err?.message} - assuming child`); + return true; + } + }; + + const projectId = process.env.ADA_PROJECT_ID || ""; + + const notifyAda = async (event, reason, rawEvent = null) => { + log(`Notify: event=${event}, reason=${reason}, terminal_id=${terminalId}, project_id=${projectId}, port=${port}`); + try { + // URL-encode the raw event payload if provided + const payload = rawEvent ? encodeURIComponent(JSON.stringify(rawEvent)) : ''; + const url = `http://127.0.0.1:${port}/hook/agent-event?terminal_id=${terminalId}&project_id=${projectId}&event=${event}&agent=opencode&payload=${payload}`; + log(`Sending to: ${url}`); + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(2000) + }); + log(`Sent successfully, status: ${response.status}`); + } catch (e) { + log(`Error sending: ${e.message}`); + } + }; + + const handleBusy = async (sessionID) => { + if (!rootSessionID) { + rootSessionID = sessionID; + log(`Root session set: ${rootSessionID}`); + } + + if (sessionID !== rootSessionID) { + log(`Ignoring busy from non-root session: ${sessionID}`); + return; + } + + if (currentState === 'idle') { + currentState = 'busy'; + stopSent = false; + await notifyAda('Start', 'busy'); + } else { + log('Already busy, skipping Start'); + } + }; + + const handleStop = async (sessionID, reason) => { + if (rootSessionID && sessionID !== rootSessionID) { + log(`Ignoring stop from non-root session: ${sessionID}, reason: ${reason}`); + return; + } + + if (currentState === 'busy' && !stopSent) { + currentState = 'idle'; + stopSent = true; + log(`Stopping, reason: ${reason}`); + await notifyAda('Stop', reason); + rootSessionID = null; + log('Reset rootSessionID for next session'); + } else { + log(`Skipping Stop - state: ${currentState}, stopSent: ${stopSent}, reason: ${reason}`); + } + }; + + return { + // Generic event handler - OpenCode routes all events through this + event: async ({ event }) => { + const sessionID = event.properties?.sessionID; + log(`Event: ${event.type}, sessionID: ${sessionID}, props: ${JSON.stringify(event.properties || {})}`); + + // Always send raw event to frontend for logging (before any filtering) + await notifyAda('raw', event.type, event); + + // Skip child/subagent sessions for state management + if (await isChildSession(sessionID)) { + log('Skipping child session for state management'); + return; + } + + // Handle session status changes + if (event.type === 'session.status') { + const status = event.properties?.status; + log(`Status type: ${status?.type}`); + if (status?.type === 'busy') { + await handleBusy(sessionID); + await notifyAda('Start', 'session.status.busy', event); + } else if (status?.type === 'idle') { + await handleStop(sessionID, 'session.status.idle'); + await notifyAda('Stop', 'session.status.idle', event); + } + } + + // Handle session.idle event directly + if (event.type === 'session.idle') { + await handleStop(sessionID, 'session.idle'); + await notifyAda('Stop', 'session.idle', event); + } + + // Handle session errors + if (event.type === 'session.error') { + await handleStop(sessionID, 'session.error'); + await notifyAda('Stop', 'session.error', event); + } + }, + + // Permission hook - fires when OpenCode needs user permission + "permission.ask": async (_permission, output) => { + log(`Permission: status=${output.status}`); + // Always send raw event + await notifyAda('raw', 'permission.ask', { permission: _permission, output }); + if (output.status === 'ask') { + log('Permission requested'); + await notifyAda('Permission', 'permission.ask', { permission: _permission, output }); + } + }, + }; +}; +"#; + + let path = plugins_dir.join("ada-notify.js"); + fs::write(&path, plugin)?; + Ok(()) +} + +/// Copy the OpenCode plugin to ~/.config/opencode/plugin/ where OpenCode expects it +fn ensure_opencode_plugin(ada_plugins_dir: &Path) -> std::io::Result<()> { + let opencode_config = dirs::home_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found"))? + .join(".config") + .join("opencode") + .join("plugin"); + + // Create the OpenCode plugins directory if it doesn't exist + fs::create_dir_all(&opencode_config)?; + + // Copy the Ada plugin to OpenCode's plugins directory + let src = ada_plugins_dir.join("ada-notify.js"); + let dst = opencode_config.join("ada-notify.js"); + + if src.exists() { + fs::copy(&src, &dst)?; + } + + Ok(()) +} + +pub fn ensure_claude_settings(ada_home: &Path) -> std::io::Result<()> { + let settings_path = ada_home.join("claude-settings.json"); + let notify_path = ada_home.join("hooks/notify.sh"); + let notify_path_str = notify_path.to_string_lossy(); + + let desired = build_desired_hooks(¬ify_path_str); + let mut root = Value::Object(Map::new()); + let mut needs_write = false; + + if settings_path.exists() { + match fs::read_to_string(&settings_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + { + Some(value) => { + root = value; + } + None => { + needs_write = true; + } + } + } else { + needs_write = true; + } + + if !root.is_object() { + root = Value::Object(Map::new()); + needs_write = true; + } + + let root_obj = root.as_object_mut().expect("root is object"); + let hooks_val = root_obj + .entry("hooks") + .or_insert_with(|| Value::Object(Map::new())); + + if !hooks_val.is_object() { + *hooks_val = Value::Object(Map::new()); + needs_write = true; + } + + let hooks_obj = hooks_val.as_object_mut().expect("hooks is object"); + for (event, value) in desired { + let replace = match hooks_obj.get(&event) { + Some(existing) => !hook_event_valid(existing), + None => true, + }; + if replace { + hooks_obj.insert(event, value); + needs_write = true; + } + } + + if needs_write { + let settings = serde_json::to_string_pretty(&root) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + + // Use atomic write: write to temp file, then rename + // This prevents the race condition where Claude reads a non-existent file + let temp_path = ada_home.join("claude-settings.json.tmp"); + fs::write(&temp_path, &settings)?; + fs::rename(&temp_path, &settings_path)?; + } + + Ok(()) +} + +/// Ensure Codex config.toml has Ada's notification hook configured. +/// If user already has a notify command, we create a wrapper that chains both. +pub fn ensure_codex_config(hooks_dir: &Path) -> std::io::Result<()> { + let codex_home = dirs::home_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found"))? + .join(".codex"); + + // Create .codex directory if it doesn't exist + fs::create_dir_all(&codex_home)?; + + let config_path = codex_home.join("config.toml"); + let ada_notify_script = hooks_dir.join("codex-notify.sh"); + let ada_notify_str = ada_notify_script.to_string_lossy().to_string(); + let wrapper_script = hooks_dir.join("codex-notify-wrapper.sh"); + let wrapper_str = wrapper_script.to_string_lossy().to_string(); + + // Read existing config or create new one + let mut doc: DocumentMut = if config_path.exists() { + let content = fs::read_to_string(&config_path)?; + content.parse().unwrap_or_else(|_| DocumentMut::new()) + } else { + DocumentMut::new() + }; + + // Check current notify setting + let existing_notify: Option> = doc.get("notify").and_then(|v| { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(String::from)) + .collect() + }) + }); + + // Determine what action to take + enum Action { + None, // Already configured correctly + SetDirect, // No existing notify, set directly to Ada's script + CreateWrapper(Vec), // User has notify, create wrapper that chains both + } + + let action = match &existing_notify { + None => Action::SetDirect, + Some(cmd) => { + // Check if already pointing to our wrapper + if cmd.len() == 2 && cmd[0] == "bash" && cmd[1] == wrapper_str { + Action::None + } + // Check if already pointing directly to our script (no user command) + else if cmd.len() == 2 && cmd[0] == "bash" && cmd[1] == ada_notify_str { + Action::None + } + // User has their own notify command - need to create wrapper + else { + Action::CreateWrapper(cmd.clone()) + } + } + }; + + match action { + Action::None => { + // Already configured correctly, nothing to do + } + Action::SetDirect => { + // No existing notify, set directly to Ada's script + let mut notify_array = Array::new(); + notify_array.push("bash"); + notify_array.push(ada_notify_str.as_str()); + doc["notify"] = value(notify_array); + + // Atomic write + let temp_path = codex_home.join("config.toml.tmp"); + fs::write(&temp_path, doc.to_string())?; + fs::rename(&temp_path, &config_path)?; + } + Action::CreateWrapper(user_cmd) => { + // Create wrapper script that calls both user's command and Ada's script + create_codex_chained_wrapper(hooks_dir, &user_cmd, &ada_notify_str)?; + + // Update config to point to wrapper + let mut notify_array = Array::new(); + notify_array.push("bash"); + notify_array.push(wrapper_str.as_str()); + doc["notify"] = value(notify_array); + + // Atomic write + let temp_path = codex_home.join("config.toml.tmp"); + fs::write(&temp_path, doc.to_string())?; + fs::rename(&temp_path, &config_path)?; + } + } + + Ok(()) +} + +/// Create a wrapper script that chains the user's notify command with Ada's script +fn create_codex_chained_wrapper( + hooks_dir: &Path, + user_cmd: &[String], + ada_script: &str, +) -> std::io::Result<()> { + // Escape user command for shell + let user_cmd_escaped: Vec = user_cmd + .iter() + .map(|arg| { + if arg.contains(' ') || arg.contains('"') || arg.contains('\'') { + format!("'{}'", arg.replace('\'', "'\\''")) + } else { + arg.clone() + } + }) + .collect(); + let user_cmd_str = user_cmd_escaped.join(" "); + + let wrapper = format!( + r#"#!/bin/bash +# Ada Codex notification wrapper +# This script chains the user's original notify command with Ada's status tracking. +# User's original command: {user_cmd_str} + +JSON="$1" + +# Run user's original notify command first (don't let it block) +{user_cmd_str} "$JSON" & + +# Run Ada's notification script +bash "{ada_script}" "$JSON" + +# Wait for user's command to finish (with timeout) +wait + +exit 0 +"# + ); + + let path = hooks_dir.join("codex-notify-wrapper.sh"); + fs::write(&path, wrapper)?; + set_executable(&path)?; + Ok(()) +} + +fn build_desired_hooks(notify_path: &str) -> Vec<(String, Value)> { + let hook_entry = json!([ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": format!("bash \"{}\"", notify_path) } + ] + } + ]); + + // Register ALL Claude Code hook events for comprehensive tracking + // See: https://code.claude.com/docs/en/hooks + vec![ + // Session lifecycle + ("SessionStart".to_string(), hook_entry.clone()), + ("SessionEnd".to_string(), hook_entry.clone()), + + // User interaction + ("UserPromptSubmit".to_string(), hook_entry.clone()), + + // Tool execution (PreToolUse, PostToolUse, PostToolUseFailure use matchers) + ("PreToolUse".to_string(), hook_entry.clone()), + ("PostToolUse".to_string(), hook_entry.clone()), + ("PostToolUseFailure".to_string(), hook_entry.clone()), + + // Permission + ("PermissionRequest".to_string(), hook_entry.clone()), + + // Notifications + ("Notification".to_string(), hook_entry.clone()), + + // Agent completion + ("Stop".to_string(), hook_entry.clone()), + ("SubagentStart".to_string(), hook_entry.clone()), + ("SubagentStop".to_string(), hook_entry.clone()), + + // Context management + ("PreCompact".to_string(), hook_entry.clone()), + + // Setup + ("Setup".to_string(), hook_entry), + ] +} + +fn hook_event_valid(value: &Value) -> bool { + let entries = match value.as_array() { + Some(entries) if !entries.is_empty() => entries, + _ => return false, + }; + + for entry in entries { + let obj = match entry.as_object() { + Some(obj) => obj, + None => return false, + }; + match obj.get("hooks").and_then(|hooks| hooks.as_array()) { + Some(_) => {} + None => return false, + } + } + + true +} + +/// Ensure Gemini CLI settings.json has Ada's notification hook configured. +/// Gemini CLI uses ~/.gemini/settings.json for global configuration. +pub fn ensure_gemini_settings(ada_home: &Path) -> std::io::Result<()> { + let gemini_home = dirs::home_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found"))? + .join(".gemini"); + + // Create .gemini directory if it doesn't exist + fs::create_dir_all(&gemini_home)?; + + let settings_path = gemini_home.join("settings.json"); + let notify_path = ada_home.join("hooks/gemini-notify.sh"); + let notify_path_str = notify_path.to_string_lossy(); + + let desired = build_gemini_hooks(¬ify_path_str); + let mut root = Value::Object(Map::new()); + let mut needs_write = false; + + if settings_path.exists() { + match fs::read_to_string(&settings_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + { + Some(value) => { + root = value; + } + None => { + needs_write = true; + } + } + } else { + needs_write = true; + } + + if !root.is_object() { + root = Value::Object(Map::new()); + needs_write = true; + } + + let root_obj = root.as_object_mut().expect("root is object"); + let hooks_val = root_obj + .entry("hooks") + .or_insert_with(|| Value::Object(Map::new())); + + if !hooks_val.is_object() { + *hooks_val = Value::Object(Map::new()); + needs_write = true; + } + + let hooks_obj = hooks_val.as_object_mut().expect("hooks is object"); + for (event, value) in desired { + let replace = match hooks_obj.get(&event) { + Some(existing) => !hook_event_valid(existing), + None => true, + }; + if replace { + hooks_obj.insert(event, value); + needs_write = true; + } + } + + if needs_write { + let settings = serde_json::to_string_pretty(&root) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + + // Use atomic write: write to temp file, then rename + let temp_path = gemini_home.join("settings.json.tmp"); + fs::write(&temp_path, &settings)?; + fs::rename(&temp_path, &settings_path)?; + } + + Ok(()) +} + +fn build_gemini_hooks(notify_path: &str) -> Vec<(String, Value)> { + let hook_entry = json!([ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": format!("bash \"{}\"", notify_path) } + ] + } + ]); + + // Gemini CLI uses different event names + vec![ + ("BeforeAgent".to_string(), hook_entry.clone()), + ("AfterAgent".to_string(), hook_entry.clone()), + ("Notification".to_string(), hook_entry), + ] +} + +/// Ensure Cursor hooks.json has Ada's notification hook configured. +/// Cursor Agent uses ~/.cursor/hooks.json for global configuration. +pub fn ensure_cursor_hooks(ada_home: &Path) -> std::io::Result<()> { + let cursor_home = dirs::home_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found"))? + .join(".cursor"); + + // Create .cursor directory if it doesn't exist + fs::create_dir_all(&cursor_home)?; + + let hooks_path = cursor_home.join("hooks.json"); + let notify_path = ada_home.join("hooks/cursor-notify.sh"); + let notify_path_str = notify_path.to_string_lossy(); + + let desired = build_cursor_hooks(¬ify_path_str); + let mut root = Value::Object(Map::new()); + let mut needs_write = false; + + if hooks_path.exists() { + match fs::read_to_string(&hooks_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + { + Some(value) => { + root = value; + } + None => { + needs_write = true; + } + } + } else { + needs_write = true; + } + + if !root.is_object() { + root = Value::Object(Map::new()); + needs_write = true; + } + + let root_obj = root.as_object_mut().expect("root is object"); + let hooks_val = root_obj + .entry("hooks") + .or_insert_with(|| Value::Object(Map::new())); + + if !hooks_val.is_object() { + *hooks_val = Value::Object(Map::new()); + needs_write = true; + } + + let hooks_obj = hooks_val.as_object_mut().expect("hooks is object"); + for (event, value) in desired { + let replace = match hooks_obj.get(&event) { + Some(existing) => !hook_event_valid(existing), + None => true, + }; + if replace { + hooks_obj.insert(event, value); + needs_write = true; + } + } + + if needs_write { + let settings = serde_json::to_string_pretty(&root) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + + // Use atomic write: write to temp file, then rename + let temp_path = cursor_home.join("hooks.json.tmp"); + fs::write(&temp_path, &settings)?; + fs::rename(&temp_path, &hooks_path)?; + } + + Ok(()) +} + +fn build_cursor_hooks(notify_path: &str) -> Vec<(String, Value)> { + let hook_entry = json!([ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": format!("bash \"{}\"", notify_path) } + ] + } + ]); + + // Cursor uses different event names + vec![ + ("sessionStart".to_string(), hook_entry.clone()), + ("stop".to_string(), hook_entry.clone()), + ("preToolUse".to_string(), hook_entry), + ] +} + +fn set_executable(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + fs::set_permissions(path, Permissions::from_mode(0o755))?; + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dec5086..52ef990 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,15 +4,20 @@ mod git; mod clients; mod state; mod error; +pub mod daemon; +mod runtime; +pub mod constants; +mod util; +pub mod cli; use state::AppState; -use tauri::Manager; +use tauri::{Manager, RunEvent}; pub use error::{Error, Result}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -51,7 +56,34 @@ pub fn run() { clients::commands::list_clients, clients::commands::get_client, clients::commands::detect_installed_clients, + // Runtime commands + runtime::commands::get_runtime_config, + runtime::commands::set_shell_override, + // Daemon commands + daemon::tauri_commands::check_daemon_status, + daemon::tauri_commands::connect_to_daemon, + daemon::tauri_commands::start_daemon, + daemon::tauri_commands::get_connection_state, + // CLI installation commands + cli::install::check_cli_installed, + cli::install::install_cli, + cli::install::uninstall_cli, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + if let RunEvent::Exit = event { + // In dev mode, shutdown the daemon when GUI closes + #[cfg(debug_assertions)] + { + if let Some(state) = app_handle.try_state::() { + if let Ok(daemon) = state.get_daemon() { + // Use block_on since we're in the exit handler + let _ = tauri::async_runtime::block_on(daemon.shutdown()); + } + } + } + } + }); } diff --git a/src-tauri/src/project/commands.rs b/src-tauri/src/project/commands.rs index 33e27c6..41cd9d1 100644 --- a/src-tauri/src/project/commands.rs +++ b/src-tauri/src/project/commands.rs @@ -285,8 +285,33 @@ pub async fn open_project( pub async fn list_projects( state: State<'_, AppState>, ) -> Result> { - let projects = state.projects.read(); - let summaries: Vec = projects.values().map(|p| p.into()).collect(); + let sessions = match state.get_daemon() { + Ok(daemon) => daemon.list_sessions().await.unwrap_or_default(), + Err(_) => Vec::new(), + }; + let mut terminal_counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut main_terminal_ids: std::collections::HashMap = std::collections::HashMap::new(); + for session in sessions { + *terminal_counts.entry(session.project_id.clone()).or_insert(0) += 1; + if session.is_main { + main_terminal_ids.insert(session.project_id.clone(), session.id.clone()); + } + } + + let projects: Vec = state.projects.read().values().cloned().collect(); + let summaries: Vec = projects + .iter() + .map(|p| ProjectSummary { + id: p.id.clone(), + name: p.name.clone(), + path: p.path.to_string_lossy().to_string(), + terminal_count: terminal_counts.get(&p.id).copied().unwrap_or(0), + created_at: p.created_at, + updated_at: p.updated_at, + main_terminal_id: main_terminal_ids.get(&p.id).cloned(), + is_git_repo: p.is_git_repo, + }) + .collect(); Ok(summaries) } @@ -333,6 +358,26 @@ pub async fn get_project( } } + let sessions = match state.get_daemon() { + Ok(daemon) => daemon.list_sessions().await.unwrap_or_default(), + Err(_) => Vec::new(), + }; + let mut terminal_ids = Vec::new(); + let mut main_terminal_id = None; + + for session in sessions { + if session.project_id == project_id { + if session.is_main { + main_terminal_id = Some(session.id.clone()); + } + terminal_ids.push(session.id); + } + } + + let mut project = project; + project.terminal_ids = terminal_ids; + project.main_terminal_id = main_terminal_id; + Ok(project) } @@ -348,31 +393,26 @@ pub async fn delete_project( return Err(Error::ProjectNotFound(project_id)); } - // Clean up terminals associated with this project - let terminal_ids_to_remove: Vec = { - let terminals = state.terminals.read(); - terminals + // Clean up daemon sessions associated with this project + let mut removed_count = 0; + if let Ok(daemon) = state.get_daemon() { + let sessions = daemon.list_sessions().await.unwrap_or_default(); + let terminal_ids_to_remove: Vec = sessions .iter() - .filter(|(_, t)| t.project_id == project_id) - .map(|(id, _)| id.clone()) - .collect() - }; + .filter(|t| t.project_id == project_id) + .map(|t| t.id.clone()) + .collect(); - for terminal_id in &terminal_ids_to_remove { - // Remove PTY handle (will stop the process) - state.pty_handles.write().remove(terminal_id); - // Remove output buffer - state.output_buffers.write().remove(terminal_id); - // Remove terminal from state - state.terminals.write().remove(terminal_id); - // Delete terminal file - let _ = state.delete_terminal_file(terminal_id); + removed_count = terminal_ids_to_remove.len(); + for terminal_id in &terminal_ids_to_remove { + let _ = daemon.close_session(terminal_id).await; + } } eprintln!( "[Ada] Deleted project {} and {} associated terminals", project_id, - terminal_ids_to_remove.len() + removed_count ); // Delete persisted project file @@ -416,35 +456,22 @@ pub async fn update_project_settings( // Auto-create main terminal if default_client is set and no main terminal exists if should_create_main { if let Some(client_id) = client_id { - // Check if main terminal already exists - let needs_main_terminal = { - match &updated_project.main_terminal_id { - None => true, - Some(main_id) => { - let terminals = state.terminals.read(); - !terminals.contains_key(main_id) - } + // Try to create main terminal, but don't fail if it errors + // (e.g., client not installed) + match create_main_terminal_internal(&state, &request.project_id, &client_id).await { + Ok(_terminal_info) => { + eprintln!("[Ada] Auto-created main terminal for project {}", request.project_id); } - }; - - if needs_main_terminal { - // Try to create main terminal, but don't fail if it errors - // (e.g., client not installed) - match create_main_terminal_internal(&state, &request.project_id, &client_id) { - Ok(_terminal_info) => { - eprintln!("[Ada] Auto-created main terminal for project {}", request.project_id); - } - Err(e) => { - eprintln!("[Ada] Failed to auto-create main terminal: {}", e); - // Don't propagate error - settings were still updated successfully - } + Err(e) => { + eprintln!("[Ada] Failed to auto-create main terminal: {}", e); + // Don't propagate error - settings were still updated successfully } } } } // Re-read project to get updated main_terminal_id if it was created - let final_project = { + let mut final_project = { let projects = state.projects.read(); projects .get(&request.project_id) @@ -452,5 +479,23 @@ pub async fn update_project_settings( .unwrap_or(updated_project) }; + let sessions = match state.get_daemon() { + Ok(daemon) => daemon.list_sessions().await.unwrap_or_default(), + Err(_) => Vec::new(), + }; + let mut terminal_ids = Vec::new(); + let mut main_terminal_id = None; + for session in sessions { + if session.project_id == request.project_id { + if session.is_main { + main_terminal_id = Some(session.id.clone()); + } + terminal_ids.push(session.id); + } + } + + final_project.terminal_ids = terminal_ids; + final_project.main_terminal_id = main_terminal_id; + Ok(final_project) } diff --git a/src-tauri/src/project/types.rs b/src-tauri/src/project/types.rs index 32181bc..e3a6b8f 100644 --- a/src-tauri/src/project/types.rs +++ b/src-tauri/src/project/types.rs @@ -51,18 +51,6 @@ impl AdaProject { is_git_repo, } } - - pub fn add_terminal(&mut self, terminal_id: String) { - if !self.terminal_ids.contains(&terminal_id) { - self.terminal_ids.push(terminal_id); - self.updated_at = Utc::now(); - } - } - - pub fn remove_terminal(&mut self, terminal_id: &str) { - self.terminal_ids.retain(|id| id != terminal_id); - self.updated_at = Utc::now(); - } } /// Request to create a new project diff --git a/src-tauri/src/runtime/commands.rs b/src-tauri/src/runtime/commands.rs new file mode 100644 index 0000000..0d75218 --- /dev/null +++ b/src-tauri/src/runtime/commands.rs @@ -0,0 +1,21 @@ +use tauri::State; + +use crate::daemon::protocol::RuntimeConfig; +use crate::error::Result; +use crate::state::AppState; + +#[tauri::command] +pub async fn get_runtime_config( + state: State<'_, AppState>, +) -> Result { + state.get_daemon()?.get_runtime_config().await +} + +#[tauri::command] +pub async fn set_shell_override( + state: State<'_, AppState>, + shell: Option, +) -> Result { + state.get_daemon()?.set_shell_override(shell).await?; + state.get_daemon()?.get_runtime_config().await +} diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs new file mode 100644 index 0000000..82b6da3 --- /dev/null +++ b/src-tauri/src/runtime/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e2b8c2d..c04b0d9 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,129 +5,301 @@ use parking_lot::RwLock; use tauri::AppHandle; use crate::project::AdaProject; -use crate::terminal::{Terminal, PtyHandle, TerminalOutputBuffer, TerminalData, TerminalStatus}; +use crate::daemon::client::DaemonClient; +use crate::daemon::tauri_commands::ConnectionState; use crate::clients::ClientConfig; use crate::error::{Error, Result}; pub struct AppState { pub projects: RwLock>, - pub terminals: RwLock>, - pub pty_handles: RwLock>, - pub output_buffers: RwLock>>, pub clients: RwLock>, pub data_dir: PathBuf, - pub app_handle: AppHandle, + /// Daemon client - Optional in production mode, connected with user consent + pub daemon: RwLock>>, + /// Stored app handle for reconnection + app_handle: RwLock>, } impl AppState { pub fn new(app_handle: AppHandle) -> Result { + let dir_name = if cfg!(debug_assertions) { "ada-dev" } else { "ada" }; let data_dir = dirs::data_dir() .ok_or_else(|| Error::ConfigError("Could not find data directory".into()))? - .join("ada"); + .join(dir_name); std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(data_dir.join("projects"))?; - std::fs::create_dir_all(data_dir.join("terminals"))?; + + // Always try to connect to daemon, spawn it if needed + let daemon = match tauri::async_runtime::block_on(Self::ensure_daemon_and_connect(app_handle.clone())) { + Ok(client) => Some(Arc::new(client)), + Err(e) => { + tracing::error!(error = %e, "failed to connect to daemon"); + None + } + }; let state = Self { projects: RwLock::new(HashMap::new()), - terminals: RwLock::new(HashMap::new()), - pty_handles: RwLock::new(HashMap::new()), - output_buffers: RwLock::new(HashMap::new()), clients: RwLock::new(HashMap::new()), data_dir, - app_handle, + daemon: RwLock::new(daemon), + app_handle: RwLock::new(Some(app_handle)), }; // Load persisted projects state.load_projects()?; - // Load persisted terminals - state.load_terminals()?; - // Initialize default clients state.init_default_clients(); Ok(state) } - - fn load_projects(&self) -> Result<()> { - let projects_dir = self.data_dir.join("projects"); - if projects_dir.exists() { - for entry in std::fs::read_dir(&projects_dir)? { - let entry = entry?; - let path = entry.path(); + /// Ensure daemon is running (spawn via CLI if needed) and connect to it + async fn ensure_daemon_and_connect(app_handle: AppHandle) -> Result { + use std::time::Duration; - if path.extension().is_some_and(|ext| ext == "json") { - let content = std::fs::read_to_string(&path)?; - if let Ok(project) = serde_json::from_str::(&content) { - self.projects.write().insert(project.id.clone(), project); + let dev_mode = cfg!(debug_assertions); + let data_dir = Self::get_data_dir(dev_mode)?; + let port_path = data_dir.join("daemon/port"); + + // Check if daemon is already running + if let Ok(port_str) = std::fs::read_to_string(&port_path) { + if let Ok(port) = port_str.trim().parse::() { + if std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() { + tracing::info!(port, "daemon already running, connecting"); + return DaemonClient::connect(app_handle).await; + } + } + } + + // Daemon not running, spawn it using CLI + tracing::info!("daemon not running, spawning via CLI"); + Self::spawn_daemon_via_cli(dev_mode)?; + + // Wait for daemon to start + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(250)).await; + + if let Ok(port_str) = std::fs::read_to_string(&port_path) { + if let Ok(port) = port_str.trim().parse::() { + if std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() { + tracing::info!(port, "daemon started, connecting"); + return DaemonClient::connect(app_handle).await; } } } } - Ok(()) + Err(Error::TerminalError("Daemon did not start within 5 seconds".into())) } - fn load_terminals(&self) -> Result<()> { - let terminals_dir = self.data_dir.join("terminals"); + /// Spawn daemon using the CLI binary + fn spawn_daemon_via_cli(dev_mode: bool) -> Result<()> { + use std::process::Command; - if terminals_dir.exists() { - for entry in std::fs::read_dir(&terminals_dir)? { - let entry = entry?; - let path = entry.path(); + // Find the CLI binary + let cli_path = Self::resolve_cli_path()?; - if path.extension().is_some_and(|ext| ext == "json") { - let content = std::fs::read_to_string(&path)?; - if let Ok(mut terminal_data) = serde_json::from_str::(&content) { - // Mark terminal as stopped since the PTY is gone - terminal_data.terminal.status = TerminalStatus::Stopped; + if !cli_path.exists() { + return Err(Error::TerminalError(format!( + "CLI binary not found at {}", + cli_path.display() + ))); + } + + let mut cmd = Command::new(&cli_path); + cmd.arg("daemon").arg("start"); + + if dev_mode { + cmd.arg("--dev"); + } + + // Forward logging environment variables + for var in ["ADA_LOG_LEVEL", "ADA_LOG_STDERR", "ADA_LOG_DIR", "ADA_LOG_DISABLE"] { + if let Ok(value) = std::env::var(var) { + cmd.env(var, value); + } + } + + tracing::info!(cli = %cli_path.display(), dev_mode, "spawning daemon via CLI"); - let terminal_id = terminal_data.terminal.id.clone(); + cmd.spawn() + .map_err(|e| Error::TerminalError(format!("Failed to spawn daemon via CLI: {}", e)))?; - // Restore output buffer - let buffer = Arc::new(TerminalOutputBuffer::new()); - buffer.restore(terminal_data.output_history); + Ok(()) + } - self.terminals.write().insert(terminal_id.clone(), terminal_data.terminal); - self.output_buffers.write().insert(terminal_id, buffer); + /// Resolve path to the CLI binary (sidecar) + fn resolve_cli_path() -> Result { + Self::resolve_sidecar_path("ada-cli") + } + + /// Resolve path to a sidecar binary + /// + /// Tauri bundles sidecars with target triple suffix. This function checks: + /// 1. Bundled app location (macOS: Resources/binaries/, others: next to exe) + /// 2. Development location (target/debug/ or target/release/) + /// 3. System PATH + fn resolve_sidecar_path(name: &str) -> Result { + let target_triple = Self::get_target_triple(); + let exe_suffix = if cfg!(windows) { ".exe" } else { "" }; + let sidecar_name = format!("{}-{}{}", name, target_triple, exe_suffix); + let plain_name = format!("{}{}", name, exe_suffix); + + if let Ok(current_exe) = std::env::current_exe() { + // For bundled macOS apps: Ada.app/Contents/MacOS/Ada -> Ada.app/Contents/Resources/binaries/ + #[cfg(target_os = "macos")] + { + if let Some(macos_dir) = current_exe.parent() { + let resources_dir = macos_dir.parent().map(|p| p.join("Resources/binaries")); + if let Some(resources) = resources_dir { + let candidate = resources.join(&sidecar_name); + if candidate.exists() { + tracing::debug!(path = %candidate.display(), "found sidecar in app bundle"); + return Ok(candidate); + } } } } + + // For Windows/Linux or dev mode: next to executable + if let Some(parent) = current_exe.parent() { + // Check for sidecar with target triple (bundled) + let candidate = parent.join(&sidecar_name); + if candidate.exists() { + tracing::debug!(path = %candidate.display(), "found sidecar next to exe"); + return Ok(candidate); + } + + // Check for plain name (dev mode) + let candidate = parent.join(&plain_name); + if candidate.exists() { + tracing::debug!(path = %candidate.display(), "found binary next to exe"); + return Ok(candidate); + } + } } - Ok(()) + // Development: check target/debug and target/release + if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let target_dir = std::path::PathBuf::from(manifest_dir).join("target"); + for profile in ["debug", "release"] { + let candidate = target_dir.join(profile).join(&plain_name); + if candidate.exists() { + tracing::debug!(path = %candidate.display(), "found binary in target dir"); + return Ok(candidate); + } + } + } + + // Fallback: check PATH + which::which(&plain_name) + .map_err(|_| Error::TerminalError(format!("Could not find {} sidecar binary", name))) } - pub fn save_project(&self, project: &AdaProject) -> Result<()> { - let project_file = self.data_dir.join("projects").join(format!("{}.json", project.id)); - let content = serde_json::to_string_pretty(project)?; - std::fs::write(project_file, content)?; - Ok(()) + fn get_target_triple() -> &'static str { + #[cfg(all(target_arch = "x86_64", target_os = "macos"))] + return "x86_64-apple-darwin"; + + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + return "aarch64-apple-darwin"; + + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + return "x86_64-unknown-linux-gnu"; + + #[cfg(all(target_arch = "aarch64", target_os = "linux"))] + return "aarch64-unknown-linux-gnu"; + + #[cfg(all(target_arch = "x86_64", target_os = "windows"))] + return "x86_64-pc-windows-msvc"; + + #[cfg(not(any( + all(target_arch = "x86_64", target_os = "macos"), + all(target_arch = "aarch64", target_os = "macos"), + all(target_arch = "x86_64", target_os = "linux"), + all(target_arch = "aarch64", target_os = "linux"), + all(target_arch = "x86_64", target_os = "windows"), + )))] + return "unknown-unknown-unknown"; } - pub fn save_terminal(&self, terminal_id: &str) -> Result<()> { - let terminals = self.terminals.read(); - let terminal = terminals - .get(terminal_id) - .ok_or_else(|| Error::TerminalNotFound(terminal_id.to_string()))?; + fn get_data_dir(dev_mode: bool) -> Result { + let dir_name = if dev_mode { "ada-dev" } else { "ada" }; + dirs::data_dir() + .ok_or_else(|| Error::ConfigError("Could not find data directory".into())) + .map(|d| d.join(dir_name)) + } - let output_history = self.output_buffers + /// Get the daemon client, returning an error if not connected + pub fn get_daemon(&self) -> Result> { + self.daemon .read() - .get(terminal_id) - .map(|b| b.get_history()) - .unwrap_or_default(); + .clone() + .ok_or_else(|| Error::TerminalError("Daemon not connected".into())) + } - let terminal_data = TerminalData { - terminal: terminal.clone(), - output_history, - }; + /// Connect to the daemon + pub async fn connect_daemon(&self, app_handle: AppHandle) -> Result<()> { + let client = DaemonClient::connect(app_handle.clone()).await?; + *self.daemon.write() = Some(Arc::new(client)); + *self.app_handle.write() = Some(app_handle); + Ok(()) + } + + /// Get the connection state + pub fn get_connection_state(&self) -> ConnectionState { + if self.daemon.read().is_some() { + ConnectionState::Connected + } else { + // Check if daemon is running but we're not connected + let port_path = self.data_dir.join("daemon/port"); + if port_path.exists() { + if let Ok(content) = std::fs::read_to_string(&port_path) { + if let Ok(port) = content.trim().parse::() { + if std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok() { + return ConnectionState::Disconnected; + } + } + } + } + ConnectionState::NotRunning + } + } + + fn load_projects(&self) -> Result<()> { + let projects_dir = self.data_dir.join("projects"); + + if projects_dir.exists() { + for entry in std::fs::read_dir(&projects_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().is_some_and(|ext| ext == "json") { + let content = std::fs::read_to_string(&path)?; + match serde_json::from_str::(&content) { + Ok(project) => { + self.projects.write().insert(project.id.clone(), project); + } + Err(e) => { + eprintln!("Warning: Corrupt project file {}: {}", path.display(), e); + // Backup corrupt file for potential recovery + let backup = path.with_extension("json.corrupt"); + let _ = std::fs::rename(&path, &backup); + } + } + } + } + } - let terminal_file = self.data_dir.join("terminals").join(format!("{}.json", terminal_id)); - let content = serde_json::to_string_pretty(&terminal_data)?; - std::fs::write(terminal_file, content)?; + Ok(()) + } + + pub fn save_project(&self, project: &AdaProject) -> Result<()> { + let project_file = self.data_dir.join("projects").join(format!("{}.json", project.id)); + let content = serde_json::to_string_pretty(project)?; + crate::util::atomic_write(&project_file, content.as_bytes())?; Ok(()) } @@ -139,14 +311,6 @@ impl AppState { Ok(()) } - pub fn delete_terminal_file(&self, terminal_id: &str) -> Result<()> { - let terminal_file = self.data_dir.join("terminals").join(format!("{}.json", terminal_id)); - if terminal_file.exists() { - std::fs::remove_file(terminal_file)?; - } - Ok(()) - } - fn init_default_clients(&self) { use crate::clients::{ClientConfig, ClientType}; diff --git a/src-tauri/src/terminal/commands.rs b/src-tauri/src/terminal/commands.rs index ff43e54..8b9a43b 100644 --- a/src-tauri/src/terminal/commands.rs +++ b/src-tauri/src/terminal/commands.rs @@ -1,20 +1,26 @@ use tauri::State; -use chrono::Utc; -use std::sync::Arc; use std::path::PathBuf; +use crate::clients::ClientConfig; +use crate::daemon::protocol::CreateSessionRequest; use crate::error::{Error, Result}; -use crate::state::AppState; use crate::git; -use super::{Terminal, TerminalInfo, TerminalStatus, TerminalMode, CreateTerminalRequest, ResizeTerminalRequest, TerminalOutputBuffer}; -use super::pty::{spawn_pty, write_to_pty, resize_pty}; +use crate::state::AppState; +use crate::terminal::{CommandSpec, TerminalInfo, TerminalMode, CreateTerminalRequest, ResizeTerminalRequest}; + +fn build_command_spec(client: &ClientConfig) -> CommandSpec { + CommandSpec { + command: client.command.clone(), + args: client.args.clone(), + env: client.env.clone(), + } +} #[tauri::command] pub async fn create_terminal( state: State<'_, AppState>, request: CreateTerminalRequest, ) -> Result { - // Get project let project = { let projects = state.projects.read(); projects @@ -23,7 +29,6 @@ pub async fn create_terminal( .ok_or_else(|| Error::ProjectNotFound(request.project_id.clone()))? }; - // Get client configuration let client = { let clients = state.clients.read(); clients @@ -34,14 +39,11 @@ pub async fn create_terminal( let terminal_id = uuid::Uuid::new_v4().to_string(); - // Determine working directory, worktree path, branch, and folder_path based on mode let (working_dir, worktree_path, branch, folder_path) = match request.mode { TerminalMode::Main | TerminalMode::CurrentBranch => { - // Run at project root on current branch (project.path.clone(), None, None, None) } TerminalMode::Folder => { - // Run in a subfolder of project let folder = request.folder_path.as_ref().ok_or_else(|| { Error::InvalidRequest("Folder mode requires folder_path".into()) })?; @@ -53,14 +55,11 @@ pub async fn create_terminal( (working_dir, None, None, Some(folder_path_buf)) } TerminalMode::Worktree => { - // Run in an isolated worktree let branch_spec = request.worktree_branch.as_ref().ok_or_else(|| { Error::InvalidRequest("Worktree mode requires worktree_branch".into()) })?; - // Parse branch spec - could be "wt-baseBranch/newBranchName" or just a branch name let actual_branch = if branch_spec.starts_with("wt-") { - // Extract the new branch name from the format let rest = branch_spec.strip_prefix("wt-").unwrap(); if let Some(slash_pos) = rest.find('/') { rest[slash_pos + 1..].to_string() @@ -75,10 +74,8 @@ pub async fn create_terminal( .clone() .unwrap_or_else(|| project.path.join(".worktrees")); - // Use the actual branch name for the worktree path let worktree_path = worktree_base.join(actual_branch.replace('/', "-")); - // Create worktree if it doesn't exist if !worktree_path.exists() { git::create_worktree_internal(&project.path, branch_spec, &worktree_path)?; } @@ -87,65 +84,43 @@ pub async fn create_terminal( } }; - // Create output buffer - let output_buffer = Arc::new(TerminalOutputBuffer::new()); - - // Spawn PTY - let pty_handle = spawn_pty( - &state.app_handle, - &terminal_id, - &working_dir, - &client, - 120, - 30, - output_buffer.clone(), - )?; - - let terminal = Terminal { - id: terminal_id.clone(), + let create_request = CreateSessionRequest { + terminal_id: terminal_id.clone(), project_id: request.project_id.clone(), name: request.name, - client_id: request.client_id, - working_dir, + client_id: request.client_id.clone(), + working_dir: working_dir.to_string_lossy().to_string(), branch, - worktree_path, - status: TerminalStatus::Running, - created_at: Utc::now(), - mode: request.mode, + worktree_path: worktree_path.as_ref().map(|p| p.to_string_lossy().to_string()), + folder_path: folder_path.as_ref().map(|p| p.to_string_lossy().to_string()), is_main: false, - folder_path, + mode: request.mode, + command: build_command_spec(&client), + cols: 120, + rows: 30, }; - let terminal_info = TerminalInfo::from(&terminal); - - // Add terminal, pty handle, and output buffer to state - state.terminals.write().insert(terminal_id.clone(), terminal); - state.pty_handles.write().insert(terminal_id.clone(), pty_handle); - state.output_buffers.write().insert(terminal_id.clone(), output_buffer); - - // Update project - { - let mut projects = state.projects.write(); - if let Some(project) = projects.get_mut(&request.project_id) { - project.add_terminal(terminal_id.clone()); - let _ = state.save_project(project); - } - } - - // Save terminal to disk - let _ = state.save_terminal(&terminal_id); + let terminal_info = state.get_daemon()?.create_session(create_request).await?; Ok(terminal_info) } -/// Internal function to create the main terminal for a project -/// Can be called from other modules (e.g., project settings update) -pub fn create_main_terminal_internal( +pub async fn create_main_terminal_internal( state: &AppState, project_id: &str, client_id: &str, ) -> Result { - // Get project + let existing_main = state + .get_daemon()? + .list_sessions() + .await? + .into_iter() + .find(|session| session.project_id == project_id && session.is_main); + + if let Some(existing) = existing_main { + return Ok(existing); + } + let project = { let projects = state.projects.read(); projects @@ -154,15 +129,6 @@ pub fn create_main_terminal_internal( .ok_or_else(|| Error::ProjectNotFound(project_id.to_string()))? }; - // Check if main terminal already exists - if let Some(main_id) = &project.main_terminal_id { - let terminals = state.terminals.read(); - if let Some(terminal) = terminals.get(main_id) { - return Ok(TerminalInfo::from(terminal)); - } - } - - // Get client configuration let client = { let clients = state.clients.read(); clients @@ -171,7 +137,6 @@ pub fn create_main_terminal_internal( .ok_or_else(|| Error::ClientNotFound(client_id.to_string()))? }; - // Verify client is installed if !client.installed { return Err(Error::InvalidRequest(format!( "Client '{}' is not installed", @@ -181,66 +146,34 @@ pub fn create_main_terminal_internal( let terminal_id = uuid::Uuid::new_v4().to_string(); - // Create output buffer - let output_buffer = Arc::new(TerminalOutputBuffer::new()); - - // Spawn PTY at project root - let pty_handle = spawn_pty( - &state.app_handle, - &terminal_id, - &project.path, - &client, - 120, - 30, - output_buffer.clone(), - )?; - - let terminal = Terminal { - id: terminal_id.clone(), + let create_request = CreateSessionRequest { + terminal_id: terminal_id.clone(), project_id: project_id.to_string(), name: "main".to_string(), client_id: client_id.to_string(), - working_dir: project.path.clone(), + working_dir: project.path.to_string_lossy().to_string(), branch: None, worktree_path: None, - status: TerminalStatus::Running, - created_at: Utc::now(), - mode: TerminalMode::Main, - is_main: true, folder_path: None, + is_main: true, + mode: TerminalMode::Main, + command: build_command_spec(&client), + cols: 120, + rows: 30, }; - let terminal_info = TerminalInfo::from(&terminal); - - // Add terminal, pty handle, and output buffer to state - state.terminals.write().insert(terminal_id.clone(), terminal); - state.pty_handles.write().insert(terminal_id.clone(), pty_handle); - state.output_buffers.write().insert(terminal_id.clone(), output_buffer); - - // Update project with main terminal ID - { - let mut projects = state.projects.write(); - if let Some(project) = projects.get_mut(project_id) { - project.add_terminal(terminal_id.clone()); - project.main_terminal_id = Some(terminal_id.clone()); - let _ = state.save_project(project); - } - } - - // Save terminal to disk - let _ = state.save_terminal(&terminal_id); + let terminal_info = state.get_daemon()?.create_session(create_request).await?; Ok(terminal_info) } -/// Create the main terminal for a project (Tauri command wrapper) #[tauri::command] pub async fn create_main_terminal( state: State<'_, AppState>, project_id: String, client_id: String, ) -> Result { - create_main_terminal_internal(&state, &project_id, &client_id) + create_main_terminal_internal(&state, &project_id, &client_id).await } #[tauri::command] @@ -248,13 +181,11 @@ pub async fn list_terminals( state: State<'_, AppState>, project_id: String, ) -> Result> { - let terminals = state.terminals.read(); - let infos: Vec = terminals - .values() + let sessions = state.get_daemon()?.list_sessions().await?; + Ok(sessions + .into_iter() .filter(|t| t.project_id == project_id) - .map(|t| t.into()) - .collect(); - Ok(infos) + .collect()) } #[tauri::command] @@ -262,11 +193,7 @@ pub async fn get_terminal( state: State<'_, AppState>, terminal_id: String, ) -> Result { - let terminals = state.terminals.read(); - terminals - .get(&terminal_id) - .map(|t| t.into()) - .ok_or_else(|| Error::TerminalNotFound(terminal_id)) + state.get_daemon()?.get_session(&terminal_id).await } #[tauri::command] @@ -275,14 +202,7 @@ pub async fn write_terminal( terminal_id: String, data: String, ) -> Result<()> { - let pty_handles = state.pty_handles.read(); - let pty_handle = pty_handles - .get(&terminal_id) - .ok_or_else(|| Error::TerminalNotFound(terminal_id.clone()))?; - - write_to_pty(pty_handle, data.as_bytes())?; - - Ok(()) + state.get_daemon()?.write_to_session(&terminal_id, &data).await } #[tauri::command] @@ -290,14 +210,7 @@ pub async fn resize_terminal( state: State<'_, AppState>, request: ResizeTerminalRequest, ) -> Result<()> { - let pty_handles = state.pty_handles.read(); - let pty_handle = pty_handles - .get(&request.terminal_id) - .ok_or_else(|| Error::TerminalNotFound(request.terminal_id.clone()))?; - - resize_pty(pty_handle, request.cols, request.rows)?; - - Ok(()) + state.get_daemon()?.resize_session(&request.terminal_id, request.cols, request.rows).await } #[tauri::command] @@ -305,32 +218,12 @@ pub async fn close_terminal( state: State<'_, AppState>, terminal_id: String, ) -> Result<()> { - // Check if this is a main terminal - cannot be closed - { - let terminals = state.terminals.read(); - if let Some(terminal) = terminals.get(&terminal_id) { - if terminal.is_main { - return Err(Error::InvalidRequest("Cannot close the main terminal".into())); - } - } + let terminal = state.get_daemon()?.get_session(&terminal_id).await?; + if terminal.is_main { + return Err(Error::InvalidRequest("Cannot close the main terminal".into())); } - // Remove terminal, pty handle, and output buffer - let terminal = state.terminals.write().remove(&terminal_id); - state.pty_handles.write().remove(&terminal_id); - state.output_buffers.write().remove(&terminal_id); - - // Delete terminal file - let _ = state.delete_terminal_file(&terminal_id); - - if let Some(terminal) = terminal { - // Update project - let mut projects = state.projects.write(); - if let Some(project) = projects.get_mut(&terminal.project_id) { - project.remove_terminal(&terminal_id); - let _ = state.save_project(project); - } - } + state.get_daemon()?.close_session(&terminal_id).await?; Ok(()) } @@ -340,12 +233,7 @@ pub async fn get_terminal_history( state: State<'_, AppState>, terminal_id: String, ) -> Result> { - let output_buffers = state.output_buffers.read(); - let buffer = output_buffers - .get(&terminal_id) - .ok_or_else(|| Error::TerminalNotFound(terminal_id))?; - - Ok(buffer.get_history()) + state.get_daemon()?.get_history(&terminal_id).await } #[tauri::command] @@ -353,25 +241,8 @@ pub async fn mark_terminal_stopped( state: State<'_, AppState>, terminal_id: String, ) -> Result { - // Update terminal status to stopped - { - let mut terminals = state.terminals.write(); - if let Some(t) = terminals.get_mut(&terminal_id) { - t.status = TerminalStatus::Stopped; - } - } - - // Remove pty handle since the process is no longer running - state.pty_handles.write().remove(&terminal_id); - - // Save terminal to disk - let _ = state.save_terminal(&terminal_id); - - let terminals = state.terminals.read(); - let terminal = terminals - .get(&terminal_id) - .ok_or_else(|| Error::TerminalNotFound(terminal_id))?; - Ok(TerminalInfo::from(terminal)) + let _status = state.get_daemon()?.mark_session_stopped(&terminal_id).await?; + state.get_daemon()?.get_session(&terminal_id).await } #[tauri::command] @@ -380,7 +251,6 @@ pub async fn switch_terminal_agent( terminal_id: String, new_client_id: String, ) -> Result { - // Get client configuration let client = { let clients = state.clients.read(); clients @@ -389,48 +259,9 @@ pub async fn switch_terminal_agent( .ok_or_else(|| Error::ClientNotFound(new_client_id.clone()))? }; - // Get or create output buffer (fresh for new agent) - let output_buffer = Arc::new(TerminalOutputBuffer::new()); - - // Get terminal and update client_id - let working_dir = { - let mut terminals = state.terminals.write(); - let terminal = terminals - .get_mut(&terminal_id) - .ok_or_else(|| Error::TerminalNotFound(terminal_id.clone()))?; - terminal.client_id = new_client_id; - terminal.working_dir.clone() - }; - - // Spawn new PTY with new client - let pty_handle = spawn_pty( - &state.app_handle, - &terminal_id, - &working_dir, - &client, - 120, - 30, - output_buffer.clone(), - )?; - - // Update terminal status - { - let mut terminals = state.terminals.write(); - if let Some(t) = terminals.get_mut(&terminal_id) { - t.status = TerminalStatus::Running; - } - } - - // Store pty handle and output buffer - state.pty_handles.write().insert(terminal_id.clone(), pty_handle); - state.output_buffers.write().insert(terminal_id.clone(), output_buffer); - - // Save terminal to disk - let _ = state.save_terminal(&terminal_id); - - let terminals = state.terminals.read(); - let terminal = terminals.get(&terminal_id).unwrap(); - Ok(TerminalInfo::from(terminal)) + state.get_daemon()? + .switch_session_agent(&terminal_id, &new_client_id, build_command_spec(&client)) + .await } #[tauri::command] @@ -438,61 +269,5 @@ pub async fn restart_terminal( state: State<'_, AppState>, terminal_id: String, ) -> Result { - // Get the existing terminal - let terminal = { - let terminals = state.terminals.read(); - terminals - .get(&terminal_id) - .cloned() - .ok_or_else(|| Error::TerminalNotFound(terminal_id.clone()))? - }; - - // Get client configuration - let client = { - let clients = state.clients.read(); - clients - .get(&terminal.client_id) - .cloned() - .ok_or_else(|| Error::ClientNotFound(terminal.client_id.clone()))? - }; - - // Kill existing PTY if running (allows restart of both stopped and running terminals) - { - let mut pty_handles = state.pty_handles.write(); - pty_handles.remove(&terminal_id); - // PTY handle is dropped here, which closes file descriptors and sends SIGHUP - } - - // Create fresh output buffer (clears history for clean restart) - let output_buffer = Arc::new(TerminalOutputBuffer::new()); - - // Spawn new PTY - let pty_handle = spawn_pty( - &state.app_handle, - &terminal_id, - &terminal.working_dir, - &client, - 120, - 30, - output_buffer.clone(), - )?; - - // Update terminal status - { - let mut terminals = state.terminals.write(); - if let Some(t) = terminals.get_mut(&terminal_id) { - t.status = TerminalStatus::Running; - } - } - - // Store pty handle and output buffer - state.pty_handles.write().insert(terminal_id.clone(), pty_handle); - state.output_buffers.write().insert(terminal_id.clone(), output_buffer); - - // Save terminal to disk - let _ = state.save_terminal(&terminal_id); - - let terminals = state.terminals.read(); - let terminal = terminals.get(&terminal_id).unwrap(); - Ok(TerminalInfo::from(terminal)) + state.get_daemon()?.restart_session(&terminal_id).await } diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs index 61e775c..a501285 100644 --- a/src-tauri/src/terminal/mod.rs +++ b/src-tauri/src/terminal/mod.rs @@ -3,8 +3,7 @@ mod types; pub mod pty; pub use types::{ - Terminal, TerminalStatus, TerminalMode, TerminalInfo, - CreateTerminalRequest, ResizeTerminalRequest, PtyHandle, - TerminalData, TerminalOutputBuffer, + AgentStatus, CommandSpec, Terminal, TerminalStatus, TerminalMode, TerminalInfo, + CreateTerminalRequest, ResizeTerminalRequest, PtyHandle, TerminalOutput, }; pub use commands::create_main_terminal_internal; diff --git a/src-tauri/src/terminal/pty.rs b/src-tauri/src/terminal/pty.rs index dd9ac69..470f568 100644 --- a/src-tauri/src/terminal/pty.rs +++ b/src-tauri/src/terminal/pty.rs @@ -1,134 +1,7 @@ -use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; -use std::io::Read; -use std::path::Path; -use std::sync::Arc; -use parking_lot::Mutex; -use tauri::{AppHandle, Emitter}; +use portable_pty::PtySize; -use crate::clients::ClientConfig; use crate::error::{Error, Result}; -use super::types::{PtyHandle, TerminalOutput, TerminalOutputBuffer}; - -pub fn spawn_pty( - app_handle: &AppHandle, - terminal_id: &str, - working_dir: &Path, - client: &ClientConfig, - cols: u16, - rows: u16, - output_buffer: Arc, -) -> Result { - let pty_system = NativePtySystem::default(); - - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| Error::TerminalError(e.to_string()))?; - - // Use full path to command (macOS GUI apps don't inherit shell PATH) - let command_path = client.get_command_path(); - let mut cmd = CommandBuilder::new(&command_path); - cmd.args(&client.args); - cmd.cwd(working_dir); - - // Set up proper PATH environment for the PTY - // This ensures child processes can find common tools - if let Some(home) = dirs::home_dir() { - let path_dirs = vec![ - home.join(".local/bin"), - home.join(".cargo/bin"), - home.join(".bun/bin"), - std::path::PathBuf::from("/opt/homebrew/bin"), - std::path::PathBuf::from("/opt/homebrew/sbin"), - std::path::PathBuf::from("/usr/local/bin"), - std::path::PathBuf::from("/usr/bin"), - std::path::PathBuf::from("/bin"), - std::path::PathBuf::from("/usr/sbin"), - std::path::PathBuf::from("/sbin"), - ]; - - let path_value: String = path_dirs - .iter() - .filter(|p| p.exists()) - .map(|p| p.to_string_lossy().to_string()) - .collect::>() - .join(":"); - - cmd.env("PATH", &path_value); - cmd.env("HOME", home.to_string_lossy().to_string()); - } - - // Set TERM for proper terminal emulation - cmd.env("TERM", "xterm-256color"); - - // Set environment variables from client config - for (key, value) in &client.env { - cmd.env(key, value); - } - - // Spawn the child process - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| Error::TerminalError(e.to_string()))?; - - // Drop the slave to avoid blocking - drop(pair.slave); - - // Get reader for output - let mut reader = pair - .master - .try_clone_reader() - .map_err(|e| Error::TerminalError(e.to_string()))?; - - // Spawn a thread to read output and emit events - let app_handle_clone = app_handle.clone(); - let terminal_id_clone = terminal_id.to_string(); - - // Get the writer before spawning the read thread - let writer = pair - .master - .take_writer() - .map_err(|e| Error::TerminalError(e.to_string()))?; - - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - - loop { - match reader.read(&mut buffer) { - Ok(0) => break, // EOF - Ok(n) => { - let output = String::from_utf8_lossy(&buffer[..n]).to_string(); - - // Store in output buffer for persistence - output_buffer.append(output.clone()); - - // Emit output event for frontend - let _ = app_handle_clone.emit( - "terminal-output", - TerminalOutput { - terminal_id: terminal_id_clone.clone(), - data: output, - }, - ); - } - Err(_) => break, - } - } - - // Emit terminal closed event - let _ = app_handle_clone.emit("terminal-closed", terminal_id_clone); - }); - - Ok(PtyHandle { - master: Arc::new(Mutex::new(pair.master)), - writer: Arc::new(Mutex::new(writer)), - }) -} +use super::types::PtyHandle; pub fn write_to_pty(pty_handle: &PtyHandle, data: &[u8]) -> Result<()> { use std::io::Write; diff --git a/src-tauri/src/terminal/types.rs b/src-tauri/src/terminal/types.rs index 8151c8e..cd5507a 100644 --- a/src-tauri/src/terminal/types.rs +++ b/src-tauri/src/terminal/types.rs @@ -1,14 +1,11 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use std::collections::VecDeque; use chrono::{DateTime, Utc}; use parking_lot::Mutex; use portable_pty::MasterPty; -/// Maximum number of output chunks to store per terminal -const MAX_OUTPUT_HISTORY: usize = 1000; - /// Terminal mode determines how the terminal operates #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -24,6 +21,23 @@ pub enum TerminalMode { Worktree, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum AgentStatus { + #[default] + Idle, + Working, + Permission, + Review, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandSpec { + pub command: String, + pub args: Vec, + pub env: HashMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Terminal { pub id: String, @@ -35,6 +49,10 @@ pub struct Terminal { pub worktree_path: Option, pub status: TerminalStatus, pub created_at: DateTime, + pub command: CommandSpec, + pub shell: Option, + #[serde(default)] + pub agent_status: AgentStatus, /// Terminal mode (Main, Folder, CurrentBranch, Worktree) #[serde(default)] pub mode: TerminalMode, @@ -46,47 +64,6 @@ pub struct Terminal { pub folder_path: Option, } -/// Stored terminal data for persistence -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminalData { - pub terminal: Terminal, - #[serde(default)] - pub output_history: Vec, -} - -/// In-memory terminal output buffer -pub struct TerminalOutputBuffer { - pub buffer: Mutex>, -} - -impl TerminalOutputBuffer { - pub fn new() -> Self { - Self { - buffer: Mutex::new(VecDeque::with_capacity(MAX_OUTPUT_HISTORY)), - } - } - - pub fn append(&self, data: String) { - let mut buffer = self.buffer.lock(); - if buffer.len() >= MAX_OUTPUT_HISTORY { - buffer.pop_front(); - } - buffer.push_back(data); - } - - pub fn get_history(&self) -> Vec { - self.buffer.lock().iter().cloned().collect() - } - - pub fn restore(&self, history: Vec) { - let mut buffer = self.buffer.lock(); - buffer.clear(); - for item in history.into_iter().take(MAX_OUTPUT_HISTORY) { - buffer.push_back(item); - } - } -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TerminalStatus { @@ -131,6 +108,10 @@ pub struct TerminalInfo { pub worktree_path: Option, pub status: TerminalStatus, pub created_at: DateTime, + pub command: CommandSpec, + pub shell: Option, + #[serde(default)] + pub agent_status: AgentStatus, pub mode: TerminalMode, pub is_main: bool, pub folder_path: Option, @@ -148,6 +129,9 @@ impl From<&Terminal> for TerminalInfo { worktree_path: terminal.worktree_path.as_ref().map(|p| p.to_string_lossy().to_string()), status: terminal.status, created_at: terminal.created_at, + command: terminal.command.clone(), + shell: terminal.shell.clone(), + agent_status: terminal.agent_status, mode: terminal.mode, is_main: terminal.is_main, folder_path: terminal.folder_path.as_ref().map(|p| p.to_string_lossy().to_string()), diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs new file mode 100644 index 0000000..f1f59e7 --- /dev/null +++ b/src-tauri/src/util.rs @@ -0,0 +1,13 @@ +use std::fs; +use std::path::Path; + +/// Atomically write content to a file. +/// +/// Writes to a temporary file first, then renames to the target path. +/// This ensures the file is never in a partially-written state. +pub fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, content)?; + fs::rename(&tmp, path)?; + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 21c0796..0065797 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "identifier": "com.ada.agent", "version": "0.0.1-alpha", "build": { - "beforeDevCommand": "bun run dev", + "beforeDevCommand": "./scripts/dev-setup.sh && bun run dev", "devUrl": "http://localhost:5173", - "beforeBuildCommand": "bun run build", + "beforeBuildCommand": "./scripts/build-sidecars.sh && bun run build", "frontendDist": "../dist" }, "app": { @@ -29,6 +29,10 @@ "bundle": { "active": true, "targets": ["dmg", "app"], + "externalBin": [ + "binaries/ada-cli", + "binaries/ada-daemon" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/components/attention-badge.tsx b/src/components/attention-badge.tsx deleted file mode 100644 index df551f0..0000000 --- a/src/components/attention-badge.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from "@/lib/utils" - -interface AttentionBadgeProps { - count: number - className?: string - size?: "sm" | "md" -} - -/** - * Orange pulsing badge that shows the count of terminals with unseen output. - * Displayed when terminals have new output that the user hasn't viewed yet. - */ -export function AttentionBadge({ count, className, size = "sm" }: AttentionBadgeProps) { - if (count === 0) return null - - const sizeClasses = size === "sm" - ? "h-4 min-w-4 text-[10px]" - : "h-5 min-w-5 text-xs" - - return ( - - {count} - - ) -} diff --git a/src/components/cli-install-card.tsx b/src/components/cli-install-card.tsx new file mode 100644 index 0000000..dc2bc00 --- /dev/null +++ b/src/components/cli-install-card.tsx @@ -0,0 +1,127 @@ +import { useQuery } from "@tanstack/react-query" +import { Terminal, Check, Download, Trash2, AlertCircle } from "lucide-react" +import { cliInstallStatusQuery, useInstallCli, useUninstallCli } from "@/lib/queries/cli" +import { Button } from "@/components/ui/button" + +/** + * CLI Installation Card + * + * Shows the installation status of the Ada CLI and allows users + * to install/uninstall it to their system PATH. + */ +export function CliInstallCard() { + const { data: status, isLoading } = useQuery(cliInstallStatusQuery) + const installMutation = useInstallCli() + const uninstallMutation = useUninstallCli() + + const isInstalling = installMutation.isPending + const isUninstalling = uninstallMutation.isPending + const isBusy = isInstalling || isUninstalling + const canInstall = status?.canInstall ?? false + + if (isLoading) { + return ( +
+
+ + Checking CLI status... +
+
+ ) + } + + // Hide completely in dev mode + if (!canInstall) { + return null + } + + return ( +
+
+
+ +
+

CLI Tool

+

+ {status?.installed + ? status.upToDate + ? "Installed and up to date" + : "Installed (update available)" + : "Not installed"} +

+
+
+ +
+ {status?.installed ? ( + <> + + + {!status.upToDate && canInstall && ( + + )} + + ) : ( + + )} +
+
+ + {status?.installed && status.installPath && ( +
+ {status.installPath} +
+ )} + + {(installMutation.isError || uninstallMutation.isError) && ( +
+ + {installMutation.error?.message || + uninstallMutation.error?.message || + "An error occurred"} +
+ )} + + {!status?.installed && ( +

+ Install the CLI to use ada commands from your terminal. + You'll be prompted for your password. +

+ )} +
+ ) +} diff --git a/src/components/project-sidebar.tsx b/src/components/project-sidebar.tsx index d8a04a3..09fdce6 100644 --- a/src/components/project-sidebar.tsx +++ b/src/components/project-sidebar.tsx @@ -46,8 +46,6 @@ import { useDeleteProject, } from "@/lib/queries" import { useSidebarCollapsed, useTerminalUIStore } from "@/stores/terminal-ui-store" -import { useProjectUnseenCount } from "@/lib/tauri-events" -import { AttentionBadge } from "@/components/attention-badge" import type { ProjectSummary } from "@/lib/types" export function ProjectSidebar() { @@ -220,12 +218,6 @@ interface ProjectItemProps { } function ProjectItem({ project, isSelected, isCollapsed, onSelect, onHover, onDelete }: ProjectItemProps) { - // Fetch terminals - registration happens automatically in queryFn - useQuery(terminalsQueryOptions(project.id)) - - // Get unseen count - O(1) lookup from store - const unseenCount = useProjectUnseenCount(project.id) - if (isCollapsed) { return ( @@ -241,9 +233,6 @@ function ProjectItem({ project, isSelected, isCollapsed, onSelect, onHover, onDe {project.name.charAt(0).toUpperCase()} - {unseenCount > 0 && ( - - )} @@ -267,12 +256,11 @@ function ProjectItem({ project, isSelected, isCollapsed, onSelect, onHover, onDe diff --git a/src/components/terminal-strip.tsx b/src/components/terminal-strip.tsx index b3af329..02a8711 100644 --- a/src/components/terminal-strip.tsx +++ b/src/components/terminal-strip.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, memo, useEffect } from "react" -import { Plus, X, RotateCcw, Home, Bot, Circle, AlertCircle } from "lucide-react" +import { Plus, X, RotateCcw, Home, Bot, AlertCircle } from "lucide-react" import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button" import { @@ -12,32 +12,7 @@ import { import { ConfirmationDialog } from "@/components/confirmation-dialog" import { cn } from "@/lib/utils" import { getModeInfo } from "@/lib/terminal-utils" -import { useTerminalHasUnseen } from "@/lib/tauri-events" -import type { TerminalInfo, ClientSummary, TerminalStatus } from "@/lib/types" - -// Get border class based on unseen state and status -const getStatusBorderClass = (hasUnseen: boolean, status?: TerminalStatus): string => { - if (status === "stopped") return "border-yellow-500/50" - if (hasUnseen) return "border-orange-500 animate-pulse shadow-[0_0_12px_rgba(249,115,22,0.6)]" - if (status === "running") return "border-green-500/50" - return "border-gray-600/50" -} - -// Get status indicator content -const getStatusIndicator = (hasUnseen: boolean, isStopped: boolean) => { - if (isStopped) { - return stopped - } - if (hasUnseen) { - return ( -
- - new output -
- ) - } - return ready -} +import type { TerminalInfo, ClientSummary } from "@/lib/types" interface TerminalStripProps { terminals: TerminalInfo[] @@ -47,7 +22,6 @@ interface TerminalStripProps { defaultClientId: string | null onSelectTerminal: (terminalId: string) => void onCloseTerminal: (terminalId: string) => void - onRestartTerminal: (terminalId: string) => void onNewTerminal: () => void onSelectDefaultClient: (clientId: string) => void } @@ -60,7 +34,6 @@ export function TerminalStrip({ defaultClientId, onSelectTerminal, onCloseTerminal, - onRestartTerminal, onNewTerminal, onSelectDefaultClient, }: TerminalStripProps) { @@ -106,7 +79,6 @@ export function TerminalStrip({ installedClients={installedClients} isActive={isMainTerminalActive} onSelect={onSelectTerminal} - onRestart={onRestartTerminal} onSelectClient={onSelectDefaultClient} /> @@ -118,7 +90,6 @@ export function TerminalStrip({ isActive={terminal.id === activeTerminalId} onSelect={onSelectTerminal} onClose={handleCloseClick} - onRestart={onRestartTerminal} /> ))} @@ -149,7 +120,7 @@ export function TerminalStrip({ } // ============================================================================= -// Wrapper Components with Activity Tracking +// Wrapper Components // ============================================================================= interface MainTerminalCardWrapperProps { @@ -158,7 +129,6 @@ interface MainTerminalCardWrapperProps { installedClients: ClientSummary[] isActive: boolean onSelect: (terminalId: string) => void - onRestart: (terminalId: string) => void onSelectClient: (clientId: string) => void } @@ -168,33 +138,21 @@ function MainTerminalCardWrapper({ installedClients, isActive, onSelect, - onRestart, onSelectClient, }: MainTerminalCardWrapperProps) { - // Check if this terminal has unseen output - const hasUnseen = useTerminalHasUnseen(mainTerminal?.id ?? "") - const handleSelect = useCallback(() => { if (mainTerminal) { onSelect(mainTerminal.id) } }, [mainTerminal, onSelect]) - const handleRestart = useCallback(() => { - if (mainTerminal) { - onRestart(mainTerminal.id) - } - }, [mainTerminal, onRestart]) - return ( ) @@ -205,7 +163,6 @@ interface TerminalCardWrapperProps { isActive: boolean onSelect: (terminalId: string) => void onClose: (terminal: TerminalInfo, shiftKey: boolean) => void - onRestart: (terminalId: string) => void } function TerminalCardWrapper({ @@ -213,11 +170,7 @@ function TerminalCardWrapper({ isActive, onSelect, onClose, - onRestart, }: TerminalCardWrapperProps) { - // Check if this terminal has unseen output - const hasUnseen = useTerminalHasUnseen(terminal.id) - const handleSelect = useCallback(() => { onSelect(terminal.id) }, [terminal.id, onSelect]) @@ -226,18 +179,12 @@ function TerminalCardWrapper({ onClose(terminal, shiftKey) }, [terminal, onClose]) - const handleRestart = useCallback(() => { - onRestart(terminal.id) - }, [terminal.id, onRestart]) - return ( ) } @@ -250,10 +197,8 @@ interface MainTerminalCardProps { mainTerminal: TerminalInfo | null defaultClientId: string | null installedClients: ClientSummary[] - hasUnseen: boolean isActive: boolean onSelect: () => void - onRestart?: () => void onSelectClient: (clientId: string) => void } @@ -261,28 +206,27 @@ const MainTerminalCard = memo(function MainTerminalCard({ mainTerminal, defaultClientId, installedClients, - hasUnseen, isActive, onSelect, - onRestart, onSelectClient, }: MainTerminalCardProps) { const hasAgent = !!defaultClientId - const isStopped = mainTerminal?.status === "stopped" // Track how long we've been waiting for terminal creation const [initializingTooLong, setInitializingTooLong] = useState(false) - // If we have an agent but no terminal, start a timer + // If we have an agent but no terminal, start a timer to show error state useEffect(() => { if (hasAgent && !mainTerminal) { - setInitializingTooLong(false) const timer = setTimeout(() => { setInitializingTooLong(true) }, 8000) // Show error state after 8 seconds - return () => clearTimeout(timer) + return () => { + clearTimeout(timer) + setInitializingTooLong(false) + } } - setInitializingTooLong(false) + // When terminal appears or agent is deselected, cleanup resets state }, [hasAgent, mainTerminal]) // If no agent selected, show selection state @@ -397,44 +341,20 @@ const MainTerminalCard = memo(function MainTerminalCard({ )} > {/* Mini Terminal Preview with integrated content */} -
+
{/* Mode indicator badge - top left */}
main
- {/* Status indicator - center */} -
- {getStatusIndicator(hasUnseen, isStopped)} -
+ {/* Center spacer */} +
{/* Name overlay - bottom with gradient */}

Main Terminal

- - {/* Restart overlay for stopped terminals */} - {isStopped && onRestart && ( -
- -
- )}
) @@ -442,22 +362,17 @@ const MainTerminalCard = memo(function MainTerminalCard({ interface TerminalCardProps { terminal: TerminalInfo - hasUnseen: boolean isActive: boolean onSelect: () => void onClose: (shiftKey: boolean) => void - onRestart: () => void } const TerminalCard = memo(function TerminalCard({ terminal, - hasUnseen, isActive, onSelect, onClose, - onRestart, }: TerminalCardProps) { - const isStopped = terminal.status === "stopped" const modeInfo = getModeInfo(terminal.mode) const ModeIcon = modeInfo.icon @@ -484,11 +399,7 @@ const TerminalCard = memo(function TerminalCard({ {/* Mini Terminal Preview with integrated content */} -
+
{/* Mode indicator badge - top left */}
- {/* Status indicator - center */} -
- {getStatusIndicator(hasUnseen, isStopped)} -
+ {/* Center spacer */} +
{/* Name overlay - bottom with gradient */}

{terminal.name}

- - {/* Restart overlay for stopped terminals */} - {isStopped && ( -
- -
- )}
) diff --git a/src/components/terminal-view.tsx b/src/components/terminal-view.tsx index c8aa690..3f29a0a 100644 --- a/src/components/terminal-view.tsx +++ b/src/components/terminal-view.tsx @@ -3,39 +3,23 @@ import { Terminal } from "@xterm/xterm" import { FitAddon } from "@xterm/addon-fit" import { WebLinksAddon } from "@xterm/addon-web-links" import { SearchAddon } from "@xterm/addon-search" -import { Search, X, ChevronUp, ChevronDown, RotateCcw, RefreshCw, XCircle, Terminal as TerminalIcon } from "lucide-react" +import { Search, X, ChevronUp, ChevronDown, RotateCcw, Terminal as TerminalIcon } from "lucide-react" import { useTerminalOutput } from "@/lib/tauri-events" import { terminalApi } from "@/lib/api" -import { useMarkTerminalStopped } from "@/lib/queries" +import { useMarkTerminalStopped, useReconnectTerminal } from "@/lib/queries" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { ConfirmationDialog } from "@/components/confirmation-dialog" import { cn } from "@/lib/utils" import { getModeInfo } from "@/lib/terminal-utils" -import type { TerminalInfo, ClientSummary, TerminalStatus } from "@/lib/types" - -// Get border class for terminal view based on status -const getTerminalBorderClass = (status?: TerminalStatus): string => { - if (status === "stopped") return "border-yellow-500/50" - if (status === "running") return "border-green-500/50" - return "border-gray-600/50" -} +import type { TerminalInfo } from "@/lib/types" interface TerminalViewProps { terminalId: string terminal?: TerminalInfo currentBranch?: string - clients?: ClientSummary[] onRestart?: () => void - onSwitchAgent?: (clientId: string) => void onClose?: () => void } @@ -43,9 +27,7 @@ export function TerminalView({ terminalId, terminal, currentBranch = "", - clients = [], onRestart, - onSwitchAgent, onClose, }: TerminalViewProps) { const terminalRef = useRef(null) @@ -55,24 +37,112 @@ export function TerminalView({ const searchInputRef = useRef(null) const writtenCountRef = useRef(0) const inputHandlerAttached = useRef(false) + // Track reconnect state locally per-terminal (NOT shared isPending from React Query) + const isReconnectingRef = useRef(false) + // Track previous status to detect transitions to "stopped" + const prevStatusRef = useRef(undefined) // Mutation to mark terminal as stopped when PTY is dead const { mutate: markStopped } = useMarkTerminalStopped() + // Mutation to reconnect terminal (preserves history) + // NOTE: Don't use isPending - it's shared across all components using this hook! + const { mutate: reconnect } = useReconnectTerminal() + // Get terminal output (auto-fetches history on first load, live updates via events) const terminalOutput = useTerminalOutput(terminalId) const [isSearchOpen, setIsSearchOpen] = useState(false) const [searchQuery, setSearchQuery] = useState("") const [searchResultCount, setSearchResultCount] = useState(null) - const [selectedNewAgent, setSelectedNewAgent] = useState("") const [closeConfirmation, setCloseConfirmation] = useState(false) - const isStopped = terminal?.status === "stopped" const isMain = terminal?.is_main ?? false const modeInfo = terminal ? getModeInfo(terminal.mode) : null const ModeIcon = modeInfo?.icon ?? TerminalIcon + // Auto-reconnect when terminal status becomes "stopped" (PTY died) + useEffect(() => { + const currentStatus = terminal?.status + const prevStatus = prevStatusRef.current + + // Detect transition to "stopped" - reset reconnecting flag for fresh attempt + if (currentStatus === "stopped" && prevStatus !== "stopped") { + console.log( + `%c[TERMINAL STATUS CHANGE]%c ${prevStatus || "initial"} → stopped, resetting reconnect state`, + "background: #f59e0b; color: black; padding: 2px 6px; border-radius: 3px;", + "", + terminalId + ) + isReconnectingRef.current = false + } + + // Update previous status + prevStatusRef.current = currentStatus + + const shouldReconnect = currentStatus === "stopped" && !isReconnectingRef.current + + console.log( + `%c[TERMINAL AUTO-RECONNECT CHECK]%c`, + "background: #f59e0b; color: black; padding: 2px 6px; border-radius: 3px;", + "", + { + terminalId, + status: currentStatus, + isReconnecting: isReconnectingRef.current, + shouldReconnect, + } + ) + + if (shouldReconnect) { + console.warn( + `%c[TERMINAL AUTO-RECONNECT]%c Initiating reconnect`, + "background: #22c55e; color: white; padding: 2px 6px; border-radius: 3px;", + "", + terminalId + ) + isReconnectingRef.current = true + + // Set a timeout in case the reconnect hangs + const timeoutId = setTimeout(() => { + console.error( + `%c[TERMINAL AUTO-RECONNECT]%c Timeout after 10s`, + "background: #ef4444; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #ef4444;", + terminalId + ) + isReconnectingRef.current = false + }, 10000) + + reconnect(terminalId, { + onSuccess: () => { + clearTimeout(timeoutId) + console.log( + `%c[TERMINAL AUTO-RECONNECT]%c Success`, + "background: #22c55e; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #22c55e;", + terminalId + ) + // Reset the flag after a delay to allow future reconnects if needed + setTimeout(() => { + isReconnectingRef.current = false + }, 5000) + }, + onError: (reconnectErr) => { + clearTimeout(timeoutId) + console.error( + `%c[TERMINAL AUTO-RECONNECT]%c Failed`, + "background: #ef4444; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #ef4444;", + terminalId, + reconnectErr + ) + isReconnectingRef.current = false + }, + }) + } + }, [terminal?.status, terminalId, reconnect]) + // Get context info for header const getContextInfo = () => { if (!terminal) return "" @@ -184,6 +254,48 @@ export function TerminalView({ xtermRef.current.onData((data) => { terminalApi.write(terminalId, data).catch((err) => { const errorMsg = String(err) + + // If PTY is not running, auto-reconnect (preserves history) + if (errorMsg.includes("PTY is not running")) { + console.log( + `%c[TERMINAL WRITE ERROR]%c PTY not running`, + "background: #ef4444; color: white; padding: 2px 6px; border-radius: 3px;", + "", + { terminalId, isReconnecting: isReconnectingRef.current } + ) + + // If already reconnecting, just wait - don't spam reconnect attempts + if (isReconnectingRef.current) { + console.log(" Reconnect already in progress, waiting...") + return + } + + console.warn(" Initiating reconnect from write error handler") + isReconnectingRef.current = true + reconnect(terminalId, { + onSuccess: () => { + console.log("Terminal reconnected successfully (from write error):", terminalId) + setTimeout(() => { + isReconnectingRef.current = false + }, 5000) + }, + onError: (reconnectErr) => { + console.error("Terminal reconnect failed (from write error):", reconnectErr) + isReconnectingRef.current = false + markStopped(terminalId) + }, + }) + return + } + + // If daemon connection is closed, don't try to reconnect - daemon needs to restart first + if (errorMsg.includes("Daemon connection closed")) { + console.warn("Daemon connection closed, waiting for daemon restart:", terminalId) + // Reset reconnect state so we can try again after daemon restarts + isReconnectingRef.current = false + return + } + // If we get an I/O error, the PTY is dead - mark terminal as stopped if (errorMsg.includes("Input/output error") || errorMsg.includes("os error 5")) { console.warn("Terminal PTY is dead, marking as stopped:", terminalId) @@ -194,7 +306,7 @@ export function TerminalView({ }) }) inputHandlerAttached.current = true - }, [terminalId, markStopped]) + }, [terminalId, markStopped, reconnect]) // Write terminal output incrementally (handles both history and live updates) useEffect(() => { @@ -206,6 +318,7 @@ export function TerminalView({ xtermRef.current.clear() writtenCountRef.current = 0 inputHandlerAttached.current = false + isReconnectingRef.current = false } if (terminalOutput.length === 0) { @@ -286,10 +399,7 @@ export function TerminalView({ }, [isSearchOpen, closeSearch]) return ( -
+
{/* Terminal Header */}
{/* Left: Mode Badge */} @@ -396,95 +506,6 @@ export function TerminalView({
)} - {/* Stopped Overlay */} - {isStopped && ( -
-
-
-
- -
-

Agent Stopped

-

- The agent process has exited. -

-
- -
- {/* Restart with same agent */} - {(() => { - const currentClient = clients.find(c => c.id === terminal?.client_id) - return ( -
- -

- Starts fresh session (history cleared) -

-
- ) - })()} - - {/* Switch agent */} - {clients.length > 1 && onSwitchAgent && ( -
-

Or switch to a different agent:

-
- - -
-
- )} - - {/* Close terminal (not for main) */} - {!isMain && onClose && ( - - )} -
-
-
- )} - {/* Terminal */}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index fa2b442..7dbd9de 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { buttonVariants } from "@/lib/button-variants" const AlertDialog = AlertDialogPrimitive.Root diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 65d4fcd..e7bb593 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,38 +1,9 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import type { VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) +import { buttonVariants } from "@/lib/button-variants" export interface ButtonProps extends React.ButtonHTMLAttributes, @@ -54,4 +25,4 @@ const Button = React.forwardRef( ) Button.displayName = "Button" -export { Button, buttonVariants } +export { Button } diff --git a/src/lib/api.ts b/src/lib/api.ts index 2d12434..e724e39 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,7 @@ import type { WorktreeInfo, ClientConfig, ClientSummary, + RuntimeConfig, } from "./types" export interface UpdateProjectSettingsRequest { @@ -111,3 +112,11 @@ export const clientApi = { detectInstalled: (): Promise => invoke("detect_installed_clients"), } + +// Runtime API +export const runtimeApi = { + getConfig: (): Promise => + invoke("get_runtime_config"), + setShellOverride: (shell: string | null): Promise => + invoke("set_shell_override", { shell }), +} diff --git a/src/lib/button-variants.ts b/src/lib/button-variants.ts new file mode 100644 index 0000000..7f96851 --- /dev/null +++ b/src/lib/button-variants.ts @@ -0,0 +1,31 @@ +import { cva } from "class-variance-authority" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) diff --git a/src/lib/queries/cli.ts b/src/lib/queries/cli.ts new file mode 100644 index 0000000..1a6a55c --- /dev/null +++ b/src/lib/queries/cli.ts @@ -0,0 +1,42 @@ +import { invoke } from "@tauri-apps/api/core" +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" +import type { CliInstallStatus } from "@/lib/types" + +/** + * Query to check CLI installation status + */ +export const cliInstallStatusQuery = queryOptions({ + queryKey: ["cli", "installStatus"], + queryFn: () => invoke("check_cli_installed"), + staleTime: 10000, // Cache for 10 seconds +}) + +/** + * Mutation to install CLI to PATH + */ +export function useInstallCli() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => invoke("install_cli"), + onSuccess: (data) => { + // Update the cache with new status + queryClient.setQueryData(["cli", "installStatus"], data) + }, + }) +} + +/** + * Mutation to uninstall CLI from PATH + */ +export function useUninstallCli() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => invoke("uninstall_cli"), + onSuccess: (data) => { + // Update the cache with new status + queryClient.setQueryData(["cli", "installStatus"], data) + }, + }) +} diff --git a/src/lib/queries/daemon.ts b/src/lib/queries/daemon.ts new file mode 100644 index 0000000..b18f9ed --- /dev/null +++ b/src/lib/queries/daemon.ts @@ -0,0 +1,53 @@ +import { invoke } from "@tauri-apps/api/core" +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" +import type { ConnectionState, DaemonStatusInfo } from "@/lib/types" + +/** + * Query to check daemon status + */ +export const daemonStatusQuery = queryOptions({ + queryKey: ["daemon", "status"], + queryFn: () => invoke("check_daemon_status"), + retry: false, + staleTime: 5000, // Cache for 5 seconds +}) + +/** + * Query to get connection state + */ +export const connectionStateQuery = queryOptions({ + queryKey: ["daemon", "connectionState"], + queryFn: () => invoke("get_connection_state"), + retry: false, + staleTime: 1000, // Cache for 1 second +}) + +/** + * Mutation to connect to daemon + */ +export function useConnectDaemon() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => invoke("connect_to_daemon"), + onSuccess: () => { + // Invalidate status queries on successful connection + queryClient.invalidateQueries({ queryKey: ["daemon"] }) + }, + }) +} + +/** + * Mutation to start daemon + */ +export function useStartDaemon() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => invoke("start_daemon"), + onSuccess: () => { + // Invalidate status queries after starting daemon + queryClient.invalidateQueries({ queryKey: ["daemon"] }) + }, + }) +} diff --git a/src/lib/queries/index.ts b/src/lib/queries/index.ts index 1db0290..dfb2997 100644 --- a/src/lib/queries/index.ts +++ b/src/lib/queries/index.ts @@ -17,6 +17,7 @@ export { useCreateMainTerminal, useCloseTerminal, useRestartTerminal, + useReconnectTerminal, useMarkTerminalStopped, useSwitchTerminalAgent, useWriteTerminal, @@ -37,3 +38,9 @@ export { useCreateWorktree, useRemoveWorktree, } from "./git" + +// Runtime query options & mutations +export { + runtimeConfigQueryOptions, + useSetShellOverride, +} from "./runtime" diff --git a/src/lib/queries/runtime.ts b/src/lib/queries/runtime.ts new file mode 100644 index 0000000..462a23d --- /dev/null +++ b/src/lib/queries/runtime.ts @@ -0,0 +1,21 @@ +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" +import { runtimeApi } from "../api" +import { queryKeys } from "../query-client" + +export const runtimeConfigQueryOptions = () => + queryOptions({ + queryKey: queryKeys.runtime.config(), + queryFn: () => runtimeApi.getConfig(), + staleTime: 1000 * 60 * 5, + }) + +export function useSetShellOverride() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (shell: string | null) => runtimeApi.setShellOverride(shell), + onSuccess: (config) => { + queryClient.setQueryData(queryKeys.runtime.config(), config) + }, + }) +} diff --git a/src/lib/queries/terminals.ts b/src/lib/queries/terminals.ts index 2bc0ea8..7c76b45 100644 --- a/src/lib/queries/terminals.ts +++ b/src/lib/queries/terminals.ts @@ -1,21 +1,13 @@ import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" import { terminalApi } from "../api" import { queryKeys } from "../query-client" -import { unseenStore } from "@/stores/unseen-store" import type { CreateTerminalRequest } from "../types" // Query Options export const terminalsQueryOptions = (projectId: string) => queryOptions({ queryKey: queryKeys.terminals.list(projectId), - queryFn: async () => { - const terminals = await terminalApi.list(projectId) - // Register terminals with unseen store for O(1) lookups - unseenStore.registerTerminals( - terminals.map((t) => ({ id: t.id, project_id: t.project_id })) - ) - return terminals - }, + queryFn: () => terminalApi.list(projectId), enabled: !!projectId, }) @@ -40,9 +32,6 @@ export function useCreateTerminal() { return useMutation({ mutationFn: (request: CreateTerminalRequest) => terminalApi.create(request), onSuccess: (newTerminal) => { - // Register new terminal with unseen store immediately - unseenStore.registerTerminal(newTerminal.id, newTerminal.project_id) - queryClient.invalidateQueries({ queryKey: queryKeys.terminals.list(newTerminal.project_id), }) @@ -60,9 +49,6 @@ export function useCreateMainTerminal() { mutationFn: ({ projectId, clientId }: { projectId: string; clientId: string }) => terminalApi.createMain(projectId, clientId), onSuccess: (newTerminal) => { - // Register new terminal with unseen store immediately - unseenStore.registerTerminal(newTerminal.id, newTerminal.project_id) - queryClient.invalidateQueries({ queryKey: queryKeys.terminals.list(newTerminal.project_id), }) @@ -99,20 +85,15 @@ export function useCloseTerminal() { } ) - // Unregister from unseen store - unseenStore.unregisterTerminal(terminalId) - return { previousTerminals } }, - onError: (_err, { projectId, terminalId }, context) => { + onError: (_err, { projectId }, context) => { // Rollback on error if (context?.previousTerminals) { queryClient.setQueryData( queryKeys.terminals.list(projectId), context.previousTerminals ) - // Re-register if rollback - unseenStore.registerTerminal(terminalId, projectId) } }, onSettled: (_, __, { projectId }) => { @@ -155,6 +136,79 @@ export function useRestartTerminal() { }) } +/** + * Reconnect a terminal that lost its PTY connection. + * Unlike restart, this preserves the terminal output history. + * Use this for automatic recovery when PTY is not running. + */ +export function useReconnectTerminal() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (terminalId: string) => { + console.log( + `%c[RECONNECT MUTATION]%c Calling terminalApi.restart`, + "background: #8b5cf6; color: white; padding: 2px 6px; border-radius: 3px;", + "", + terminalId + ) + + // Add timeout to prevent hanging forever if daemon dies + const timeoutMs = 15000 + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Reconnect timed out after 15s")), timeoutMs) + }) + + try { + const result = await Promise.race([ + terminalApi.restart(terminalId), + timeoutPromise, + ]) + console.log( + `%c[RECONNECT MUTATION]%c terminalApi.restart resolved`, + "background: #8b5cf6; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #22c55e;", + result + ) + return result + } catch (err) { + console.error( + `%c[RECONNECT MUTATION]%c terminalApi.restart rejected/timed out`, + "background: #8b5cf6; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #ef4444;", + err + ) + throw err + } + }, + onSuccess: (updatedTerminal) => { + console.log( + `%c[RECONNECT MUTATION]%c onSuccess`, + "background: #8b5cf6; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #22c55e;", + updatedTerminal.id + ) + // DO NOT clear terminal output - preserve history for reconnect + // Just update terminal detail and status + queryClient.setQueryData( + queryKeys.terminals.detail(updatedTerminal.id), + updatedTerminal + ) + queryClient.invalidateQueries({ + queryKey: queryKeys.terminals.list(updatedTerminal.project_id), + }) + }, + onError: (err) => { + console.error( + `%c[RECONNECT MUTATION]%c onError`, + "background: #8b5cf6; color: white; padding: 2px 6px; border-radius: 3px;", + "color: #ef4444;", + err + ) + }, + }) +} + export function useMarkTerminalStopped() { const queryClient = useQueryClient() diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 1a1ef6f..2b3264a 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -23,6 +23,14 @@ export const queryKeys = { detail: (id: string) => [...queryKeys.terminals.all, "detail", id] as const, history: (id: string) => [...queryKeys.terminals.all, "history", id] as const, }, + agents: { + all: ["agents"] as const, + status: (terminalId: string) => [...queryKeys.agents.all, "status", terminalId] as const, + }, + runtime: { + all: ["runtime"] as const, + config: () => [...queryKeys.runtime.all, "config"] as const, + }, clients: { all: ["clients"] as const, list: () => [...queryKeys.clients.all, "list"] as const, diff --git a/src/lib/router-instance.ts b/src/lib/router-instance.ts new file mode 100644 index 0000000..55a3c8d --- /dev/null +++ b/src/lib/router-instance.ts @@ -0,0 +1,26 @@ +import { createRouter } from "@tanstack/react-router" +import type { QueryClient } from "@tanstack/react-query" +import { routeTree } from "../routeTree.gen" +import { queryClient } from "./query-client" + +// Create router context type +export interface RouterContext { + queryClient: QueryClient +} + +// Create a new router instance +export const router = createRouter({ + routeTree, + context: { + queryClient, + }, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, // Pass all preload events to TanStack Query +}) + +// Register the router instance for type safety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router + } +} diff --git a/src/lib/tauri-events.ts b/src/lib/tauri-events.ts index a7a6dd5..5eb25e8 100644 --- a/src/lib/tauri-events.ts +++ b/src/lib/tauri-events.ts @@ -1,32 +1,36 @@ -import { useEffect } from "react" -import { listen, type UnlistenFn } from "@tauri-apps/api/event" -import { useQuery, type QueryClient } from "@tanstack/react-query" -import type { TerminalInfo } from "./types" -import { queryKeys } from "./query-client" -import { terminalApi } from "./api" -import { unseenStore } from "@/stores/unseen-store" - -// Re-export hooks from unseen store for convenience -export { - useTerminalHasUnseen, - useProjectUnseenCount, - useMarkTerminalSeen, - unseenStore, -} from "@/stores/unseen-store" +import { useEffect } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; +import type { AgentStatus, TerminalInfo, ClientConfig } from "./types"; +import { queryKeys } from "./query-client"; +import { terminalApi } from "./api"; // ============================================================================= // Event Types from Tauri Backend // ============================================================================= interface TerminalOutputEvent { - terminal_id: string - data: string + terminal_id: string; + data: string; } interface TerminalStatusEvent { - terminal_id: string - project_id: string - status: "running" | "stopped" + terminal_id: string; + project_id: string; + status: "running" | "stopped"; +} + +interface AgentStatusEvent { + terminal_id: string; + status: AgentStatus; +} + +interface HookEvent { + terminal_id: string; + project_id: string | null; + agent: string; + event: string; + payload: string | null; } // ============================================================================= @@ -34,75 +38,212 @@ interface TerminalStatusEvent { // ============================================================================= export const eventQueryKeys = { - terminalOutput: (terminalId: string) => ["terminals", "output", terminalId] as const, -} + terminalOutput: (terminalId: string) => + ["terminals", "output", terminalId] as const, +}; // ============================================================================= // Event Handlers // ============================================================================= +// Batch terminal output to avoid excessive re-renders +const outputBuffers = new Map(); +let flushScheduled = false; + +function flushOutputBuffers(queryClient: QueryClient) { + flushScheduled = false; + + for (const [terminal_id, chunks] of outputBuffers) { + if (chunks.length === 0) continue; + + // Batch append all chunks at once + queryClient.setQueryData( + eventQueryKeys.terminalOutput(terminal_id), + (oldData = []) => [...oldData, ...chunks], + ); + } + + outputBuffers.clear(); +} + function handleTerminalOutput( queryClient: QueryClient, - event: TerminalOutputEvent + event: TerminalOutputEvent, ) { - const { terminal_id, data } = event + const { terminal_id, data } = event; - // Append output to the terminal's output cache - queryClient.setQueryData( - eventQueryKeys.terminalOutput(terminal_id), - (oldData = []) => [...oldData, data] - ) + // Buffer the output chunk + const buffer = outputBuffers.get(terminal_id) ?? []; + buffer.push(data); + outputBuffers.set(terminal_id, buffer); - // Mark as unseen (store handles active terminal check internally) - unseenStore.markUnseen(terminal_id) + // Schedule a flush on next animation frame (batches rapid events) + if (!flushScheduled) { + flushScheduled = true; + requestAnimationFrame(() => flushOutputBuffers(queryClient)); + } } function handleTerminalStatus( queryClient: QueryClient, - event: TerminalStatusEvent + event: TerminalStatusEvent, ) { - const { terminal_id, project_id, status } = event + const { terminal_id, project_id, status } = event; - // Register terminal -> project mapping - unseenStore.registerTerminal(terminal_id, project_id) + console.log( + `%c[TERMINAL STATUS]%c ${status}`, + "background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px;", + status === "running" ? "color: #22c55e; font-weight: bold;" : "color: #eab308; font-weight: bold;", + ); + console.log(" terminal_id:", terminal_id); + console.log(" project_id:", project_id); + console.log(" status:", status); + console.log(" timestamp:", new Date().toISOString()); // Update the terminal in the terminals list cache queryClient.setQueryData( queryKeys.terminals.list(project_id), (oldData) => { - if (!oldData) return oldData - return oldData.map((t) => - t.id === terminal_id ? { ...t, status } : t - ) - } - ) + if (!oldData) return oldData; + return oldData.map((t) => (t.id === terminal_id ? { ...t, status } : t)); + }, + ); // Also update the individual terminal detail if cached queryClient.setQueryData( queryKeys.terminals.detail(terminal_id), (oldData) => { - if (!oldData) return oldData - return { ...oldData, status } - } - ) + if (!oldData) return oldData; + return { ...oldData, status }; + }, + ); } -function handleTerminalClosed( - queryClient: QueryClient, - terminalId: string -) { - // Get project ID from our store - O(1) - const projectId = unseenStore.getProjectId(terminalId) - - if (projectId) { - handleTerminalStatus(queryClient, { - terminal_id: terminalId, - project_id: projectId, - status: "stopped", - }) +function handleAgentStatus(queryClient: QueryClient, event: AgentStatusEvent) { + const { terminal_id, status } = event; + + const statusColors: Record = { + idle: "color: #6b7280;", + working: "color: #22c55e;", + permission: "color: #f59e0b;", + review: "color: #ef4444;", + }; + + console.log( + `%c[AGENT STATUS]%c ${status}`, + "background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px;", + statusColors[status] + " font-weight: bold;", + ); + console.log(" terminal_id:", terminal_id); + console.log(" status:", status); + console.log(" timestamp:", new Date().toISOString()); + + queryClient.setQueryData(queryKeys.agents.status(terminal_id), status); + + queryClient.setQueryData( + queryKeys.terminals.detail(terminal_id), + (oldData) => { + if (!oldData) return oldData; + return { ...oldData, agent_status: status }; + }, + ); + + const listQueries = queryClient.getQueriesData({ + queryKey: ["terminals", "list"], + }); + for (const [key, data] of listQueries) { + if (!data) continue; + const updated = data.map((t) => + t.id === terminal_id ? { ...t, agent_status: status } : t, + ); + queryClient.setQueryData(key, updated); } } +function handleTerminalClosed(queryClient: QueryClient, terminalId: string) { + console.log( + `%c[TERMINAL CLOSED]%c`, + "background: #dc2626; color: white; padding: 2px 6px; border-radius: 3px;", + "", + ); + console.log(" terminal_id:", terminalId); + console.log(" timestamp:", new Date().toISOString()); + + // Invalidate terminal queries to refresh status + queryClient.invalidateQueries({ + queryKey: queryKeys.terminals.detail(terminalId), + }); +} + +function handleHookEvent(queryClient: QueryClient, event: HookEvent) { + const agentColors: Record = { + claude: "background: #d97706;", + codex: "background: #059669;", + opencode: "background: #7c3aed;", + gemini: "background: #2563eb;", + cursor: "background: #dc2626;", + unknown: "background: #6b7280;", + }; + + // Look up project name from cache + let projectName = event.project_id || "unknown"; + if (event.project_id) { + const projectData = queryClient.getQueryData<{ name: string }>( + queryKeys.projects.detail(event.project_id) + ); + if (projectData?.name) { + projectName = projectData.name; + } + } + + // Look up terminal name from cache + let terminalName = "unknown"; + const terminalData = queryClient.getQueryData( + queryKeys.terminals.detail(event.terminal_id) + ); + if (terminalData?.name) { + terminalName = terminalData.name; + } else if (event.project_id) { + // Try to find in project's terminal list + const terminals = queryClient.getQueryData( + queryKeys.terminals.list(event.project_id) + ); + const terminal = terminals?.find((t) => t.id === event.terminal_id); + if (terminal?.name) { + terminalName = terminal.name; + } + } + + // Look up client display name from cache + let agentDisplayName = event.agent; + const clients = queryClient.getQueryData(queryKeys.clients.list()); + const client = clients?.find((c) => c.id === event.agent || c.name.toLowerCase().includes(event.agent)); + if (client?.name) { + agentDisplayName = client.name; + } + + const bgColor = agentColors[event.agent] || agentColors.unknown; + + console.log( + `%c[HOOK ${event.agent.toUpperCase()}]%c ${event.event}`, + `${bgColor} color: white; padding: 2px 6px; border-radius: 3px;`, + "color: #a78bfa; font-weight: bold;", + ); + console.log(" project:", projectName); + console.log(" terminal:", terminalName); + console.log(" agent:", agentDisplayName); + console.log(" event:", event.event); + if (event.payload) { + try { + const parsed = JSON.parse(event.payload); + console.log(" payload:", parsed); + } catch { + console.log(" payload:", event.payload); + } + } + console.log(" timestamp:", new Date().toISOString()); +} + // ============================================================================= // Main Hook - Initialize Event Listeners // ============================================================================= @@ -113,36 +254,50 @@ function handleTerminalClosed( */ export function useTauriEvents(queryClient: QueryClient) { useEffect(() => { - const unlisteners: Promise[] = [] + const unlisteners: Promise[] = []; // Terminal output events unlisteners.push( listen("terminal-output", (event) => { - handleTerminalOutput(queryClient, event.payload) - }) - ) + handleTerminalOutput(queryClient, event.payload); + }), + ); // Terminal closed events (backward compatibility - just sends ID) unlisteners.push( listen("terminal-closed", (event) => { - handleTerminalClosed(queryClient, event.payload) - }) - ) + handleTerminalClosed(queryClient, event.payload); + }), + ); // Terminal status events (if backend sends them) unlisteners.push( listen("terminal-status", (event) => { - handleTerminalStatus(queryClient, event.payload) - }) - ) + handleTerminalStatus(queryClient, event.payload); + }), + ); + + // Agent status events + unlisteners.push( + listen("agent-status-change", (event) => { + handleAgentStatus(queryClient, event.payload); + }), + ); + + // Raw hook events (for logging/debugging all agent events) + unlisteners.push( + listen("hook-event", (event) => { + handleHookEvent(queryClient, event.payload); + }), + ); // Cleanup on unmount return () => { unlisteners.forEach((unlisten) => { - unlisten.then((fn) => fn()) - }) - } - }, [queryClient]) + unlisten.then((fn) => fn()); + }); + }; + }, [queryClient]); } // ============================================================================= @@ -161,9 +316,9 @@ export function useTerminalOutput(terminalId: string): string[] { staleTime: Infinity, gcTime: 1000 * 60 * 30, enabled: !!terminalId, - }) + }); - return data + return data; } // ============================================================================= @@ -173,16 +328,23 @@ export function useTerminalOutput(terminalId: string): string[] { /** * Clear terminal output from cache (e.g., when switching agents) */ -export function clearTerminalOutput(queryClient: QueryClient, terminalId: string) { - queryClient.setQueryData(eventQueryKeys.terminalOutput(terminalId), []) +export function clearTerminalOutput( + queryClient: QueryClient, + terminalId: string, +) { + queryClient.setQueryData(eventQueryKeys.terminalOutput(terminalId), []); } /** * Remove terminal data from cache when terminal is closed */ -export function removeTerminalFromCache(queryClient: QueryClient, terminalId: string) { - queryClient.removeQueries({ queryKey: eventQueryKeys.terminalOutput(terminalId) }) - unseenStore.unregisterTerminal(terminalId) +export function removeTerminalFromCache( + queryClient: QueryClient, + terminalId: string, +) { + queryClient.removeQueries({ + queryKey: eventQueryKeys.terminalOutput(terminalId), + }); } /** @@ -191,17 +353,7 @@ export function removeTerminalFromCache(queryClient: QueryClient, terminalId: st export function loadTerminalHistory( queryClient: QueryClient, terminalId: string, - history: string[] -) { - queryClient.setQueryData(eventQueryKeys.terminalOutput(terminalId), history) -} - -/** - * Mark a terminal as seen (for imperative use) - */ -export function markTerminalSeen( - _queryClient: QueryClient, - terminalId: string + history: string[], ) { - unseenStore.setActiveTerminal(terminalId) + queryClient.setQueryData(eventQueryKeys.terminalOutput(terminalId), history); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 31c1836..a221645 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -38,6 +38,13 @@ export interface CreateProjectRequest { // Terminal types export type TerminalStatus = "starting" | "running" | "stopped" | "error" export type TerminalMode = "main" | "folder" | "current_branch" | "worktree" +export type AgentStatus = "idle" | "working" | "permission" | "review" + +export interface CommandSpec { + command: string + args: string[] + env: Record +} export interface TerminalInfo { id: string @@ -49,6 +56,9 @@ export interface TerminalInfo { worktree_path: string | null status: TerminalStatus created_at: string + command: CommandSpec + shell: string | null + agent_status: AgentStatus mode: TerminalMode is_main: boolean folder_path: string | null @@ -110,3 +120,35 @@ export interface ClientSummary { description: string installed: boolean } + +// Runtime config +export interface RuntimeConfig { + ada_home: string + data_dir: string + daemon_port: number + notification_port: number + shell_override: string | null +} + +// Daemon types +export type ConnectionState = "connected" | "disconnected" | "notRunning" | "connecting" + +export interface DaemonStatusInfo { + running: boolean + connected: boolean + pid: number | null + port: number | null + uptimeSecs: number | null + sessionCount: number | null + version: string | null +} + +// CLI installation types +export interface CliInstallStatus { + installed: boolean + installPath: string | null + bundledPath: string | null + upToDate: boolean + /** Whether installation is available (false in dev mode unless ADA_ALLOW_DEV_INSTALL=1) */ + canInstall: boolean +} diff --git a/src/router.tsx b/src/router.tsx index c91af8a..492c12c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,29 +1,5 @@ -import { createRouter, RouterProvider } from "@tanstack/react-router" -import type { QueryClient } from "@tanstack/react-query" -import { routeTree } from "./routeTree.gen" -import { queryClient } from "./lib/query-client" - -// Create router context type -export interface RouterContext { - queryClient: QueryClient -} - -// Create a new router instance -export const router = createRouter({ - routeTree, - context: { - queryClient, - }, - defaultPreload: "intent", - defaultPreloadStaleTime: 0, // Pass all preload events to TanStack Query -}) - -// Register the router instance for type safety -declare module "@tanstack/react-router" { - interface Register { - router: typeof router - } -} +import { RouterProvider } from "@tanstack/react-router" +import { router } from "./lib/router-instance" export function Router() { return diff --git a/src/routes/project.$projectId.$terminalId.tsx b/src/routes/project.$projectId.$terminalId.tsx index d0ae9fe..6a50db6 100644 --- a/src/routes/project.$projectId.$terminalId.tsx +++ b/src/routes/project.$projectId.$terminalId.tsx @@ -34,10 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { clearTerminalOutput, removeTerminalFromCache, - useProjectUnseenCount, - useMarkTerminalSeen, } from "@/lib/tauri-events"; -import { AttentionBadge } from "@/components/attention-badge"; import { setLastTerminal } from "@/lib/terminal-history"; import { projectQueryOptions, @@ -132,27 +129,11 @@ function TerminalPage() { const [isCreateTerminalOpen, setIsCreateTerminalOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); - // Find terminals const mainTerminal = terminals.find((t) => t.is_main) || null; const activeTerminal = terminals.find((t) => t.id === terminalId) || mainTerminal; - // Track unseen count for this project - O(1) lookup from store - // (terminals are auto-registered when fetched via terminalsQueryOptions) - const unseenCount = useProjectUnseenCount(projectId); - - // Mark terminal as seen when viewed - const markSeen = useMarkTerminalSeen(); - const activeTerminalId = activeTerminal?.id; - - // Mark the active terminal as seen when it changes - useEffect(() => { - if (activeTerminalId) { - markSeen(activeTerminalId); - } - }, [activeTerminalId, markSeen]); - // Navigate to a terminal const selectTerminal = useCallback( (id: string) => { @@ -241,7 +222,6 @@ function TerminalPage() {
{currentProject.name} - {unseenCount > 0 && }