# 0.2 — Phase 3: FDA wizard + sync status + XPC-supervised sync (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Phase 3 completes the no-terminal end-user experience. First launch on a fresh Mac runs an FDA wizard that deep-links into System Settings and auto-advances when the helper can read `chat.db`. Sync runs in a per-account XPC service the app supervises; a status-bar (menu-bar) icon shows per-account last-sync time. Settings sheet has a master "Background sync enabled" toggle that actually starts/stops the XPC service.

**Architecture:** App becomes LSUIElement (status-bar-only by default; the AccountsView opens via a menu-bar item). One embedded XPC service `com.accountpilot.SyncService.xpc` lives at `Contents/XPCServices/`. The Swift `SyncSupervisor` is the NSXPCConnection client. The XPC service spawns `accountpilot mail sync-once <id> --json` / `accountpilot imessage sync-once <id> --json` per enabled account on a configurable interval (default: continuous IDLE for mail, 10s poll for iMessage). Status is read via `accountpilot status --json`.

**Scope note — XPC-while-app-alive vs. true launchd survival:** macOS embedded XPC services die when their parent app exits. The master brief's "force-quit while sync running → sync continues" criterion strictly needs a LaunchAgent registered via `SMAppService.daemon`, which is a meaningfully different lift (signed login-item, separate bundle, different sandboxing model). Phase 3 ships the status-bar-app model (LSUIElement: app is effectively always-on; cmd+Q quits sync too — that's expected). True background-after-quit lands in a follow-up "Phase 3b — LaunchAgent" once the XPC API + protocol are stable.

**Tech Stack:** Swift 5.9 / SwiftUI · NSXPCConnection / NSXPCInterface · NSStatusItem (menu bar) · `Process` (Swift→Python subprocess) · existing AccountPilotPlugin sync hooks · XcodeGen with multi-target spec · `codesign` (depth-first walk extended to `Contents/XPCServices/`).

---

## Prerequisites

Before starting Task 1, ensure:

- [ ] Phase 2 PR has merged to `main`. Verify: `git log --oneline main | head -3` shows the Phase 2 merge commit.
- [ ] On a Phase 3 branch: `git checkout -b 0.2-p3-fda-wizard-sync` (off main).
- [ ] `pip install -e ".[dev]"` works; `pytest -q` is green from main.
- [ ] `dist/AccountPilot.app` from Phase 2 still builds end-to-end (`./app/scripts/build-app.sh` ends with `==> all verification passed`).
- [ ] You have at least one populated account in your local DB (Gmail or iMessage) so the sync flows have something to do during smoke tests.

## File structure (created or modified by this phase)

```
src/accountpilot/core/cli/
├── status_cmd.py                    # MODIFY: add --json with per-account last-sync + error counts
└── sync_once_cmd.py                 # CREATE: `accountpilot mail sync-once`, `accountpilot imessage sync-once`
                                     #   (delegates to plugin.sync_once)

src/accountpilot/cli.py              # MODIFY: register sync_once group

tests/accountpilot/unit/cli/
├── test_status_cmd_json.py          # CREATE
└── test_sync_once_cmd.py            # CREATE

app/AccountPilot/
├── Models/
│   └── SyncStatus.swift             # CREATE: Codable mirror of `accountpilot status --json`
├── Services/
│   └── SyncSupervisor.swift         # CREATE: NSXPCConnection client, ObservableObject
├── Shared/
│   └── SyncServiceProtocol.swift    # CREATE: @objc NSXPCInterface protocol (linked into both targets)
├── Views/
│   ├── Onboarding/
│   │   └── FDAWizardView.swift      # CREATE: helper-grant detection + auto-advance
│   ├── StatusBarMenu.swift          # CREATE: NSMenu for the menu-bar item
│   └── SettingsView.swift           # CREATE: sync-enabled toggle, advanced settings
├── ContentView.swift                # MODIFY: routes to FDAWizardView vs AccountsView based on grant state
├── AccountPilotApp.swift            # MODIFY: MenuBarExtra + Settings scene + LSUIElement-true flow
└── Info.plist                       # auto-regen: LSUIElement = true

app/AccountPilotXPC/
├── SyncServiceMain.swift            # CREATE: NSXPCListener entry point
└── SyncServiceImpl.swift            # CREATE: protocol implementation (calls bundled Python)

app/project.yml                      # MODIFY: add AccountPilotXPC target + Shared sources sharing
app/scripts/
├── build-app.sh                     # MODIFY: copy + sign XPC service bundle into Contents/XPCServices/
└── verify-app.sh                    # MODIFY: add XPC service path + signing assertions

CHANGELOG.md                         # MODIFY: Phase 3 entries under [0.2.0]
```

`AccountPilot/Shared/` is a directory whose Swift files are members of BOTH the AccountPilot app target and the AccountPilotXPC service target — XcodeGen handles this with a shared sources path listed in both targets' `sources` arrays.

---

## Task 1: `accountpilot status --json` — per-account sync state

**Goal:** A single command the SyncSupervisor reads on a timer to populate the status-bar menu. Mirrors what Phase 1's `status` command emits but in the standard JSON envelope. Includes `last_sync_at`, `last_error`, `pending_messages` per account.

**Files:**
- Modify: `src/accountpilot/core/cli/status_cmd.py`
- Create: `tests/accountpilot/unit/cli/test_status_cmd_json.py`

The schema:
```json
{
  "ok": true,
  "data": {
    "accounts": [
      {
        "id": 1, "source": "gmail", "identifier": "ada@example.com",
        "last_sync_at": "2026-05-05T10:23:00+00:00",  // null if never synced
        "last_error": null,                           // string if last sync failed
        "synced_count": 4521                          // running total of messages synced for this account
      }
    ],
    "generated_at": "2026-05-05T10:25:00+00:00"
  },
  "error": null
}
```

The `sync_status` table (existing, see `src/accountpilot/core/db/migrations/001_init.sql:141`) already has rows keyed by `account_id` with `last_sync_at` and similar columns. This task does NOT add new schema; it reads what's there.

- [ ] **Step 1: Read the current `status_cmd.py` to see what it already does**

```bash
cat src/accountpilot/core/cli/status_cmd.py
```

The existing command emits a human-readable per-account table. The `--json` flag we're adding emits the envelope above instead.

- [ ] **Step 2: Write the failing test** — `tests/accountpilot/unit/cli/test_status_cmd_json.py`:

```python
"""Tests for `accountpilot status --json`."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.status_cmd import status_cmd
from accountpilot.core.db.connection import open_db

if TYPE_CHECKING:
    from pathlib import Path


@pytest.fixture
async def db_with_synced_account(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db) as conn:
        await conn.execute(
            "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
            "VALUES (1, 'Ada', 'Lovelace', 1, '2026-01-01T00:00:00+00:00', "
            "'2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO accounts (id, owner_id, source, account_identifier, "
            "enabled, created_at, updated_at) "
            "VALUES (1, 1, 'gmail', 'ada@example.com', 1, "
            "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO sync_status (account_id, last_sync_at, last_error, synced_count) "
            "VALUES (1, '2026-05-05T10:23:00+00:00', NULL, 4521)"
        )
        await conn.commit()
    return db


def test_status_json_envelope(db_with_synced_account: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        status_cmd, ["--json", "--db-path", str(db_with_synced_account)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    accounts = payload["data"]["accounts"]
    assert len(accounts) == 1
    a = accounts[0]
    assert a["id"] == 1
    assert a["source"] == "gmail"
    assert a["last_sync_at"] == "2026-05-05T10:23:00+00:00"
    assert a["last_error"] is None
    assert a["synced_count"] == 4521
    assert payload["data"]["generated_at"] is not None


def test_status_json_account_never_synced(tmp_path: Path) -> None:
    db = tmp_path / "fresh.db"
    import asyncio
    async def _setup() -> None:
        async with open_db(db) as conn:
            await conn.execute(
                "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
                "VALUES (1, 'Ada', NULL, 1, '2026-01-01T00:00:00+00:00', "
                "'2026-01-01T00:00:00+00:00')"
            )
            await conn.execute(
                "INSERT INTO accounts (id, owner_id, source, account_identifier, "
                "enabled, created_at, updated_at) "
                "VALUES (1, 1, 'imessage', '+15551234567', 1, "
                "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
            )
            await conn.commit()
    asyncio.run(_setup())

    runner = CliRunner()
    result = runner.invoke(status_cmd, ["--json", "--db-path", str(db)])
    payload = json.loads(result.output)
    a = payload["data"]["accounts"][0]
    assert a["last_sync_at"] is None
    assert a["last_error"] is None
    assert a["synced_count"] == 0
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_status_cmd_json.py -v`
Expected: FAIL — `--json` not yet supported.

