llmt-cli 0.2.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.
llmt/telemetry/beat.py ADDED
@@ -0,0 +1,221 @@
1
+ """Heartbeat orchestration: collect -> build STRICT wire payload -> POST ->
2
+ answer challenges. The single privacy chokepoint.
3
+
4
+ ``build_heartbeat_payload`` emits EXACTLY the frozen request fields and nothing
5
+ else — no paths, no scan data, no machine info. The strict-payload test asserts
6
+ this by inspecting the object this function returns.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from typing import Any, Callable, Optional
13
+
14
+ from ..errors import RateLimitedError
15
+ from . import config as tcfg
16
+ from .artifacts import ArtifactLocator, Challenge, answer_challenge
17
+ from .collect import CollectedTorrent
18
+ from .errors import TelemetryConfigError, TelemetryConsentError
19
+ from .wire import AgentClient
20
+
21
+ AGENT_VERSION_FALLBACK = "0.0.0"
22
+
23
+ #: EXACTLY the fields the frozen wire allows per torrent entry. Used by the
24
+ #: strict-payload test as the allow-list.
25
+ WIRE_TORRENT_FIELDS = frozenset(
26
+ {"infohash_v1", "infohash_v2", "name", "state", "progress", "artifacts"})
27
+
28
+
29
+ def _now_iso() -> str:
30
+ return datetime.datetime.now(datetime.timezone.utc).replace(
31
+ microsecond=0).isoformat().replace("+00:00", "Z")
32
+
33
+
34
+ def _torrent_entry(t: CollectedTorrent,
35
+ artifacts: list) -> dict[str, Any]:
36
+ """Serialize ONE torrent to exactly the frozen wire fields. No path/scan
37
+ data can appear here — this is the privacy boundary."""
38
+ return {
39
+ "infohash_v1": t.infohash_v1,
40
+ "infohash_v2": t.infohash_v2,
41
+ "name": t.name,
42
+ "state": t.state,
43
+ "progress": float(t.progress),
44
+ "artifacts": [
45
+ {"sha256": a["sha256"], "complete": bool(a["complete"])}
46
+ for a in artifacts
47
+ ],
48
+ }
49
+
50
+
51
+ def build_heartbeat_payload(
52
+ agent_version: str,
53
+ client_name: str,
54
+ client_version: Optional[str],
55
+ torrents: list,
56
+ artifact_resolver: Optional[Callable[[CollectedTorrent], list]] = None,
57
+ max_torrents: int = 500,
58
+ ) -> dict[str, Any]:
59
+ """Build the exact ``POST /agent/v1/heartbeats`` request body.
60
+
61
+ Only torrents with at least one match key (v1/v2/name) are included, capped
62
+ at ``max_torrents`` (contract: <=500).
63
+ """
64
+ entries: list = []
65
+ for t in torrents:
66
+ if not t.has_key():
67
+ continue
68
+ arts = artifact_resolver(t) if artifact_resolver is not None else []
69
+ entries.append(_torrent_entry(t, arts))
70
+ if len(entries) >= max_torrents:
71
+ break
72
+ return {
73
+ "agent_version": agent_version,
74
+ "client": {"name": client_name,
75
+ "version": client_version if client_version else None},
76
+ "torrents": entries,
77
+ }
78
+
79
+
80
+ def default_artifact_resolver(locator: ArtifactLocator):
81
+ """Resolve a torrent's artifact sha256s by hashing its local content files
82
+ (cached). ``complete`` reflects the honest possession signal: the client
83
+ reports the torrent as fully seeding. Files that cannot be hashed are
84
+ simply omitted (never fabricated)."""
85
+ def _resolve(t: CollectedTorrent) -> list:
86
+ complete = t.state == "seeding"
87
+ out: list = []
88
+ seen: set = set()
89
+ for fp in t.local_files:
90
+ sha = locator.sha256_of(fp)
91
+ if sha and sha not in seen:
92
+ seen.add(sha)
93
+ out.append({"sha256": sha, "complete": complete})
94
+ return out
95
+ return _resolve
96
+
97
+
98
+ def run_beat(
99
+ *,
100
+ source,
101
+ agent_version: str,
102
+ max_torrents: Optional[int] = None,
103
+ client_factory: Optional[Callable[[], AgentClient]] = None,
104
+ report_artifacts: Optional[bool] = None,
105
+ chunk_size: Optional[int] = None,
106
+ locator: Optional[ArtifactLocator] = None,
107
+ ) -> dict[str, Any]:
108
+ """Run one heartbeat cycle. Raises TelemetryConsentError (NO network) when
109
+ telemetry is disabled, and TelemetryConfigError when no token is stored.
110
+
111
+ ``source`` is a collect.*TelemetrySource (or None); ``client_factory``
112
+ builds the AgentClient (injected in tests).
113
+ """
114
+ cfg = tcfg.load_config()
115
+ tele = cfg["telemetry"]
116
+ if not tele.get("enabled"):
117
+ raise TelemetryConsentError(
118
+ "telemetry is disabled; run `llmt telemetry enable` first. "
119
+ "(Nothing was sent.)")
120
+ token = tcfg.load_token(cfg)
121
+ if not token:
122
+ raise TelemetryConfigError(
123
+ "telemetry is enabled but no token is stored; run "
124
+ "`llmt telemetry enable` to store one.")
125
+ if source is None:
126
+ raise TelemetryConfigError(
127
+ "no torrent client configured; pass --qb-url/--tr-url or set "
128
+ "LLMT_QB_URL/LLMT_TR_URL so telemetry can read your torrents.")
129
+
130
+ max_torrents = int(max_torrents if max_torrents is not None
131
+ else tele.get("max_torrents_per_heartbeat", 500))
132
+ if report_artifacts is None:
133
+ report_artifacts = bool(tele.get("report_artifacts", True))
134
+ if chunk_size is None:
135
+ chunk_size = int(tele.get("challenge_chunk_size",
136
+ tcfg.DEFAULT_CHALLENGE_CHUNK))
137
+
138
+ torrents = source.collect(max_torrents=max_torrents)
139
+ if locator is None:
140
+ locator = ArtifactLocator()
141
+ resolver = default_artifact_resolver(locator) if report_artifacts else None
142
+
143
+ client = (client_factory() if client_factory is not None
144
+ else AgentClient(tele["endpoint"], token))
145
+
146
+ payload = build_heartbeat_payload(
147
+ agent_version=agent_version,
148
+ client_name=getattr(source, "kind", "unknown"),
149
+ client_version=None,
150
+ torrents=torrents,
151
+ artifact_resolver=resolver,
152
+ max_torrents=max_torrents,
153
+ )
154
+
155
+ summary: dict[str, Any] = {
156
+ "at": _now_iso(),
157
+ "torrents_reported": len(payload["torrents"]),
158
+ "accepted": 0,
159
+ "unmatched": [],
160
+ "challenges": {"total": 0, "pass": 0, "fail": 0, "expired": 0,
161
+ "already-used": 0, "unresolved": 0},
162
+ "ok": False,
163
+ "error": None,
164
+ }
165
+
166
+ try:
167
+ resp = client.heartbeat(payload)
168
+ except RateLimitedError as exc:
169
+ summary["error"] = f"rate-limited (retry after {exc.retry_after}s)" \
170
+ if exc.retry_after else "rate-limited"
171
+ summary["retry_after"] = exc.retry_after
172
+ tcfg.record_last_beat(_slim(summary))
173
+ raise
174
+
175
+ if isinstance(resp.get("accepted"), int):
176
+ summary["accepted"] = resp["accepted"]
177
+ um = resp.get("unmatched")
178
+ if isinstance(um, list):
179
+ summary["unmatched"] = [i for i in um if isinstance(i, int)]
180
+
181
+ # Answer challenges promptly, same run. Candidate files = every local file
182
+ # across the reported torrents.
183
+ candidate_paths: list = []
184
+ seen_paths: set = set()
185
+ for t in torrents:
186
+ for fp in t.local_files:
187
+ if fp not in seen_paths:
188
+ seen_paths.add(fp)
189
+ candidate_paths.append(fp)
190
+
191
+ for raw in (resp.get("challenges") or []):
192
+ ch = Challenge.parse(raw)
193
+ if ch is None:
194
+ continue
195
+ summary["challenges"]["total"] += 1
196
+ hash_hex = answer_challenge(ch, candidate_paths, locator, chunk_size)
197
+ if hash_hex is None:
198
+ # Cannot answer honestly (file absent/incomplete): submit nothing.
199
+ summary["challenges"]["unresolved"] += 1
200
+ continue
201
+ result = client.answer_challenge(ch.nonce, hash_hex)
202
+ bucket = result if result in summary["challenges"] else "fail"
203
+ summary["challenges"][bucket] += 1
204
+
205
+ locator.save()
206
+ summary["ok"] = True
207
+ tcfg.record_last_beat(_slim(summary))
208
+ return summary
209
+
210
+
211
+ def _slim(summary: dict) -> dict:
212
+ ch = summary.get("challenges", {})
213
+ return {
214
+ "at": summary.get("at"),
215
+ "torrents_reported": summary.get("torrents_reported"),
216
+ "accepted": summary.get("accepted"),
217
+ "challenges_total": ch.get("total", 0),
218
+ "challenges_passed": ch.get("pass", 0),
219
+ "ok": summary.get("ok", False),
220
+ "error": summary.get("error"),
221
+ }
llmt/telemetry/cmd.py ADDED
@@ -0,0 +1,358 @@
1
+ """``llmt telemetry`` subcommands: enable | disable | status | whoami | beat.
2
+
3
+ Off-by-default, explicit opt-in (DR-27). ``enable`` prints the exact consent
4
+ statement and requires confirmation (interactive y/N or ``--yes``). ``disable``
5
+ stops sending and deletes the stored token. The token value is NEVER printed
6
+ after it is saved and NEVER logged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import os
13
+ import sys
14
+ import time
15
+ from typing import Optional
16
+
17
+ from .. import __version__
18
+ from ..clients import (
19
+ QBittorrentClient, TransmissionClient, build_client,
20
+ )
21
+ from ..errors import LlmtError, RateLimitedError
22
+ from ..output import emit_json, safe_text
23
+ from . import beat as beatmod
24
+ from . import collect as collectmod
25
+ from . import config as tcfg
26
+ from .consent import consent_text
27
+ from .errors import (
28
+ TelemetryAuthError, TelemetryConfigError, TelemetryConsentError,
29
+ TelemetryScopeError,
30
+ )
31
+ from .wire import AgentClient, clamp_retry_after, looks_like_token
32
+
33
+
34
+ # --------------------------------------------------------------------------- #
35
+ # argparse wiring (called from cli.build_parser)
36
+ # --------------------------------------------------------------------------- #
37
+
38
+ def _add_client_args(sp) -> None:
39
+ sp.add_argument("--client",
40
+ choices=["qbittorrent", "transmission", "none"],
41
+ default=None,
42
+ help="Torrent client to read (default: auto from "
43
+ "--qb-url/--tr-url or LLMT_QB_URL/LLMT_TR_URL).")
44
+ sp.add_argument("--qb-url", default=None, help="qBittorrent WebUI URL "
45
+ "(env LLMT_QB_URL).")
46
+ sp.add_argument("--qb-user", default=None, help="(env LLMT_QB_USER).")
47
+ sp.add_argument("--qb-pass", default=None, help="(env LLMT_QB_PASS).")
48
+ sp.add_argument("--tr-url", default=None, help="Transmission RPC URL "
49
+ "(env LLMT_TR_URL).")
50
+ sp.add_argument("--tr-user", default=None, help="(env LLMT_TR_USER).")
51
+ sp.add_argument("--tr-pass", default=None, help="(env LLMT_TR_PASS).")
52
+
53
+
54
+ def add_telemetry_args(sp) -> None:
55
+ sp.description = (
56
+ "Opt-in seeding telemetry (OFF by default). Sends only torrent "
57
+ "infohashes/names/states/progress + artifact SHA-256s + challenge "
58
+ "range hashes. Never sends paths or scan data. View/delete your "
59
+ "server-side data on the website (Account -> Telemetry).")
60
+ sub = sp.add_subparsers(dest="tele_action", required=True)
61
+
62
+ en = sub.add_parser("enable",
63
+ help="Show the consent statement and opt in.")
64
+ en.add_argument("--token", default=None,
65
+ help="Telemetry token from the website (env "
66
+ "LLMT_AGENT_TOKEN). If omitted you are prompted.")
67
+ en.add_argument("--endpoint", default=None,
68
+ help="Telemetry endpoint base (default: config / "
69
+ f"{tcfg.DEFAULT_ENDPOINT}).")
70
+ en.add_argument("--yes", "-y", action="store_true",
71
+ help="Confirm consent non-interactively (for scripts).")
72
+ en.set_defaults(func=cmd_enable)
73
+
74
+ di = sub.add_parser("disable",
75
+ help="Stop sending and delete the stored token.")
76
+ di.set_defaults(func=cmd_disable)
77
+
78
+ st = sub.add_parser("status",
79
+ help="Show consent state, token presence, last beat.")
80
+ st.add_argument("--json", action="store_true")
81
+ st.set_defaults(func=cmd_status)
82
+
83
+ wa = sub.add_parser("whoami",
84
+ help="Show the identity behind the stored token "
85
+ "(GET /agent/v1/me).")
86
+ wa.add_argument("--endpoint", default=None)
87
+ wa.add_argument("--json", action="store_true")
88
+ wa.set_defaults(func=cmd_whoami)
89
+
90
+ be = sub.add_parser("beat",
91
+ help="Send one heartbeat (or --loop) and answer any "
92
+ "possession challenges.")
93
+ _add_client_args(be)
94
+ be.add_argument("--endpoint", default=None)
95
+ be.add_argument("--interval", type=int, default=None,
96
+ help="Loop interval in minutes (default: config "
97
+ "telemetry.interval_minutes).")
98
+ be.add_argument("--loop", action="store_true",
99
+ help="Keep beating on the interval cadence "
100
+ "(cron/one-shot `beat` is the primary mode).")
101
+ grp = be.add_mutually_exclusive_group()
102
+ grp.add_argument("--report-artifacts", dest="report_artifacts",
103
+ action="store_true", default=None,
104
+ help="Include artifact SHA-256s (default: config).")
105
+ grp.add_argument("--no-report-artifacts", dest="report_artifacts",
106
+ action="store_false",
107
+ help="Omit artifact SHA-256s from heartbeats.")
108
+ be.add_argument("--json", action="store_true")
109
+ be.add_argument("--timeout", type=int, default=30)
110
+ be.set_defaults(func=cmd_beat)
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # helpers
115
+ # --------------------------------------------------------------------------- #
116
+
117
+ def _effective_endpoint(args) -> str:
118
+ ep = getattr(args, "endpoint", None)
119
+ if ep:
120
+ return ep
121
+ return tcfg.telemetry_cfg()["endpoint"]
122
+
123
+
124
+ def _agent_client(args, *, timeout: int = 30) -> AgentClient:
125
+ token = tcfg.load_token()
126
+ if not token:
127
+ raise TelemetryConfigError(
128
+ "no telemetry token stored; run `llmt telemetry enable` first.")
129
+ return AgentClient(_effective_endpoint(args), token, timeout=timeout)
130
+
131
+
132
+ def _confirmed(args) -> bool:
133
+ if getattr(args, "yes", False):
134
+ return True
135
+ if not sys.stdin.isatty():
136
+ return False
137
+ try:
138
+ ans = input("Enable telemetry and send heartbeats? [y/N] ").strip().lower()
139
+ except EOFError:
140
+ return False
141
+ return ans in ("y", "yes")
142
+
143
+
144
+ # --------------------------------------------------------------------------- #
145
+ # enable / disable
146
+ # --------------------------------------------------------------------------- #
147
+
148
+ def cmd_enable(args) -> int:
149
+ print(consent_text())
150
+ if not _confirmed(args):
151
+ print("Telemetry NOT enabled. Nothing was stored or sent.",
152
+ file=sys.stderr)
153
+ return 1
154
+
155
+ token = args.token or os.environ.get("LLMT_AGENT_TOKEN")
156
+ if not token:
157
+ if not sys.stdin.isatty():
158
+ raise TelemetryConfigError(
159
+ "no token provided; pass --token or set LLMT_AGENT_TOKEN "
160
+ "(cannot prompt without a TTY).")
161
+ token = getpass.getpass("Paste your telemetry token (input hidden): ")
162
+ token = (token or "").strip()
163
+ if not token:
164
+ raise TelemetryConfigError("empty token; nothing stored.")
165
+ if not looks_like_token(token):
166
+ print("warning: token does not look like an 'llmta_...' token; "
167
+ "storing it anyway (the server will reject it if invalid).",
168
+ file=sys.stderr)
169
+
170
+ cfg = tcfg.load_config()
171
+ if getattr(args, "endpoint", None):
172
+ cfg["telemetry"]["endpoint"] = args.endpoint
173
+ cfg["telemetry"]["enabled"] = True
174
+ tcfg.save_config(cfg)
175
+ path = tcfg.save_token(token, cfg)
176
+
177
+ print("Telemetry ENABLED.")
178
+ print(f" token stored at {path} (mode 0600; never printed again)")
179
+ print(f" endpoint: {cfg['telemetry']['endpoint']}")
180
+ print(" next: `llmt telemetry whoami` to confirm, then `llmt telemetry "
181
+ "beat` (or a cron job / --loop) to send heartbeats.")
182
+ print(" disable any time with `llmt telemetry disable`.")
183
+ return 0
184
+
185
+
186
+ def cmd_disable(args) -> int:
187
+ # Stop sending first (consent off), then delete the token. A delete failure
188
+ # is reported honestly with a non-zero exit rather than silently claimed.
189
+ tcfg.set_enabled(False)
190
+ print("Telemetry DISABLED. No further heartbeats will be sent.")
191
+ try:
192
+ removed = tcfg.delete_token()
193
+ except TelemetryConfigError as exc:
194
+ print(f" WARNING: {exc}", file=sys.stderr)
195
+ return exc.exit_code
196
+ print(" stored token deleted." if removed
197
+ else " no stored token was present.")
198
+ print(" To also remove data the site already holds, use the website "
199
+ "(Account -> Telemetry).")
200
+ return 0
201
+
202
+
203
+ # --------------------------------------------------------------------------- #
204
+ # status
205
+ # --------------------------------------------------------------------------- #
206
+
207
+ def cmd_status(args) -> int:
208
+ tele = tcfg.telemetry_cfg()
209
+ present = tcfg.token_present()
210
+ info = {
211
+ "enabled": bool(tele.get("enabled")),
212
+ "token_present": present,
213
+ "endpoint": tele.get("endpoint"),
214
+ "interval_minutes": tele.get("interval_minutes"),
215
+ "max_torrents_per_heartbeat": tele.get("max_torrents_per_heartbeat"),
216
+ "report_artifacts": tele.get("report_artifacts"),
217
+ "token_path": tcfg.token_path(),
218
+ "last_beat": tele.get("last_beat"),
219
+ }
220
+ if getattr(args, "json", False):
221
+ emit_json(info)
222
+ return 0
223
+ print("llmt telemetry status")
224
+ print(f" consent (enabled): {'yes' if info['enabled'] else 'no'}")
225
+ print(f" token stored: {'yes' if present else 'no'}")
226
+ print(f" endpoint: {info['endpoint']}")
227
+ print(f" interval (min): {info['interval_minutes']}")
228
+ print(f" max torrents/beat: {info['max_torrents_per_heartbeat']}")
229
+ print(f" report artifacts: {info['report_artifacts']}")
230
+ lb = info["last_beat"]
231
+ if isinstance(lb, dict):
232
+ state = "ok" if lb.get("ok") else f"error: {lb.get('error')}"
233
+ print(f" last beat: {lb.get('at')} — {state}; "
234
+ f"reported {lb.get('torrents_reported')}, "
235
+ f"accepted {lb.get('accepted')}, "
236
+ f"challenges {lb.get('challenges_passed')}/"
237
+ f"{lb.get('challenges_total')} passed")
238
+ else:
239
+ print(" last beat: (never)")
240
+ return 0
241
+
242
+
243
+ # --------------------------------------------------------------------------- #
244
+ # whoami
245
+ # --------------------------------------------------------------------------- #
246
+
247
+ def cmd_whoami(args) -> int:
248
+ if not tcfg.telemetry_cfg().get("enabled"):
249
+ # DR-27: no network while telemetry is disabled, even with a token.
250
+ raise TelemetryConsentError(
251
+ "telemetry is disabled; run `llmt telemetry enable` first. "
252
+ "(Nothing was sent.)")
253
+ client = _agent_client(args)
254
+ resp = client.me()
255
+ if getattr(args, "json", False):
256
+ emit_json(resp)
257
+ return 0
258
+ user = resp.get("user") if isinstance(resp.get("user"), dict) else {}
259
+ scopes = resp.get("scopes") if isinstance(resp.get("scopes"), list) else []
260
+ consent = resp.get("telemetry_consent")
261
+ print("llmt telemetry whoami")
262
+ print(f" handle: {safe_text(str(user.get('handle', '?')))}")
263
+ print(f" user id: {user.get('id', '?')}")
264
+ print(f" scopes: {', '.join(safe_text(str(s)) for s in scopes) or '-'}")
265
+ print(f" server-side telemetry consent: {consent}")
266
+ print(" View or delete the data the site holds on the website "
267
+ "(Account -> Telemetry).")
268
+ return 0
269
+
270
+
271
+ # --------------------------------------------------------------------------- #
272
+ # beat
273
+ # --------------------------------------------------------------------------- #
274
+
275
+ def _build_source(args):
276
+ client = build_client(args)
277
+ return collectmod.build_source(client)
278
+
279
+
280
+ def _client_factory(args):
281
+ ep = _effective_endpoint(args)
282
+ timeout = getattr(args, "timeout", 30)
283
+
284
+ def _make() -> AgentClient:
285
+ token = tcfg.load_token()
286
+ if not token:
287
+ raise TelemetryConfigError(
288
+ "no telemetry token stored; run `llmt telemetry enable`.")
289
+ return AgentClient(ep, token, timeout=timeout)
290
+ return _make
291
+
292
+
293
+ def _emit_beat(summary: dict, as_json: bool) -> None:
294
+ if as_json:
295
+ emit_json(summary)
296
+ return
297
+ ch = summary["challenges"]
298
+ print(f"heartbeat sent at {summary['at']}: reported "
299
+ f"{summary['torrents_reported']} torrent(s), server accepted "
300
+ f"{summary['accepted']}.")
301
+ if ch["total"]:
302
+ print(f" challenges: {ch['total']} issued — {ch['pass']} pass, "
303
+ f"{ch['fail']} fail, {ch['expired']} expired, "
304
+ f"{ch['already-used']} already-used, "
305
+ f"{ch['unresolved']} unresolved (file absent/incomplete).")
306
+ else:
307
+ print(" challenges: none issued.")
308
+
309
+
310
+ def cmd_beat(args) -> int:
311
+ source = _build_source(args)
312
+ factory = _client_factory(args)
313
+ as_json = getattr(args, "json", False)
314
+
315
+ def _one() -> dict:
316
+ return beatmod.run_beat(
317
+ source=source,
318
+ agent_version=__version__,
319
+ client_factory=factory,
320
+ report_artifacts=getattr(args, "report_artifacts", None),
321
+ )
322
+
323
+ if not getattr(args, "loop", False):
324
+ summary = _one()
325
+ _emit_beat(summary, as_json)
326
+ return 0
327
+
328
+ interval = args.interval if args.interval is not None else \
329
+ tcfg.telemetry_cfg().get("interval_minutes", 15)
330
+ interval = max(1, int(interval))
331
+ print(f"telemetry loop: beating every {interval} min "
332
+ f"(Ctrl-C to stop).", file=sys.stderr)
333
+ while True:
334
+ try:
335
+ summary = _one()
336
+ _emit_beat(summary, as_json)
337
+ sleep_s = interval * 60
338
+ except RateLimitedError as exc:
339
+ max_ra = int(tcfg.telemetry_cfg().get(
340
+ "max_retry_after_seconds", tcfg.DEFAULT_MAX_RETRY_AFTER))
341
+ ra = clamp_retry_after(exc.retry_after, max_ra)
342
+ sleep_s = ra if ra is not None else interval * 60
343
+ print(f"rate-limited; sleeping {sleep_s}s before next beat "
344
+ f"(retry_after clamped to <= {max_ra}s).", file=sys.stderr)
345
+ except (TelemetryAuthError, TelemetryScopeError,
346
+ TelemetryConsentError, TelemetryConfigError) as exc:
347
+ # Non-transient: stop rather than hammer.
348
+ print(f"error: {exc}", file=sys.stderr)
349
+ return getattr(exc, "exit_code", 1)
350
+ except LlmtError as exc:
351
+ # Transient (network/timeout): report and keep the cadence.
352
+ print(f"beat error (will retry): {exc}", file=sys.stderr)
353
+ sleep_s = interval * 60
354
+ try:
355
+ time.sleep(sleep_s)
356
+ except KeyboardInterrupt:
357
+ print("\ntelemetry loop stopped.", file=sys.stderr)
358
+ return 0