Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev/changelog/add-mngr-donate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Registered the new `libs/mngr_donate` workspace member (root `pyproject.toml` coverage list + `uv.lock`).
3 changes: 3 additions & 0 deletions libs/mngr/changelog/add-mngr-donate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Registered the new `imbue-mngr-donate` plugin (`mngr donate`) in the plugin catalog as a default-installable DEPENDENT gated on `imbue-mngr-usage`.

- Fixed a crash when listing the local host on newer Apple Silicon Macs (M4/M5). psutil's `cpu_freq()` reports the metric as available but raises when read (Apple changed the private CPU voltage-state data it parses), which surfaced as `Error processing host ...` during `mngr list` and any command built on it -- including `mngr donate`, which then wrongly reported "No Claude usage data". CPU frequency is now read defensively: if psutil can't read it, the frequency is reported as unavailable instead of aborting host discovery.
38 changes: 38 additions & 0 deletions libs/mngr/docs/commands/secondary/donate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!-- This file is auto-generated. Do not edit directly. -->
<!-- To modify, edit the command's help metadata and run: uv run python scripts/make_cli_docs.py -->

# mngr donate
**Usage:**

```text
mngr donate [OPTIONS]
```
**Options:**

## Common

| Name | Type | Description | Default |
| ---- | ---- | ----------- | ------- |
| `--format` | text | Output format (human, json, jsonl, FORMAT): Output format for results. When a template is provided, fields use standard python templating like 'name: {agent.name}' See below for available fields. | `human` |
| `-q`, `--quiet` | boolean | Suppress all console output | `False` |
| `-v`, `--verbose` | integer range | Increase verbosity (default: BUILD); -v for DEBUG, -vv for TRACE | `0` |
| `--log-file` | path | Path to log file (overrides default ~/.mngr/events/logs/<timestamp>-<pid>.json) | None |
| `--log-commands`, `--no-log-commands` | boolean | Log commands that were executed | None |
| `--headless` | boolean | Disable all interactive behavior (prompts, TUI, editor). Also settable via MNGR_HEADLESS env var or 'headless' config key. | `False` |
| `--safe` | boolean | Always query all providers during discovery (disable event-stream optimization). Use this when interfacing with mngr from multiple machines. | `False` |
| `--plugin`, `--enable-plugin` | text | Enable a plugin [repeatable] | None |
| `--disable-plugin` | text | Disable a plugin [repeatable] | None |
| `-S`, `--setting` | text | Override a config setting for this invocation (KEY=VALUE, dot-separated paths; append __extend to the leaf key to extend list/dict/set fields) [repeatable] | None |

## Other Options

| Name | Type | Description | Default |
| ---- | ---- | ----------- | ------- |
| `--skill` | text | Donation skill to run (the subdir name under the host-dir skill cache). | `document-review` |
| `--skill-repo` | text | Upstream git repo the skill (code + prompts) is checked out from. | `https://gitlab.com/sinnott-armstrong-lab/elsi-checklist/credits-for-science/document-review-skill.git` |
| `--skill-ref` | text | Git ref to check out: a branch to track, or a pinned commit for a reviewed version. | `c2e9bbe799c20c9da3896c2205991164f10555fd` |
| `--agent-name` | text | Name for the created donation agent. | `donate-extra-quota-bio` |
| `--dry-run` | boolean | Report the spare-capacity decision without creating an agent. | `False` |
| `--start` | boolean | Schedule donate to run automatically (installs a launchd LaunchAgent; macOS only) and exit. | `False` |
| `--stop` | boolean | Remove the scheduled donate LaunchAgent and exit. | `False` |
| `--interval-minutes` | integer range | With --start: how often the scheduled donate runs. | `10` |
8 changes: 8 additions & 0 deletions libs/mngr/imbue/mngr/plugin_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,14 @@ class CatalogEntry(FrozenModel):
is_recommended=True,
gate=RequiredPackagesGate(packages=("imbue-mngr-pi-coding", "imbue-mngr-usage")),
),
CatalogEntry(
entry_point_name="donate",
package_name="imbue-mngr-donate",
description="Spend spare Claude capacity on a donation skill (`mngr donate`)",
tier=PluginTier.DEPENDENT,
is_recommended=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is fairly experimental, I'd probably keep is_recommended False by default for now

Once we're ready to release it (and after we've done some testing, have some initial science partners, etc), then we can flip it to True

