Stagger-warm coding-agent accounts (Claude Code, Codex, ...) so their usage windows open early and reset at different times.
Project description
cwarm
Stagger-warm multiple coding-agent accounts so their rolling usage windows
open early in the day and reset at different times. Today it warms Claude Code
accounts (paired with claude-swap,
cswap); the agent layer is generic, so other CLIs (e.g. Codex) can be added.
When the active account exhausts its window, another is already warm to switch
into.
This tool stores no credentials — the account-switcher (claude-swap) is the
source of truth for accounts and tokens. cwarm only orchestrates it.
How it works
Claude Code's 5-hour window starts on an account's first message and resets exactly 5 hours later. It's a fixed budget, not free capacity — warming only relocates the dead/regeneration time so it lands outside your working hours. Staggering the warmups (e.g. 05:00, 07:30, 10:00) keeps at least one fresh account available through the day.
For each due account, cwarm:
- switches to it (for Claude:
cswap --switch-to <id>), - waits
settle_secondsfor the credential swap to land, - sends one minimal message via the agent's command (for Claude:
claude -p "Hi") — anchoring that account's window,
then restores whichever account you had active before the batch (always, even if a warmup fails).
A weekly cap is shared across web, app, and Claude Code. Warming several accounts daily consumes some of it. Tracking that cap is out of scope.
Agents
An "agent" is just two things: a command that sends a non-interactive prompt
(<cli> -p "Hi") and an optional account-switcher. They live as a small data
table in cwarm/agent.py — not a class per agent:
| agent | command | switcher |
|---|---|---|
claude |
claude -p |
cswap (claude-swap) |
Each account picks its agent via the agent field (default claude). To add a
coding agent, add one row. Only a new switcher (something other than cswap)
needs code — a sibling module to cwarm/cswap.py. An agent with no switcher
warms whatever account that CLI currently has active (no multi-account swapping).
Requirements
- For the
claudeagent:claude-swapinstalled and configured with every target account added (cswap --add-account/cswap --add-token sk-ant-oat01-…), and Claude Code (claude) runnable non-interactively. - Python 3.12+.
Platform support
cwarm is pure Python and runs anywhere the agent's CLIs (claude, cswap) do:
- Linux — fully supported, with the bundled
systemduser service. - macOS — the tool and
cwarm daemonwork the same; for boot persistence uselaunchdorcroninstead of systemd. - Windows — works too;
tzdatais pulled in automatically (Windows has no system IANA tz database). Use Task Scheduler or runcwarm daemonas a service instead of systemd.
cwarm run/daemon are cross-platform; only the deployment recipe differs.
Install
cd ~/workspace/apps/cwarm
uv venv
uv pip install -e .
cp config.example.json config.json # then edit ids/schedules
config.json is gitignored — it holds your real account ids.
Configuration (config.json)
No tokens. Accounts are referenced by the handle their agent uses — for Claude, a
cswap slot number or email.
{
"defaults": {
"agent": "claude",
"message": "Hi",
"timezone": "Asia/Kolkata",
"settle_seconds": 3,
"skip_if_warm": true
},
"accounts": [
{ "id": "work@example.com", "enabled": true, "schedules": ["0 5 * * 1-5", "0 11 * * 1-5", "0 21 * * 1-5"] },
{ "id": "2", "enabled": true, "schedule": "30 7 * * 1-5" },
{ "id": "personal@x.com", "enabled": true, "schedules": ["0 10 * * *", "0 18 * * *"] },
{ "id": "4", "enabled": false, "schedule": "30 12 * * *" }
]
}
An account can warm at several times a day — give it a schedules array
(e.g. 05:00, 11:00, 21:00). Use the singular schedule string for a single
time. Both keys may be present; their union (de-duplicated) is used. Each cron
time becomes its own daemon job, fired in the account's timezone.
| Field | Required | Default | Notes |
|---|---|---|---|
id |
yes | — | unique; the agent's account handle (cswap slot or email) |
schedule / schedules |
yes | — | one (string) or many (array) 5-field cron times, read in the account's timezone |
agent |
no | defaults / claude |
which coding agent warms this account |
enabled |
no | true |
false skips the account entirely |
message |
no | defaults / "Hi" |
the warmup message |
timezone |
no | defaults / Asia/Kolkata |
IANA tz name |
settle_seconds |
no | defaults / 3 |
delay after switching before sending |
skip_if_warm |
no | defaults / false |
skip if the window is already open |
Usage
cwarm init # write a starter config.json
cwarm validate # check config + agents; sends nothing
cwarm list # show accounts, live window state, next run
cwarm run # warm all enabled accounts now
cwarm run --account work@example.com # warm just one
cwarm daemon # long-lived; fires each account on its cron
# global flags
cwarm --config /path/to/config.json --log-file /path/to/cwarm.log <command>
init— writes a starterconfig.json(won't clobber an existing one without--force).validate— confirms the JSON matches the schema, each account's agent CLI (and its switcher) is installed, and every configuredidexists. Exits non-zero and sends nothing on any problem.list— read-only table of every account: agent, enabled, live window state (warm/cold), next scheduled run, and its cron times. Sends nothing.run— warms enabled accounts immediately (one batch, one save/restore per switcher). Good for testing or a systemcrontab. Non-zero if any warmup failed.daemon— schedules one job per account per cron time, in the account's timezone. Warmups are always serial.
Logging
Every attempt emits one structured line to stderr (and the log file if set):
2026-06-21T05:00:03+0530 INFO save-active switcher=cswap account=work@example.com ref=1
2026-06-21T05:00:09+0530 INFO warmup account=work@example.com outcome=ok reset=10:00
2026-06-21T05:00:10+0530 INFO restore-active switcher=cswap account=work@example.com ref=1 outcome=ok
Outcomes: ok (with the window reset time), failed (with an error
summary), skipped (skip_if_warm and already warm).
Deployment
systemd (recommended)
mkdir -p ~/.config/systemd/user ~/.local/state/cwarm
cp systemd/cwarm.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now cwarm
loginctl enable-linger "$USER" # run without an active login session
journalctl --user -u cwarm -f
Alternative: system crontab
One line per warmup time invoking the one-shot mode (repeat a line per account to warm it several times a day):
0 5 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
0 11 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
0 21 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
30 7 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account 2
Energy
The daemon does not poll — it sleeps on an event until the next scheduled warmup, so idle cost is ~25 MB RAM and effectively 0% CPU (measured: 1 voluntary context switch over 3 s idle). There is no busy-loop to optimise.
The real per-warmup energy is the claude -p "Hi" call: it boots the Node
Claude Code CLI and sends a real LLM inference request to anchor the window.
That's irreducible — anchoring requires a server-side message. So the only
meaningful lever is not sending redundant ones:
skip_if_warm: true(the example default) — before switching, parsecswap --list; if the account's window is already open, logskippedand send nothing. This skips the entire heavyclaude -pcall, the single biggest energy saving available.- Stagger, don't stack — overlapping schedules waste warmups (and the shared weekly cap). One warmup per window per account is enough to anchor it.
For literally zero idle footprint, use systemd timers or cron instead of the daemon — no process is resident between warmups — but the saving over the sleeping daemon is marginal.
System restart
Yes. Via the systemd user service plus linger:
systemctl --user enable+loginctl enable-linger "$USER"→ the daemon starts on boot, with no login session required.Restart=always(RestartSec=10) → if the process ever exits — crash or otherwise — systemd brings it straight back.- On every (re)start the schedule is rebuilt fresh from
config.json(in-memory jobstore; the config is the single source of truth — no stale persisted state to reconcile). - Short downtime is tolerated:
misfire_grace_time=3600+coalesce=Truemean a warmup missed by under an hour still fires once on recovery. - Long power-off is not caught up by design: a 05:00 warmup missed because
the machine was off until noon is skipped, not fired late — firing it hours
late would defeat the staggering. Edit
config.jsonandsystemctl --user restart cwarmto re-plan.
Safety
- No credentials stored or logged — the switcher (
claude-swap) owns them. - Switching changes your local active account; run warmups at off-hours. The save/restore guarantees your default is unchanged after a batch.
- Only configure accounts you legitimately own or are authorised to use.
Contributing / releasing
Commits follow Conventional Commits
(feat:, fix:, docs:, ci:, …). Lint and tests run in CI:
ruff check .
pytest
Versioning and the changelog are managed by Commitizen.
To release: run the Release & Publish workflow from the Actions tab
("Run workflow", optionally choosing the bump size). In one run it bumps the
version (pyproject.toml + cwarm/__init__.py), updates CHANGELOG.md, tags
and creates the GitHub Release, then builds and publishes to PyPI via Trusted
Publishing — no token stored. The next version is inferred from the Conventional
Commits since the last release.
Or do it locally:
cz bump # bump version + changelog + create the vX.Y.Z tag
git push --follow-tags
The project is in 0.x (major_version_zero), so breaking changes bump the
minor until 1.0.0.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cwarm-0.1.0.tar.gz.
File metadata
- Download URL: cwarm-0.1.0.tar.gz
- Upload date:
- Size: 22.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
322be821945635cd3aa21ef5bf865ca0560970ac389eee947e396af58d4e1547
|
|
| MD5 |
abd7c7e3a0e37a3bcb56a53bfdb613b8
|
|
| BLAKE2b-256 |
b8755033b3ac5b9ecb1a0ce17bd0d28994b1edcd88e8c8e9f391dbac943825d7
|
Provenance
The following attestation bundles were made for cwarm-0.1.0.tar.gz:
Publisher:
publish.yml on wonderbyte/cwarm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cwarm-0.1.0.tar.gz -
Subject digest:
322be821945635cd3aa21ef5bf865ca0560970ac389eee947e396af58d4e1547 - Sigstore transparency entry: 1894014447
- Sigstore integration time:
-
Permalink:
wonderbyte/cwarm@430e3d9a6a2bd7ac5425b1213fc21df6dbba3702 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/wonderbyte
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@430e3d9a6a2bd7ac5425b1213fc21df6dbba3702 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file cwarm-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cwarm-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a29d0ead4a93fc1f1cf1e6a96a7f029418cab2b8ae47874d6fdd521ab71e6197
|
|
| MD5 |
d52a34f8fd321bc3189c5c969346d17e
|
|
| BLAKE2b-256 |
54a7b3780e24b8acdf3a2a9bedc9505e474932d5bab4e0bba309922f71bf1506
|
Provenance
The following attestation bundles were made for cwarm-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on wonderbyte/cwarm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cwarm-0.1.0-py3-none-any.whl -
Subject digest:
a29d0ead4a93fc1f1cf1e6a96a7f029418cab2b8ae47874d6fdd521ab71e6197 - Sigstore transparency entry: 1894014591
- Sigstore integration time:
-
Permalink:
wonderbyte/cwarm@430e3d9a6a2bd7ac5425b1213fc21df6dbba3702 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/wonderbyte
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@430e3d9a6a2bd7ac5425b1213fc21df6dbba3702 -
Trigger Event:
workflow_dispatch
-
Statement type: