cwarm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cwarm/__init__.py +3 -0
- cwarm/agent.py +56 -0
- cwarm/cli.py +228 -0
- cwarm/config.py +226 -0
- cwarm/cron.py +64 -0
- cwarm/cswap.py +123 -0
- cwarm/log.py +58 -0
- cwarm/schedule.py +128 -0
- cwarm/warmup.py +95 -0
- cwarm-0.1.0.dist-info/METADATA +277 -0
- cwarm-0.1.0.dist-info/RECORD +14 -0
- cwarm-0.1.0.dist-info/WHEEL +4 -0
- cwarm-0.1.0.dist-info/entry_points.txt +2 -0
- cwarm-0.1.0.dist-info/licenses/LICENSE +21 -0
cwarm/__init__.py
ADDED
cwarm/agent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""What makes one coding agent different from another — as plain data.
|
|
2
|
+
|
|
3
|
+
Sending a warmup is uniform across agents: a single non-interactive prompt
|
|
4
|
+
(`<cli> -p "Hi"`). The only agent-specific part is the **account switcher** that
|
|
5
|
+
decides which credentials are active. So agents are rows in a table, not classes:
|
|
6
|
+
|
|
7
|
+
name command switcher
|
|
8
|
+
claude ("claude", "-p") "cswap" # claude-swap manages the accounts
|
|
9
|
+
|
|
10
|
+
Add a coding agent by adding a row. Only a *new* switcher (something other than
|
|
11
|
+
cswap) needs code — a sibling module to cswap.py, registered in `_SWITCHERS`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
from . import cswap
|
|
20
|
+
|
|
21
|
+
DEFAULT_AGENT = "claude"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Agent:
|
|
26
|
+
name: str
|
|
27
|
+
command: tuple[str, ...] # warmup invocation; the message is appended
|
|
28
|
+
switcher: str | None # account-switcher backend, or None for no switching
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_AGENTS: dict[str, Agent] = {
|
|
32
|
+
"claude": Agent("claude", ("claude", "-p"), switcher="cswap"),
|
|
33
|
+
# To add Codex once its non-interactive invocation is confirmed:
|
|
34
|
+
# "codex": Agent("codex", ("codex", "exec"), switcher=None),
|
|
35
|
+
# switcher=None warms whichever account that CLI has active (no multi-account
|
|
36
|
+
# switching). If Codex can swap accounts, add a cswap-style module and name it.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Switcher backends, keyed by the name used in Agent.switcher.
|
|
40
|
+
_SWITCHERS: dict[str, ModuleType] = {"cswap": cswap}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def known_agents() -> list[str]:
|
|
44
|
+
return list(_AGENTS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_agent(name: str) -> Agent:
|
|
48
|
+
try:
|
|
49
|
+
return _AGENTS[name]
|
|
50
|
+
except KeyError:
|
|
51
|
+
raise ValueError(f"unknown agent {name!r}; known agents: {', '.join(_AGENTS)}") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_switcher(name: str | None) -> ModuleType | None:
|
|
55
|
+
"""The switcher module for an agent, or None when the agent does no switching."""
|
|
56
|
+
return _SWITCHERS.get(name) if name else None
|
cwarm/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Command-line entrypoint: init | validate | list | run | daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .agent import get_agent, get_switcher
|
|
14
|
+
from .config import Account, Config, ConfigError, load_config
|
|
15
|
+
from .cron import next_fire
|
|
16
|
+
from .cswap import CswapError
|
|
17
|
+
from .log import log_event, setup_logging
|
|
18
|
+
from .schedule import run_batch, run_daemon
|
|
19
|
+
from .warmup import FAILED
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONFIG = "config.json"
|
|
22
|
+
|
|
23
|
+
CONFIG_TEMPLATE = """{
|
|
24
|
+
"defaults": {
|
|
25
|
+
"agent": "claude",
|
|
26
|
+
"message": "Hi",
|
|
27
|
+
"timezone": "Asia/Kolkata",
|
|
28
|
+
"settle_seconds": 3,
|
|
29
|
+
"skip_if_warm": true
|
|
30
|
+
},
|
|
31
|
+
"accounts": [
|
|
32
|
+
{ "id": "1", "enabled": true, "schedules": ["0 5 * * 1-5", "0 13 * * 1-5"] },
|
|
33
|
+
{ "id": "2", "enabled": true, "schedule": "30 7 * * 1-5" },
|
|
34
|
+
{ "id": "3", "enabled": false, "schedule": "0 10 * * *" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
parser = _build_parser()
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
if args.command is None:
|
|
44
|
+
parser.print_help()
|
|
45
|
+
return 2
|
|
46
|
+
|
|
47
|
+
setup_logging(log_file=args.log_file)
|
|
48
|
+
|
|
49
|
+
# `init` writes the config, so it must run before we try to load one.
|
|
50
|
+
if args.command == "init":
|
|
51
|
+
return _cmd_init(args.config, args.force)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
config = load_config(args.config)
|
|
55
|
+
except ConfigError as exc:
|
|
56
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
|
|
59
|
+
if args.command == "validate":
|
|
60
|
+
return _cmd_validate(config)
|
|
61
|
+
if args.command == "list":
|
|
62
|
+
return _cmd_list(config)
|
|
63
|
+
if args.command == "run":
|
|
64
|
+
return _cmd_run(config, args.account)
|
|
65
|
+
if args.command == "daemon":
|
|
66
|
+
return _cmd_daemon(config)
|
|
67
|
+
return 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
prog="cwarm",
|
|
73
|
+
description="Stagger-warm coding-agent accounts so their usage windows reset at different times.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--version", action="version", version=f"cwarm {__version__}")
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"-c", "--config", default=DEFAULT_CONFIG, type=Path,
|
|
78
|
+
help=f"path to the JSON config (default: {DEFAULT_CONFIG})",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--log-file", default=None, type=Path,
|
|
82
|
+
help="also append structured logs to this file",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
86
|
+
|
|
87
|
+
init = sub.add_parser("init", help="write a starter config.json")
|
|
88
|
+
init.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
89
|
+
|
|
90
|
+
sub.add_parser("validate", help="validate config and environment; send nothing")
|
|
91
|
+
sub.add_parser("list", help="show configured accounts, window state, and next run")
|
|
92
|
+
|
|
93
|
+
run = sub.add_parser("run", help="warm all enabled accounts (or one) immediately")
|
|
94
|
+
run.add_argument("--account", default=None, help="warm only this account id (slot or email)")
|
|
95
|
+
|
|
96
|
+
sub.add_parser("daemon", help="run long-lived, firing each account on its cron schedule")
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cmd_init(config_path: Path, force: bool) -> int:
|
|
101
|
+
if config_path.exists() and not force:
|
|
102
|
+
print(f"init: {config_path} already exists — use --force to overwrite", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
config_path.write_text(CONFIG_TEMPLATE)
|
|
105
|
+
print(f"init: wrote {config_path} — edit the account ids/schedules, then run `cwarm validate`")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_validate(config: Config) -> int:
|
|
110
|
+
"""Schema OK; each agent's CLI present and (if it switches) every id known. Sends nothing."""
|
|
111
|
+
for agent_name, accounts in _by_agent(config).items():
|
|
112
|
+
agent = get_agent(agent_name)
|
|
113
|
+
if shutil.which(agent.command[0]) is None:
|
|
114
|
+
print(f"validate: agent '{agent_name}': '{agent.command[0]}' is not on PATH", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
switcher = get_switcher(agent.switcher)
|
|
118
|
+
if switcher is None:
|
|
119
|
+
continue # no switcher: can't enumerate accounts, nothing more to check
|
|
120
|
+
if not switcher.is_installed():
|
|
121
|
+
print(f"validate: agent '{agent_name}': switcher '{agent.switcher}' is not on PATH", file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
try:
|
|
124
|
+
listed = switcher.list_accounts()
|
|
125
|
+
except CswapError as exc:
|
|
126
|
+
print(f"validate: agent '{agent_name}': could not list accounts: {exc}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
missing = [a.id for a in accounts if not any(la.matches(a.id) for la in listed)]
|
|
130
|
+
if missing:
|
|
131
|
+
known = ", ".join(f"{la.slot}:{la.email}" for la in listed) or "(none)"
|
|
132
|
+
print(
|
|
133
|
+
f"validate: these ids are not known to agent '{agent_name}': "
|
|
134
|
+
+ ", ".join(missing)
|
|
135
|
+
+ f"\n known accounts: {known}",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
enabled = len(config.enabled_accounts())
|
|
141
|
+
used = ", ".join(sorted(_by_agent(config)))
|
|
142
|
+
print(f"validate: ok — {len(config.accounts)} account(s) configured, {enabled} enabled, agents: {used}")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cmd_list(config: Config) -> int:
|
|
147
|
+
"""Read-only overview: each account's agent, window state (live), and next run. Sends nothing."""
|
|
148
|
+
listings: dict[str, list | None] = {}
|
|
149
|
+
|
|
150
|
+
def window_state(account: Account) -> str:
|
|
151
|
+
switcher = get_switcher(get_agent(account.agent).switcher)
|
|
152
|
+
if switcher is None:
|
|
153
|
+
return "n/a"
|
|
154
|
+
name = get_agent(account.agent).switcher
|
|
155
|
+
if name not in listings:
|
|
156
|
+
try:
|
|
157
|
+
listings[name] = switcher.list_accounts()
|
|
158
|
+
except CswapError:
|
|
159
|
+
listings[name] = None
|
|
160
|
+
listed = listings[name]
|
|
161
|
+
if listed is None:
|
|
162
|
+
return "?"
|
|
163
|
+
match = next((a for a in listed if a.matches(account.id)), None)
|
|
164
|
+
if match is None:
|
|
165
|
+
return "unknown"
|
|
166
|
+
return {True: "warm", False: "cold", None: "?"}[match.window_open]
|
|
167
|
+
|
|
168
|
+
rows = [
|
|
169
|
+
(
|
|
170
|
+
account.id,
|
|
171
|
+
account.agent,
|
|
172
|
+
"yes" if account.enabled else "no",
|
|
173
|
+
window_state(account),
|
|
174
|
+
_next_run(account) if account.enabled else "-",
|
|
175
|
+
", ".join(account.schedules),
|
|
176
|
+
)
|
|
177
|
+
for account in config.accounts
|
|
178
|
+
]
|
|
179
|
+
_print_table(("ID", "AGENT", "ON", "WINDOW", "NEXT RUN", "SCHEDULE(S)"), rows)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _cmd_run(config: Config, account: str | None) -> int:
|
|
184
|
+
account_ids = [account] if account else None
|
|
185
|
+
try:
|
|
186
|
+
results = run_batch(config, account_ids)
|
|
187
|
+
except ValueError as exc:
|
|
188
|
+
print(f"run: {exc}", file=sys.stderr)
|
|
189
|
+
return 2
|
|
190
|
+
failed = sum(1 for r in results if r.outcome == FAILED)
|
|
191
|
+
log_event("run", outcome="done", total=len(results), failed=failed)
|
|
192
|
+
return 1 if failed else 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _cmd_daemon(config: Config) -> int:
|
|
196
|
+
run_daemon(config)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _by_agent(config: Config) -> dict[str, list[Account]]:
|
|
201
|
+
grouped: dict[str, list[Account]] = {}
|
|
202
|
+
for account in config.accounts:
|
|
203
|
+
grouped.setdefault(account.agent, []).append(account)
|
|
204
|
+
return grouped
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _next_run(account: Account) -> str:
|
|
208
|
+
"""Soonest upcoming fire across the account's schedules, in its timezone."""
|
|
209
|
+
now = datetime.now(ZoneInfo(account.timezone))
|
|
210
|
+
fires = [f for cron in account.schedules if (f := next_fire(cron, account.timezone, now))]
|
|
211
|
+
if not fires:
|
|
212
|
+
return "-"
|
|
213
|
+
return min(fires).strftime("%a %H:%M (%d %b)")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_table(headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> None:
|
|
217
|
+
widths = [len(h) for h in headers]
|
|
218
|
+
for row in rows:
|
|
219
|
+
widths = [max(w, len(str(c))) for w, c in zip(widths, row, strict=False)]
|
|
220
|
+
line = " ".join(h.ljust(w) for h, w in zip(headers, widths, strict=False))
|
|
221
|
+
print(line)
|
|
222
|
+
print(" ".join("-" * w for w in widths))
|
|
223
|
+
for row in rows:
|
|
224
|
+
print(" ".join(str(c).ljust(w) for c, w in zip(row, widths, strict=False)))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|
cwarm/config.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Load and validate the cwarm JSON config (§7).
|
|
2
|
+
|
|
3
|
+
No tokens live here — accounts are referenced by the handle their agent uses
|
|
4
|
+
(e.g. a claude-swap slot number or email). Per-account fields fall back to
|
|
5
|
+
`defaults`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
14
|
+
|
|
15
|
+
from .agent import DEFAULT_AGENT, known_agents
|
|
16
|
+
from .cron import to_trigger
|
|
17
|
+
|
|
18
|
+
# 5-field cron, the subset APScheduler's CronTrigger.from_crontab accepts.
|
|
19
|
+
_CRON_FIELDS = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(ValueError):
|
|
23
|
+
"""Raised when the config file is missing, malformed, or invalid."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Defaults:
|
|
28
|
+
agent: str = DEFAULT_AGENT
|
|
29
|
+
message: str = "Hi"
|
|
30
|
+
timezone: str = "Asia/Kolkata"
|
|
31
|
+
settle_seconds: int = 3
|
|
32
|
+
skip_if_warm: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Account:
|
|
37
|
+
id: str
|
|
38
|
+
agent: str
|
|
39
|
+
schedules: tuple[str, ...]
|
|
40
|
+
enabled: bool
|
|
41
|
+
message: str
|
|
42
|
+
timezone: str
|
|
43
|
+
settle_seconds: int
|
|
44
|
+
skip_if_warm: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Config:
|
|
49
|
+
defaults: Defaults
|
|
50
|
+
accounts: list[Account]
|
|
51
|
+
|
|
52
|
+
def enabled_accounts(self) -> list[Account]:
|
|
53
|
+
return [a for a in self.accounts if a.enabled]
|
|
54
|
+
|
|
55
|
+
def find(self, account_id: str) -> Account | None:
|
|
56
|
+
return next((a for a in self.accounts if a.id == account_id), None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config(path: str | Path) -> Config:
|
|
60
|
+
"""Read, parse, and validate the config at `path`. Raises ConfigError."""
|
|
61
|
+
path = Path(path)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
raise ConfigError(f"config file not found: {path}")
|
|
64
|
+
try:
|
|
65
|
+
raw = json.loads(path.read_text())
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
raise ConfigError(f"{path}: invalid JSON: {exc}") from exc
|
|
68
|
+
return _build(raw, path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build(raw: object, path: Path) -> Config:
|
|
72
|
+
if not isinstance(raw, dict):
|
|
73
|
+
raise ConfigError(f"{path}: top level must be a JSON object")
|
|
74
|
+
|
|
75
|
+
defaults = _build_defaults(raw.get("defaults", {}))
|
|
76
|
+
|
|
77
|
+
raw_accounts = raw.get("accounts")
|
|
78
|
+
if not isinstance(raw_accounts, list) or not raw_accounts:
|
|
79
|
+
raise ConfigError(f"{path}: 'accounts' must be a non-empty array")
|
|
80
|
+
|
|
81
|
+
accounts: list[Account] = []
|
|
82
|
+
seen: set[str] = set()
|
|
83
|
+
for i, entry in enumerate(raw_accounts):
|
|
84
|
+
account = _build_account(entry, defaults, i)
|
|
85
|
+
if account.id in seen:
|
|
86
|
+
raise ConfigError(f"{path}: duplicate account id {account.id!r}")
|
|
87
|
+
seen.add(account.id)
|
|
88
|
+
accounts.append(account)
|
|
89
|
+
|
|
90
|
+
return Config(defaults=defaults, accounts=accounts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_defaults(raw: object) -> Defaults:
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
raise ConfigError("'defaults' must be a JSON object")
|
|
96
|
+
base = Defaults()
|
|
97
|
+
agent = raw.get("agent", base.agent)
|
|
98
|
+
message = raw.get("message", base.message)
|
|
99
|
+
timezone = raw.get("timezone", base.timezone)
|
|
100
|
+
settle = raw.get("settle_seconds", base.settle_seconds)
|
|
101
|
+
skip = raw.get("skip_if_warm", base.skip_if_warm)
|
|
102
|
+
_check_agent("defaults", agent)
|
|
103
|
+
_check_message("defaults", message)
|
|
104
|
+
_check_timezone("defaults", timezone)
|
|
105
|
+
_check_settle("defaults", settle)
|
|
106
|
+
_check_bool("defaults.skip_if_warm", skip)
|
|
107
|
+
return Defaults(
|
|
108
|
+
agent=agent,
|
|
109
|
+
message=message,
|
|
110
|
+
timezone=timezone,
|
|
111
|
+
settle_seconds=settle,
|
|
112
|
+
skip_if_warm=skip,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_account(raw: object, defaults: Defaults, index: int) -> Account:
|
|
117
|
+
where = f"accounts[{index}]"
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ConfigError(f"{where}: must be a JSON object")
|
|
120
|
+
|
|
121
|
+
account_id = raw.get("id")
|
|
122
|
+
if not isinstance(account_id, str) or not account_id.strip():
|
|
123
|
+
raise ConfigError(f"{where}: 'id' is required and must be a non-empty string")
|
|
124
|
+
account_id = account_id.strip()
|
|
125
|
+
|
|
126
|
+
schedules = _collect_schedules(raw, f"{where} ({account_id})")
|
|
127
|
+
|
|
128
|
+
enabled = raw.get("enabled", True)
|
|
129
|
+
_check_bool(f"{where} ({account_id}).enabled", enabled)
|
|
130
|
+
|
|
131
|
+
agent = raw.get("agent", defaults.agent)
|
|
132
|
+
message = raw.get("message", defaults.message)
|
|
133
|
+
timezone = raw.get("timezone", defaults.timezone)
|
|
134
|
+
settle = raw.get("settle_seconds", defaults.settle_seconds)
|
|
135
|
+
skip = raw.get("skip_if_warm", defaults.skip_if_warm)
|
|
136
|
+
_check_agent(f"{where} ({account_id})", agent)
|
|
137
|
+
_check_message(f"{where} ({account_id})", message)
|
|
138
|
+
_check_timezone(f"{where} ({account_id})", timezone)
|
|
139
|
+
_check_settle(f"{where} ({account_id})", settle)
|
|
140
|
+
_check_bool(f"{where} ({account_id}).skip_if_warm", skip)
|
|
141
|
+
|
|
142
|
+
return Account(
|
|
143
|
+
id=account_id,
|
|
144
|
+
agent=agent,
|
|
145
|
+
schedules=schedules,
|
|
146
|
+
enabled=enabled,
|
|
147
|
+
message=message,
|
|
148
|
+
timezone=timezone,
|
|
149
|
+
settle_seconds=settle,
|
|
150
|
+
skip_if_warm=skip,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _collect_schedules(raw: dict, where: str) -> tuple[str, ...]:
|
|
155
|
+
"""Gather warmup times from `schedule` (string) and/or `schedules` (array).
|
|
156
|
+
|
|
157
|
+
Either or both keys may be present; the result is the de-duplicated union,
|
|
158
|
+
so one account can fire at several times of day (e.g. 05:00, 11:00, 21:00).
|
|
159
|
+
"""
|
|
160
|
+
collected: list[str] = []
|
|
161
|
+
|
|
162
|
+
single = raw.get("schedule")
|
|
163
|
+
if single is not None:
|
|
164
|
+
if not isinstance(single, str) or not single.strip():
|
|
165
|
+
raise ConfigError(f"{where}: 'schedule' must be a non-empty cron string")
|
|
166
|
+
collected.append(single.strip())
|
|
167
|
+
|
|
168
|
+
multi = raw.get("schedules")
|
|
169
|
+
if multi is not None:
|
|
170
|
+
if not isinstance(multi, list) or not multi:
|
|
171
|
+
raise ConfigError(f"{where}: 'schedules' must be a non-empty array of cron strings")
|
|
172
|
+
for entry in multi:
|
|
173
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
174
|
+
raise ConfigError(f"{where}: every 'schedules' entry must be a non-empty cron string")
|
|
175
|
+
collected.append(entry.strip())
|
|
176
|
+
|
|
177
|
+
if not collected:
|
|
178
|
+
raise ConfigError(f"{where}: provide 'schedule' (string) or 'schedules' (array of 5-field cron)")
|
|
179
|
+
|
|
180
|
+
for cron in collected:
|
|
181
|
+
_check_cron(where, cron)
|
|
182
|
+
|
|
183
|
+
return tuple(dict.fromkeys(collected)) # de-dup, preserve order
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_agent(where: str, value: object) -> None:
|
|
187
|
+
if not isinstance(value, str) or value not in known_agents():
|
|
188
|
+
raise ConfigError(
|
|
189
|
+
f"{where}.agent: must be one of {known_agents()}, got {value!r}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_bool(where: str, value: object) -> None:
|
|
194
|
+
if not isinstance(value, bool):
|
|
195
|
+
raise ConfigError(f"{where}: must be a boolean")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _check_message(where: str, value: object) -> None:
|
|
199
|
+
if not isinstance(value, str) or not value:
|
|
200
|
+
raise ConfigError(f"{where}.message: must be a non-empty string")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_settle(where: str, value: object) -> None:
|
|
204
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
205
|
+
raise ConfigError(f"{where}.settle_seconds: must be a non-negative integer")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_timezone(where: str, value: object) -> None:
|
|
209
|
+
if not isinstance(value, str):
|
|
210
|
+
raise ConfigError(f"{where}.timezone: must be a string")
|
|
211
|
+
try:
|
|
212
|
+
ZoneInfo(value)
|
|
213
|
+
except (ZoneInfoNotFoundError, ValueError) as exc:
|
|
214
|
+
raise ConfigError(f"{where}.timezone: unknown timezone {value!r}") from exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_cron(where: str, value: str) -> None:
|
|
218
|
+
fields = value.split()
|
|
219
|
+
if len(fields) != _CRON_FIELDS:
|
|
220
|
+
raise ConfigError(
|
|
221
|
+
f"{where}.schedule: expected a 5-field cron expression, got {len(fields)} fields: {value!r}"
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
to_trigger(value, "UTC") # tz irrelevant here; this also validates each field
|
|
225
|
+
except (ValueError, TypeError) as exc:
|
|
226
|
+
raise ConfigError(f"{where}.schedule: invalid cron {value!r}: {exc}") from exc
|
cwarm/cron.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Translate 5-field crontab expressions into APScheduler CronTriggers.
|
|
2
|
+
|
|
3
|
+
APScheduler's own `CronTrigger.from_crontab()` does **not** convert the
|
|
4
|
+
day-of-week numbering: crontab uses 0/7=Sunday..6=Saturday, while APScheduler
|
|
5
|
+
uses 0=Monday..6=Sunday. Passing crontab numbers straight through shifts every
|
|
6
|
+
weekday by one (so `1-5`, meant as Mon-Fri, schedules as Tue-Sat). We translate
|
|
7
|
+
the day-of-week field to weekday names so a crontab expression means what a user
|
|
8
|
+
typing it into `crontab` would expect.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
16
|
+
|
|
17
|
+
# crontab day-of-week: index 0..6 = Sun..Sat (and 7 also = Sun).
|
|
18
|
+
_DOW_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_trigger(expression: str, timezone: str) -> CronTrigger:
|
|
22
|
+
minute, hour, day, month, dow = expression.split()
|
|
23
|
+
return CronTrigger(
|
|
24
|
+
minute=minute,
|
|
25
|
+
hour=hour,
|
|
26
|
+
day=day,
|
|
27
|
+
month=month,
|
|
28
|
+
day_of_week=_translate_dow(dow),
|
|
29
|
+
timezone=timezone,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def next_fire(expression: str, timezone: str, now: datetime) -> datetime | None:
|
|
34
|
+
return to_trigger(expression, timezone).get_next_fire_time(None, now)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _translate_dow(field: str) -> str:
|
|
38
|
+
if field == "*":
|
|
39
|
+
return "*"
|
|
40
|
+
return ",".join(_translate_part(part) for part in field.split(","))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _translate_part(part: str) -> str:
|
|
44
|
+
step = ""
|
|
45
|
+
if "/" in part:
|
|
46
|
+
part, _, step = part.partition("/")
|
|
47
|
+
step = "/" + step
|
|
48
|
+
if part == "*":
|
|
49
|
+
return "*" + step
|
|
50
|
+
if "-" in part:
|
|
51
|
+
lo, hi = part.split("-", 1)
|
|
52
|
+
return f"{_name(lo)}-{_name(hi)}{step}"
|
|
53
|
+
return _name(part) + step
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _name(token: str) -> str:
|
|
57
|
+
"""crontab day-of-week token -> APScheduler weekday name (numbers only)."""
|
|
58
|
+
try:
|
|
59
|
+
n = int(token)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return token.lower() # already a name like "mon"
|
|
62
|
+
if not 0 <= n <= 7:
|
|
63
|
+
raise ValueError(f"day-of-week out of range (0-7): {token!r}")
|
|
64
|
+
return _DOW_NAMES[0 if n == 7 else n]
|
cwarm/cswap.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The one account-switcher backend: claude-swap (`cswap`).
|
|
2
|
+
|
|
3
|
+
It switches Claude Code credentials and reports per-account usage-window state —
|
|
4
|
+
the only genuinely agent-specific machinery in cwarm. A different coding agent
|
|
5
|
+
that can swap accounts would get a sibling module exposing the same surface
|
|
6
|
+
(`status` / `list_accounts` / `switch_to`), referenced from `agent.py`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
CSWAP = "cswap"
|
|
17
|
+
|
|
18
|
+
# `Status: Account-3 (email@x [Org])`
|
|
19
|
+
_STATUS_RE = re.compile(r"^Status:\s+Account-(\d+)\s+\(([^\s\[\]()]+)")
|
|
20
|
+
# A listed account header: ` 3: email@x [Org] (active)`
|
|
21
|
+
_LIST_HEAD_RE = re.compile(r"^\s*(\d+):\s+(\S+)\s+\[.*?\](?P<active>\s+\(active\))?\s*$")
|
|
22
|
+
# A 5h usage line with a reset time: `├ 5h: 3% resets 14:09 in 4h 19m`
|
|
23
|
+
_FIVE_H_RE = re.compile(r"5h:\s*\d+%\s*resets\s+(\d{1,2}:\d{2})")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CswapError(RuntimeError):
|
|
27
|
+
"""A cswap invocation failed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ActiveAccount:
|
|
32
|
+
slot: str
|
|
33
|
+
email: str
|
|
34
|
+
window_reset: str | None # HH:MM if a window is open, else None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def ref(self) -> str:
|
|
38
|
+
"""The id to pass back to switch_to (slot is the most stable handle)."""
|
|
39
|
+
return self.slot
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ListedAccount:
|
|
44
|
+
slot: str
|
|
45
|
+
email: str
|
|
46
|
+
active: bool
|
|
47
|
+
window_open: bool | None # True=warm, False=closed, None=usage unavailable
|
|
48
|
+
|
|
49
|
+
def matches(self, account_id: str) -> bool:
|
|
50
|
+
return account_id == self.slot or account_id.lower() == self.email.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_installed() -> bool:
|
|
54
|
+
return shutil.which(CSWAP) is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def status() -> ActiveAccount | None:
|
|
58
|
+
"""Currently-active account, or None if none could be determined."""
|
|
59
|
+
out = _run(["--status"])
|
|
60
|
+
slot = email = reset = None
|
|
61
|
+
for line in out.splitlines():
|
|
62
|
+
m = _STATUS_RE.match(line)
|
|
63
|
+
if m:
|
|
64
|
+
slot, email = m.group(1), m.group(2)
|
|
65
|
+
continue
|
|
66
|
+
if reset is None:
|
|
67
|
+
fm = _FIVE_H_RE.search(line)
|
|
68
|
+
if fm:
|
|
69
|
+
reset = fm.group(1)
|
|
70
|
+
if slot is None or email is None:
|
|
71
|
+
return None
|
|
72
|
+
return ActiveAccount(slot=slot, email=email, window_reset=reset)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_accounts() -> list[ListedAccount]:
|
|
76
|
+
"""All managed accounts with their usage-window state."""
|
|
77
|
+
out = _run(["--list"])
|
|
78
|
+
accounts: list[ListedAccount] = []
|
|
79
|
+
current: dict | None = None
|
|
80
|
+
|
|
81
|
+
def flush() -> None:
|
|
82
|
+
if current is not None:
|
|
83
|
+
accounts.append(ListedAccount(**current))
|
|
84
|
+
|
|
85
|
+
for line in out.splitlines():
|
|
86
|
+
head = _LIST_HEAD_RE.match(line)
|
|
87
|
+
if head:
|
|
88
|
+
flush()
|
|
89
|
+
current = {
|
|
90
|
+
"slot": head.group(1),
|
|
91
|
+
"email": head.group(2),
|
|
92
|
+
"active": head.group("active") is not None,
|
|
93
|
+
"window_open": None,
|
|
94
|
+
}
|
|
95
|
+
continue
|
|
96
|
+
if current is None:
|
|
97
|
+
continue
|
|
98
|
+
if "Running instances" in line:
|
|
99
|
+
break
|
|
100
|
+
if _FIVE_H_RE.search(line):
|
|
101
|
+
current["window_open"] = True
|
|
102
|
+
elif "usage unavailable" in line.lower():
|
|
103
|
+
current["window_open"] = None
|
|
104
|
+
flush()
|
|
105
|
+
return accounts
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def switch_to(account_id: str) -> None:
|
|
109
|
+
"""Make `account_id` (slot number or email) the active account."""
|
|
110
|
+
_run(["--switch-to", account_id])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _run(args: list[str]) -> str:
|
|
114
|
+
try:
|
|
115
|
+
proc = subprocess.run([CSWAP, *args], capture_output=True, text=True, timeout=60)
|
|
116
|
+
except FileNotFoundError as exc:
|
|
117
|
+
raise CswapError(f"{CSWAP} is not installed or not on PATH") from exc
|
|
118
|
+
except subprocess.TimeoutExpired as exc:
|
|
119
|
+
raise CswapError(f"cswap {' '.join(args)} timed out") from exc
|
|
120
|
+
if proc.returncode != 0:
|
|
121
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
122
|
+
raise CswapError(f"cswap {' '.join(args)} exited {proc.returncode}: {detail}")
|
|
123
|
+
return proc.stdout
|
cwarm/log.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Structured, single-line logging (FR11, G6).
|
|
2
|
+
|
|
3
|
+
Each warmup attempt and each save/restore emits exactly one line so a silent
|
|
4
|
+
failure is impossible to miss. Output goes to stderr and, optionally, a file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_LOGGER_NAME = "cwarm"
|
|
14
|
+
_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
|
15
|
+
_DATEFMT = "%Y-%m-%dT%H:%M:%S%z"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setup_logging(log_file: str | Path | None = None, level: int = logging.INFO) -> logging.Logger:
|
|
19
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
20
|
+
logger.setLevel(level)
|
|
21
|
+
logger.handlers.clear()
|
|
22
|
+
formatter = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
|
23
|
+
|
|
24
|
+
stream = logging.StreamHandler(sys.stderr)
|
|
25
|
+
stream.setFormatter(formatter)
|
|
26
|
+
logger.addHandler(stream)
|
|
27
|
+
|
|
28
|
+
if log_file is not None:
|
|
29
|
+
path = Path(log_file)
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
file_handler = logging.FileHandler(path)
|
|
32
|
+
file_handler.setFormatter(formatter)
|
|
33
|
+
logger.addHandler(file_handler)
|
|
34
|
+
|
|
35
|
+
return logger
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_logger() -> logging.Logger:
|
|
39
|
+
return logging.getLogger(_LOGGER_NAME)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fields(**fields: object) -> str:
|
|
43
|
+
return " ".join(f"{k}={v}" for k, v in fields.items() if v is not None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def log_attempt(account_id: str, outcome: str, reset: str | None = None, error: str | None = None) -> None:
|
|
47
|
+
"""One structured line per warmup attempt (FR11)."""
|
|
48
|
+
logger = get_logger()
|
|
49
|
+
msg = "warmup " + _fields(account=account_id, outcome=outcome, reset=reset, error=error)
|
|
50
|
+
if outcome == "failed":
|
|
51
|
+
logger.error(msg)
|
|
52
|
+
else:
|
|
53
|
+
logger.info(msg)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log_event(event: str, **fields: object) -> None:
|
|
57
|
+
"""One structured line for batch lifecycle events (save/restore, etc.)."""
|
|
58
|
+
get_logger().info(f"{event} " + _fields(**fields))
|
cwarm/schedule.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Batch execution and the long-lived daemon (FR3, FR5, FR8).
|
|
2
|
+
|
|
3
|
+
Warmups are strictly serial — a switcher has only one active account at a time.
|
|
4
|
+
For each switcher the batch touches, the active account is captured before and
|
|
5
|
+
restored after, even on failure (FR3). The daemon maps each enabled account's
|
|
6
|
+
cron+timezone to a job and serialises all firings through a single lock.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from apscheduler.schedulers.blocking import BlockingScheduler
|
|
14
|
+
|
|
15
|
+
from .agent import get_agent, get_switcher
|
|
16
|
+
from .config import Account, Config
|
|
17
|
+
from .cron import to_trigger
|
|
18
|
+
from .cswap import ActiveAccount, CswapError
|
|
19
|
+
from .log import log_event
|
|
20
|
+
from .warmup import FAILED, WarmResult, warm_account
|
|
21
|
+
|
|
22
|
+
# Serialises warmups so two coincident cron firings never run concurrently (FR5).
|
|
23
|
+
_warmup_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_batch(config: Config, account_ids: list[str] | None = None) -> list[WarmResult]:
|
|
27
|
+
"""Warm the selected accounts serially, saving and restoring each switcher's active one.
|
|
28
|
+
|
|
29
|
+
`account_ids=None` warms every enabled account. Restore (FR3) runs in a
|
|
30
|
+
`finally` so it survives any individual warmup failure.
|
|
31
|
+
"""
|
|
32
|
+
accounts = _select(config, account_ids)
|
|
33
|
+
with _warmup_lock:
|
|
34
|
+
return _run_serial(accounts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_serial(accounts: list[Account]) -> list[WarmResult]:
|
|
38
|
+
# Distinct switcher backends this batch will touch (agents may share one).
|
|
39
|
+
switchers = {get_agent(a.agent).switcher for a in accounts}
|
|
40
|
+
switchers.discard(None)
|
|
41
|
+
saved = {name: _save_active(name) for name in switchers}
|
|
42
|
+
results: list[WarmResult] = []
|
|
43
|
+
try:
|
|
44
|
+
for account in accounts:
|
|
45
|
+
results.append(warm_account(account))
|
|
46
|
+
finally:
|
|
47
|
+
for name, active in saved.items():
|
|
48
|
+
_restore_active(name, active)
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _save_active(switcher_name: str) -> ActiveAccount | None:
|
|
53
|
+
try:
|
|
54
|
+
active = get_switcher(switcher_name).status()
|
|
55
|
+
except CswapError as exc:
|
|
56
|
+
log_event("save-active", switcher=switcher_name, outcome=FAILED, error=str(exc))
|
|
57
|
+
return None
|
|
58
|
+
if active:
|
|
59
|
+
log_event("save-active", switcher=switcher_name, account=active.email, ref=active.ref)
|
|
60
|
+
else:
|
|
61
|
+
log_event("save-active", switcher=switcher_name, outcome="none", error="no-active-account")
|
|
62
|
+
return active
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _restore_active(switcher_name: str, saved: ActiveAccount | None) -> None:
|
|
66
|
+
if saved is None:
|
|
67
|
+
log_event("restore-active", switcher=switcher_name, outcome="skipped", error="nothing-to-restore")
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
get_switcher(switcher_name).switch_to(saved.ref)
|
|
71
|
+
log_event("restore-active", switcher=switcher_name, account=saved.email, ref=saved.ref, outcome="ok")
|
|
72
|
+
except CswapError as exc:
|
|
73
|
+
log_event("restore-active", switcher=switcher_name, account=saved.email, outcome=FAILED, error=str(exc))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _select(config: Config, account_ids: list[str] | None) -> list[Account]:
|
|
77
|
+
if account_ids is None:
|
|
78
|
+
return config.enabled_accounts()
|
|
79
|
+
selected: list[Account] = []
|
|
80
|
+
for account_id in account_ids:
|
|
81
|
+
account = config.find(account_id)
|
|
82
|
+
if account is None:
|
|
83
|
+
raise ValueError(f"account id {account_id!r} not found in config")
|
|
84
|
+
if not account.enabled:
|
|
85
|
+
log_event("select", account=account_id, outcome="skipped", error="disabled")
|
|
86
|
+
continue
|
|
87
|
+
selected.append(account)
|
|
88
|
+
return selected
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_daemon(config: Config) -> None:
|
|
92
|
+
"""Long-lived scheduler: one cron job per enabled account (FR8)."""
|
|
93
|
+
# Explicit scheduler tz avoids tzlocal probing /etc/timezone; each job still
|
|
94
|
+
# carries its own per-account timezone.
|
|
95
|
+
scheduler = BlockingScheduler(timezone=config.defaults.timezone)
|
|
96
|
+
enabled = config.enabled_accounts()
|
|
97
|
+
if not enabled:
|
|
98
|
+
log_event("daemon", outcome="no-enabled-accounts")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
jobs = 0
|
|
102
|
+
for account in enabled:
|
|
103
|
+
for idx, cron in enumerate(account.schedules):
|
|
104
|
+
trigger = to_trigger(cron, account.timezone)
|
|
105
|
+
scheduler.add_job(
|
|
106
|
+
_fire,
|
|
107
|
+
trigger=trigger,
|
|
108
|
+
args=[account],
|
|
109
|
+
id=f"cwarm:{account.agent}:{account.id}:{idx}",
|
|
110
|
+
name=f"warmup {account.agent}:{account.id} [{cron}]",
|
|
111
|
+
max_instances=1,
|
|
112
|
+
coalesce=True,
|
|
113
|
+
misfire_grace_time=3600,
|
|
114
|
+
)
|
|
115
|
+
log_event("schedule", account=account.id, cron=repr(cron), tz=account.timezone)
|
|
116
|
+
jobs += 1
|
|
117
|
+
|
|
118
|
+
log_event("daemon", outcome="started", jobs=jobs)
|
|
119
|
+
try:
|
|
120
|
+
scheduler.start()
|
|
121
|
+
except (KeyboardInterrupt, SystemExit):
|
|
122
|
+
log_event("daemon", outcome="stopped")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fire(account: Account) -> None:
|
|
126
|
+
"""A single account's scheduled firing: its own save/restore batch."""
|
|
127
|
+
with _warmup_lock:
|
|
128
|
+
_run_serial([account])
|
cwarm/warmup.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Warm a single account: (optionally switch) -> settle -> send message (FR4).
|
|
2
|
+
|
|
3
|
+
The send is just the agent's configured command with the message appended
|
|
4
|
+
(`claude -p "Hi"`). If the agent has a switcher, we switch to the target account
|
|
5
|
+
first; a fresh process then reads the swapped credentials at startup, so no
|
|
6
|
+
interactive restart is needed — the `settle_seconds` delay covers the write race.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
from .agent import get_agent, get_switcher
|
|
17
|
+
from .config import Account
|
|
18
|
+
from .cswap import CswapError
|
|
19
|
+
from .log import log_attempt
|
|
20
|
+
|
|
21
|
+
# Outcomes (FR11)
|
|
22
|
+
OK = "ok"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
|
|
26
|
+
_SEND_TIMEOUT = 180 # seconds for a single warmup message
|
|
27
|
+
_SEND_ERRORS = (CswapError, OSError, subprocess.SubprocessError, RuntimeError)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class WarmResult:
|
|
32
|
+
account_id: str
|
|
33
|
+
outcome: str
|
|
34
|
+
reset: str | None = None
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def warm_account(account: Account) -> WarmResult:
|
|
39
|
+
"""Switch to `account` (if its agent switches), settle, and anchor its window."""
|
|
40
|
+
agent = get_agent(account.agent)
|
|
41
|
+
switcher = get_switcher(agent.switcher)
|
|
42
|
+
|
|
43
|
+
if account.skip_if_warm and switcher:
|
|
44
|
+
try:
|
|
45
|
+
if _is_warm(switcher, account):
|
|
46
|
+
log_attempt(account.id, SKIPPED, error="already-warm")
|
|
47
|
+
return WarmResult(account.id, SKIPPED, error="already-warm")
|
|
48
|
+
except CswapError as exc:
|
|
49
|
+
# Can't tell if warm — fall through and warm it rather than skip blindly.
|
|
50
|
+
log_attempt(account.id, "warn", error=f"skip-check-failed: {exc}")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
if switcher:
|
|
54
|
+
switcher.switch_to(account.id)
|
|
55
|
+
if account.settle_seconds > 0:
|
|
56
|
+
time.sleep(account.settle_seconds)
|
|
57
|
+
_send(agent.command, account.message)
|
|
58
|
+
except _SEND_ERRORS as exc:
|
|
59
|
+
result = WarmResult(account.id, FAILED, error=_summary(exc))
|
|
60
|
+
log_attempt(account.id, FAILED, error=result.error)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
reset = _reset(switcher)
|
|
64
|
+
log_attempt(account.id, OK, reset=reset)
|
|
65
|
+
return WarmResult(account.id, OK, reset=reset)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _send(command: tuple[str, ...], message: str) -> None:
|
|
69
|
+
"""Send one non-interactive warmup message via the agent's CLI."""
|
|
70
|
+
proc = subprocess.run([*command, message], capture_output=True, text=True, timeout=_SEND_TIMEOUT)
|
|
71
|
+
if proc.returncode != 0:
|
|
72
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
73
|
+
raise RuntimeError(detail[-1] if detail else f"{command[0]} exited {proc.returncode}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_warm(switcher: ModuleType, account: Account) -> bool:
|
|
77
|
+
"""True if the account's usage window is already open (skip-if-warm, FR6)."""
|
|
78
|
+
listed = next((a for a in switcher.list_accounts() if a.matches(account.id)), None)
|
|
79
|
+
return bool(listed and listed.window_open)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _reset(switcher: ModuleType | None) -> str | None:
|
|
83
|
+
"""Best-effort window reset time for the log line."""
|
|
84
|
+
if switcher is None:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
active = switcher.status()
|
|
88
|
+
except CswapError:
|
|
89
|
+
return None
|
|
90
|
+
return active.window_reset if active else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _summary(exc: Exception) -> str:
|
|
94
|
+
text = str(exc).strip().replace("\n", " ")
|
|
95
|
+
return text[:200] if text else exc.__class__.__name__
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cwarm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stagger-warm coding-agent accounts (Claude Code, Codex, ...) so their usage windows open early and reset at different times.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: apscheduler<4,>=3.10
|
|
8
|
+
Requires-Dist: tzdata; platform_system == 'Windows'
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: commitizen>=3; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# cwarm
|
|
16
|
+
|
|
17
|
+
[](https://github.com/wonderbyte/cwarm/actions/workflows/ci.yml)
|
|
18
|
+
[](https://pypi.org/project/cwarm/)
|
|
19
|
+
[](https://pypi.org/project/cwarm/)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
|
|
22
|
+
Stagger-warm multiple **coding-agent accounts** so their rolling usage windows
|
|
23
|
+
open early in the day and reset at different times. Today it warms **Claude Code**
|
|
24
|
+
accounts (paired with [`claude-swap`](https://pypi.org/project/claude-swap/),
|
|
25
|
+
`cswap`); the agent layer is generic, so other CLIs (e.g. Codex) can be added.
|
|
26
|
+
When the active account exhausts its window, another is already warm to switch
|
|
27
|
+
into.
|
|
28
|
+
|
|
29
|
+
This tool **stores no credentials** — the account-switcher (`claude-swap`) is the
|
|
30
|
+
source of truth for accounts and tokens. cwarm only orchestrates it.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
Claude Code's 5-hour window starts on an account's first message and resets
|
|
35
|
+
exactly 5 hours later. It's a fixed budget, not free capacity — warming only
|
|
36
|
+
*relocates* the dead/regeneration time so it lands outside your working hours.
|
|
37
|
+
Staggering the warmups (e.g. 05:00, 07:30, 10:00) keeps at least one fresh
|
|
38
|
+
account available through the day.
|
|
39
|
+
|
|
40
|
+
For each due account, cwarm:
|
|
41
|
+
|
|
42
|
+
1. switches to it (for Claude: `cswap --switch-to <id>`),
|
|
43
|
+
2. waits `settle_seconds` for the credential swap to land,
|
|
44
|
+
3. sends one minimal message via the agent's command (for Claude: `claude -p "Hi"`)
|
|
45
|
+
— anchoring that account's window,
|
|
46
|
+
|
|
47
|
+
then restores whichever account you had active before the batch (always, even
|
|
48
|
+
if a warmup fails).
|
|
49
|
+
|
|
50
|
+
> A **weekly cap** is shared across web, app, and Claude Code. Warming several
|
|
51
|
+
> accounts daily consumes some of it. Tracking that cap is out of scope.
|
|
52
|
+
|
|
53
|
+
## Agents
|
|
54
|
+
|
|
55
|
+
An "agent" is just two things: a **command** that sends a non-interactive prompt
|
|
56
|
+
(`<cli> -p "Hi"`) and an optional **account-switcher**. They live as a small data
|
|
57
|
+
table in `cwarm/agent.py` — not a class per agent:
|
|
58
|
+
|
|
59
|
+
| agent | command | switcher |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `claude` | `claude -p` | `cswap` (claude-swap) |
|
|
62
|
+
|
|
63
|
+
Each account picks its agent via the `agent` field (default `claude`). To add a
|
|
64
|
+
coding agent, add one row. Only a *new* switcher (something other than `cswap`)
|
|
65
|
+
needs code — a sibling module to `cwarm/cswap.py`. An agent with no switcher
|
|
66
|
+
warms whatever account that CLI currently has active (no multi-account swapping).
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
- For the `claude` agent: `claude-swap` installed and configured with every
|
|
71
|
+
target account added (`cswap --add-account` / `cswap --add-token sk-ant-oat01-…`),
|
|
72
|
+
and Claude Code (`claude`) runnable non-interactively.
|
|
73
|
+
- Python 3.12+.
|
|
74
|
+
|
|
75
|
+
## Platform support
|
|
76
|
+
|
|
77
|
+
cwarm is pure Python and runs anywhere the agent's CLIs (`claude`, `cswap`) do:
|
|
78
|
+
|
|
79
|
+
- **Linux** — fully supported, with the bundled `systemd` user service.
|
|
80
|
+
- **macOS** — the tool and `cwarm daemon` work the same; for boot persistence
|
|
81
|
+
use `launchd` or `cron` instead of systemd.
|
|
82
|
+
- **Windows** — works too; `tzdata` is pulled in automatically (Windows has no
|
|
83
|
+
system IANA tz database). Use Task Scheduler or run `cwarm daemon` as a
|
|
84
|
+
service instead of systemd.
|
|
85
|
+
|
|
86
|
+
`cwarm run`/`daemon` are cross-platform; only the deployment recipe differs.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd ~/workspace/apps/cwarm
|
|
92
|
+
uv venv
|
|
93
|
+
uv pip install -e .
|
|
94
|
+
cp config.example.json config.json # then edit ids/schedules
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`config.json` is gitignored — it holds your real account ids.
|
|
98
|
+
|
|
99
|
+
## Configuration (`config.json`)
|
|
100
|
+
|
|
101
|
+
No tokens. Accounts are referenced by the handle their agent uses — for Claude, a
|
|
102
|
+
`cswap` **slot number** or **email**.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"defaults": {
|
|
107
|
+
"agent": "claude",
|
|
108
|
+
"message": "Hi",
|
|
109
|
+
"timezone": "Asia/Kolkata",
|
|
110
|
+
"settle_seconds": 3,
|
|
111
|
+
"skip_if_warm": true
|
|
112
|
+
},
|
|
113
|
+
"accounts": [
|
|
114
|
+
{ "id": "work@example.com", "enabled": true, "schedules": ["0 5 * * 1-5", "0 11 * * 1-5", "0 21 * * 1-5"] },
|
|
115
|
+
{ "id": "2", "enabled": true, "schedule": "30 7 * * 1-5" },
|
|
116
|
+
{ "id": "personal@x.com", "enabled": true, "schedules": ["0 10 * * *", "0 18 * * *"] },
|
|
117
|
+
{ "id": "4", "enabled": false, "schedule": "30 12 * * *" }
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
An account can warm at **several times a day** — give it a `schedules` array
|
|
123
|
+
(e.g. 05:00, 11:00, 21:00). Use the singular `schedule` string for a single
|
|
124
|
+
time. Both keys may be present; their union (de-duplicated) is used. Each cron
|
|
125
|
+
time becomes its own daemon job, fired in the account's `timezone`.
|
|
126
|
+
|
|
127
|
+
| Field | Required | Default | Notes |
|
|
128
|
+
| --- | --- | --- | --- |
|
|
129
|
+
| `id` | yes | — | unique; the agent's account handle (cswap slot or email) |
|
|
130
|
+
| `schedule` / `schedules` | yes | — | one (string) or many (array) 5-field cron times, read in the account's `timezone` |
|
|
131
|
+
| `agent` | no | `defaults` / `claude` | which coding agent warms this account |
|
|
132
|
+
| `enabled` | no | `true` | `false` skips the account entirely |
|
|
133
|
+
| `message` | no | `defaults` / `"Hi"` | the warmup message |
|
|
134
|
+
| `timezone` | no | `defaults` / `Asia/Kolkata` | IANA tz name |
|
|
135
|
+
| `settle_seconds` | no | `defaults` / `3` | delay after switching before sending |
|
|
136
|
+
| `skip_if_warm` | no | `defaults` / `false` | skip if the window is already open |
|
|
137
|
+
|
|
138
|
+
## Usage
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cwarm init # write a starter config.json
|
|
142
|
+
cwarm validate # check config + agents; sends nothing
|
|
143
|
+
cwarm list # show accounts, live window state, next run
|
|
144
|
+
cwarm run # warm all enabled accounts now
|
|
145
|
+
cwarm run --account work@example.com # warm just one
|
|
146
|
+
cwarm daemon # long-lived; fires each account on its cron
|
|
147
|
+
|
|
148
|
+
# global flags
|
|
149
|
+
cwarm --config /path/to/config.json --log-file /path/to/cwarm.log <command>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- **`init`** — writes a starter `config.json` (won't clobber an existing one
|
|
153
|
+
without `--force`).
|
|
154
|
+
- **`validate`** — confirms the JSON matches the schema, each account's agent CLI
|
|
155
|
+
(and its switcher) is installed, and every configured `id` exists. Exits
|
|
156
|
+
non-zero and sends nothing on any problem.
|
|
157
|
+
- **`list`** — read-only table of every account: agent, enabled, live window
|
|
158
|
+
state (warm/cold), next scheduled run, and its cron times. Sends nothing.
|
|
159
|
+
- **`run`** — warms enabled accounts immediately (one batch, one save/restore per
|
|
160
|
+
switcher). Good for testing or a system `crontab`. Non-zero if any warmup failed.
|
|
161
|
+
- **`daemon`** — schedules one job per account per cron time, in the account's
|
|
162
|
+
timezone. Warmups are always serial.
|
|
163
|
+
|
|
164
|
+
### Logging
|
|
165
|
+
|
|
166
|
+
Every attempt emits one structured line to stderr (and the log file if set):
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
2026-06-21T05:00:03+0530 INFO save-active switcher=cswap account=work@example.com ref=1
|
|
170
|
+
2026-06-21T05:00:09+0530 INFO warmup account=work@example.com outcome=ok reset=10:00
|
|
171
|
+
2026-06-21T05:00:10+0530 INFO restore-active switcher=cswap account=work@example.com ref=1 outcome=ok
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Outcomes: `ok` (with the window `reset` time), `failed` (with an `error`
|
|
175
|
+
summary), `skipped` (`skip_if_warm` and already warm).
|
|
176
|
+
|
|
177
|
+
## Deployment
|
|
178
|
+
|
|
179
|
+
### systemd (recommended)
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
mkdir -p ~/.config/systemd/user ~/.local/state/cwarm
|
|
183
|
+
cp systemd/cwarm.service ~/.config/systemd/user/
|
|
184
|
+
systemctl --user daemon-reload
|
|
185
|
+
systemctl --user enable --now cwarm
|
|
186
|
+
loginctl enable-linger "$USER" # run without an active login session
|
|
187
|
+
journalctl --user -u cwarm -f
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Alternative: system crontab
|
|
191
|
+
|
|
192
|
+
One line per warmup time invoking the one-shot mode (repeat a line per account
|
|
193
|
+
to warm it several times a day):
|
|
194
|
+
|
|
195
|
+
```cron
|
|
196
|
+
0 5 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
|
|
197
|
+
0 11 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
|
|
198
|
+
0 21 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account work@example.com
|
|
199
|
+
30 7 * * 1-5 cd ~/workspace/apps/cwarm && .venv/bin/cwarm run --account 2
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Energy
|
|
203
|
+
|
|
204
|
+
The daemon does **not** poll — it sleeps on an event until the next scheduled
|
|
205
|
+
warmup, so idle cost is ~25 MB RAM and effectively 0% CPU (measured: 1 voluntary
|
|
206
|
+
context switch over 3 s idle). There is no busy-loop to optimise.
|
|
207
|
+
|
|
208
|
+
The real per-warmup energy is the `claude -p "Hi"` call: it boots the Node
|
|
209
|
+
Claude Code CLI **and** sends a real LLM inference request to anchor the window.
|
|
210
|
+
That's irreducible — anchoring *requires* a server-side message. So the only
|
|
211
|
+
meaningful lever is **not sending redundant ones**:
|
|
212
|
+
|
|
213
|
+
- **`skip_if_warm: true`** (the example default) — before switching, parse
|
|
214
|
+
`cswap --list`; if the account's window is already open, log `skipped` and send
|
|
215
|
+
nothing. This skips the entire heavy `claude -p` call, the single biggest
|
|
216
|
+
energy saving available.
|
|
217
|
+
- **Stagger, don't stack** — overlapping schedules waste warmups (and the shared
|
|
218
|
+
weekly cap). One warmup per window per account is enough to anchor it.
|
|
219
|
+
|
|
220
|
+
For literally zero idle footprint, use systemd timers or cron instead of the
|
|
221
|
+
daemon — no process is resident between warmups — but the saving over the
|
|
222
|
+
sleeping daemon is marginal.
|
|
223
|
+
|
|
224
|
+
## System restart
|
|
225
|
+
|
|
226
|
+
Yes. Via the systemd **user** service plus linger:
|
|
227
|
+
|
|
228
|
+
- `systemctl --user enable` + `loginctl enable-linger "$USER"` → the daemon
|
|
229
|
+
**starts on boot**, with no login session required.
|
|
230
|
+
- `Restart=always` (RestartSec=10) → if the process ever exits — crash or
|
|
231
|
+
otherwise — systemd brings it straight back.
|
|
232
|
+
- On every (re)start the schedule is **rebuilt fresh from `config.json`**
|
|
233
|
+
(in-memory jobstore; the config is the single source of truth — no stale
|
|
234
|
+
persisted state to reconcile).
|
|
235
|
+
- **Short downtime is tolerated:** `misfire_grace_time=3600` + `coalesce=True`
|
|
236
|
+
mean a warmup missed by under an hour still fires once on recovery.
|
|
237
|
+
- **Long power-off is *not* caught up by design:** a 05:00 warmup missed because
|
|
238
|
+
the machine was off until noon is skipped, not fired late — firing it hours
|
|
239
|
+
late would defeat the staggering. Edit `config.json` and
|
|
240
|
+
`systemctl --user restart cwarm` to re-plan.
|
|
241
|
+
|
|
242
|
+
## Safety
|
|
243
|
+
|
|
244
|
+
- No credentials stored or logged — the switcher (`claude-swap`) owns them.
|
|
245
|
+
- Switching changes your local active account; run warmups at off-hours. The
|
|
246
|
+
save/restore guarantees your default is unchanged after a batch.
|
|
247
|
+
- Only configure accounts you legitimately own or are authorised to use.
|
|
248
|
+
|
|
249
|
+
## Contributing / releasing
|
|
250
|
+
|
|
251
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/)
|
|
252
|
+
(`feat:`, `fix:`, `docs:`, `ci:`, …). Lint and tests run in CI:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
ruff check .
|
|
256
|
+
pytest
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Versioning and the changelog are managed by
|
|
260
|
+
[Commitizen](https://commitizen-tools.github.io/commitizen/).
|
|
261
|
+
|
|
262
|
+
**To release:** run the **Release & Publish** workflow from the Actions tab
|
|
263
|
+
("Run workflow", optionally choosing the bump size). In one run it bumps the
|
|
264
|
+
version (`pyproject.toml` + `cwarm/__init__.py`), updates `CHANGELOG.md`, tags
|
|
265
|
+
and creates the GitHub Release, then builds and publishes to PyPI via Trusted
|
|
266
|
+
Publishing — no token stored. The next version is inferred from the Conventional
|
|
267
|
+
Commits since the last release.
|
|
268
|
+
|
|
269
|
+
Or do it locally:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
cz bump # bump version + changelog + create the vX.Y.Z tag
|
|
273
|
+
git push --follow-tags
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The project is in `0.x` (`major_version_zero`), so breaking changes bump the
|
|
277
|
+
minor until `1.0.0`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cwarm/__init__.py,sha256=FDso22uyVsq49FrEiFaqSYU7q75DIlS4WAM7BzeB3qE,104
|
|
2
|
+
cwarm/agent.py,sha256=1niSHpLvj8-q6Xk8aVpDzjYVFo_IJ7xcOqq-Rx2wzFE,1923
|
|
3
|
+
cwarm/cli.py,sha256=hPqvsJNncKahDsm4K7BpLNmV3GR_QTk2f4lj6-I5UsA,8009
|
|
4
|
+
cwarm/config.py,sha256=K_-xoqf3asvG_7Mqz7AHlknBEd-V0eVaZ0L7mb2nlG0,7627
|
|
5
|
+
cwarm/cron.py,sha256=MMn6j8EM4xTF-2Stq1fStVj9a-YjOTBRve4x5mI-jgU,2048
|
|
6
|
+
cwarm/cswap.py,sha256=AAXHH3D2AxJjKPVce867cy2KlmxYoKyNCMzmyXwyUtk,3916
|
|
7
|
+
cwarm/log.py,sha256=n05ORA0VXL--H522Cf3wcR7Hl1B2Q1keqBQWyw7WOcU,1807
|
|
8
|
+
cwarm/schedule.py,sha256=yY75pVZNYNvfY8flXzvo2TD9jKCpcr4LuW1D81fabvA,4856
|
|
9
|
+
cwarm/warmup.py,sha256=24ZxFGQr4ApYN6LNiBZqjhEp6tMi_ZbFIqwQVS4uZvg,3350
|
|
10
|
+
cwarm-0.1.0.dist-info/METADATA,sha256=16UPPqOJC8phIOHPhcDjyaBlBKPqP1eBf7x8_q3k634,11726
|
|
11
|
+
cwarm-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
cwarm-0.1.0.dist-info/entry_points.txt,sha256=4eosRqN9NniPmsb-9AtdKGgVMiPZetacDNplZSv7Bjw,41
|
|
13
|
+
cwarm-0.1.0.dist-info/licenses/LICENSE,sha256=SJ8m54t7cDXiLyEdsMUys5G61_P1mMpHkXQVh87pheM,1067
|
|
14
|
+
cwarm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wonderbyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|