- [ ] **Step 4: Add `--json` to `status_cmd`**

In `src/accountpilot/core/cli/status_cmd.py`, add the helper and a JSON branch:

```python
import json
from datetime import UTC, datetime
from typing import Any


def _emit_envelope(*, data: Any | None = None, error: dict[str, str] | None = None) -> None:
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))
```

In the existing `status_cmd` function body, add `--json` flag support. The query reads from accounts ⨝ sync_status (LEFT JOIN — accounts without a sync_status row report nulls/0):

```python
@click.command("status")
@click.option("--json", "json_out", is_flag=True)
@_db_option
def status_cmd(db_path: Path, json_out: bool) -> None:
    async def _run() -> None:
        async with (
            open_db(db_path) as db,
            db.execute(
                "SELECT a.id, a.source, a.account_identifier AS identifier, "
                "       s.last_sync_at, s.last_error, COALESCE(s.synced_count, 0) AS synced_count "
                "FROM accounts a "
                "LEFT JOIN sync_status s ON s.account_id = a.id "
                "ORDER BY a.id"
            ) as cur,
        ):
            rows = await cur.fetchall()

        if json_out:
            _emit_envelope(data={
                "accounts": [
                    {
                        "id": r["id"],
                        "source": r["source"],
                        "identifier": r["identifier"],
                        "last_sync_at": r["last_sync_at"],
                        "last_error": r["last_error"],
                        "synced_count": r["synced_count"],
                    }
                    for r in rows
                ],
                "generated_at": datetime.now(UTC).isoformat(),
            })
            return

        # …existing human-readable rendering, unchanged…

    asyncio.run(_run())
```

(Read `status_cmd.py` first; preserve the existing human-readable code path verbatim. Only ADD the `--json` branch and the helper.)

- [ ] **Step 5: Run tests + full CLI suite**

Run: `pytest tests/accountpilot/unit/cli/test_status_cmd_json.py -v` → 2 PASS.
Run: `pytest tests/accountpilot/unit/cli/ -q` → no regressions.
Run: `ruff check src tests` → no errors.
Run: `mypy src/accountpilot` → no errors.

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/status_cmd.py tests/accountpilot/unit/cli/test_status_cmd_json.py
git commit -m "feat(cli): status --json with per-account sync metadata"
```

---

## Task 2: `accountpilot mail sync-once <id> --json` and `imessage sync-once <id> --json`

**Goal:** Per-account, one-shot sync that the XPC service calls on a timer. Returns the count of new messages written and any error string.

The plugins already implement `async def sync_once(account_id) -> None`. This task wraps that call in two new CLI commands (one per plugin) that emit the standard envelope.

**Files:**
- Create: `src/accountpilot/core/cli/sync_once_cmd.py`
- Create: `tests/accountpilot/unit/cli/test_sync_once_cmd.py`
- Modify: `src/accountpilot/cli.py` (register)

The schema:
```json
{
  "ok": true,
  "data": {
    "account_id": 1, "source": "gmail",
    "synced_count_delta": 12,        // new messages this invocation wrote
    "duration_seconds": 4.2
  },
  "error": null
}
```

On error: `error.code` is `SYNC_FAILED`, `error.message` is the exception text. The CLI exits with code 0 either way (envelope carries the result).

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_sync_once_cmd.py`:

```python
"""Tests for `accountpilot mail sync-once <id>` and the iMessage equivalent.

The actual sync is mocked — these tests verify the CLI wiring + envelope."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

from click.testing import CliRunner

from accountpilot.core.cli.sync_once_cmd import sync_once_group

if TYPE_CHECKING:
    from pathlib import Path


def test_mail_sync_once_success(tmp_path: Path) -> None:
    """sync-once returns synced_count_delta on a clean run."""
    with patch(
        "accountpilot.core.cli.sync_once_cmd._run_mail_sync_once",
        new=AsyncMock(return_value=12),
    ):
        runner = CliRunner()
        result = runner.invoke(
            sync_once_group,
            ["mail", "1", "--json", "--db-path", str(tmp_path / "test.db")],
        )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["account_id"] == 1
    assert payload["data"]["source"] == "mail"
    assert payload["data"]["synced_count_delta"] == 12
    assert "duration_seconds" in payload["data"]
    assert payload["error"] is None


def test_mail_sync_once_failure_emits_error_envelope(tmp_path: Path) -> None:
    with patch(
        "accountpilot.core.cli.sync_once_cmd._run_mail_sync_once",
        new=AsyncMock(side_effect=RuntimeError("IMAP timeout")),
    ):
        runner = CliRunner()
        result = runner.invoke(
            sync_once_group,
            ["mail", "1", "--json", "--db-path", str(tmp_path / "test.db")],
        )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "SYNC_FAILED"
    assert "IMAP timeout" in payload["error"]["message"]


def test_imessage_sync_once_success(tmp_path: Path) -> None:
    with patch(
        "accountpilot.core.cli.sync_once_cmd._run_imessage_sync_once",
        new=AsyncMock(return_value=3),
    ):
        runner = CliRunner()
        result = runner.invoke(
            sync_once_group,
            ["imessage", "1", "--json", "--db-path", str(tmp_path / "test.db")],
        )
    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["data"]["source"] == "imessage"
    assert payload["data"]["synced_count_delta"] == 3
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_sync_once_cmd.py -v`
Expected: FAIL — module does not exist.

- [ ] **Step 3: Implement `sync_once_cmd.py`**

`src/accountpilot/core/cli/sync_once_cmd.py`:

```python
# AccountPilot — unified per-machine account sync framework
# Copyright (C) 2026 Hasan Arda Eren <ardaeren13@gmail.com>
# Licensed under AGPL-3.0-or-later.

"""accountpilot {mail,imessage} sync-once — one-shot per-account sync.

The XPC service from Phase 3 calls these on a timer instead of running
the long-lived `daemon` subcommand. One-shot keeps the XPC service
simple (spawn → wait → emit JSON → reap) and matches the standard
envelope used elsewhere.
"""

from __future__ import annotations

import asyncio
import json
import time
from pathlib import Path
from typing import Any

import click

from accountpilot.core import paths


def _emit_envelope(*, data: Any | None = None, error: dict[str, str] | None = None) -> None:
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))


def _db_option(f):
    return click.option(
        "--db-path",
        type=click.Path(path_type=Path),
        default=lambda: paths.data_dir() / "accountpilot.db",
        show_default="$ACCOUNTPILOT_DATA_DIR/accountpilot.db",
    )(f)


async def _run_mail_sync_once(account_id: int, db_path: Path) -> int:
    """Execute the mail plugin's sync_once for one account, return count."""
    from accountpilot.plugins.mail.plugin import MailPlugin
    plugin = MailPlugin()
    # Each plugin tracks its own delta internally; sync_once returns int per
    # the AccountPilotPlugin contract (Phase 3 prerequisite — verify the
    # contract before this task; see Notes below).
    return await plugin.sync_once(account_id=account_id, db_path=db_path)


async def _run_imessage_sync_once(account_id: int, db_path: Path) -> int:
    from accountpilot.plugins.imessage.plugin import IMessagePlugin
    plugin = IMessagePlugin()
    return await plugin.sync_once(account_id=account_id, db_path=db_path)


@click.group("sync-once")
def sync_once_group() -> None:
    """One-shot sync wrappers (used by the .app's XPC service)."""


@sync_once_group.command("mail")
@click.argument("account_id", type=int)
@click.option("--json", "json_out", is_flag=True)
@_db_option
def mail_sync_once(account_id: int, json_out: bool, db_path: Path) -> None:
    """Run a single sync_once cycle for a mail account."""
    started = time.monotonic()
    try:
        delta = asyncio.run(_run_mail_sync_once(account_id, db_path))
    except Exception as exc:
        if json_out:
            _emit_envelope(error={"code": "SYNC_FAILED", "message": str(exc)})
            return
        raise
    duration = round(time.monotonic() - started, 2)
    if json_out:
        _emit_envelope(data={
            "account_id": account_id,
            "source": "mail",
            "synced_count_delta": delta,
            "duration_seconds": duration,
        })
        return
    click.echo(f"mail sync-once: account={account_id} delta={delta} {duration}s")


@sync_once_group.command("imessage")
@click.argument("account_id", type=int)
@click.option("--json", "json_out", is_flag=True)
@_db_option
def imessage_sync_once(account_id: int, json_out: bool, db_path: Path) -> None:
    started = time.monotonic()
    try:
        delta = asyncio.run(_run_imessage_sync_once(account_id, db_path))
    except Exception as exc:
        if json_out:
            _emit_envelope(error={"code": "SYNC_FAILED", "message": str(exc)})
            return
        raise
    duration = round(time.monotonic() - started, 2)
    if json_out:
        _emit_envelope(data={
            "account_id": account_id,
            "source": "imessage",
            "synced_count_delta": delta,
            "duration_seconds": duration,
        })
        return
    click.echo(f"imessage sync-once: account={account_id} delta={delta} {duration}s")
```

