agentgear 0.1.4

Install and self-heal the plugin your Rust binary ships into Claude Code and 24 other coding agents, via a derive macro.
Documentation
//! The SessionStart entrypoint. The hook is part of the plugin, so it only ever
//! fires on an install that already exists: self_heal repairs *broken* installs,
//! never resurrects an *absent* one (a resurrected uninstall reads as adware).
//!
//! Fans out over the plugin's configured agents (user scope only — project scope
//! has no stable session context to key on in v1). Each *detected* backend's
//! per-agent marker × `probe()` state drives the table (design §5):
//!
//! | marker | state | action |
//! |--------|-------|--------|
//! | absent | Absent | no-op |
//! | absent | Healthy | adopt: reconcile + write marker (no-op maps to `Adopted`) |
//! | absent | Disabled/NeedsRepair | reconcile + write marker (its own outcome) |
//! | present| Absent, marker mid-reinstall | reconcile + write marker (our own uninstall never finished) |
//! | present| Absent | clear marker, no-op (never resurrect) |
//! | present| Disabled | no-op (never re-enable) |
//! | present| Healthy | no-op |
//! | present| NeedsRepair | reconcile + write marker (repair/update) |
//!
//! The plugin-native backends record the staged tree's hash in that marker, so with no
//! marker `claude` and `copilot-cli` reach the adopt row only where nothing needs the
//! hash: a github source, or an install strictly newer than this binary. The two
//! backends part company there: `copilot-cli` freezes every strictly-newer install,
//! `claude` freezes only one another binary of the same tool owns, so a github
//! registration ahead of the binary takes the repair row instead of adopting. At
//! this binary's own version under a local source the tree the harness holds is
//! unaccounted for, `probe` classifies `NeedsRepair`, and the heal re-hands the tree as
//! a `Repaired`. A config-family backend compares its own rendered config and adopts.
//!
//! The restart-pending flag is Claude-only: CC has no mid-session hot-reload, so a
//! repair strands the running session; config-family harnesses re-read each session and
//! need no nag. Every CC reconcile above keys the flag on its own outcome, a takeover of
//! an unowned install included: changed something sets it, changed nothing clears it.
//! What strands the session is the tree moving under it, never whose install it was. The
//! two rows that run no reconcile (healthy, clean uninstall) clear it; the disabled row
//! leaves it alone, since it converges nothing either way.

use crate::agents::{AgentBackend, BackendState};
use crate::error::Result;
use crate::host::{AgentReport, AgentStatus, Desired, Outcome, Plugin, Scope, SkipReason, Source};
use crate::install::resolve;
use crate::{lock, restart, stamp};

pub(crate) fn self_heal_report(plugin: &Plugin, source: Source) -> Result<AgentReport> {
    // The SessionStart hook targets user-scoped installs; project scope has no
    // stable session context to key on in v1.
    let scope = Scope::User;
    let _lock = lock::acquire()?;
    // Like the lock, a missing data root fails every agent's marker read/write
    // identically: a whole-call precondition, not a per-agent failure.
    crate::install::data_dir_precondition(plugin)?;

    let mut report = AgentReport::new();
    for id in plugin.agents {
        report.push(id, heal_status(plugin, &source, &scope, id));
    }
    Ok(report)
}

/// One agent's heal slice; a failure here becomes its `Failed` entry and never
/// aborts the fan-out (a session-start heal must not let one broken agent stop
/// the other 24 from healing).
fn heal_status(plugin: &Plugin, source: &Source, scope: &Scope, id: &'static str) -> AgentStatus {
    let backend = match resolve(id) {
        Ok(backend) => backend,
        Err(e) => return AgentStatus::Failed(e.to_string()),
    };
    if !backend.detect() {
        // a tool that isn't installed has nothing to heal
        return AgentStatus::Skipped(SkipReason::NotDetected);
    }
    if !backend.capabilities().scopes.contains(&scope.as_cli()) {
        // no user-scope surface (e.g. a repo-config-only IDE backend)
        return AgentStatus::Skipped(SkipReason::ScopeUnsupported);
    }
    // This agent's OWN marker settles its source (never a sibling's — the
    // per-agent-marker invariant); `source` is the caller's `DEFAULT_SOURCE`,
    // used only absent a persisted `--path` for this specific agent.
    let marker = match stamp::read(plugin, scope, id) {
        Ok(marker) => marker,
        Err(e) => return AgentStatus::Failed(e.to_string()),
    };
    let resolved_source = stamp::source_from_marker(marker.as_ref(), source.clone());
    // A github source has no local tree for a config-merge backend to probe or
    // render from; only a plugin-native backend can heal off it. A visible skip,
    // matching install/update.
    if matches!(resolved_source, Source::GitHub { .. }) && !backend.capabilities().plugins {
        return AgentStatus::Skipped(SkipReason::SourceUnsupported);
    }
    let interrupted = marker.as_ref().is_some_and(|m| m.reinstalling);
    match heal_agent(&*backend, plugin, resolved_source, scope, marker.is_some(), interrupted, id == "claude") {
        Ok(outcome) => AgentStatus::Converged(outcome),
        Err(e) => AgentStatus::Failed(e.to_string()),
    }
}

