# LaunchAgent management

> How `wizard install` writes `dev.agentcookie.source` / `dev.agentcookie.sink` plists, bootstrap with `launchctl`, log paths, and the v0.9 soup-to-nuts lifecycle.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `internal/launchd/plist.go`
- `examples/launchd-sink.plist`
- `docs/runbook-v0.9-soup-to-nuts.md`
- `internal/cli/wizard.go`

---

---
title: "LaunchAgent management"
description: "How `wizard install` writes `dev.agentcookie.source` / `dev.agentcookie.sink` plists, bootstrap with `launchctl`, log paths, and the v0.9 soup-to-nuts lifecycle."
---

`agentcookie wizard install` writes a per-user LaunchAgent plist to `~/Library/LaunchAgents/dev.agentcookie.<role>.plist` and bootstraps it into the GUI domain (`gui/<uid>`) via `launchctl`, so the source watcher or sink listener runs inside the user's login session with Keychain and Chrome lifecycle privileges that SSH-launched processes lack. Plist generation lives in `internal/launchd/plist.go`; the wizard wraps it with role-specific args, a fixed log directory at `~/.agentcookie/logs/`, and a `launchctl kickstart -k` fallback when bootstrap races another job.

## Plist labels, paths, and roles

| Role | Label | ProgramArguments | Plist path |
| --- | --- | --- | --- |
| source | `dev.agentcookie.source` | `<binary> source --watch` | `~/Library/LaunchAgents/dev.agentcookie.source.plist` |
| sink | `dev.agentcookie.sink` | `<binary> sink` | `~/Library/LaunchAgents/dev.agentcookie.sink.plist` |

The label is derived from `Spec.Role` (`"dev.agentcookie." + role`) and the plist file lives under the per-user `~/Library/LaunchAgents/` directory, so there is no cross-user collision risk. `Spec.BinaryPath` must be absolute; the wizard fills it from `os.Executable()` and `filepath.Abs`, so the LaunchAgent always points at the `agentcookie` binary that ran `wizard install` (typically `~/go/bin/agentcookie`).

<Note>
Source-side `--watch` is passed as `ExtraArgs` from the wizard; the sink takes no extra args. The plist template renders one `<string>` per argv element, so `ProgramArguments` is `[BinaryPath, role, ...ExtraArgs]`.
</Note>

## Generated plist shape

The template in `internal/launchd/plist.go` produces the same fixed `<dict>` keys for both roles, with `Label`, `ProgramArguments`, `StandardOutPath`, and `StandardErrorPath` substituted per `Spec`.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>dev.agentcookie.source</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/you/go/bin/agentcookie</string>
    <string>source</string>
    <string>--watch</string>
  </array>

  <key>RunAtLoad</key>
  <true/>

  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key>
    <false/>
    <key>Crashed</key>
    <true/>
  </dict>

  <key>ThrottleInterval</key>
  <integer>10</integer>

  <key>ProcessType</key>
  <string>Background</string>

  <key>StandardOutPath</key>
  <string>/Users/you/.agentcookie/logs/source.out.log</string>

  <key>StandardErrorPath</key>
  <string>/Users/you/.agentcookie/logs/source.err.log</string>
</dict>
</plist>
```

### Lifecycle keys

<ParamField body="RunAtLoad" type="bool" required>
Always `true`. The agent starts at login and as soon as the plist is bootstrapped.
</ParamField>

<ParamField body="KeepAlive" type="dict" required>
Conditional restart: `SuccessfulExit=false` and `Crashed=true`. A clean exit (`os.Exit(0)`) does **not** trigger relaunch — only crashes do. This is intentional so a successful `agentcookie source --once` style invocation could end, but the long-running daemons do not exit on their own under normal operation.
</ParamField>

<ParamField body="ThrottleInterval" type="int" default="10">
Minimum seconds between restart attempts.
</ParamField>

<ParamField body="ProcessType" type="string" default="Background">
Signals macOS that the agent is a long-running background job, not interactive.
</ParamField>

<ParamField body="StandardOutPath / StandardErrorPath" type="path" required>
Absolute paths under `LogDir`. The wizard always sets `LogDir = ~/.agentcookie/logs`.
</ParamField>

## Log paths

`Render` derives standard out/err paths by joining `LogDir` with `<role>.out.log` and `<role>.err.log`. With the wizard's default `LogDir = ~/.agentcookie/logs`:

:::files
~/
└── .agentcookie/
    └── logs/
        ├── source.out.log
        ├── source.err.log
        ├── sink.out.log
        └── sink.err.log
:::

`Install` creates `~/Library/LaunchAgents/` and `LogDir` with `0o755` before writing the plist (mode `0o644`). Tail the err log to watch sink activity (`agentcookie sink: wrote N cookies …`, `probe ok`, or a `probe FAIL` stop-the-line); doctor checks point at the same path when the listener is unbound.

<Warning>
The committed `examples/launchd-sink.plist` is a **manual-install fallback**, not what `wizard install` writes. It uses `/tmp/agentcookie-sink.out.log` and an unconditional `KeepAlive`, and requires you to replace `REPLACE_WITH_FULL_PATH_TO_AGENTCOOKIE` and `REPLACE_WITH_USERNAME` before `launchctl bootstrap`. The wizard-generated plist supersedes it.
</Warning>

## Install, bootstrap, uninstall

`launchd.Install(spec)` performs the full write-and-load sequence and is idempotent — re-running it produces a refresh, not a duplicate.

```text
Install(spec):
  1. Render plist XML (validates Role, BinaryPath, LogDir).
  2. mkdir -p ~/Library/LaunchAgents          (0755)
  3. mkdir -p spec.LogDir                     (0755)
  4. write plist file                         (0644)
  5. launchctl bootout  gui/<uid>/<label>     (errors ignored: idempotent)
  6. launchctl bootstrap gui/<uid> <plistPath>
  7. return plistPath
