diff --git a/AGENTS.md b/AGENTS.md index df8117c..003f906 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,9 +56,18 @@ decided by TTY + config + flags. `init` is the canonical onboarding command (the `resurrect save`, and cleans continuum's stale boot. These are SAFE for an active session — the boot agent's script is idempotent (`has-session` → exit 0, never spawns a duplicate or touches existing panes), and a first `resurrect save` is read-only w.r.t. the live session. It mirrors - the `models` schedule exception (a non-interactive launchd agent is safe to (re)load). Gate the - whole activation behind `RIG_TMUX_DRY_RUN` (the unit suite + CI set it). Migration backs up the - original (`~/.tmux.conf.rig-bak-`, timestamped) and never overwrites an existing backup. + the **stateless background daemons** exception (safe to (re)load because no live user session + rides on them): the `models` schedule (a non-interactive cron) and the `tg_ctl` inbound daemon + (`tg_ctl` block) both (re)load via launchd. `tg_ctl` writes the `ai.hyperide.tg-ctl.plist` + LaunchAgent **byte-exact** to the working hand-created file (so a re-apply is a no-op `skipped`, + never a spurious rewrite) and (re)loads it with `launchctl bootout`/`bootstrap` in the + `gui/` domain; it also boots out + removes the dead predecessor `com.ultra.codex-tg-bot`. + Gate the tmux activation behind `RIG_TMUX_DRY_RUN`, and `tg_ctl` behind `RIG_TG_CTL_DRY_RUN` + (mirrors `RIG_SCHEDULE_DRY_RUN`) — which writes the managed plist but skips every + live/destructive mutation (the `launchctl` bootstrap/bootout AND the stale-predecessor teardown: + no bootout, no on-disk backup+remove). The unit suite + CI set these, so tests/smoke NEVER touch + the real launchd domain or delete the predecessor file. Migration backs up the original + (`~/.tmux.conf.rig-bak-`, timestamped) and never overwrites an existing backup. ## The integration seam (agent-tools) diff --git a/docs/config-schema.md b/docs/config-schema.md index 28e0c4b..c707715 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -40,6 +40,7 @@ agents_md: { ... } # AGENTS.md (canonical) + CLAUDE.md (symlink), def github: { ... } # GitHub repo branch ruleset via gh api, default ON (no-op without a github remote) tmux: { ... } # rig-managed tmux config (generate + migrate ~/.tmux.conf), opt-in gitignore: { ... } # rig-managed block in the GLOBAL git excludesfile (ignores **/.claude/worktrees/ in EVERY repo), default ON +tg_ctl: { ... } # tg-ctl inbound daemon as a macOS boot LaunchAgent, default ON (macOS-only) ``` If `agent_tools_source` is omitted, rig resolves it from `$RIG_AGENT_TOOLS_SOURCE`, then @@ -679,6 +680,65 @@ reconciles. Shown in the **global** section of status (not the repo section). --- +## `tg_ctl` + +rig provisions the **tg-ctl inbound control daemon** (tg-cli's long-poll / inject-into-tmux / +voice→text daemon, run as `tg-ctl run`) as a **macOS boot LaunchAgent** so it auto-starts at +login/boot — exactly like the tmux boot service. **Default ON** (an absent or empty `tg_ctl:` +block still provisions it, so `rig init` on a clean machine sets it up with no config). This is a +**per-MACHINE** concern (one inbound daemon per machine), so the block belongs in the **GLOBAL** +layer (`~/.config/rig/config.yaml`) — never a committed repo `rig.yaml`. **macOS-only** (launchd); +off darwin it is a no-op. + +```yaml +tg_ctl: + enabled: true # provision the tg-ctl LaunchAgent (default true; false = off) + boot: true # write + load the boot agent (default true) + # everything below is auto-discovered per-machine — override only if non-standard: + label: ai.hyperide.tg-ctl # launchd Label / plist filename stem (advanced) + bun_path: ~/.bun/bin/bun # the bun binary (default: `which bun` → ~/.bun fallback) + tg_ctl_path: ~/.files/bin/tg-ctl # the tg-ctl Bun script + config_dir: ~/.config/tg-cli # tg-cli config + launchd logs (default honors $TG_CTL_CONFIG_DIR) +``` + +| Key | Type | Default | Meaning | +|-----|------|---------|---------| +| `enabled` | bool | `true` | `false` = **don't touch tg-ctl at all** — rig emits no action, so it neither provisions NOR cleans up NOR reports drift (a hands-off opt-out). Use `boot: false` instead to keep rig tracking a leftover plist. | +| `boot` | bool | `true` | `false` = provisioned-but-no-boot: rig writes/loads nothing, but a leftover plist (or the stale predecessor) IS still surfaced as drift so you see the orphan | +| `label` | str | `ai.hyperide.tg-ctl` | launchd Label / plist filename stem (one identity for install/drift/remove) | +| `bun_path` | str | discovered | the bun binary; default `which bun`, else `~/.bun/bin/bun` | +| `tg_ctl_path` | str | `~/.files/bin/tg-ctl` | the tg-ctl Bun script launchd runs (`bun run`) | +| `config_dir` | str | `~/.config/tg-cli` | tg-cli config dir; the launchd out/err logs land here (honors `$TG_CTL_CONFIG_DIR`) | + +> **`enabled: false` vs `boot: false`.** `enabled: false` is a complete opt-out — rig stops +> emitting the action, so a previously-installed plist is NOT cleaned up or flagged (rig is +> hands-off). If you want rig to keep watching for / flagging a leftover plist while not running +> the daemon, use `boot: false` (the action still runs; drift surfaces the orphan). + +**What rig writes.** `~/Library/LaunchAgents/ai.hyperide.tg-ctl.plist` — a `RunAtLoad` + +`KeepAlive` agent that runs `bun ~/.files/bin/tg-ctl run` with a login PATH and the tg-cli config +dir's logs. The plist is **byte-exact** to a hand-created working file, so `rig apply` against an +already-correct live plist is a true **no-op** (`skipped`), never a spurious rewrite. + +**(Re)load mechanism.** Unlike tmux-boot (which only writes the plist), rig **(re)loads** the agent +via `launchctl bootout`/`bootstrap` in the per-user `gui/` domain, so a clean `rig init` starts +the daemon without a reboot, and a changed plist is picked up on the next `apply`. + +**Stale-predecessor teardown.** If the dead predecessor service +`~/Library/LaunchAgents/com.ultra.codex-tg-bot.plist` exists, `rig apply` **boots it out**, backs it +up (timestamped), and removes it. + +**Drift.** `rig status` flags (in the **GLOBAL** section) when the agent is missing, divergent, or +written-but-not-loaded; a leftover plist when `boot: false`, or the stale predecessor, surfaces as a +disk→config **extra**. `rig apply` reconciles. + +**Dry-run seam.** `RIG_TG_CTL_DRY_RUN=1` writes the managed plist into the configured (HOME-isolated) +path but skips every live/destructive mutation — the gui-domain `launchctl bootstrap`/`bootout` AND +the stale-predecessor teardown (its bootout and the on-disk backup+remove of its plist) — so +tests/smoke never touch the real launchd domain or delete the predecessor file. + +--- + ## Validation `apply`/`status`/`init` validate before touching disk and **fail closed** on: @@ -693,6 +753,7 @@ that is not a list of strings, an unknown `tmux`/`tmux.` key, a bad `tmux.a `tmux.resurrect.processes` that is not a list of strings, a `tmux.continuum.save_interval` that is not an int >= 1, a non-bool `tmux` boolean knob, a non-mapping `gitignore` block / non-bool `gitignore.enabled` / unknown `gitignore` key / non-string `gitignore.excludesfile` / a -`gitignore.entries` that is not a list of strings or that contains a rig-managed marker line, and an -`agent_tools_source` that is not an agent-tools checkout. `--dry-run` prints the resolved plan and -exits 0 without writing. +`gitignore.entries` that is not a list of strings or that contains a rig-managed marker line, an +unknown `tg_ctl` key, a non-bool `tg_ctl.enabled`/`tg_ctl.boot`, a non-string +`tg_ctl.label`/`bun_path`/`tg_ctl_path`/`config_dir`, and an `agent_tools_source` that is not an +agent-tools checkout. `--dry-run` prints the resolved plan and exits 0 without writing. diff --git a/riglib/actions/runner.py b/riglib/actions/runner.py index a7e7144..01e23e4 100644 --- a/riglib/actions/runner.py +++ b/riglib/actions/runner.py @@ -1107,6 +1107,54 @@ def _launchctl_load_enable(plist: Path) -> int: return res.returncode +# ── gui-domain launchctl (modern bootstrap/bootout) ───────────────────────────────── +# The model-freshness schedule uses the legacy ``launchctl load/unload`` (``_launchctl_loaded`` +# above); the tg-ctl inbound daemon uses the MODERN per-user ``gui/`` domain verbs +# (``bootstrap``/``bootout``), which is what macOS recommends and what loads a fresh agent without +# a reboot. Kept separate so the two services don't share a verb set. +def _gui_domain() -> str: + """The per-user GUI launchd domain target ``gui/`` for the current user.""" + return f"gui/{os.getuid()}" + + +def _launchctl_gui(verb: str, plist_path: str) -> int: + """``launchctl gui/ `` — bootstrap (load) / bootout (unload) an agent in + the per-user GUI domain. Returns the rc; a non-zero rc from ``bootout`` of an unloaded agent + is harmless (the caller bootstraps after). One shell for both verbs (DRY).""" + try: + res = subprocess.run( + ["launchctl", verb, _gui_domain(), plist_path], + capture_output=True, text=True, timeout=20, + ) + except (OSError, subprocess.SubprocessError): + return 1 + return res.returncode + + +# Thin, self-documenting wrappers so call sites read as bootout/bootstrap (and tests can spy on +# each verb independently). +def _launchctl_bootout(plist_path: str) -> int: + return _launchctl_gui("bootout", plist_path) + + +def _launchctl_bootstrap(plist_path: str) -> int: + return _launchctl_gui("bootstrap", plist_path) + + +def _launchctl_gui_loaded(label: str) -> bool: + """True when ``label`` is loaded in the per-user GUI domain (``launchctl print gui// +