Turn a fresh Mac mini (Apple Silicon or Intel) into an always-on, headless development server for Claude Code, in one idempotent command, and keep it that way.
You never sit at the Mac mini: work happens through Claude Code Remote Control (Claude app, claude.ai/code), SSH and Tailscale. devhost sets the machine up, runs one Remote Control service per project, and records usage and resource data for every Claude account in one place.
Part A is the devhost CLI and collector (runs on the Mac mini); part B is devhost Desktop (the macOS app on the MacBook). Both are implemented. This is what has been checked, and how (state of main on 2026-11-04):
| Area | State | How |
|---|---|---|
| Lint, typecheck, unit and UI tests (406 tests, TypeScript) | verified | npm run check on every push and pull request (ubuntu CI job quick), against a stateful fake Mac, not a real one |
| Contract drift (generated types vs schemas), versioning baseline | verified | npm run gen + git diff --exit-code in the manual CI check job; packages/contract/test fails on a breaking or additive schema change without a version bump |
Rust: devhost-core and the Tauri shell |
verified | cargo check and cargo test in the quick job (Linux) |
| Desktop app build, universal macOS binary | verified | manual macOS CI run (workflow_dispatch, app job), 2026-10-16 |
apply --dry-run, uninstall --dry-run, --json result on macOS |
verified | smoke test in the manual macOS check job, with a temporary $HOME |
| Setup, login, Remote Control, trust, reattach, work volume basics | verified on a real MacBook | fixes from running it there (see git log: sudo and launchd handling, pty trust answers, the login the services really use, Intel Mac minis) |
| Headless Mac mini at boot, nobody logged in (services, network wait, Tailscale, Colima) | not verified | designed for it; never booted unattended on a Mac mini |
| Encrypted work volume unlocked at boot without a GUI login (unlock service, Keychain item) | not verified | the UUID check and orphan cleanup are tested on the fake Mac only |
KeepAlive restarts and the throttle after a crash or a long stop |
not verified | plist contents are tested, launchd's behaviour is not |
Login expiry while running (401, authProbe, the alert) |
not verified | parsing and alerts are tested on recorded output |
Root helper validating a WorkingDirectory on the work volume (/Volumes/<name> + marker UUID) |
not verified | the JXA script runs only under macOS |
Orphaned-service reconciliation (services.orphans: removed environments, Colima after a runtime switch) |
not verified | new in the last commits; fake-Mac tests only |
check (ubuntu-26.04) CI leg |
not verified | runs by hand only; the quick job covers the same npm and cargo checks on ubuntu-latest |
The bundled app running against the host (real ssh round trip, tray, Terminal/VS Code launchers, web-app setup) |
not verified | the build is verified, not the use of it |
Details and the open questions about macOS commands are under "Verified against the docs" and "devhost Desktop" below.
On the Mac mini, from a clone of this repository:
./bootstrap.shThat installs Xcode Command Line Tools, Homebrew and Node 24, builds devhost and installs it into the Homebrew prefix, runs devhost init (three questions), then devhost apply --dry-run and devhost apply. --no-apply stops after init.
Node: when nvm is installed ($NVM_DIR, default ~/.nvm), bootstrap runs nvm install 24 (which also upgrades to the newest 24.x and leaves your nvm default alone); otherwise it installs Homebrew's node@24. DEVHOST_NODE_VERSION=26 ./bootstrap.sh picks another major. devhost is a small wrapper script that runs the CLI with the absolute path of that Node, because ssh and launchd have a minimal PATH that never contains an nvm Node; the collector service uses the same Node. After removing that Node version (for example nvm uninstall), run ./bootstrap.sh again.
Apple Silicon and Intel are both supported. Homebrew lives in /opt/homebrew on Apple Silicon and in /usr/local on Intel; devhost picks the prefix from the architecture (shell block, Docker CLI plugins, service tool paths). On Intel, Homebrew no longer ships prebuilt bottles, so some formulae build from source (slow, and occasionally failing). Under Rosetta, bootstrap.sh stops and doctor fails: run them from a native terminal.
Then:
devhost account add --name personal --default --git-email me@example.com
devhost account add --name business --git-email me@work.example # prints a URL: open it anywhere, sign in, paste the code
devhost env create --account business --name acme-api --repo git@github.com:acme/api.git
devhost doctor # everything green?
devhost top # live view; also works over SSH from a phoneThe environment now shows up in the Claude app under the business account, and comes back by itself after a reboot.
- Account: one claude.ai login. At most one is the default (uses Claude Code's default config dir,
CLAUDE_CONFIG_DIRunset); every other account uses~/.claude-<name>. Accounts are data in the config, none are built in. Account root:~/work/<name>/. - Environment: one project (a git repo under an account root) with its own Remote Control service, a 100-port block (
$DEVHOST_PORT_BASE…+99), and optional Docker Compose services (projectdevhost-<name>). - Routing: direnv sets
CLAUDE_CONFIG_DIRfor everything below an account root; git identity is switched withincludeIf; services get the same variables explicitly.
| Command | |
|---|---|
init [--force] [--hostname h] [--docker colima|orbstack|none] [--no-tailscale] |
Wizard → ~/.config/devhost/config.yaml; generates the ntfy topic. |
apply [--dry-run] [--only <step>…] |
Idempotent setup. --dry-run prints the plan, including every sudo command. |
doctor |
Read-only checks with one-line fixes (✅/plugin.<name>.<check>. |
plugin list, plugin widgets, plugin run <plugin> <action> [--env n] [--session id] [--dry-run] [--yes], plugin install <source> [--name] [--enable], plugin uninstall <name>, plugin enable|disable <name> |
devhost plugins (see Plugins below). An action that removes or stops something asks first. |
status |
One-screen summary. |
account list | add | remove |
Add: --name --git-email [--git-name] [--default] [--daily-cost-alert <usd>] [--no-login]. Remove: refused while the account has environments; --logout also signs out; never deletes the root, the config dir or credentials. |
login <account> |
Sign in, accept workspace trust and the one-time Remote Control confirmation for each environment, restart its services. The first sign-in records the account's claude.ai login (accounts[].claudeLogin); a sign-in as anyone else, or as another account's login, is signed out again and refused (the environments would register under the wrong claude.ai account). --switch-login accepts a new login on purpose and forgets the account's Remote Control state (bridge pointers, device id), so its environments register afresh under it. The services (LaunchDaemons) sign in with <config dir>/.credentials.json; a sign-in from a terminal on the Mac stores the new login in the Keychain and leaves that file as it was, so login checks whose login the file holds (claude.ai's profile endpoint), copies the Keychain sign-in into it when they differ (or refuses), and then starts Remote Control afresh. doctor shows who each account's services are signed in as. |
env list | create | delete | start | stop | restart | sleep | wake | logs |
Create: --account --name [--repo] [--no-rc] [--compose auto|off] [--spawn worktree|same-dir] [--permission-mode <mode>] [--daily-cost-alert <usd>] [--hourly-cost-alert <usd>] (--spawn and --permission-mode are passed to claude remote-control). Delete keeps files unless --delete-files (shows unpushed commits, asks for the typed name). Running env create again for an environment that is in the config continues (or repeats) its setup with the config's settings, e.g. after a failed docker compose up; the app has Try again and Environments → … → Run setup again. A compose network left from before is reported with how to remove it (devhost never removes it). sleep stops the compose services running now (they stay noted), wake starts exactly those again; the collector does both on its own: asleep after docker.idleSleepMinutes (default 60) without a session, awake with the next one. A service stopped by hand stays stopped. |
config get | set | validate | edit |
Always schema-validated, comments preserved; changing config does not apply it. |
dash [--range 1h|24h|7d|30d] [--account] [--env] [--model] [--follow], top [--once] |
Dashboard data (--json) and a refreshing terminal table. |
agents list |
The subagent catalog (built-in: explorer, reviewer, test-runner, docs, plus agents.custom) and the agents each account and environment has. Choose them with agents: [name, …] on an account or environment (config set or the app's Agents screen), then apply --only agents: the agent files go to <Claude config dir>/agents/ and <environment>/.claude/agents/; only files devhost marked are ever changed. agents.delegation: true adds a short subagent rule to each account's CLAUDE.md. |
session handoff <id> [--new] |
A session's handoff note, to continue its work in a new session: the one written since the session's last activity, or a new one (--new always writes). Written by a one-off fork of the session (claude -p --resume <id> --fork-session --no-session-persistence, hooks off; the session itself is untouched), which costs about one cache write of its context. The collector writes one automatically for a session idle for claude.handoff.idleMinutes (55; 0 turns it off) with at least claude.handoff.minContextTokens (50,000) of context, just before its prompt cache expires, and notifies you. Notes are in ~/.local/share/devhost/handoff/. |
session reattach <id> [--dry-run] |
Bring an ended Remote Control session back, e.g. after a reboot (the environment's service then registers anew and the old session is left without a machine). Only for sessions a service started in its own worktree (.claude/worktrees/bridge-cse_<id> ↔ session_<id> on claude.ai): runs claude remote-control --session-id session_<id> there as a one-shot LaunchDaemon (com.devhost.reattach.cse_<id>, not restarted when the session ends; earlier ended ones are removed on the next reattach and by uninstall). Usually not needed: writing in the session on claude.ai continues it under the environment's service, and a service renews its bridge pointer before it starts, so after a long stop (Claude Code drops a pointer older than 4 hours) it comes back with its own environment and session. Sessions in the environment's folder cannot be reattached: the folder is the service's. While a reattach job runs, claude.ai lists a single-session environment named bridge-cse_… instead of the environment. |
update [--check] [--all] [--force] [--dry-run] |
Update Claude Code: brew upgrade --cask claude-code, then restart every Remote Control service onto it (a service runs its sessions from the versioned Caskroom path the upgrade deletes) and list the sessions they had open (each continues under its environment as soon as you write in it on claude.ai; running reattach jobs are removed, since they hide the environment on claude.ai). Waits while a session is in the middle of a turn unless --force; refuses to run from inside a session of a service it would restart. --check only reports; --all also upgrades the outdated Brewfile packages. The collector does it on its own (claude.update: auto, the default) inside claude.updateWindow (default 02:00-06:00), and notifies; notify only tells you, off neither. The app: Dashboard → host card → Claude Code → Update now…. |
storage list | status | wait | adopt <volume> | set-passphrase | migrate --account <a> [--cleanup] | prepare <disk> --label <name> --yes-erase |
The external work volume (see Work volume below). prepare erases a disk: CLI only, never from the app. |
collector run |
The collector in the foreground (launchd runs it for you). |
notify --test |
Send a test notification. |
uninstall [--keep-packages] [--purge-data] [--purge-storage] [--dry-run] |
Removes launchd jobs (including orphaned com.devhost.* ones), managed blocks and generated files. --purge-data also deletes the collector database; --purge-storage removes the work volume's unlock service and Keychain item (never the volume or its data). Projects, Claude config dirs and credentials stay. |
version |
CLI and contract versions. |
Global flags: --json, --verbose, --yes, --config <path>.
Also available for the desktop app (same contract): config raw (the file with comments), config put (replace from stdin, only if valid), config validate --stdin, collector logs, and dash includes the list of models seen.
| Step | Does |
|---|---|
system.helper |
With machine.rootHelper: true: installs the root helper /Library/PrivilegedHelperTools/app.devhost.root (root:wheel 0755) and /etc/sudoers.d/devhost (checked with visudo -c first), which lets you run it, and only it, without a password. From then on devhost's root operations (installing, starting, stopping and removing its own LaunchDaemons, hostname, power, Remote Login) go through it: no sudo password, so the desktop app and ssh need nobody at the Mac mini's keyboard. Installing or updating the helper asks for the password once. false removes both. |
system.hostname / system.power / system.ssh |
scutil, pmset -a sleep 0 disksleep 0 autorestart 1 womp 1, Remote Login (sudo). A power flag set to false means "leave alone". macOS blocking Remote Login (Full Disk Access) → exact manual steps, exit 3. |
packages |
Renders ~/.config/devhost/Brewfile, brew bundle install --no-upgrade (brew's output shows while it runs). Only installs what is missing: upgrading stays brew upgrade, so an apply does not upgrade or rebuild everything that had a release. Adds what the config implies: direnv, Colima + docker + compose + buildx (or OrbStack), tailscale, claude-code. |
runtimes |
Installs mise with its own installer (curl -fsSL https://mise.run | sh, a checksummed prebuilt binary in ~/.local/bin/mise, updated with mise self-update) unless a mise is already on PATH, then mise use --global for each of runtimes. Not Homebrew: it has no bottles for Intel Macs any more, and building mise there means building llvm and rust first (hours). |
shell |
The # >>> devhost >>> block in ~/.zshrc: Homebrew, mise (from PATH or ~/.local/bin), direnv hook, claude-<account> aliases. |
docker.config, docker.runtime |
Compose/buildx plugin path; Colima as a boot-time LaunchDaemon with the configured resources. |
tailscale |
sudo brew services start tailscale (system daemon, no GUI login needed). Logging in is never automated: you get sudo tailscale up instructions. |
claude.binary |
Records the absolute path of claude for plists (the collector runs on the Node that runs devhost). |
accounts.claude |
Per account: merges settings into the user-level settings.json (keeps your keys), adds OTel + hooks, and a managed block in CLAUDE.md. |
accounts.routing, accounts.git |
Account roots, <root>/.envrc + direnv allow, includeIf per account root. |
collector |
LaunchDaemon com.devhost.collector. |
agents |
The configured subagent definitions of each account and environment (devhost agents list); only files devhost marked are changed. |
storage.volume / storage.ownership / storage.spotlight / storage.automount / storage.unlock |
With a work volume: identity and mount check, ownership enabled, Spotlight off, AutomountDisksWithoutUserLogin, and the boot-time unlock service for an encrypted volume (its volume UUID is compared with the configured one; with no encrypted volume configured the service is removed). |
services.orphans |
com.devhost.* LaunchDaemons no environment or step owns any more (a removed environment, Colima after a runtime switch). apply lists them and asks before removing them; uninstall removes them. |
env.<name> |
Repo (clone or git init), .envrc (git-excluded), compose, LaunchDaemon com.devhost.rc.<account>.<name> (UserName = you, KeepAlive, ThrottleInterval 30). Each start writes [devhost] started <time> to its log and waits (up to 3 minutes) until api.anthropic.com resolves: at boot launchd starts it before the network is up. The collector rotates the service logs in ~/Library/Logs/devhost once a day (<log>.1.gz … compressed, emptied in place) and keeps collector.retention.logDays (default 7) days; a log nothing wrote to for that long is removed. doctor reads the log from the last start: a sign-in error there (expired token, 401) fails with devhost login <account>. Missing port blocks are allocated and written back to the config. |
--only system selects every system.* step, --only storage every storage.* step; --only env.acme-api one environment, --only services.orphans the orphan cleanup.
Safety: a timestamped backup (.devhost-bak-YYYYMMDD-HHMMSS) before changing any existing file; every sudo is announced with its reason and listed in --dry-run; no account tokens, keys or passwords are stored (the one secret devhost keeps is the collector's local bearer token, below); no prompt text, model output, tool input or file content is collected (a test greps the whole database for planted text); apply and env create are safe to interrupt and re-run (SIGINT stops after the current step). The ntfy topic is the one quasi-secret: anyone who knows it can read your notifications.
Notifications go through a public relay. The default server is https://ntfy.sh, a public service (notify.server points elsewhere, e.g. a ntfy you host). Each alert is sent as a POST to <server>/<topic> with a title, a priority, tags and a short message (environment, account, session title if you set one, numbers). They carry no prompt text: a session title taken from your last prompt is never sent. The relay and anyone who knows the topic can still read what is sent, so keep the topic private and treat the messages as visible to ntfy.sh.
The collector accepts only local, authenticated clients. It listens on 127.0.0.1 only and rejects, before reading a body: any request with an Origin header (a browser page cannot feed it, which also stops CSRF), any Host other than 127.0.0.1:<its port> (DNS rebinding), and, on /v1/* and /hooks, a missing or wrong bearer token (Authorization: Bearer …, or ?token= in the hook URL; compared in constant time). The token is created once at ~/.local/share/devhost/collector-token (mode 600); apply writes it into each account's Claude settings (OTEL_EXPORTER_OTLP_HEADERS and the hook URL). Anyone who can read your home directory can read it, but then they can read the database too.
Generated CLAUDE.md and dev servers. The managed block in each account's CLAUDE.md tells Claude Code to start dev servers bound to 0.0.0.0 on a port from the environment's block ($DEVHOST_PORT_BASE…+99) and to report them as http://<hostname>:<port>. That is what makes a dev server reachable from your MacBook or phone over Tailscale, but 0.0.0.0 also means every network the Mac mini is on: a server Claude starts is open to the LAN, with whatever authentication the project has (usually none). On an untrusted network, edit the block's wording (templates/CLAUDE.md.mustache before apply, or the rule in the account's CLAUDE.md outside the managed block), or limit the ports with the macOS firewall.
The collector is the one long-running process: it receives Claude Code OpenTelemetry (OTLP/HTTP JSON) and hook calls on 127.0.0.1:4318, samples CPU/RAM (system, per environment, best effort per session), Docker, disk, Tailscale and each account's login, and stores everything in SQLite (~/.local/share/devhost/devhost.db) with hourly rollups (retention.rawDays/rollupDays). Usage and sessions belong to the account whose login sent them: the collector records each account's organization id and email from claude auth status, and a session whose telemetry carries a known one is that account's, even when the process read another account's config directory (the Claude app's own Claude Code always reads ~/.claude). Otherwise the account comes from the environment directory, then from the config directory's hook URL. Subagents come from the SubagentStart/SubagentStop hooks (their id and type only, never prompt or answer); every sample also counts the agents at work (sessions running a turn plus running subagents) per account and in total, rolled up hourly as peak and average. Where the time goes: from Claude Code's api_request and tool_result events (tool name and duration only), per session merged so parallel work counts once: the model's share, the time spent only on this host's tools (Bash, Read, Edit…), its CPU-bound part (from the session's CPU samples), network tools, the time per tool, and what a 2× or 4× faster CPU would save; plus memory: the largest session, system peak, swap and time under memory pressure (dashboard cards "Where the time goes" and "Memory", dash --json → profile). It starts with the first tool run recorded. Token mix: each token kind's share of the tokens and its estimated share of the cost, per model the output, context and cost per request, and how output per request and the cache hit rate change over the range: a step there with the same kind of work usually means the model changed (cards "Token mix" and "Per request over time", dash --json → tokens). Thinking tokens are billed as output and are not reported apart. Remote access is only via SSH (devhost top, devhost dash --json).
Keeping the collector light (spec A10.9): one ps per 5 s tick, attributed by process tree (never lsof); every external source single-flight with a timeout, a still-running source skips its tick; Tailscale every 60 s straight from tailscaled's LocalAPI on its unix socket (macOS's tailscale CLI runs lsof on every call; with the GUI Tailscale app doctor recommends the open source tailscaled); the dashboard snapshot precomputed each tick, so dash --follow and the plugins only read it. The collector records its own CPU, memory and child processes; doctor fails above 3 % CPU over 5 minutes or on overlapping commands. npm run soak checks all of this for 10 minutes against a copy of this host's data.
Alerts (ntfy, one notification per state change): memory above the limit for 5 min, low free disk, an account's estimated daily cost above its limit, an environment's estimated cost today or in the last 60 minutes above its limits (the hourly one names the session that spent most, to catch a runaway), a Remote Control service restarting more than 3 times in 10 min, a login that stopped being valid (reminder every 24 h).
Known limits (also shown next to the numbers):
- Cost is an estimate from Claude Code; on a subscription it is API-equivalent value, not billed money.
- Subscription plan limits (session/weekly usage) are not in the telemetry and are not shown.
- Per-session CPU/RAM only for Remote Control sessions; per-environment numbers are reliable.
- Cloud sessions and sessions on other machines are out of scope: the Mac mini only.
Every command takes --json. Short commands print { "contractVersion": 1, "ok": true, "data": {…}, "errors": [] }. Long ones (apply, env create, login, account add, uninstall, dash --follow) stream NDJSON events: step, log, prompt, snapshot, and a final result. A prompt event (open-url, input, confirm) is answered by writing {"id":"…","value":…} as one line to stdin. Exit codes: 0 ok · 1 checks failed · 2 usage/config error · 3 human action required (e.g. a sudo password with no terminal) · 130 cancelled.
Versioning. contractVersion is the major: it changes only for a breaking change (a removed or changed field, a new required one), and the app refuses a CLI with a different major. contractMinor (in packages/contract/src/lite.ts, currently 1) is bumped for additions (new optional fields, events, enum values), and capabilities (currently snapshot-event) lists optional features, so a client can test for a feature instead of a version. Both are part of the envelope schema as optional fields; the current CLI does not print them yet, they are constants of the contract package. A test compares the schemas with packages/contract/test/baseline.json and fails when the surface changed without the matching bump. The CLI's own version (devhost version, with the git commit when known) is separate from the contract's.
Schemas live in packages/contract/schemas/*.schema.json; the TypeScript types are generated (npm run gen) and committed; CI fails on drift.
A devhost plugin is an ES module whose default export lists actions and checks (packages/cli/src/plugins/api.ts; definePlugin gives the types). It runs in the CLI on the Mac mini, as you, and changes things only through ctx.exec, so --dry-run, the plan and the root helper work as for apply. A plugin is full-trust code, not sandboxed: ctx.exec is how a well-behaved plugin makes its changes visible in the plan, but the module is plain Node.js running as your user, so it can also read your files, use the network or spawn processes directly (and, with the root helper, anything ctx.exec can ask it to do). Install only plugins whose source you have read; plugin install pins the commit it cloned, and a plugin stays off until you enable it. An action is scoped to the host, an environment or a session; the desktop app shows host actions on its Plugins screen, environment actions in the environment's "…" menu and session actions in the session's Open menu. Checks appear in devhost doctor.
-
Advice (
advice: [{ check, on?, run(ctx, check) }]): a note under another check, devhost's or another plugin's, indevhost doctorand the app's Doctor screen.checkis a check id,*standing for one dot-separated part (env.*.service);onthe statuses it is asked for (default warn and fail).runreturns{ message, fix?, action? }ornull;action(one of the plugin's host actions) becomes the fixdevhost plugin run <plugin> <action>and a button in the app. Example: disk-cleanup undersystem.disk. -
Widgets (
widgets: [{ id, title, size?, refreshSeconds?, run(ctx, params) }]): cards on the app's dashboard, below the host's health;size: 'wide'spans the page.runreturns one card or several ([{ key, title, … }], e.g. one per account), each{ status?, summary?, account?, tiles?: [{ label, value, sub? }], rows?: [{ label, value, note? }], meter?: { label, value, max }, chart?: { title, series, colorBy?, points: [{ ts, values }], axis, format, height? }, note?, action? }: text values are the plugin's to format, a chart takes numbers (stacked columns over time;colorBy: 'account'gives series the accounts' colors),accountcolors the card,actionis a button. Filters (filters: [{ id, label, kind: 'segmented' | 'select', default?, allLabel?, options(ctx) }]) appear above the plugin's cards; their values reachrunasparamsand the app remembers them.ctx.stats({ range, account?, env?, model? })is the collector's dashboard data.devhost plugin widgets [--param <plugin>.<filter>=<value>]…prints them; the app loads them on connect, when a filter changes, everyrefreshSeconds(default 300) and after a card's action ran. -
Plugins of bankyadam's (private repositories; install with the SSH URL):
devhost-plugin-disk-cleanup: frees disk space (Homebrew and npm caches, old Remote Control worktrees that are clean and pushed);report,clean, and a doctor check.devhost plugin install git@github.com:bankyadam/devhost-plugin-disk-cleanup.git --enabledevhost-plugin-session-stop: stops a Remote Control session's localclaudeprocess to free memory (only a devhost service's child in the session's directory).devhost plugin install git@github.com:bankyadam/devhost-plugin-session-stop.git --enable
-
Built in:
stats, the dashboard's usage statistics: filters (time range, account, environment, model, tokens or cost, stacked by account or token type), a card per account (today, this week, the daily cost alert), usage over time and agents at work. It reads the collector throughctx.statslike any plugin could. Built-in plugins (src/plugins/builtin) are on unlessplugins.<name>.enabled: falseand cannot be uninstalled. The dashboard itself keeps the host's health, the environments and the sessions. -
Your own:
~/.config/devhost/plugins/<name>/index.js, loaded only withplugins.<name>.enabled: true;plugins.<name>.optionsisctx.options. A plugin uses only the plugin API (ctx.exec,ctx.sessions(),ctx.services(), …) and Node's own modules, never devhost's other modules, so it can live in its own repository. -
From GitHub:
devhost plugin install <https://github.com/owner/repo | owner/repo | git@github.com:owner/repo.git>[#branch-or-tag] [--name n] [--enable]clones it (shallow) into~/.config/devhost/plugins/<name>(name: the repository's withoutdevhost-plugin-), installs its npm dependencies with--ignore-scripts, and records the source and commit (plugin listshows them). Nothing of it runs until it is enabled.plugin uninstall <name>removes it and its config entry;plugin enable|disable <name>. Built-in plugins can only be disabled. The app's Plugins screen does the same (Install from GitHub…, Enable/Disable, Uninstall…). For a private repository use itsgit@github.com:URL (your SSH key).
plugins:
disk-cleanup: { enabled: true, options: { warnGiB: 10 } }
my-tool: { enabled: true, options: { who: me } }Optional: project data (account roots, environments, worktrees, node_modules) on one external APFS volume, the system, Claude config, credentials, the collector database and logs on the internal disk.
devhost storage list # external disks and their APFS volumes
devhost storage adopt disk5s1 # or its UUID / mount point: config + /Volumes/<name>/.devhost-volume
printf %s "$PASS" | devhost storage set-passphrase # encrypted volume: into the System keychain (stdin only)
devhost apply # mount at boot, unlock service, ownership, Spotlight off
devhost storage migrate --account personal # move an existing account (old copy kept until --cleanup)- The volume is the configured one only when it is mounted at
/Volumes/<label>, diskutil reports its APFS UUID and the marker file names it. devhost never creates anything under a/Volumes/<label>that is not mounted. - New accounts get
<root>/<name>on the volume (account add --rootoverrides). Paths are real paths everywhere (no symlinks). - Services of environments on the volume, and Colima, start through
devhost storage wait; Colima mounts the volume's root (virtiofs). Environments on the internal disk keep their services untouched: adopting a volume or applying again restarts none of them. - An encrypted volume unlocks at boot without a login: a root service (
com.devhost.storage.unlock) pipes the passphrase from the System keychain intodiskutil apfs unlockVolume -stdinpassphrase. prepare <disk>erases an external disk as APFS (--encrypted: passphrase from stdin) and is the only destructive path, withmigrate --cleanup.uninstall --purge-storageremoves the unlock service and the keychain item, never the volume.- Not yet verified on real hardware (this host has no external SSD): mounting at boot without a login on macOS 26, the pre-login unlock from the System keychain, and the keychain write itself (the Keychain was locked in the session that built it).
A macOS app for the MacBook that configures and controls the Mac mini and shows one dashboard across all Claude accounts. It replaces typing devhost commands over SSH; it does not replace the Claude app for talking to sessions.
- No server component. Every operation is
ssh <host> /usr/bin/env PATH=/opt/homebrew/bin:/usr/local/bin:… devhost <command> --json(the fixed PATH findsdevhoston either Mac mini architecture; the wrapper brings its own Node) through the systemssh, over Tailscale, using your~/.ssh/config, agent and keys (BatchMode, one multiplexed connection per host viaControlMaster/ControlPersist). Long commands stream NDJSON; prompt answers (login code, workspace trust, typed confirmations) are written to the command's stdin, never onto a command line. The app stores only the host name and UI preferences (no credentials, no keys, no telemetry copies). - Screens. Dashboard (health, per-account totals with the cost-alert meter, usage chart by account or token type with a table view, Agents at work (how many agents worked at once: sessions running a turn plus their subagents, now, the peak in the range and per bucket), filters); Sessions (the idle and active ones, with waiting-for-input highlighted, each session's subagents (running, total, types) and title (read from its transcript when the dashboard is built, never stored; Remote Control sessions keep their title on claude.ai, so their last prompt is shown instead, in italics), its context (the last request's, in warning over 300K), session actions, the handoff note with a copy button); Environments (CPU, RAM, containers, disk, open sessions and estimated cost today per environment, flagged over its limit; cost alerts under "…" → Cost alerts…; with QR codes on, a code of each environment's Remote Control link, and on Sessions of each Remote Control session's, for a phone to open, e.g. with the app on a second monitor; a click enlarges it; ▶ opens the environment's Remote Control environment on claude.ai/code, where new sessions start, in the account's web app; start/stop/restart, logs and delete with typed confirmation under "…"; create wizard); Accounts (new-account wizard that continues into the login, re-login with the browser step, Safari web app setup, remove); Plugins (the host's plugins and their host actions, with a preview for the ones that remove things); Configuration (form generated from the config schema plus a YAML tab, live validation by the CLI on the Mac mini, invalid configs cannot be saved, then dry-run diff → confirm → live apply; steps needing
sudoopen a Terminal window so the app never sees the password, unless the root helper does them); Doctor; Logs (collector and Remote Control logs grouped by account, followed live) (every running command, setups included, shows an endless spinner at the running step and how long it has been going, so a slow step is not mistaken for a hung one); menu bar item (a monochrome>_like the other menu bar icons; yellow or red only in its cursor for "something to look at" or an alert, plus the number of sessions waiting, and quick links; Monitor opens a window for a second monitor: every idle or active session as a compact card, all always on screen, with a state lamp, a prompt icon when it waits for input, its context and cost, and the QR code of its Remote Control session, next to a strip of the numbers for the work at hand). - Compatibility. On connect it runs
devhost version --jsonand refuses a different contract major with a clear message. An unreachable Mac mini is shown as such, the last data stays visible, and the app reconnects by itself. - Safety. The webview can only ask the Rust shell for
devhost <known subcommand> ...on the configured host, three fixed Terminal actions (devhost …, a shell in a project directory,claude --resume <id>),code --remote, http(s) URLs and the account web apps (only valid account names reach a file path; only a PNG of at most 4 MB is written as an icon). Quoting and validation live inpackages/app/corewith unit tests (hostile hosts, directories, arguments). - Several accounts side by side. The Claude app is signed in to one account at a time, and its signed builds ignore a second data directory, so it cannot run once per account. A Safari web app keeps its own cookies instead: Accounts → Set up… draws an icon in the account's color (the devhost tile with the account name), saves it under
~/Library/Application Support/app.devhost.desktop/web-app-icons/, opens claude.ai/code in Safari and shows the icon in Finder. Safari has no API for Add to Dock, so that step is yours: File → Add to Dock…, nameClaude – <account>(exactly), drag the icon onto the dialog's icon, sign in. Done, check looks for~/Applications/Claude – <account>.app; a web app made earlier under another name can be picked instead (Already have one?, remembered per account). From then on every "open Claude" for that account opens its web app, and without one the default browser: ▶ in Environments opens the environment's Remote Control link (https://claude.ai/code?environment=env_…, whichdevhost env list --jsonreads from the service log asremoteControl.url), the session menu likewise. A Remote Control service started with--spawn worktreehas no session until you start one there. - Root helper (
machine.rootHelper). A JavaScript for Automation script run by the system's/usr/bin/osascript, so no Node, Homebrew or other user-writable file takes part in it. It does only fixed operations and checks each: service labels arecom.devhost.*; a plist it installs or loads may only haveLabel,UserName(must be you),ProgramArguments,EnvironmentVariables,WorkingDirectory(inside your home),RunAtLoad,KeepAliveandThrottleInterval, and it installs the bytes it checked; hostnames and power settings are validated. So the passwordless rule never gives more than your account already has: every service runs as you. Services log through/bin/shas you (exec >>"$0" 2>&1 && exec "$@") instead ofStandardOutPath, which launchd opens as root (a symlink in your log directory could otherwise point it at any file). Not through the helper, still asking for the password: installing the helper itself, and Tailscale'sbrew services(its daemon runs as root from a user-writable Homebrew prefix). - Session actions. Open in claude.ai/code opens the session list (the page lists Remote Control sessions by name,
<account>-<env>); Terminal: resume runsclaude --resume <id>in the environment directory with the account's direnv environment; VS Code appears only if thecodeCLI is found; Dev server openshttp://<host>:<port base>.
Requirements: a Mac on macOS 26 or later (the bundle's minimum), Node 24, Rust (stable), ssh access to the Mac mini. The app is a universal binary (Apple Silicon and Intel, universal-apple-darwin); the Mac mini server side supports both too. No signing or notarization (personal local builds).
npm ci && npm run build:core
npm run dev -w @devhost/app # UI in a browser against a built-in demo host (no ssh)
npm run tauri -w @devhost/app dev # the real app
npm run tauri:build -w @devhost/app # universal → packages/app/src-tauri/target/universal-apple-darwin/release/bundle/macos/devhost.app
npm run tauri:build:arm -w @devhost/app # Apple Silicon only (faster)What was verified without a Mac (the build itself was later verified on macOS CI, see the status table): 67 UI tests that drive the screens against the real CLI (in-process, on the fake Mac) including both wizards, login and trust prompts, delete confirmations, the config → dry-run → apply flow, the sudo → Terminal path, reconnect after an outage and the version refusal; 15 Rust tests for the shell logic; and the Tauri crate type-checks for both aarch64-apple-darwin and x86_64-apple-darwin (cargo check, with a stub C compiler for one Objective-C helper because the check ran on Linux). Not verified: running the bundled app against a host, the real ssh round trip, the tray item, the Terminal/VS Code launchers, and the web app setup in the bundled app (WKWebView drawing the icon, Safari and Finder opening, the ~/Applications check).
MIT, see LICENSE. CHANGELOG.md lists user-visible changes under Unreleased until a version is tagged (sections Added, Changed, Fixed); the version is still 0.1.0, and breaking changes to the JSON output go through the contract version above, not the changelog alone.
npm ci
npm run check # lint + typecheck + tests (TypeScript and Rust)
npm run build
npm run gen # after editing a schemaAll process execution goes through one Runner interface; packages/cli/test/helpers.ts has a stateful fake Mac, so tests prove things like "a second apply changes nothing" without touching a real system. Test hooks: DEVHOST_LAUNCHD_DIR (where plists are installed), DEVHOST_TRUST_IDLE_MS.
bootstrap.sh
packages/
contract/ JSON Schemas + generated types + validators
cli/ devhost CLI and collector
src/{main.ts, commands*.ts, apply.ts, config/, steps/, runner/, collector/, doctor/, init/}
templates/ config.yaml, Brewfile, launchd.plist, CLAUDE.md
app/ devhost Desktop (Part B): Tauri v2 + React
src/ transport/ (Tauri + demo), client/ (typed devhost --json calls), screens/, ui/, lib/
core/ devhost-core: ssh command building, quoting, validation (pure Rust, tested everywhere)
src-tauri/ the Tauri shell around the system ssh
test/ tests that run the real CLI behind the app
Checked on code.claude.com while building:
- Homebrew cask
claude-code(stable channel); native installercurl -fsSL https://claude.ai/install.sh | bash; Homebrew installs do not auto-update. claude auth statusexits 0 when logged in, 1 when not; the credential store and Keychain entry are keyed byCLAUDE_CONFIG_DIR, so the default account must be checked with the variable unset.claude remote-controlflags--name,--spawn same-dir|worktree,--permission-mode,--[no-]create-session-in-dir; it asksTrust <dir>? [y/N]andEnable Remote Control? (y/n)once; server mode exits after ~10 min without network.- HTTP hooks are not supported for
SessionStart(only command hooks), so that event posts the payload withcurl; the other four events use HTTP hooks.allowedHttpHookUrlsis written as a glob (http://127.0.0.1:4318/hooks*). - OTel variables, metric names (
claude_code.token.usagewithtypeinput/output/cacheRead/cacheCreation,cost.usage,session.count, …), theapi_requestevent, default delta temporality, and that exporter variables are ignored in project settings.
Still to verify on a real Mac (not testable on Linux): mise ls --global --json field names, pmset -g custom and systemsetup -setremotelogin on the current macOS, sudo brew services for Tailscale at boot, Colima's start --foreground under launchd, the softwareupdate path in bootstrap.sh, whether trusting a parent directory covers new subdirectories (devhost records trust per environment directory and checks ancestors), and the pty (script) trick used to answer the trust questions without a terminal.
.envrcusessource_up_if_existsinstead ofsource_up: for the default account there is no parent.envrc, andsource_upwould print an error.- Collector port vs. account ranges: the spec's own example puts 4318 inside the
businessrange (4000–4999). That is allowed; an environment's block may just not contain the collector port, so allocation skips 4300–4399 there. - Dry-run is exact: steps run their
apply()against a recording executor; read-only checks still execute. - Services are system LaunchDaemons, so
env start|stop|restartneedsudo(a password prompt in a terminal; exit 3 without one).env stoplasts untilenv startor the next reboot. - Uninstall leaves Tailscale, SSH, hostname/power settings, the Colima VM, mise runtimes,
config.yaml, and (without--purge-data) the database, so the machine stays reachable. Packages are only removed after a confirmation, and Homebrew refuses ones others depend on. node:sqlite(Node 24) is used so there is no native dependency; its experimental warning is silenced.account add --force-default-switchis rejected with an explanation (out of scope for v1, as in the spec).
packagesfails with "Installing has failed!" → runbrew install <pkg>to see brew's own error. On an Intel Mac Homebrew no longer ships bottles (Tier 3), so formulae build from source; if that stops at "Cannot find a C++ compiler that supports both C++11" andclang++cannot find<string>, a stale/Library/Developer/CommandLineTools/usr/include/c++(a few leftover files) hides the SDK's libc++ headers:sudo mv /Library/Developer/CommandLineTools/usr/include/c++ /Library/Developer/CommandLineTools/usr/include/c++.bak, thendevhost applyagain.- The account's web app shows only archived sessions (Team/Enterprise) → the org requires trusted devices, and each Safari web app has its own storage, so claude.ai sees it as a new device: verify it with a passkey in that web app too (open a session or sign in again there to get the prompt), even if Safari already has one. A Remote Control service started with
--spawn worktreehas no session until you start one with New in its environment. - Service exits with "Workspace not trusted" →
devhost login <account>(accepts trust, restarts services). Claude Code has no flag for it, and its trust dialog ignores an answer that "arrived too quickly after the question to count", so devhost answers on a pseudo-terminal (templates/pty-run.py, run by the system's/usr/bin/python3) and waits at least 1.5 s after the question, whether a person or--yessaid yes. It then checks that Claude Code really recorded the trust (hasTrustDialogAcceptedin~/.claude.json, or the account's config directory) and says so when it did not:trust.<env>is only ✅ then. With--yes(ordevhost login <account> --yes) it needs nobody at the keyboard. - Remote Control service says not logged in, but
claude auth statusworks for you → the services run in the system domain without a GUI session and may not reach the login Keychain. Log in over SSH (the Keychain write fails there and Claude Code falls back to~/.claude*/.credentials.json), thendevhost doctorshows the credential store. - After a reboot nothing starts → FileVault is on and waiting for the disk to be unlocked;
doctorreports it. - No data in the dashboard →
devhost doctor(collector.*checks); Claude Code must be started afterapplywrote the OTel settings. - Re-login:
devhost login <account>(the collector notifies you when a login stops being valid).