```

The wizard calls `Install` through `installLaunchAgent`, which adds one race-recovery step: when `launchctl bootstrap` reports the job is already loaded, it falls back to `launchctl kickstart -k gui/<uid>/<label>` to force-restart the existing job.

```go
// internal/cli/wizard.go
func installLaunchAgent(spec launchd.Spec) error {
    _, err := launchd.Install(spec)
    if err != nil {
        if errIsAlreadyLoaded(err) {
            uid := fmt.Sprintf("%d", os.Getuid())
            return exec.Command("launchctl", "kickstart", "-k",
                "gui/"+uid+"/"+spec.Label()).Run()
        }
        return err
    }
    return nil
}
```

`launchd.Uninstall(spec)` is the inverse: `launchctl bootout gui/<uid>/<label>` followed by `os.Remove` of the plist. Missing files are tolerated; both halves of the call are idempotent.

`launchd.IsInstalled(spec)` shells out to `launchctl print gui/<uid>/<label>` and returns true when the label appears in the output. The wizard does not currently call this; it is exposed for external tooling and tests.

## Wizard install flow

The wizard chooses `Spec.ExtraArgs` per role and reuses `binPath` from `os.Executable()` so the running binary is what gets installed.

<Steps>
<Step title="Resolve binary and log dir">
`binPath, _ = os.Executable(); binPath, _ = filepath.Abs(binPath)` and `logDir = ~/.agentcookie/logs`. Both paths are inserted verbatim into the plist.
</Step>
<Step title="Source side: --watch agent">
After dropping `source.yaml`/`blocklist.yaml` and completing pairing, install:

```go
launchd.Spec{
  Role:       launchd.RoleSource,
  BinaryPath: binPath,
  LogDir:     logDir,
  ExtraArgs:  []string{"--watch"},
}
```

stderr prints `agentcookie wizard: source LaunchAgent installed and started`.
</Step>
<Step title="Sink side: bare daemon">
After rendering `sink.yaml` (with `skip_chrome_sqlite` / `cdp` / `delivery` resolved by the keychain-open path) and completing the sink half of the handshake, install:

```go
launchd.Spec{
  Role:       launchd.RoleSink,
  BinaryPath: binPath,
  LogDir:     logDir,
}
```

stderr prints `agentcookie wizard: sink LaunchAgent installed and started`.
</Step>
<Step title="Skip on demand">
`--skip-daemon` returns without calling `installLaunchAgent`, leaving configs and pairing on disk so the operator can run `agentcookie source --once` or `agentcookie sink` interactively for diagnostics.
</Step>
</Steps>

### Wizard uninstall

```bash
agentcookie wizard uninstall --as source        # remove LaunchAgent, keep configs
agentcookie wizard uninstall --as source --purge  # also delete configs and paired keys
```

`runWizardUninstall` calls `launchd.Uninstall(spec)` for the requested role and prints `agentcookie wizard: <role> LaunchAgent removed`. With `--purge`, it additionally deletes `source.yaml`, `sink.yaml`, `allowlist.yaml`, and the entire `keys/` directory under `~/.config/agentcookie/`.

## Manual operation reference

These are the same primitives the wizard wraps; useful for diagnostics or for the manual install path documented in `docs/quickstart.md`.

```bash
UID=$(id -u)

# Inspect the currently loaded job:
launchctl print gui/$UID/dev.agentcookie.sink

# Reload after editing the plist:
launchctl bootout    gui/$UID/dev.agentcookie.sink
launchctl bootstrap  gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist

# Force-restart without unloading:
launchctl kickstart -k gui/$UID/dev.agentcookie.sink

