Sparehand

Docs › Configuration

Configuration

Everything lives in ~/.config/sparehand/ on the hub (or the folder in SPAREHAND_CONFIG).

FileWhat it holds
workersOne line per worker: NAME SSH-HOST MAX-JOBS [CPUS] [SCREEN] (- = not set). Written by sparehand add, fine to edit by hand.
sparehand.confSettings below, as shell assignments.
disabledWorkers switched off on this hub, one name per line (sparehand disable/enable).
hooks/<repo>.shPer-repository setup run on the worker before each command (examples).
forwardsPort forwards, one per line: NAME PORT LOCAL-PORT (sparehand forward, forwarding.md).
app.jsonThe app: port, who may use it (allowedLogins, allowedIPs, proxySecret for a reverse proxy other than tailscale serve; lanPort, 8743, and lan, false to remove it, for devices paired on this network; see remote-access.md), sessions, liveSessions, push (sessions.md), crew, studio, advisor (advisor.md) and addDevices (false hides those features), work sources (work-items.md), prices, markup and aiAddress for members' credits (credits.md), membersAgents (true lets members run Crew tasks and have a Crew agent direct their Studio productions; off by default, since agents run as your user, crew.md), and tailscale (the tailnet and the Keychain item of the API token used to invite people by e-mail, remote-access.md).

The hub's activity log is ~/.local/state/sparehand/log (jobs started and finished, fallbacks, pauses, switches); the app's Activity tab shows it.