**Notes for the implementer:**
- The `MailPlugin.sync_once` and `IMessagePlugin.sync_once` signatures may not currently return `int`. Phase 1/2 had them return `None`. Look at `src/accountpilot/plugins/mail/plugin.py` (around line 131) and `src/accountpilot/plugins/imessage/plugin.py` (around line 108). If they return `None` today, make them return the count of newly written messages (track via a counter, increment in the existing insert path, return at end). This is a small, contained change — keep the existing semantics, just add a return value. Update both.
- Don't widen the signature with new required kwargs — `db_path` is already implicit via the plugin's `Storage` initialization; if the existing signature doesn't take `db_path`, drop it from the call site here and let the plugin resolve via `paths.data_dir()` like it does today.
- Register the group in `src/accountpilot/cli.py`: add `from accountpilot.core.cli.sync_once_cmd import sync_once_group` near the other imports, and `cli.add_command(sync_once_group)` in the registration block.

- [ ] **Step 4: Verify the plugin sync_once contract**

```bash
grep -A 20 "async def sync_once" src/accountpilot/plugins/mail/plugin.py
grep -A 20 "async def sync_once" src/accountpilot/plugins/imessage/plugin.py
```

If they return None today: modify each to return the count of new messages (track with a local counter; if the existing implementation calls Storage.write_message in a loop, increment per success). Don't touch other behavior.

- [ ] **Step 5: Run tests**

Run: `pytest tests/accountpilot/unit/cli/test_sync_once_cmd.py -v` → 3 PASS.
Run: `pytest tests/accountpilot/unit/cli/ tests/accountpilot/unit/plugins/ -q` → no regressions.
Run: `ruff check src tests && mypy src/accountpilot` → clean.

- [ ] **Step 6: Sanity check from terminal**

Run: `python3 -m accountpilot.cli sync-once --help` — should list `mail` and `imessage` subcommands.

- [ ] **Step 7: Commit**

```bash
git add src/accountpilot/core/cli/sync_once_cmd.py tests/accountpilot/unit/cli/test_sync_once_cmd.py src/accountpilot/cli.py src/accountpilot/plugins/
git commit -m "feat(cli): mail/imessage sync-once --json for XPC supervisor"
```

---

## Task 3: Swift `SyncStatus.swift` — Codable mirror of status JSON

**Files:**
- Create: `app/AccountPilot/Models/SyncStatus.swift`

- [ ] **Step 1: Write the model**

`app/AccountPilot/Models/SyncStatus.swift`:

```swift
import Foundation

/// One row of `accountpilot status --json`'s `data.accounts[]`.
struct SyncStatus: Codable, Identifiable, Hashable {
    let id: Int                    // == account id
    let source: String
    let identifier: String
    let lastSyncAt: Date?          // null if never synced
    let lastError: String?
    let syncedCount: Int

    enum CodingKeys: String, CodingKey {
        case id, source, identifier
        case lastSyncAt = "last_sync_at"
        case lastError = "last_error"
        case syncedCount = "synced_count"
    }
}

struct SyncStatusListData: Decodable {
    let accounts: [SyncStatus]
    let generatedAt: Date

    enum CodingKeys: String, CodingKey {
        case accounts
        case generatedAt = "generated_at"
    }
}
```

- [ ] **Step 2: ISO8601 decoding**

`Date` doesn't auto-decode ISO8601 strings — set the decoder strategy when decoding. Document the convention in `AccountStore`/wherever decoders are constructed for Phase 3 onwards. Add a tiny extension helper:

```swift
extension JSONDecoder {
    /// AccountPilot CLI emits ISO8601 with offset (e.g. "2026-05-05T10:23:00+00:00").
    /// Use this factory whenever decoding CLI output that includes timestamps.
    static var accountPilotCLI: JSONDecoder {
        let d = JSONDecoder()
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        d.dateDecodingStrategy = .custom { decoder in
            let str = try decoder.singleValueContainer().decode(String.self)
            if let date = formatter.date(from: str) { return date }
            throw DecodingError.dataCorruptedError(
                in: try decoder.singleValueContainer(),
                debugDescription: "expected ISO8601, got \(str)"
            )
        }
        return d
    }
}
```

Put this extension at the bottom of `SyncStatus.swift` (it's tightly coupled to the date-bearing CLI commands; Phase 4's MessagesView decoder will reuse it).

- [ ] **Step 3: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Models/SyncStatus.swift
git commit -m "feat(app): SyncStatus Codable model + ISO8601 JSONDecoder helper"
```

---

## Task 4: XcodeGen — add the XPC service target

**Goal:** Spec-out the XPC service bundle. After this task, `xcodebuild build` produces both `AccountPilot.app` and `AccountPilotSyncService.xpc`. The XPC bundle contains nothing real yet — just a placeholder Swift file that compiles. Tasks 5-7 fill it in.

**Files:**
- Modify: `app/project.yml`
- Create: `app/AccountPilotXPC/SyncServiceMain.swift` (placeholder)
- Create: `app/AccountPilotXPC/SyncServiceXPC.entitlements` (path; xcodegen generates the file)
- Create: `app/AccountPilot/Shared/.gitkeep` (so the empty Shared/ dir survives until Task 5)

- [ ] **Step 1: Read current `app/project.yml`** (Phase 1's spec)

```bash
cat app/project.yml
```

- [ ] **Step 2: Replace `app/project.yml` with the multi-target spec**

The new target list adds `AccountPilotXPC` (the XPC service bundle). Both targets share `app/AccountPilot/Shared/` sources. The XPC service has its own bundle id, entitlements, and `CFBundlePackageType=XPC!`.

```yaml
name: AccountPilot
options:
  bundleIdPrefix: com.accountpilot
  deploymentTarget:
    macOS: "13.0"
  developmentLanguage: en

settings:
  base:
    SWIFT_VERSION: "5.9"
    DEVELOPMENT_TEAM: P2R7PD8VGY
    CODE_SIGN_STYLE: Manual
    CODE_SIGN_IDENTITY: "Developer ID Application: FAZLA GIDA ANONIM SIRKETI (P2R7PD8VGY)"
    OTHER_CODE_SIGN_FLAGS: "--timestamp"
    ENABLE_HARDENED_RUNTIME: YES
    MARKETING_VERSION: "0.2.0"
    CURRENT_PROJECT_VERSION: "1"