gate=RequiredPackagesGate(packages=("imbue-mngr-usage",)),
),
# --- INDEPENDENT, no signal ---
CatalogEntry(
entry_point_name="usage",
Expand Down
32 changes: 30 additions & 2 deletions libs/mngr/imbue/mngr/providers/local/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from datetime import timezone
from functools import cached_property
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Final
from typing import Mapping
from typing import Sequence
Expand Down Expand Up @@ -59,6 +61,33 @@
HOST_ID_FILENAME: Final[str] = "host_id"
TAGS_FILENAME: Final[str] = "labels.json"

# psutil.cpu_freq() is unreliable on Apple Silicon and reading it must never abort
# host listing. psutil's has_cpu_freq() only checks that the IOKit "pmgr" entry
# exists, so on M4/M5 Macs it reports the metric as available while the actual read
# raises -- Apple changed the private voltage-state data format that psutil parses
# (psutil #2642 shifted the M4 representation to kHz; #2382 tracks the arm64 read
# raising). The failure surfaces as SystemError ("<built-in function cpu_freq>
# returned a result with an exception set") wrapping psutil's RuntimeError, so we
# treat any of these as "no reading". CPU frequency is an optional, informational
# field, so a missing value is fine -- callers already handle frequency_ghz=None.
_CPU_FREQ_READ_ERRORS: Final = (NotImplementedError, OSError, RuntimeError, SystemError)


def read_cpu_freq_ghz(read_cpu_freq: Callable[[], Any]) -> float | None:
"""Return the current CPU frequency in GHz, or None if it cannot be read.

``read_cpu_freq`` is injected (normally ``psutil.cpu_freq``) so callers can
exercise the failure path without patching psutil. See ``_CPU_FREQ_READ_ERRORS``
for why the read can fail on Apple Silicon.
"""
try:
cpu_freq = read_cpu_freq()
except _CPU_FREQ_READ_ERRORS:
return None
if cpu_freq is None:
return None
return cpu_freq.current / 1000


def get_or_create_local_host_id(base_dir: Path) -> HostId:
"""Get the persistent host ID, creating it if it doesn't exist.
Expand Down Expand Up @@ -484,8 +513,7 @@ def get_host_resources(self, host: HostInterface) -> HostResources:
"""
# Get CPU count and frequency
cpu_count = psutil.cpu_count(logical=True) or 1
cpu_freq = psutil.cpu_freq() if hasattr(psutil, "cpu_freq") else None
cpu_freq_ghz = cpu_freq.current / 1000 if cpu_freq else None
cpu_freq_ghz = read_cpu_freq_ghz(psutil.cpu_freq) if hasattr(psutil, "cpu_freq") else None

# Get memory in GB
memory = psutil.virtual_memory()
Expand Down
21 changes: 21 additions & 0 deletions libs/mngr/imbue/mngr/providers/local/instance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4

import pytest
Expand All @@ -26,6 +27,7 @@
from imbue.mngr.primitives import VolumeId
from imbue.mngr.providers.local.instance import LOCAL_HOST_NAME
from imbue.mngr.providers.local.instance import LocalProviderInstance
from imbue.mngr.providers.local.instance import read_cpu_freq_ghz
from imbue.mngr.providers.local.volume import LocalVolume
from imbue.mngr.utils.testing import make_local_provider

Expand Down Expand Up @@ -268,6 +270,25 @@ def test_get_host_resources_returns_valid_resources(local_provider: LocalProvide
assert resources.memory_gb >= 0


def test_read_cpu_freq_ghz_converts_mhz_to_ghz() -> None:
assert read_cpu_freq_ghz(lambda: SimpleNamespace(current=2400.0)) == pytest.approx(2.4)


def test_read_cpu_freq_ghz_returns_none_when_reader_returns_none() -> None:
assert read_cpu_freq_ghz(lambda: None) is None


# psutil.cpu_freq() can fail in several ways on Apple Silicon (M4/M5 raise a
# SystemError wrapping psutil's RuntimeError even though has_cpu_freq() is True);
# none of them must abort host listing, so each maps to a None reading.
@pytest.mark.parametrize("error_type", [SystemError, RuntimeError, OSError, NotImplementedError])
def test_read_cpu_freq_ghz_returns_none_when_reader_raises(error_type: type[Exception]) -> None:
def raising_reader() -> SimpleNamespace:
raise error_type("cpu_freq unavailable on this platform")

assert read_cpu_freq_ghz(raising_reader) is None


def test_host_has_local_connector(local_provider: LocalProviderInstance) -> None:
host = local_provider.create_host(HostName(LOCAL_HOST_NAME))
assert host.connector.connector_cls_name == "LocalConnector"
Expand Down
11 changes: 11 additions & 0 deletions libs/mngr_donate/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog - mngr_donate

A concise, human-friendly summary of changes for the `mngr_donate` library. Entries are categorized using the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) categories: Added, Changed, Deprecated, Removed, Fixed, Security.

For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDGED_CHANGELOG.md).