Every run is also recorded in ~/.local/state/sparehand/jobs.tsv, one tab-separated line per job: time, machine (- = it ran on the hub), repository, checkout, exit code, seconds, seconds waited, command, and a note (why it ran on the hub, or that it was stopped or handed back). A job that ran on a worker then has where its time went, in seconds: the sync, the install (dependencies and the project's hook), the command itself, and the whole time from the start of sparehand run to its end. Lines written before these were recorded end at the note. sparehand history [DAYS] summarises it (default 30 days): jobs, success rate and average time per machine, time per project on each machine, why jobs ran on the hub, and how the workers compare with the hub (below). The app's History tab shows the same, for 7, 30 or 90 days. Delete the file to start over; the choice of worker and the comparison with the hub start over with it.

Did the worker finish sooner?

Sending a job to a worker only pays when it ends sooner than it would have on the hub, so every job that ends on a worker closes with a line saying where the time went and how it compares:

[sparehand] myworker took 1m 12s (waiting 0s, sync 4s, install 0s, command 1m 6s, the rest 2s). This Mac takes about 1m 50s for it: 38s sooner on myworker.

sparehand history adds these up ("They finished 4m 10s sooner in all than the hub would have (2 were slower on a worker, by 40s)"), counting only jobs whose command has a time on the hub. The dashboard shows the same per job on the Jobs tab (the jobs finished in the last day) and the History tab, with a line for the last 7 days and a tile for the period. The hub's time is a run under whatever else the hub was doing then, so measure it (run --here) when the hub is as busy as it usually is while you work.

Rename a worker with sparehand rename NAME NEW (a switched-off worker stays off; its history keeps the old name), or the hub with sparehand rename hub NEW, which sets SPAREHAND_HUB_NAME. Worker names use letters, digits, - and _ (up to 32); the hub's name may also contain spaces. Both can also be done in the app's Edit form.

The app's Settings

The Settings button at the top of the app (or the , key) gathers everything you can change from the dashboard. Type in its search box to find a setting by name.

Keys: 1–9 open a tab in the order shown, ←/→ move between tabs when one is focused, ? opens the Helper and / searches your sessions. The browser's Back and Forward buttons go through the tabs you opened.

Settings

Set them in sparehand.conf, or as environment variables for one command (SPAREHAND_MIN_MEM=20 sparehand run make); the environment wins over the file.

SettingDefaultMeaning
SPAREHAND_MIN_MEM30% memory a worker must have free to take a job.
SPAREHAND_MAX_CPU80% CPU in use above which a worker counts as busy.
SPAREHAND_MAX_GPU50% GPU in use (lowest of 3 samples) above which a worker counts as busy: someone is gaming.
SPAREHAND_MIN_DISK5GB of disk a worker must have free to take a job. The dashboard and menu bar warn below 10 GB.
SPAREHAND_WAIT0Seconds to wait for a free worker when they are all busy (full, low on memory, CPU or GPU busy). 0 runs the command locally at once. Offline, switched-off and paused workers are never waited for.
SPAREHAND_LOCAL_FALLBACK1At a terminal (its output not piped anywhere, and no agent's hook behind it), sparehand run with no worker free, or outside a git checkout, runs the command on this computer itself and says so in one line (no worker free: running on this Mac). 0: exit 255 there too, as a script, a pipe or the hook always gets, so the caller decides.
SPAREHAND_KEEP_DAYS30sparehand clean also removes run folders unused for this many days (they are rebuilt, with their dependencies, the next time they're needed).
SPAREHAND_AUTO_CLEAN11: after a run, clean the workers in the background, at most once a day (log: ~/.local/state/sparehand/clean.log).
SPAREHAND_GPU_MATCHa regex (ollama, whisper, torchrun, nvidia-smi, CUDA_VISIBLE_DEVICES, --device cuda)A command matching it counts as a GPU job and goes only to a worker with an NVIDIA GPU, as with sparehand run --gpu. Set it to an empty value to turn the automatic detection off (gpu.md).
SPAREHAND_KILL_MEM8% free memory below which the newest job on a worker is stopped (exit 255, so it reruns elsewhere) before the worker freezes. The other jobs there carry on.
SPAREHAND_GPU_VRAM1024MB of the graphics card's memory a GPU job needs free (run --gpu --vram 6G for one job). A card that reads 0% busy can still be full: another program's model left loaded. With less free, Sparehand asks Ollama on that worker to unload its idle models and waits for the room. 0: don't look.
SPAREHAND_MAX_GPU_TEMP85°C: a worker whose GPU is hotter takes no new job until it cools down, unless the heat is from Sparehand's own GPU job there (a clip, a model): then CPU jobs still go to its idle cores.
SPAREHAND_KILL_GPU_TEMP93°C: the newest job on a worker whose GPU stays this hot (30 s) is stopped; the newest GPU job, when Sparehand GPU jobs are running there.
SPAREHAND_HUB_JOBSautoHow many jobs this computer runs at once when every worker is full: auto is a quarter of its CPUs, at least 2. Then a job waits for whichever machine frees up first instead of piling onto this one. 0: no limit.
SPAREHAND_HUB_WAIT1800Seconds such a job waits; after that it runs here anyway. A coding agent's command waits as long as the hook gave it (up to 5 minutes, added to its timeout).
SPAREHAND_HUB_LEASE300Seconds a job handed back to its caller (exit 255) counts as running here: Sparehand can't see when the caller's own run ends. Such jobs count only while this computer is loaded (CPU at 60% or more, memory below SPAREHAND_MIN_MEM free, or memory pressure); --or-local runs always count.
SPAREHAND_WORKER_MODEmanualauto: close a worker's apps when it gets a job and reopen them after SPAREHAND_IDLE_MIN idle minutes. manual: only sparehand focus. off: never.
SPAREHAND_IDLE_MIN10See above.
SPAREHAND_SYNC_ENV01: also copy git-ignored .env* files (security.md).
SPAREHAND_PULL_DOTFILES01: a session on a worker also brings back its changes to .claude, .vscode and .idea folders and to the checkout's top-level dot-files (.git never comes back). Only for workers you trust as much as the hub (security.md).
SPAREHAND_WAKE1Wake a sleeping worker (Wake-on-LAN) when a job needs one. 0: off.
SPAREHAND_WAKE_WAIT90Seconds to wait for a woken worker.
SPAREHAND_CLOUD1Start a stopped cloud worker when a job needs it (cloud-gpu.md). 0: off.
SPAREHAND_CLOUD_WAIT240Seconds to wait for a started cloud worker to answer.
SPAREHAND_CLOUD_AFTER0Seconds a merely busy home worker gets to free up before a cloud worker is started.
SPAREHAND_GCLOUDThe gcloud to use, when it isn't on the PATH or in the usual install places.
SPAREHAND_RUNPOD_KEY, SPAREHAND_AWS_KEY, SPAREHAND_AZURE_KEY, SPAREHAND_GCP_KEYA cloud provider's credential, on a hub without a Keychain (on a Mac, sparehand cloud key PROVIDER keeps it in the Keychain): Runpod's API key; AWS's ACCESS-KEY-ID:SECRET; Azure's TENANT:CLIENT:SECRET:SUBSCRIPTION; Google's service account JSON key (as is, or base64). Never written to a file by Sparehand (cloud-gpu.md).
SPAREHAND_RUNPOD_API, SPAREHAND_AWS_API, SPAREHAND_AZURE_API, SPAREHAND_AZURE_LOGIN, SPAREHAND_AZURE_PRICES, SPAREHAND_GCP_API, SPAREHAND_GCP_BILLING, SPAREHAND_GCP_LOGIN, SPAREHAND_HUB_IP, SPAREHAND_CLOUD_SSH_PORTthe providers' ownWhere each provider's API is, the hub's public address (for the firewall rule a provider gets) and the SSH port a VM answers on; the tests point them at stand-ins.
SPAREHAND_SLEEP_IDLE_MIN0When above 0, a Mac or Linux worker goes to sleep after this many minutes without Sparehand jobs and without keyboard or mouse use (not while its GPU is busy). Windows PCs follow their own power settings.
SPAREHAND_RESTART_DAYS30Days up after which the dashboard, the app and sparehand status recommend restarting a machine; 0: never for that alone (an update or a driver waiting for a restart still does).
SPAREHAND_TTYautoWhether sparehand restart may ask for a Mac's password, so a Mac with FileVault comes back by itself (see wake and sleep): auto when it runs in a terminal, 1 or 0 to say so.
SPAREHAND_DEPS_CACHE1Workers keep installed node_modules per lockfile version and give a new checkout of the same project a copy instead of a fresh install. 0: off.
SPAREHAND_BUSY_MIN3Automatic worker mode leaves a worker's apps open when its keyboard or mouse was used in the last this many minutes (Mac and Linux; not knowable on Windows).
SPAREHAND_HISTORY10: don't let the job history (how long this command took on each machine) steer the choice of worker.
SPAREHAND_GET–Paths run brings back from the worker after every job, like run --get (e.g. coverage,dist).
SPAREHAND_WITH–Git-ignored paths run sends along with every job, like run --with (e.g. fixtures,src/generated); never .env files.
SPAREHAND_PASS_ENV–Names of variables of your shell that go with every job, e.g. CI,NODE_ENV. Nothing else of the hub's environment reaches the worker (and jobs never get a terminal). The same names reach what the dashboard starts (Crew's agents, Studio's jobs and ffmpeg): those get only the session's basics (PATH, HOME, LANG, TMPDIR…) and Sparehand's own settings, never its API keys or anything else the dashboard's shell exported.
SPAREHAND_FROM_AGENT–Set to 1 by the coding agents' hook: waking a machine is capped at 40 s and waiting for a free worker at 30 s, within the agent's own command timeout. When this computer is full too, Claude Code's command gets up to 5 more minutes of timeout (10 in all) and waits that long for a free machine (SPAREHAND_AGENT_WAIT).
SPAREHAND_SPLIT_PARTS3 × machinesHow many parts run --split cuts a test run into. Parts are handed out as machines finish one, and a slow machine stops asking once the others would finish the rest sooner.
SPAREHAND_HUB_NAMEcomputer nameHow the hub is shown in status, the app and the menu bar (sparehand rename hub NEW sets it).
SPAREHAND_HUB_SCREEN-The hub's own VNC address for the app, e.g. 127.0.0.1:5900.

Per command only:

VariableMeaning
SPAREHAND_ONLY=a,bConsider only these workers; sparehand run --on a,b does the same for one run. A name that is no worker is an error (no worker named 'a'), not "no worker free"; - means no worker at all, so the command runs here.
SPAREHAND_FORCE=1Ignore the memory/CPU/GPU/slot limits (not pause or disabled).
SPAREHAND_GPU=1Same as sparehand run --gpu.
SPAREHAND_WHOLE=1Same as sparehand run --whole: the job needs the whole machine (gpu.md).
SPAREHAND_PASSWORD_STDIN=1For sparehand files: read the worker's administrator (sudo) password from stdin instead of asking in the terminal. The dashboard uses it; it is never put on a command line.
SPAREHAND_CONFIG=dirUse another config folder (handy for testing).
SPAREHAND_TAILSCALE=pathThe tailscale command the dashboard uses to check a proxied login (tailscale whois, security.md); - means none. Without it: the PATH, then the Mac app's.

See also: GPU jobs, forwarding ports, file sharing, Android emulators, mobile builds and GitHub Actions runners (their own settings).

How a worker is chosen

For each worker that is enabled, reachable and not paused: it needs SPAREHAND_MIN_MEM% memory free, a free job slot (a job that was chosen and is still syncing holds its slot too, for up to 10 minutes), CPU under SPAREHAND_MAX_CPU% and GPU under SPAREHAND_MAX_GPU% (unless the GPU is busy with a Sparehand GPU job). A GPU job also needs a worker with an NVIDIA GPU that no other GPU job is using. Among those, the lowest score wins (CPU busy fraction + 0.15 per running job). The job history counts too: when two or more machines have run this exact command in this repository before, a slower one gets a worse score (0.2 per "times slower", up to 0.6), so the fastest free machine is preferred; SPAREHAND_HISTORY=0 switches that off. If the hub itself is clearly less busy (by 25 points) and has the memory, the command runs on the hub: at a terminal Sparehand does that itself and says so (SPAREHAND_LOCAL_FALLBACK), and a script, a pipe or an agent's hook gets exit 255 instead, which means "run it locally".

On a Mac (the hub and macOS workers), memory "free" is counted the way Activity Monitor counts memory used: app memory, wired memory and compressed memory are in use, the rest (cached files, free pages) is free. macOS's own "free percentage" counts compressed memory as free, so a Mac with 10 GB in swap read 22% in use. While a macOS worker reports memory pressure, swap in use counts as used too. The watchdog that stops the newest job below SPAREHAND_KILL_MEM still uses macOS's own figure: it is there for a machine about to run out, and a healthy Mac often sits at 80–90% used.

Besides CPU, memory and job slots, a worker takes no new job while it is strained: its memory pressure is at warning or worse (macOS's own level; on Linux, the kernel's pressure figures where it has them, counted only while memory is also low: below 30% available for warning, 15% for critical, since loading a big model file stalls memory for a while without running out), a Mac reports its thermal state as serious or critical, or its GPU is at SPAREHAND_MAX_GPU_TEMP (not from Sparehand's own GPU job). sparehand status, the dashboard and the menu bar show it ("Hot", "Memory pressure"; each card has a Health line all the time), and the app and the dashboard's notifications say when a machine comes under strain or a job was stopped, with the reason. The last day's alerts are on the machine's card until it has been fine for 10 minutes (up, not strained, at least 30% memory free; alertClearSeconds in app.json changes the wait).

A worker that lacks one of the tools workers should have (bash, rsync, git, zsh: zsh for Sparehand's own commands there, such as Crew's) says so in sparehand status and on its card, with the command that installs it there (sudo apt-get install -y zsh). One missing bash, rsync or git takes no job (every job would fail at the sync); one missing only zsh still does. Sparehand doesn't install them by itself: that needs an administrator's password. New workers get them when they join.

sparehand doctor NAME checks one machine end to end: that it answers over SSH, that this computer's key is accepted and the machine's own key is still the one trusted, the tools (node is optional: node isn't installed: fine unless you use the AI features), the free disk, and one small file sent there and back the way a job's checkout travels. sparehand doctor does it for every worker. sparehand doctor NAME --check prints the same as lines the app reads, one per item, check<TAB>ITEM<TAB>ok|warn|bad<TAB>message (items: reachable, key, hostkey, tools, disk, roundtrip), then result<TAB>ok|warn|bad<TAB>summary; it exits 1 when something is bad.

What happens on a run

  1. The checkout is synced with rsync to ~/.sparehand/runs.noindex/<repo>-<id>/<checkout> on the worker, skipping everything git ignores. (The exclude list is generated from git ls-files --ignored because macOS ships openrsync, which ignores .gitignore filter rules when deleting and would wipe node_modules on every run.) --with fixtures,gen/api.ts (or SPAREHAND_WITH) sends those git-ignored paths too: test fixtures, generated code. Paths are relative to where you ran the command and stay inside the checkout; a folder goes with what is in it, but an ignored folder around it doesn't, and .env files never do (not by name, not inside). What it sent stays in the worker's copy afterwards, as git-ignored files there do.
  2. The .git folder isn't copied (security.md). When the checked-out commit or branch changed, git is set up in the folder there: the commits it lacks arrive as a git bundle in a store shared by the repository's checkouts on that worker (.gitcommon), and the folder's own .git points HEAD at the same commit and branch name, with no hooks and a config of a few lines. Changes you haven't committed show there as not staged.
  3. On the worker: dependencies are installed when the lockfile changed (pnpm, yarn, bun, npm; uv, Poetry with its virtualenv in the checkout, or a requirements.txt into a .venv of its own that the job then runs in, made with uv when the worker has it; without a way to make one the job runs with the worker's own python3 and says so; cargo fetch for a Cargo.lock, go mod download for a go.sum, into the machine's own caches, only where cargo or go is installed and never stopping the job), each keyed by its lockfile's hash, then your hook runs, then the command, in the same subdirectory you ran sparehand run in.
  4. A watchdog checks every 2 s and stops the job if the worker is paused or its GPU stays busy for 3 checks (someone started a game). When the worker is about to run out (memory below SPAREHAND_KILL_MEM free or its pressure critical, a Mac at a critical thermal state, the GPU at SPAREHAND_KILL_GPU_TEMP), only the newest job there stops, the one that most likely pushed it over, and the next newest only if it is still too much. Then sparehand run exits 255, the reason goes into the job history, and the app sends a notification. It also stops the job when the hub's connection drops (you pressed Ctrl-C, an agent timed out, the hub went to sleep), so nothing keeps running on a worker that nobody is waiting for.
  5. Output streams back live; the command's exit code is passed through. A job stopped with sparehand stop (or the app's Stop button) exits 130. Exit 127 ("command not found": a tool the job needs isn't installed on that worker) becomes 255, so the caller runs it locally; so does a folder you ran it in that the worker hasn't got (only git-ignored files in it). Every other code is the job's own, 2 and 254 included. The worker says why it handed a job back in a note (~/.sparehand/ended.TOKEN), not in the exit code alone, so a job whose own exit code is 254 or 255 is reported as that, --or-local doesn't run it a second time, and the output says it was the job's own result. ssh also exits 255 when the connection drops; without the worker's note the job is handed back (exit 255, run it locally) and the job history says "connection lost", even when the worker answers again by then.
  6. --get coverage,dist (or SPAREHAND_GET): when the job ends, those paths are copied back from the worker to the same places here, each replaced whole, whatever the exit code (a failed run's report is the useful one). Paths are relative to where you ran the command and must stay inside the checkout. A path the job didn't make is mentioned, not an error. A path that is a symlink here (or leads through one) isn't replaced; .git, .claude, .vscode and .idea folders and symlinks pointing outside what is copied don't come back.
  7. One job per checkout per worker at a time: a second job from the same checkout waits for the first (its sync would otherwise rewrite the folder under the running job) and says so. Use another worker, or another checkout (a git worktree), to run two at once.

Moving to a new Mac

Everything Sparehand knows lives in your home folder: ~/.config/sparehand (workers, settings, forwards, app.json, hooks), ~/.local/state/sparehand (history, markers) and your SSH key in ~/.ssh, which is what the workers trust. Migration Assistant copies all of it, and the Keychain (file-sharing sign-ins, the licence) comes along with your login keychain, so on the new Mac you install the app and carry on: the workers, the shared folders and the tunnels are there.

Setting the new Mac up by hand instead: copy those two folders and the SSH key pair, or pair each worker again from the new Mac (Add a machine → Pair with a code). A licence key is activated per Mac: on the old Mac, Settings → Licence → Use the licence on another Mac… releases it first (or deactivate it from the email the key came in). The old Mac's run folders on the workers are reused by the new one, since they are named after the project, not the hub.

Two hubs, one worker

Each hub has an id (~/.config/sparehand/hub-id, random, made once). A worker's run folders are named with it and record it, so two hubs that share a worker keep separate folders and the daily clean-up of one never removes the other's. sparehand remove NAME leaves that machine as it is while another hub's run folders are there; sparehand remove NAME --clean is the exception: it removes the whole ~/.sparehand on that machine, the other hub's folders included.

Files on a worker

PathWhat
~/.sparehand/runs.noindex/Synced checkouts (with their installed dependencies). sparehand clean (automatic once a day) deletes folders whose checkout no longer exists on the hub, or that weren't used for SPAREHAND_KEEP_DAYS days, but never one a job is running in.
~/.sparehand/jobs/<pid>One marker per running job (checkout, command, process group).
~/.sparehand/pausedPresent while paused.
~/.sparehand/envOptional, yours: sourced before every job.
~/.sparehand/focus.sh, focus-apps, focus.logWorker mode script, the apps it closed, and its log.
~/.sparehand/cpus.cjsNode preload that limits the CPUs Node tools see (--cpus).