targets:
  AccountPilot:
    type: application
    platform: macOS
    sources:
      - path: AccountPilot
        excludes:
          - "Shared/**/*"   # listed separately so it's also linked into the XPC target
      - path: AccountPilot/Shared
    resources:
      - path: AccountPilot/Assets.xcassets
    info:
      path: AccountPilot/Info.plist
      properties:
        CFBundleIdentifier: com.accountpilot.app
        CFBundleName: AccountPilot
        CFBundleDisplayName: AccountPilot
        CFBundleShortVersionString: "0.2.0"
        CFBundleVersion: "1"
        LSMinimumSystemVersion: "13.0"
        LSUIElement: true       # status-bar app — Phase 3 flips this
        NSHumanReadableCopyright: "Copyright (C) 2026 Hasan Arda Eren. AGPL-3.0-or-later."
    entitlements:
      path: AccountPilot/AccountPilot.entitlements
      properties:
        com.apple.security.app-sandbox: false
        com.apple.security.cs.allow-jit: false
        com.apple.security.cs.allow-unsigned-executable-memory: false
        com.apple.security.cs.disable-library-validation: true
    dependencies:
      - target: AccountPilotXPC
        copy:
          destination: plugins   # macOS: "Contents/XPCServices/"
          embed: true

  AccountPilotXPC:
    type: xpc-service
    platform: macOS
    sources:
      - path: AccountPilotXPC
      - path: AccountPilot/Shared    # shared protocol + helper types
    info:
      path: AccountPilotXPC/Info.plist
      properties:
        CFBundleIdentifier: com.accountpilot.SyncService
        CFBundleName: AccountPilotSyncService
        CFBundlePackageType: XPC!
        XPCService:
          ServiceType: Application   # one instance per parent app
          RunLoopType: dispatch_main
    entitlements:
      path: AccountPilotXPC/SyncServiceXPC.entitlements
      properties:
        com.apple.security.app-sandbox: false   # needs to spawn Python subprocess
        com.apple.security.cs.disable-library-validation: true

  AccountPilotTests:
    type: bundle.unit-test
    platform: macOS
    sources:
      - path: AccountPilotTests
    settings:
      base:
        GENERATE_INFOPLIST_FILE: YES
        CODE_SIGNING_ALLOWED: NO
    dependencies:
      - target: AccountPilot
```

(XcodeGen's `type: xpc-service` produces a target with the right bundle structure. The `dependencies.copy.destination: plugins` line is XcodeGen's idiom that maps to Xcode's "Embed XPC Services" copy phase; the file lands at `Contents/XPCServices/`.)

- [ ] **Step 3: Create the placeholder XPC source + entitlements**

```bash
mkdir -p app/AccountPilotXPC app/AccountPilot/Shared
touch app/AccountPilot/Shared/.gitkeep
```

`app/AccountPilotXPC/SyncServiceMain.swift` (placeholder; Task 7 replaces):

```swift
import Foundation

// Placeholder; Task 7 replaces with the real NSXPCListener entry point.
@main
struct SyncServiceMain {
    static func main() {
        let listener = NSXPCListener.service()
        listener.resume()
    }
}
```

(XcodeGen will generate `SyncServiceXPC.entitlements` from the `entitlements.properties` block above.)

- [ ] **Step 4: Build to verify both targets compile**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -10 && cd ..
```

Expected: `** BUILD SUCCEEDED **`. The Xcode project should now have two targets; the AccountPilot.app product should contain `Contents/XPCServices/AccountPilotSyncService.xpc/`.

Verify: `find app/build/Build/Products/Release -name "*.xpc" -type d`. Expected: prints the .xpc bundle path.

- [ ] **Step 5: Commit**

```bash
git add app/project.yml app/AccountPilotXPC/ app/AccountPilot/Shared/
git commit -m "build(app): add AccountPilotXPC target + Shared sources path"
```

---

## Task 5: `Shared/SyncServiceProtocol.swift` — NSXPCInterface contract

**Goal:** The protocol the XPC service exposes and the supervisor calls. Lives under `app/AccountPilot/Shared/` so both targets see it.

**Files:**
- Create: `app/AccountPilot/Shared/SyncServiceProtocol.swift`
- Create: `app/AccountPilot/Shared/SyncServiceProtocolTests.swift` (in tests target — see Task 11 for full test structure)

- [ ] **Step 1: Write the protocol**

`app/AccountPilot/Shared/SyncServiceProtocol.swift`:

```swift
import Foundation

/// XPC contract between the parent app (SyncSupervisor) and the
/// embedded SyncService.
///
/// **All methods are async** — XPC reply blocks are bridged to Swift
/// concurrency by the framework. Errors flow through the Result type
/// because raw `throws` over XPC requires NSError, not arbitrary Swift
/// errors.
@objc public protocol SyncServiceProtocol {
    /// Start sync supervision. The service spawns one timer per
    /// enabled account and starts calling `accountpilot ... sync-once`
    /// at the configured cadence. Idempotent: calling twice is a no-op.
    func startSync(reply: @escaping (Bool, NSError?) -> Void)

    /// Stop all timers and let in-flight subprocesses drain. Safe to
    /// call when stopped.
    func stopSync(reply: @escaping (Bool, NSError?) -> Void)

    /// Trigger a one-shot sync NOW for a specific account, regardless of
    /// the timer cadence. Returns the synced_count_delta on success.
    func syncNow(accountID: Int, reply: @escaping (Int, NSError?) -> Void)

    /// Snapshot of the service's view of the world. Used by the
    /// supervisor to populate the status-bar menu.
    func currentStatus(reply: @escaping (Data, NSError?) -> Void)
    // ^^ `Data` is the JSON-encoded SyncStatusListData — XPC over Swift
    //    types is finicky; sticking to Data + JSON keeps the proxy
    //    interface simple and matches what the CLI emits anyway.
}

/// Helper to construct the NSXPCInterface from this protocol.
public enum SyncServiceXPC {
    public static let serviceName = "com.accountpilot.SyncService"

    public static func interface() -> NSXPCInterface {
        NSXPCInterface(with: SyncServiceProtocol.self)
    }
}
```

- [ ] **Step 2: Build**

Both targets must compile cleanly with this shared file.

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Shared/SyncServiceProtocol.swift
git commit -m "feat(app): SyncServiceProtocol — XPC interface for sync supervisor"
```

---

## Task 6: `Services/SyncSupervisor.swift` — NSXPCConnection client

**Goal:** ObservableObject in the parent app that connects to the XPC service, starts/stops sync, exposes `@Published var status: [SyncStatus]` for SwiftUI.

**Files:**
- Create: `app/AccountPilot/Services/SyncSupervisor.swift`

- [ ] **Step 1: Write the supervisor**

```swift
import Foundation
import SwiftUI

/// Owns the NSXPCConnection to AccountPilotSyncService. Provides a
/// SwiftUI-friendly facade over the @objc protocol.
@MainActor
final class SyncSupervisor: ObservableObject {
    @Published private(set) var status: [SyncStatus] = []
    @Published private(set) var isRunning: Bool = false
    @Published private(set) var lastError: String?

    private var connection: NSXPCConnection?
    private var pollTask: Task<Void, Never>?

    func start() async {
        connect()
        await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
            proxy()?.startSync { [weak self] ok, err in
                Task { @MainActor in
                    self?.isRunning = ok
                    if let err { self?.lastError = err.localizedDescription }
                }
                cont.resume()
            }
        }
        beginPolling()
    }

    func stop() async {
        await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
            proxy()?.stopSync { [weak self] _, err in
                Task { @MainActor in
                    self?.isRunning = false
                    if let err { self?.lastError = err.localizedDescription }
                }
                cont.resume()
            }
        }
        pollTask?.cancel()
        pollTask = nil
    }

    func syncNow(accountID: Int) async -> Int {
        await withCheckedContinuation { (cont: CheckedContinuation<Int, Never>) in
            proxy()?.syncNow(accountID: accountID) { delta, err in
                Task { @MainActor in
                    if let err { self?.lastError = err.localizedDescription }
                }
                cont.resume(returning: delta)
            }
        }
    }

    func refreshStatus() async {
        await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
            proxy()?.currentStatus { [weak self] data, err in
                Task { @MainActor in
                    guard err == nil else {
                        self?.lastError = err?.localizedDescription
                        cont.resume()
                        return
                    }
                    if let decoded = try? JSONDecoder.accountPilotCLI.decode(
                        SyncStatusListData.self, from: data
                    ) {
                        self?.status = decoded.accounts
                        self?.lastError = nil
                    }
                    cont.resume()
                }
            }
        }
    }

    private func connect() {
        guard connection == nil else { return }
        let conn = NSXPCConnection(serviceName: SyncServiceXPC.serviceName)
        conn.remoteObjectInterface = SyncServiceXPC.interface()
        conn.invalidationHandler = { [weak self] in
            Task { @MainActor in
                self?.isRunning = false
                self?.connection = nil
            }
        }
        conn.interruptionHandler = { [weak self] in
            // Service crashed; XPC will respawn on next message. Mark not-running.
            Task { @MainActor in self?.isRunning = false }
        }
        conn.resume()
        connection = conn
    }

    private func proxy() -> SyncServiceProtocol? {
        connection?.remoteObjectProxyWithErrorHandler { [weak self] err in
            Task { @MainActor in self?.lastError = err.localizedDescription }
        } as? SyncServiceProtocol
    }

    private func beginPolling() {
        pollTask?.cancel()
        pollTask = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                await self?.refreshStatus()
                try? await Task.sleep(for: .seconds(5))
            }
        }
    }
}
```

- [ ] **Step 2: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..
```