## [Unreleased]

### Added

- New `imbue-mngr-donate` plugin, extracted from `imbue-mngr-usage`: provides `mngr donate`, which spends spare Claude capacity on a donation skill (default: scientific `document-review`). Depends on `imbue-mngr-usage` for the spare-capacity snapshot.
100 changes: 100 additions & 0 deletions libs/mngr_donate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# imbue-mngr-donate

`mngr donate` -- spend spare Claude capacity on a donation skill instead of letting
idle quota expire.

One invocation is a single tick: read the account-level usage snapshot (the same one
`mngr usage` shows), and *if* there's spare capacity (5h window under budget **and** the
week under its pace line), launch a headless Claude agent that runs a donation skill
(default: scientific `document-review`) to completion, then auto-cleans up. When there's
no spare capacity -- or no usage data to judge from -- it does nothing and says so.

This is a separate plugin from `imbue-mngr-usage` on purpose (measuring usage and donating
spare quota are orthogonal), but it depends on it: installing `imbue-mngr-donate` pulls in
`imbue-mngr-usage`, whose snapshot API + `usage` plugin config donate reads at runtime.

## Before you start: authentication (one-time setup)

Do this first. The donation agent is **headless**, and headless Claude authenticates
differently from the Claude app you use interactively -- skipping this step is why a
donate setup "randomly breaks" hours after it worked:

- The desktop app's login popup (`/login`) renews **only the app's own session**. It does
not refresh the credentials headless `claude` reads.
- A plain web login yields a **short-lived (~8h) access token with no refresh token**. A
donate setup leaning on it works for a few hours, then every tick fails with
`Failed to authenticate. API Error: 401 Invalid authentication credentials` -- typically
overnight.

What donate needs instead is a **long-lived (~1 year) token** minted for exactly this
use case:

```bash
# 1. Mint it (same login popup, but prints a long sk-ant-oat... token in the terminal --
# copy ALL of it; it usually wraps across lines):
claude setup-token

# 2. Stash it in the macOS keychain (prompts for a "password" -- paste the token there,
# so it never lands in a file or shell history). Add -U to overwrite an existing entry:
security add-generic-password -s mngr-donate-oauth -a "$USER" -w
```

At each tick, donate looks for that `mngr-donate-oauth` keychain entry and exports it to the
agent as `CLAUDE_CODE_OAUTH_TOKEN` (which takes precedence over the session token). If
`CLAUDE_CODE_OAUTH_TOKEN` is already set in the environment, or the entry doesn't exist, the
environment is inherited unchanged -- so without the stash, donate still runs, but only as
long as your current session token does (and starts 401ing when it lapses).

## Run a donation

```bash
# From inside a trusted git repo (the agent is sourced from the current dir):
mngr donate # one tick: donate now if there's spare capacity
mngr donate --dry-run # show the decision + numbers, launch nothing
mngr donate --skill my-skill # run a different skill (default: document-review)
```