# Tail logs:
tail -f ~/.agentcookie/logs/sink.err.log
tail -f ~/.agentcookie/logs/sink.out.log
```

`agentcookie doctor` surfaces an unbound sink listener with the same advice in its remediation field: `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist`.

## v0.9 soup-to-nuts lifecycle

The v0.9 shipping signal is an end-to-end sink lifecycle in which an agent on the second Mac uses a PP CLI as you with zero manual paste. The LaunchAgent is the long-lived piece that makes it possible from an SSH session.

```mermaid
sequenceDiagram
    participant Op as Operator / agent (SSH)
    participant Wiz as agentcookie wizard
    participant LD as launchd (gui/<uid>)
    participant Sink as agentcookie sink
    participant Src as MBP source
    participant CLI as instacart-pp-cli

    Op->>Wiz: wizard install --as sink --peer ... --code ... --pair-url ...
    Wiz->>Wiz: render sink.yaml, pair, expand partition list
    Wiz->>LD: launchctl bootout gui/<uid>/dev.agentcookie.sink (ignored)
    Wiz->>LD: launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.agentcookie.sink.plist
    LD->>Sink: spawn `agentcookie sink` (StandardOutPath/StandardErrorPath)
    Op->>Src: agentcookie source --once
    Src->>Sink: POST /sync (encrypted envelope)
    Sink-->>Sink: wrote N cookies + probe ok (logged to sink.err.log)
    Op->>CLI: ssh mac-mini 'instacart doctor'
    CLI->>Sink: reads Chrome Safe Storage + Cookies SQLite silently
    CLI-->>Op: [ok] api: logged in as <user>
```

Verification points pulled directly from the runbook:

- `~/.agentcookie/logs/sink.err` contains `agentcookie sink: wrote N cookies (+ N sidecar)` and `probe ok: 3 cookies round-tripped, meta.version=18`.
- A `probe FAIL` line is a stop-the-line; the bridge is unhealthy and the soup-to-nuts run will not succeed.
- The SSH session output must contain no `auth paste`, `dump-instacart`, or clipboard step — the CLI reads Chrome Safe Storage and the Cookies SQLite directly through the bridge.

### Why a LaunchAgent (and not nohup or SSH)

- LaunchAgents run inside the user's GUI login session, so the login keychain is unlocked. SSH-launched processes see a locked keychain and Chrome Safe Storage reads fail with `exit status 36`.
- `KeepAlive { Crashed: true }` makes the sink resilient to subprocess crashes (e.g., a stale `~/.agentcookie/chrome-profile/SingletonLock` that takes Chrome down) without restart-looping on a clean exit.
- A fixed `dev.agentcookie.<role>` label gives `launchctl print`, `kickstart -k`, and `bootout` a stable target for diagnostics and reinstalls.

## Troubleshooting checklist

<AccordionGroup>
<Accordion title="Sink LaunchAgent runs but listener does not bind">
Dry-run friction #18 (2026-05-19) traced this to a Keychain read failing under SSH-bootstrapped contexts. `lsof -nP -iTCP:9999` shows nothing while `sink.out.log` keeps writing. Re-run `agentcookie wizard install --as sink ...` at the GUI console once, or use `agentcookie wizard set-keychain-access` to upgrade trust; the LaunchAgent then binds on next start.
</Accordion>
<Accordion title="bootstrap reports the job is already loaded">
`installLaunchAgent` falls back to `launchctl kickstart -k gui/<uid>/<label>`, which is also a safe manual recovery: it force-restarts the existing job without requiring a clean `bootout`.
</Accordion>
<Accordion title="Logs missing or empty">
`Install` creates `~/.agentcookie/logs/` with `0o755`. If the directory was removed under a running agent, `launchctl bootout` + `launchctl bootstrap` recreates it. Empty logs typically mean the agent has not started yet (check `launchctl print gui/$(id -u)/dev.agentcookie.sink`).
</Accordion>
<Accordion title="Example plist installed by hand">
`examples/launchd-sink.plist` writes to `/tmp/agentcookie-sink.{out,err}.log` and uses unconditional `KeepAlive` — useful for first-touch testing, but `agentcookie wizard uninstall --as sink` only targets the wizard-installed `dev.agentcookie.sink.plist`. Remove the manual copy explicitly if you switch to the wizard path.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
The full `wizard install` flow that produces the source and sink LaunchAgents.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
SSH-only sink install: degraded-mode fallback and `wizard set-keychain-access` upgrade.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
`agentcookie doctor` checks that surface unbound listeners and a `launchctl bootstrap` remediation.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Pairing refused, sink Keychain prompts, stale Chrome `SingletonLock`, and recovery steps.
</Card>
<Card title="Source and sink topology" href="/source-sink-topology">
Role split and what each LaunchAgent reads and writes at runtime.
</Card>
<Card title="CLI reference" href="/cli-reference">
`wizard install`, `wizard uninstall`, `--skip-daemon`, and related flags.
</Card>
</CardGroup>