Expected: `** BUILD SUCCEEDED **`. The .app links the protocol; the XPC bundle is bundled but its real impl lands in Task 7 — for now the supervisor will fail to actually drive the service.

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Services/SyncSupervisor.swift
git commit -m "feat(app): SyncSupervisor — NSXPCConnection client + status polling"
```

---

## Task 7: `AccountPilotXPC/SyncServiceImpl.swift` — XPC service body

**Goal:** Real implementation of `SyncServiceProtocol`. On `startSync`, spawns one `Task` per enabled account that loops `accountpilot {plugin} sync-once <id> --json` at the configured cadence. `currentStatus` shells out to `accountpilot status --json` and returns the bytes verbatim.

**Files:**
- Replace: `app/AccountPilotXPC/SyncServiceMain.swift` (full impl)
- Create: `app/AccountPilotXPC/SyncServiceImpl.swift`
- Create: `app/AccountPilotXPC/PythonHostBundle.swift` — locates the parent .app's `Contents/Resources/python/runtime/bin/python3` from inside the XPC service

- [ ] **Step 1: Write `PythonHostBundle.swift`**

```swift
import Foundation

/// The XPC service's `Bundle.main` is the .xpc bundle, not the host
/// .app. Walk up to find the parent app's Resources/ to locate the
/// embedded Python interpreter and CLI shim.
enum PythonHostBundle {
    /// Path to the bundled python3. Throws if the .xpc isn't actually
    /// embedded inside an .app (e.g. running standalone in tests).
    static func interpreterURL() throws -> URL {
        let xpcURL = Bundle.main.bundleURL
        // .../AccountPilot.app/Contents/XPCServices/AccountPilotSyncService.xpc
        // walk up: .xpc → XPCServices → Contents → AccountPilot.app
        let appURL = xpcURL
            .deletingLastPathComponent()  // XPCServices
            .deletingLastPathComponent()  // Contents
            .deletingLastPathComponent()  // AccountPilot.app
        let interp = appURL
            .appendingPathComponent("Contents/Resources/python/runtime/bin/python3")
        guard FileManager.default.isExecutableFile(atPath: interp.path) else {
            throw NSError(
                domain: "PythonHostBundle", code: 1,
                userInfo: [NSLocalizedDescriptionKey:
                    "embedded Python not found at \(interp.path) — XPC service " +
                    "must be embedded in AccountPilot.app's Contents/XPCServices/"]
            )
        }
        return interp
    }

    static func sitePackagesURL() throws -> URL {
        let interp = try interpreterURL()
        // Walk: bin → runtime → python → Resources
        return interp
            .deletingLastPathComponent()  // bin
            .deletingLastPathComponent()  // runtime
            .deletingLastPathComponent()  // python
            .appendingPathComponent("site-packages")
    }
}
```

- [ ] **Step 2: Write `SyncServiceImpl.swift`**

```swift
import Foundation
import os

/// Concrete implementation of SyncServiceProtocol. One singleton per
/// XPC-listener instance. Holds the state of the per-account timer
/// tasks and runs `accountpilot ... sync-once` subprocesses.
final class SyncServiceImpl: NSObject, SyncServiceProtocol {

    private let log = Logger(subsystem: "com.accountpilot.SyncService", category: "supervisor")
    private var syncTasks: [Int: Task<Void, Never>] = [:]
    private let lock = NSLock()
    private var running = false

    func startSync(reply: @escaping (Bool, NSError?) -> Void) {
        lock.lock(); defer { lock.unlock() }
        if running { reply(true, nil); return }
        running = true
        Task { [weak self] in
            await self?.spawnAllAccountTimers()
        }
        log.info("startSync: running=true")
        reply(true, nil)
    }

    func stopSync(reply: @escaping (Bool, NSError?) -> Void) {
        lock.lock(); defer { lock.unlock() }
        running = false
        for (_, t) in syncTasks { t.cancel() }
        syncTasks.removeAll()
        log.info("stopSync: cancelled all timer tasks")
        reply(true, nil)
    }

    func syncNow(accountID: Int, reply: @escaping (Int, NSError?) -> Void) {
        Task { [weak self] in
            guard let self else { reply(0, nil); return }
            do {
                let delta = try await self.runSyncOnce(accountID: accountID)
                reply(delta, nil)
            } catch {
                reply(0, error as NSError)
            }
        }
    }

    func currentStatus(reply: @escaping (Data, NSError?) -> Void) {
        Task { [weak self] in
            guard let self else { reply(Data(), nil); return }
            do {
                let stdout = try await self.runCLI(args: ["status", "--json"])
                reply(stdout, nil)
            } catch {
                reply(Data(), error as NSError)
            }
        }
    }

    // MARK: - Internal

    /// Look up enabled accounts via `accounts list --json`, spawn one
    /// timer task per account.
    private func spawnAllAccountTimers() async {
        do {
            let stdout = try await runCLI(args: ["accounts", "list", "--json"])
            // Decoding via JSON-as-Data — keep it simple.
            struct Env: Decodable {
                struct Data: Decodable { let accounts: [Acct] }
                struct Acct: Decodable { let id: Int; let source: String; let enabled: Bool }
                let ok: Bool; let data: Data?
            }
            guard let env = try? JSONDecoder().decode(Env.self, from: stdout),
                  let accts = env.data?.accounts else { return }
            for acct in accts where acct.enabled {
                spawnTimer(accountID: acct.id, source: acct.source)
            }
        } catch {
            log.error("spawnAllAccountTimers failed: \(String(describing: error))")
        }
    }

    private func spawnTimer(accountID: Int, source: String) {
        lock.lock(); defer { lock.unlock() }
        guard syncTasks[accountID] == nil else { return }
        let interval: Duration = source == "imessage" ? .seconds(10) : .seconds(60)
        let task = Task { [weak self] in
            while !Task.isCancelled {
                _ = try? await self?.runSyncOnce(accountID: accountID)
                try? await Task.sleep(for: interval)
            }
        }
        syncTasks[accountID] = task
        log.info("started timer for account=\(accountID) source=\(source)")
    }

    /// Spawns `accountpilot {mail|imessage} sync-once <id> --json`.
    private func runSyncOnce(accountID: Int) async throws -> Int {
        // Pick the plugin by looking up the source via accounts list.
        // (Simpler alternative would be to pass source from the timer
        // context, but that's a bigger refactor and we're already
        // minimizing per-tick work.)
        let listOut = try await runCLI(args: ["accounts", "list", "--json"])
        struct Env: Decodable {
            struct Data: Decodable { let accounts: [Acct] }
            struct Acct: Decodable { let id: Int; let source: String }
            let ok: Bool; let data: Data?
        }
        guard let env = try? JSONDecoder().decode(Env.self, from: listOut),
              let acct = env.data?.accounts.first(where: { $0.id == accountID }) else {
            throw NSError(
                domain: "SyncService", code: 404,
                userInfo: [NSLocalizedDescriptionKey: "no account id=\(accountID)"]
            )
        }
        let plugin = acct.source == "imessage" ? "imessage" : "mail"
        let stdout = try await runCLI(args: ["sync-once", plugin, "\(accountID)", "--json"])

        struct SyncEnv: Decodable {
            struct Data: Decodable { let synced_count_delta: Int }
            struct Err: Decodable { let code: String; let message: String }
            let ok: Bool; let data: Data?; let error: Err?
        }
        let parsed = try JSONDecoder().decode(SyncEnv.self, from: stdout)
        if !parsed.ok {
            throw NSError(
                domain: "SyncService", code: 1,
                userInfo: [NSLocalizedDescriptionKey: parsed.error?.message ?? "sync failed"]
            )
        }
        return parsed.data?.synced_count_delta ?? 0
    }