## Schedule it (drain spare capacity over time)

A single tick spends at most one skill run's worth of quota. To actually *drain* spare
capacity, schedule it -- the schedule, not any one tick, is what uses up the idle quota:

```bash
mngr donate --start # install a launchd LaunchAgent (every 10 min by default)
mngr donate --start --interval-minutes 5
mngr donate --stop # remove it
```

`--start`/`--stop` are **macOS-only** and install a **launchd LaunchAgent**
(`com.imbue.mngr.donate` in `~/Library/LaunchAgents/`). launchd -- not cron -- because the
agent must run inside your **login session** to reach the macOS keychain where Claude's
subscription token lives; a cron job runs outside it and every tick fails `Not logged in`.
launchd also catches up after sleep. On other platforms, schedule `mngr donate` yourself.

## Skills: pinned code, dynamic prompts

The donation skill (its code **and** prompts) lives in the lab's own upstream git repo --
the single source of truth. `mngr donate` **checks it out** into a host-dir cache
(`<host_dir>/donate-skills/<skill>/`) and points the agent at it, so the lab can revise the
skill without an mngr release:

- `--skill-repo` — the upstream repo (default: the `document-review` skill's GitLab repo).
- `--skill-ref` — the git ref to check out. A **branch** to *track* (each tick adopts the
latest — good for a fast-moving lab), or a **pinned commit** for a reviewed, reproducible
version that imbue bumps deliberately.

**Pin the ref for anything unattended.** The donation agent runs with
`--dangerously-skip-permissions`, so whatever code is at `--skill-ref` executes on your
machine. Tracking a branch auto-adopts the lab's changes; pinning a reviewed commit is the
safe default for scheduled runs (bump the ref to adopt updates after review).

## Notes

- **Run it from a trusted git repo.** The donation agent is created from the current
directory; `--start` bakes that directory (and your `PATH`) into the LaunchAgent.
- **It needs usage data.** Spare capacity is judged from the account-level snapshot, which is
populated by mngr-managed Claude agents. With none recorded recently, `donate` reports
"can't tell" and skips rather than guessing.
- **Logs.** Each run's full event stream is tee'd to `<host_dir>/donate-logs/<agent>-<ts>.jsonl`,
and scheduled runs also append to `<host_dir>/donate-logs/schedule.log`, so a run survives the
agent's auto-destroy for later inspection.
5 changes: 5 additions & 0 deletions libs/mngr_donate/UNABRIDGED_CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Unabridged Changelog - mngr_donate

Full, unedited changelog entries consolidated nightly from individual files in `libs/mngr_donate/changelog/`.

For a concise summary, see [CHANGELOG.md](CHANGELOG.md).
Empty file.
1 change: 1 addition & 0 deletions libs/mngr_donate/changelog/add-mngr-donate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New `imbue-mngr-donate` plugin: `mngr donate` spends spare Claude capacity (from the `imbue-mngr-usage` snapshot, a dependency) on a donation skill instead of letting idle quota expire. When the 5h window has budget and the week is under pace, it launches a headless Claude agent that runs the skill to completion, then auto-cleans up; `--start`/`--stop` install/remove a launchd LaunchAgent (macOS) so idle quota drains on a schedule (launchd, not cron, so it runs in the login session where Claude's keychain token lives). The donation skill (code + prompts) is owned by its upstream git repo -- `mngr donate` checks it out at `--skill-ref` (a branch to track, or a pinned commit for a reviewed version) into a host-dir cache and points the agent at it, so the skill updates without an mngr release.
9 changes: 9 additions & 0 deletions libs/mngr_donate/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Project-level conftest for mngr_donate."""

from imbue.imbue_common.conftest_hooks import register_conftest_hooks
from imbue.mngr.utils.logging import suppress_warnings
from imbue.mngr.utils.plugin_testing import register_plugin_test_fixtures

suppress_warnings()
register_conftest_hooks(globals())
register_plugin_test_fixtures(globals())
Empty file.
Loading
Loading