/// Drive one detected backend's marker × `probe()` table. `resolved_source` is
/// the caller-resolved per-agent source and `has_marker` its marker's presence
/// (the caller reads it once for the source resolution and the table both).
/// `is_claude` gates the CC-only restart flag; convergence delegates to the
/// backend's `reconcile`.
fn heal_agent(
    backend: &dyn AgentBackend, plugin: &Plugin, resolved_source: Source, scope: &Scope, has_marker: bool, interrupted: bool,
    is_claude: bool,
) -> Result<Outcome> {
    // Probe against the SAME source `reconcile` (below) will render from, so a
    // `--path` install whose tree differs from the embedded blob does not read as
    // perpetual drift (probe wanting embedded bytes, reconcile writing path bytes).
    let state = backend.probe(plugin, scope, &resolved_source)?;
    // self_heal never re-enables a deliberate disable (the design's install-only
    // enable flip); adopt/repair both converge without touching enable state.
    let desired = Desired { source: resolved_source, reenable: false };

    match (has_marker, state) {
        (false, BackendState::Absent) => Ok(Outcome::NoOp),

        (false, BackendState::Healthy) => {
            // Adopt a healthy pre-existing install: converge (a no-op here), record
            // ownership.
            let outcome = backend.reconcile(plugin, &desired, scope)?;
            flag_restart(plugin, is_claude, &outcome);
            stamp::write(plugin, scope, &desired.source, backend.id())?;
            Ok(match outcome {
                Outcome::NoOp => Outcome::Adopted,
                other => other,
            })
        }

        (false, BackendState::Disabled | BackendState::NeedsRepair) => {
            // Take over a disabled/broken install: reconcile (leaves a disable alone,
            // repairs a break), record ownership.
            let outcome = backend.reconcile(plugin, &desired, scope)?;
            flag_restart(plugin, is_claude, &outcome);
            stamp::write(plugin, scope, &desired.source, backend.id())?;
            Ok(outcome)
        }

        // A reinstall this crate began and never finished: the plugin is absent because
        // WE removed it, so the clean-uninstall arm below would read our own interrupted
        // write as the user's choice and forget it for good. Repair instead, and let a
        // repair that fails again stay loud rather than settling into a no-op.
        (true, BackendState::Absent) if interrupted => {
            let outcome = backend.reconcile(plugin, &desired, scope)?;
            flag_restart(plugin, is_claude, &outcome);
            stamp::write(plugin, scope, &desired.source, backend.id())?;
            Ok(outcome)
        }

        (true, BackendState::Absent) => {
            // Clean uninstall under our marker: forget it, do not reinstall.
            if is_claude {
                let _ = restart::clear(plugin);
            }
            stamp::clear(plugin, scope, backend.id())?;
            Ok(Outcome::Cleared)
        }

        (true, BackendState::Disabled) => Ok(Outcome::NoOp), // never re-enable a deliberate disable

        (true, BackendState::Healthy) => {
            // Healthy + current: a fresh session already loaded this plugin, so a
            // prior restart-pending flag no longer applies. Best-effort clear.
            if is_claude {
                let _ = restart::clear(plugin);
            }
            Ok(Outcome::NoOp)
        }

        (true, BackendState::NeedsRepair) => {
            let outcome = backend.reconcile(plugin, &desired, scope)?;
            flag_restart(plugin, is_claude, &outcome);
            stamp::write(plugin, scope, &desired.source, backend.id())?;
            Ok(outcome)
        }
    }
}

/// What a CC reconcile did to the running session, recorded for the host's own notice
/// hook. A reconcile that changed something handed CC a tree the session cannot reload,
/// so it strands; one that changed nothing means this session started on what CC holds,
/// which retires any flag an earlier session left. Whose install it was never enters it:
/// a takeover that re-hands the tree strands the session exactly like an owned repair.
/// Best-effort both ways, so a flag write cannot fail a heal that otherwise succeeded.
fn flag_restart(plugin: &Plugin, is_claude: bool, outcome: &Outcome) {
    if !is_claude {
        return;
    }
    let _ = if *outcome == Outcome::NoOp { restart::clear(plugin) } else { restart::set(plugin) };
}