    /// Spawn `accountpilot <args>` against the bundled Python and
    /// return its stdout bytes.
    private func runCLI(args: [String]) async throws -> Data {
        let interp = try PythonHostBundle.interpreterURL()
        let sitePkgs = try PythonHostBundle.sitePackagesURL()

        let proc = Process()
        proc.executableURL = interp
        proc.arguments = ["-m", "accountpilot.cli"] + args
        var env = ProcessInfo.processInfo.environment
        env["PYTHONPATH"] = sitePkgs.path
        proc.environment = env

        let stdoutPipe = Pipe()
        let stderrPipe = Pipe()
        proc.standardOutput = stdoutPipe
        proc.standardError = stderrPipe

        try proc.run()
        return try await withCheckedThrowingContinuation { cont in
            DispatchQueue.global(qos: .utility).async {
                proc.waitUntilExit()
                let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
                if proc.terminationStatus != 0 {
                    let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
                    cont.resume(throwing: NSError(
                        domain: "SyncService", code: Int(proc.terminationStatus),
                        userInfo: [NSLocalizedDescriptionKey:
                            String(data: err, encoding: .utf8) ?? "unknown"]
                    ))
                    return
                }
                cont.resume(returning: data)
            }
        }
    }
}
```

- [ ] **Step 3: Replace `SyncServiceMain.swift` with the real listener entry**

```swift
import Foundation

/// XPC service entry point. macOS spawns this binary; we set up a
/// listener that vends one SyncServiceImpl per connection.
class ServiceDelegate: NSObject, NSXPCListenerDelegate {
    func listener(
        _ listener: NSXPCListener,
        shouldAcceptNewConnection newConnection: NSXPCConnection
    ) -> Bool {
        newConnection.exportedInterface = SyncServiceXPC.interface()
        newConnection.exportedObject = SyncServiceImpl()
        newConnection.resume()
        return true
    }
}

@main
struct SyncServiceMain {
    static func main() {
        let delegate = ServiceDelegate()
        let listener = NSXPCListener.service()
        listener.delegate = delegate
        listener.resume()
        // service() blocks the main thread forever; this is correct.
    }
}
```

- [ ] **Step 4: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -10 && cd ..`
Expected: `** BUILD SUCCEEDED **` for both AccountPilot.app and AccountPilotSyncService.xpc.

- [ ] **Step 5: Commit**

```bash
git add app/AccountPilotXPC/
git commit -m "feat(app): SyncService XPC implementation — per-account timers via Python CLI"
```

---

## Task 8: build-app.sh + verify-app.sh — embed + sign + verify the XPC bundle

**Goal:** Phase 1's `build-app.sh` orchestrator runs xcodebuild → bundle Python → bundle helper → codesign → notarize. With the XPC service added, xcodebuild already places the .xpc inside Contents/XPCServices/ (because of the `dependencies.copy` declaration in project.yml). We only need to (a) verify it landed and (b) make sure codesign-app.sh signs the Mach-Os inside it.

**Files:**
- Modify: `app/scripts/codesign-app.sh`
- Modify: `app/scripts/verify-app.sh`
- (No build-app.sh change needed — xcodebuild already embeds the .xpc.)

- [ ] **Step 1: Inspect what xcodebuild produces**

```bash
rm -rf dist/AccountPilot.app && cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ CODE_SIGNING_ALLOWED=NO -skipMacroValidation build > /dev/null && cd ..
find app/build/Build/Products/Release/AccountPilot.app/Contents/XPCServices -maxdepth 2 -type d
```

Expected: prints `…/Contents/XPCServices/AccountPilotSyncService.xpc`.

- [ ] **Step 2: Extend codesign-app.sh — sign the XPC service**

Read `app/scripts/codesign-app.sh`. The current script signs the embedded Python Mach-Os under `Contents/Resources/python/`, then the FDA helper under `Contents/Helpers/`, then the outer bundle. Add a step BEFORE the FDA helper signing that signs the XPC service Mach-O and re-signs its own bundle (with its own entitlements + identifier).

Insert this block between the existing "1. Sign every Mach-O…" and "2. Sign the helper…" sections:

```bash
# 1.5: Sign the XPC service bundle (Mach-O inside, then outer .xpc)
XPC_BUNDLE="$APP_BUNDLE/Contents/XPCServices/AccountPilotSyncService.xpc"
if [ -d "$XPC_BUNDLE" ]; then
    echo "==> signing XPC service Mach-O"
    XPC_BIN="$XPC_BUNDLE/Contents/MacOS/AccountPilotSyncService"
    if [ -f "$XPC_BIN" ]; then
        codesign --force --sign "$APPLE_DEV_ID" --options runtime --timestamp "$XPC_BIN"
    fi
    echo "==> signing XPC service bundle"
    XPC_ENT="$(pwd)/app/AccountPilotXPC/SyncServiceXPC.entitlements"
    codesign --force --sign "$APPLE_DEV_ID" \
        --options runtime \
        --entitlements "$XPC_ENT" \
        --identifier "com.accountpilot.SyncService" \
        --timestamp \
        "$XPC_BUNDLE"
fi
```

- [ ] **Step 3: Extend verify-app.sh — assert the XPC bundle is present + signed**

In the existing path-existence loop, add:

```bash
"Contents/XPCServices/AccountPilotSyncService.xpc/Contents/MacOS/AccountPilotSyncService" \
```

Add a new check after the helper-identity check:

```bash
# 3b. XPC service signing identity
xpc_codesign="$(codesign -dv "$APP_BUNDLE/Contents/XPCServices/AccountPilotSyncService.xpc" 2>&1)"
echo "$xpc_codesign" | grep -q "TeamIdentifier=P2R7PD8VGY" \
    || fail "XPC service missing P2R7PD8VGY team"
echo "$xpc_codesign" | grep -q "Identifier=com.accountpilot.SyncService" \
    || fail "XPC service bundle id wrong"
pass "XPC service signing identity correct"
```

- [ ] **Step 4: Run end-to-end build**

```bash
rm -rf dist/AccountPilot.app && ./app/scripts/build-app.sh 2>&1 | tail -25
```

Expected final lines: `==> all verification passed`. If the codesign step fails on the XPC bundle, read the codesign error, the most likely cause is a missing entitlements file path or an ordering issue (sign Mach-O inside before the bundle wrapper).

- [ ] **Step 5: Commit**

```bash
git add app/scripts/codesign-app.sh app/scripts/verify-app.sh
git commit -m "build(app): codesign + verify the embedded XPC service"
```

---

## Task 9: `Views/Onboarding/FDAWizardView.swift` — guided FDA grant

**Goal:** A SwiftUI view that detects the helper's EACCES state, deep-links into System Settings → Privacy & Security → Full Disk Access, and polls the helper every 1s. When a probe call succeeds, advance.

**Files:**
- Create: `app/AccountPilot/Views/Onboarding/FDAWizardView.swift`
- Create: `app/AccountPilot/Services/FDAProbe.swift`

The probe shells out to the Python `accountpilot imessage probe-fda --json` (a new command) which runs the helper with `--since-ns 1<<62` (returns zero rows but proves chat.db is openable). The Swift wizard polls this via `PythonRuntime.shared.run`.

- [ ] **Step 1: Add `accountpilot imessage probe-fda --json` (Python side)**

Look at `src/accountpilot/plugins/imessage/cli.py`. Add (or extend an existing) command:

```python
@imessage_group.command("probe-fda")
@click.option("--json", "json_out", is_flag=True)
def probe_fda(json_out: bool) -> None:
    """Probe whether the FDA helper can read chat.db. Returns
    {ok: true, data: {granted: bool, message: str}} regardless of the
    grant state — caller distinguishes via data.granted."""
    from accountpilot.plugins.imessage import helper_client
    try:
        bin_path = helper_client.find_helper_binary()
    except helper_client.HelperNotInstalledError as exc:
        if json_out:
            click.echo(json.dumps({
                "ok": True,
                "data": {"granted": False, "reason": "HELPER_MISSING", "message": str(exc)},
                "error": None,
            }))
            return
        click.echo(f"helper not installed: {exc}")
        return
    try:
        # Drain a no-op query to prove FDA + chat.db readability.
        for _ in helper_client.iter_records(since_ns=1 << 62, helper_path=bin_path):
            break
        granted = True
        reason = "OK"
        message = "helper can read chat.db"
    except helper_client.HelperEACCES:
        granted = False
        reason = "FDA_DENIED"
        message = "Full Disk Access not granted to the helper"
    except Exception as exc:
        granted = False
        reason = "PROBE_FAILED"
        message = str(exc)

    if json_out:
        click.echo(json.dumps({
            "ok": True,
            "data": {"granted": granted, "reason": reason, "message": message},
            "error": None,
        }))
    else:
        click.echo(f"FDA probe: granted={granted} reason={reason} {message}")
```

(The `iter_records` signature in `helper_client.py` may not take `helper_path`; adjust to whatever the existing API actually uses. Read `src/accountpilot/plugins/imessage/helper_client.py` to confirm.)

- [ ] **Step 2: Tests for probe-fda**

Add to `tests/accountpilot/unit/plugins/test_imessage_cli.py` (or the closest existing test file): mock `helper_client.find_helper_binary` and `iter_records`; assert the JSON shape for both granted and denied paths.

- [ ] **Step 3: Implement Swift `FDAProbe.swift`**

```swift
import Foundation

struct FDAProbeResult: Decodable {
    let granted: Bool
    let reason: String
    let message: String
}

enum FDAProbe {
    static func probe() async -> FDAProbeResult {
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "imessage", "probe-fda", "--json"]
            )
            struct Env: Decodable { let ok: Bool; let data: FDAProbeResult? }
            let env = try JSONDecoder().decode(Env.self, from: Data(stdout.utf8))
            return env.data ?? .init(granted: false, reason: "DECODE_FAIL", message: "no data")
        } catch {
            return .init(granted: false, reason: "PROBE_THREW", message: "\(error)")
        }
    }
}
```

- [ ] **Step 4: Implement `FDAWizardView.swift`**

```swift
import SwiftUI

struct FDAWizardView: View {
    @State private var probeResult: FDAProbeResult?
    @State private var pollTask: Task<Void, Never>?
    var onGranted: () -> Void

    var body: some View {
        VStack(spacing: 18) {
            Image(systemName: "lock.shield")
                .font(.system(size: 56))
                .foregroundStyle(.tint)
            Text("Grant Full Disk Access")
                .font(.title.bold())
            Text("AccountPilot reads your iMessage history through a "
                 + "small helper binary. macOS requires Full Disk Access "
                 + "for the helper.")
                .multilineTextAlignment(.center)
                .frame(maxWidth: 480)

            if let r = probeResult {
                if r.granted {
                    Label("Granted — finishing setup…", systemImage: "checkmark.seal.fill")
                        .foregroundStyle(.green)
                } else {
                    VStack(spacing: 8) {
                        Text("Status: \(r.reason)").font(.caption.monospaced())
                        Text(r.message).font(.caption).foregroundStyle(.secondary)
                    }
                }
            } else {
                ProgressView("Probing helper…")
            }

            HStack(spacing: 12) {
                Button("Open System Settings") {
                    if let url = URL(
                        string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
                    ) {
                        NSWorkspace.shared.open(url)
                    }
                }
                .keyboardShortcut(.defaultAction)
                Button("Skip for now") { onGranted() }
                    .keyboardShortcut(.cancelAction)
                    .help("Continue without iMessage sync. You can grant FDA later.")
            }
            .padding(.top, 8)
        }
        .padding(40)
        .frame(minWidth: 540, minHeight: 380)
        .task { await beginPolling() }
    }

    private func beginPolling() async {
        let result = await FDAProbe.probe()
        probeResult = result
        if result.granted {
            // Small delay so the green checkmark is visible.
            try? await Task.sleep(for: .milliseconds(800))
            onGranted()
            return
        }
        pollTask?.cancel()
        pollTask = Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                let r = await FDAProbe.probe()
                await MainActor.run { probeResult = r }
                if r.granted {
                    try? await Task.sleep(for: .milliseconds(800))
                    await MainActor.run { onGranted() }
                    return
                }
            }
        }
    }
}
```

- [ ] **Step 5: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/plugins/imessage/cli.py tests/accountpilot/unit/plugins/test_imessage_cli.py app/AccountPilot/Services/FDAProbe.swift app/AccountPilot/Views/Onboarding/
git commit -m "feat(app): FDA wizard view + probe-fda CLI"
```

---

## Task 10: `Views/StatusBarMenu.swift` + `SettingsView.swift` — menu bar surface

**Goal:** A menu-bar item (NSStatusItem managed via SwiftUI's `MenuBarExtra` scene) showing per-account last-sync, with "Sync Now" and "Open AccountPilot…" actions. A separate Settings scene for the master "Background sync enabled" toggle.

**Files:**
- Create: `app/AccountPilot/Views/StatusBarMenu.swift`
- Create: `app/AccountPilot/Views/SettingsView.swift`

- [ ] **Step 1: Write `StatusBarMenu.swift`**

```swift
import SwiftUI

struct StatusBarMenu: View {
    @ObservedObject var supervisor: SyncSupervisor
    var openMain: () -> Void

    var body: some View {
        if supervisor.status.isEmpty {
            Text("No accounts").disabled(true)
        } else {
            ForEach(supervisor.status) { s in
                Section(s.identifier) {
                    Text(lastSyncLabel(s))
                        .font(.caption)
                        .foregroundStyle(.secondary)
                    Button("Sync Now") {
                        Task { _ = await supervisor.syncNow(accountID: s.id) }
                    }
                }
            }
            Divider()
        }
        Button("Open AccountPilot…") { openMain() }
        Divider()
        Button(supervisor.isRunning ? "Pause Sync" : "Resume Sync") {
            Task {
                if supervisor.isRunning { await supervisor.stop() }
                else { await supervisor.start() }
            }
        }
        Divider()
        Button("Quit AccountPilot") { NSApp.terminate(nil) }
            .keyboardShortcut("q")
    }

    private func lastSyncLabel(_ s: SyncStatus) -> String {
        guard let d = s.lastSyncAt else { return "Never synced" }
        let f = RelativeDateTimeFormatter()
        return "Last sync: \(f.localizedString(for: d, relativeTo: .now))"
    }
}
```

- [ ] **Step 2: Write `SettingsView.swift`**

```swift
import SwiftUI

struct SettingsView: View {
    @ObservedObject var supervisor: SyncSupervisor
    @AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true

    var body: some View {
        Form {
            Section("Sync") {
                Toggle("Background sync enabled", isOn: $backgroundSyncEnabled)
                    .onChange(of: backgroundSyncEnabled) { _, newValue in
                        Task {
                            if newValue { await supervisor.start() }
                            else { await supervisor.stop() }
                        }
                    }
                if let err = supervisor.lastError {
                    LabeledContent("Last error") {
                        Text(err).font(.caption).foregroundStyle(.red)
                            .textSelection(.enabled)
                    }
                }
            }
        }
        .padding(20)
        .frame(width: 460)
    }
}
```

- [ ] **Step 3: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Views/StatusBarMenu.swift app/AccountPilot/Views/SettingsView.swift
git commit -m "feat(app): StatusBarMenu + SettingsView (sync enabled toggle)"
```

---

## Task 11: Wire app entry — MenuBarExtra + Settings scene + onboarding routing

**Goal:** Final wiring. `AccountPilotApp` now publishes a `MenuBarExtra` scene with `StatusBarMenu`, a `Settings` scene with `SettingsView`, and a `Window` scene for the AccountsView. `ContentView` decides between FDAWizardView and AccountsView based on first-launch FDA grant state.

**Files:**
- Modify: `app/AccountPilot/AccountPilotApp.swift`
- Modify: `app/AccountPilot/ContentView.swift`

- [ ] **Step 1: Replace `AccountPilotApp.swift`**

```swift
import SwiftUI

@main
struct AccountPilotApp: App {
    @StateObject private var supervisor = SyncSupervisor()
    @State private var migration: ConfigMigration.Result?

    @AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true

    var body: some Scene {
        Window("AccountPilot", id: "main") {
            ContentView(migration: migration, supervisor: supervisor)
                .frame(minWidth: 720, minHeight: 480)
                .task {
                    if migration == nil {
                        migration = await ConfigMigration.runIfNeeded()
                    }
                    if backgroundSyncEnabled {
                        await supervisor.start()
                    }
                }
        }
        .windowResizability(.contentMinSize)

        MenuBarExtra("AccountPilot", systemImage: supervisor.isRunning ? "tray.full.fill" : "tray") {
            StatusBarMenu(supervisor: supervisor) {
                NSApp.activate(ignoringOtherApps: true)
                openMainWindow()
            }
        }

        Settings {
            SettingsView(supervisor: supervisor)
        }
    }

    /// Opens (or focuses) the "main" window registered above.
    @MainActor
    private func openMainWindow() {
        // SwiftUI doesn't expose a built-in OpenWindow API for Scenes
        // declared in App body without @Environment; use the legacy URL.
        if let url = URL(string: "accountpilot://main") {
            NSWorkspace.shared.open(url)
        }
    }
}
```

- [ ] **Step 2: Modify `ContentView.swift` to route through FDAWizardView when needed**

```swift
import SwiftUI

struct ContentView: View {
    let migration: ConfigMigration.Result?
    @ObservedObject var supervisor: SyncSupervisor

    @State private var didProbeFDA: Bool = false
    @State private var fdaGranted: Bool = false

    var body: some View {
        Group {
            if !didProbeFDA {
                ProgressView("Checking permissions…")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if !fdaGranted {
                FDAWizardView(onGranted: {
                    fdaGranted = true
                })
            } else {
                VStack(spacing: 0) {
                    AccountsView()
                    Divider()
                    footer
                }
            }
        }
        .task {
            let result = await FDAProbe.probe()
            fdaGranted = result.granted
            didProbeFDA = true
        }
    }

    private var footer: some View {
        VStack(spacing: 4) {
            if case .imported(let count) = migration {
                Text("Imported \(count) account(s) from config.yaml")
                    .font(.caption).foregroundStyle(.green)
            }
            Text("AccountPilot 0.2.0")
                .font(.caption).foregroundStyle(.secondary)
        }
        .padding(.vertical, 8)
        .frame(maxWidth: .infinity)
        .background(.ultraThinMaterial)
    }
}
```

(Footer simplified — Phase 2's full version-triple isn't load-bearing now that the menu bar shows status. The duplicate-prefix cosmetic bug from Phase 2 also goes away.)

- [ ] **Step 3: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/AccountPilotApp.swift app/AccountPilot/ContentView.swift
git commit -m "feat(app): MenuBarExtra + Settings scene + FDA-first routing"
```

---

## Task 12: CHANGELOG bump

- [ ] **Step 1: Append Phase 3 entries under existing `[0.2.0]` `### Added`**

```markdown
- **FDA wizard.** First launch on a Mac without Full Disk Access for
  the helper now opens a guided wizard that deep-links into System
  Settings → Privacy & Security → Full Disk Access and auto-advances
  within ~1 second of the grant taking effect. Skip-for-now path lets
  the user proceed with mail-only sync.
- **Status-bar app + XPC sync.** AccountPilot is now a menu-bar app
  (LSUIElement). An embedded XPC service `com.accountpilot.SyncService`
  runs one timer per enabled account, calling the new
  `accountpilot {mail,imessage} sync-once <id> --json`. Status bar
  shows last-sync per account and a "Sync Now" / "Pause Sync" menu.
  Settings sheet has a master "Background sync enabled" toggle.
- **CLI: `accountpilot status --json`,
  `accountpilot sync-once {mail,imessage} <id> --json`,
  `accountpilot imessage probe-fda --json`.**
```

- [ ] **Step 2: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): Phase 3 — FDA wizard + XPC sync"
```

---

## Task 13: End-to-end build + manual smoke test

- [ ] **Step 1: Full build**

```bash
rm -rf dist/AccountPilot.app && ./app/scripts/build-app.sh 2>&1 | tail -25
```

Expected: `==> all verification passed`. The verification now includes the XPC service path + signing identity (added in Task 8).

- [ ] **Step 2: Drag-to-/Applications + open**

```bash
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
open /Applications/AccountPilot.app
```

Expected:
- A menu-bar tray icon appears (no dock icon, because LSUIElement = true)
- The main window opens to either FDAWizardView (if FDA isn't granted) or AccountsView (if it is)
- Clicking the menu-bar icon shows per-account last-sync, Sync Now, Pause Sync, Open AccountPilot, Quit

- [ ] **Step 3: FDA grant flow**

If the wizard appears: click "Open System Settings", grant FDA to AccountPilot's helper, return to the app. Within ~1s the wizard advances to AccountsView. If the wizard does not appear (FDA already granted from Phase 1/2 testing): no-op, that path is exercised by the polling loop on every `task` re-entry.

- [ ] **Step 4: Per-account sync check**

In AccountsView, confirm your accounts are listed. From the menu-bar item, click "Sync Now" on a specific account. Within ~5s for mail or ~10s for iMessage, the menu-bar's "Last sync" timestamp updates.

Verify directly via CLI:
```bash
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot status --json | python3 -m json.tool
```

The `last_sync_at` field should be a recent ISO8601 timestamp.

- [ ] **Step 5: Settings toggle check**

Open Settings (cmd+,), turn off "Background sync enabled". Confirm the menu-bar's "Pause Sync" toggle reflects this state, and that subsequent `accountpilot status --json` doesn't show new syncs (the timer should stop).

- [ ] **Step 6: No commit (verification only).**

If anything fails, stop and diagnose before pushing.

---

## Task 14: Push + PR

- [ ] **Step 1: Push**

```bash
git push -u origin 0.2-p3-fda-wizard-sync
```

- [ ] **Step 2: Open PR**

```bash
gh pr create --title "0.2 Phase 3: FDA wizard + XPC sync supervisor" --body "$(cat <<'EOF'
Implements Phase 3 of the 0.2 macOS .app pivot per
`docs/plans/0.2-p3-fda-wizard-sync.md`.

## Scope note

XPC-while-app-alive (status-bar app, sync runs while app is up). True
launchd-managed survival across force-quit lands in a follow-up phase
once the API + protocol stabilize.

## What's in here

- Python: `accountpilot status --json`, `accountpilot sync-once {mail,imessage} <id> --json`, `accountpilot imessage probe-fda --json`. Plugins' `sync_once` now returns the count of new messages.
- Swift app: LSUIElement = true, MenuBarExtra scene, Settings scene with sync-enabled toggle.
- Swift services: SyncSupervisor (NSXPCConnection client) + SyncStatus model with ISO8601 decoder helper.
- New target: AccountPilotXPC (embedded .xpc service at Contents/XPCServices/com.accountpilot.SyncService.xpc). One timer per enabled account, default cadence: 60s mail / 10s iMessage.
- FDAWizardView + FDAProbe service: deep-links into System Settings, auto-advances on grant.
- build/codesign/verify scripts updated to embed + sign the XPC bundle.

## Verification

- `./app/scripts/build-app.sh` ends in `==> all verification passed` (now 14 checks — adds XPC service presence + signing identity).
- Manual: fresh Mac with no FDA → wizard appears → grant FDA → wizard advances within ~1s.
- Manual: menu-bar's "Sync Now" updates `last_sync_at` for the selected account; CLI `status --json` from terminal matches.

## Phase 4 next

Data viewer (read-only browse + FTS5 search). Branch `0.2-p4-data-viewer` after merge.
EOF
)"
```

---

## Phase 3 done when

- [ ] All 12 implementation task commits land + verification + PR (~14 commits)
- [ ] `pytest tests/accountpilot/unit/cli/ -q` green; full pytest suite green
- [ ] `ruff check src tests` clean; `mypy src/accountpilot` clean
- [ ] `./app/scripts/build-app.sh` ends in `==> all verification passed` (with the XPC service signing checks added)
- [ ] Manual: drag to /Applications, open, FDA wizard appears (or skipped if granted), AccountsView loads, menu-bar item works, "Sync Now" works, Settings toggle pauses/resumes sync
- [ ] PR open against `main`, CI green, merged

## Phase 4 starts after

- Phase 3 PR merged
- New branch `0.2-p4-data-viewer`
- Write `docs/plans/0.2-p4-data-viewer.md` via `superpowers:writing-plans`
