cdx-manager 0.9.15 → 0.10.0

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.
@@ -4,8 +4,11 @@ import json
4
4
  import os
5
5
  import re
6
6
  import shlex
7
+ import shutil
8
+ import subprocess
7
9
  import sys
8
10
  import time
11
+ import uuid
9
12
  from datetime import datetime
10
13
 
11
14
  from .backup_bundle import read_bundle_meta
@@ -13,6 +16,7 @@ from .claude_refresh import _refresh_claude_sessions
13
16
  from .cli_args import (
14
17
  CAN_RESUME_USAGE,
15
18
  CONTEXT_USAGE,
19
+ DISK_USAGE,
16
20
  DOCTOR_USAGE,
17
21
  HANDOFF_USAGE,
18
22
  LAST_USAGE,
@@ -66,9 +70,10 @@ from .cli_helpers import (
66
70
  _write_json,
67
71
  _write_update_notice,
68
72
  )
69
- from .cli_render import _dim, _info, _style, _success, _warn
73
+ from .cli_render import _dim, _info, _pad_table, _style, _success, _warn
70
74
  from .cli_view import handle_view as handle_view # re-export for cli.py / tests
71
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_OLLAMA
75
+ from .codex_usage import consume_codex_rate_limit_reset_credit
76
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
72
77
  from .context_store import (
73
78
  clear_context,
74
79
  edit_context,
@@ -140,6 +145,21 @@ def _confirm_removal(name):
140
145
  return answer.strip().lower() in ("y", "yes")
141
146
 
142
147
 
148
+ def _confirm_reset(name):
149
+ answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
150
+ return answer.strip().lower() in ("y", "yes")
151
+
152
+
153
+ def _confirm_profile_cleanup(action):
154
+ answer = input(f"Delete matching profile {action} candidates? [y/N] ")
155
+ return answer.strip().lower() in ("y", "yes")
156
+
157
+
158
+ def _confirm_log_cleanup(target):
159
+ answer = input(f"Clear launch transcript logs for {target}? [y/N] ")
160
+ return answer.strip().lower() in ("y", "yes")
161
+
162
+
143
163
  def _resolve_confirmation(confirm_fn, name):
144
164
  confirmed = (
145
165
  asyncio.get_event_loop().run_until_complete(confirm_fn(name))
@@ -151,6 +171,412 @@ def _resolve_confirmation(confirm_fn, name):
151
171
  return confirmed
152
172
 
153
173
 
174
+ def _format_bytes(value):
175
+ size = float(value)
176
+ for unit in ("B", "KB", "MB", "GB", "TB"):
177
+ if size < 1024 or unit == "TB":
178
+ return f"{int(size)} {unit}" if unit == "B" or size.is_integer() else f"{size:.1f} {unit}"
179
+ size /= 1024
180
+
181
+
182
+ def _directory_size_bytes(path, runner=None):
183
+ if not os.path.exists(path):
184
+ raise CdxError(f"CDX home does not exist: {path}")
185
+ runner = runner or subprocess.check_output
186
+ try:
187
+ output = runner(["du", "-sk", path], text=True, stderr=subprocess.DEVNULL)
188
+ return int(str(output).split()[0]) * 1024
189
+ except (OSError, subprocess.CalledProcessError, ValueError, IndexError):
190
+ total = 0
191
+ for root, _, files in os.walk(path):
192
+ for name in files:
193
+ try:
194
+ total += os.path.getsize(os.path.join(root, name))
195
+ except OSError:
196
+ pass
197
+ return total
198
+
199
+
200
+ def _directory_child_sizes(path, runner=None, progress=None):
201
+ rows = []
202
+ try:
203
+ names = os.listdir(path)
204
+ except OSError:
205
+ return rows
206
+ children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
207
+ for index, (name, child_path) in enumerate(children, start=1):
208
+ if progress:
209
+ progress("profile", name, index, len(children))
210
+ rows.append({
211
+ "name": name,
212
+ "path": child_path,
213
+ "bytes": _directory_size_bytes(child_path, runner=runner),
214
+ })
215
+ rows.sort(key=lambda row: row["bytes"], reverse=True)
216
+ for row in rows:
217
+ row["size"] = _format_bytes(row["bytes"])
218
+ return rows
219
+
220
+
221
+ def _parse_days(value, usage):
222
+ match = re.fullmatch(r"(\d+)(?:d)?", str(value or "").strip())
223
+ if not match or int(match.group(1)) <= 0:
224
+ raise CdxError(usage)
225
+ return int(match.group(1))
226
+
227
+
228
+ def _candidate(profile, kind, path, bytes_used, reason, risk="safe", evidence=None):
229
+ return {
230
+ "profile": profile,
231
+ "kind": kind,
232
+ "path": path,
233
+ "bytes": bytes_used,
234
+ "size": _format_bytes(bytes_used),
235
+ "reason": reason,
236
+ "risk": risk,
237
+ "evidence": evidence or {},
238
+ }
239
+
240
+
241
+ def _iter_profile_dirs(base_dir):
242
+ profiles_dir = os.path.join(base_dir, "profiles")
243
+ try:
244
+ names = sorted(os.listdir(profiles_dir))
245
+ except OSError:
246
+ return
247
+ for name in names:
248
+ path = os.path.join(profiles_dir, name)
249
+ if os.path.isdir(path):
250
+ yield name, path
251
+
252
+
253
+ def _collect_old_logs(profile_name, profile_path, days, now=None):
254
+ cutoff = (time.time() if now is None else now) - (days * 86400)
255
+ paths = []
256
+ bytes_used = 0
257
+ latest_mtime = 0
258
+ for root, _, files in os.walk(profile_path):
259
+ if os.path.basename(root) != "log":
260
+ continue
261
+ for filename in files:
262
+ if not filename.endswith(".log"):
263
+ continue
264
+ path = os.path.join(root, filename)
265
+ try:
266
+ stat = os.stat(path)
267
+ except OSError:
268
+ continue
269
+ if stat.st_mtime >= cutoff:
270
+ continue
271
+ paths.append(path)
272
+ bytes_used += stat.st_size
273
+ latest_mtime = max(latest_mtime, stat.st_mtime)
274
+ if not paths:
275
+ return None
276
+ return _candidate(
277
+ profile_name,
278
+ f"old-logs-{days}d",
279
+ profile_path,
280
+ bytes_used,
281
+ f"{len(paths)} .log files older than {days}d",
282
+ risk="review",
283
+ evidence={
284
+ "days": days,
285
+ "file_count": len(paths),
286
+ "newest_old_log_mtime": datetime.fromtimestamp(latest_mtime).isoformat() if latest_mtime else None,
287
+ "sample_paths": paths[:5],
288
+ },
289
+ )
290
+
291
+
292
+ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
293
+ candidates = []
294
+ profiles = list(_iter_profile_dirs(base_dir))
295
+ for index, (profile_name, profile_path) in enumerate(profiles, start=1):
296
+ if progress:
297
+ progress("candidates", profile_name, index, len(profiles))
298
+ tmp_dir = os.path.join(profile_path, ".tmp")
299
+ tmp_targets = [
300
+ ("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
301
+ ]
302
+ try:
303
+ tmp_names = os.listdir(tmp_dir)
304
+ except OSError:
305
+ tmp_names = []
306
+ for name in tmp_names:
307
+ if name.startswith("plugins-clone-"):
308
+ tmp_targets.append(("tmp-plugin-clone", os.path.join(tmp_dir, name), "temporary plugin clone"))
309
+ elif name.startswith("plugins-backup-"):
310
+ tmp_targets.append(("tmp-plugin-backup", os.path.join(tmp_dir, name), "temporary plugin backup"))
311
+ for kind, path, reason in tmp_targets:
312
+ if not os.path.exists(path):
313
+ continue
314
+ bytes_used = _directory_size_bytes(path, runner=runner)
315
+ if bytes_used <= 0:
316
+ continue
317
+ try:
318
+ mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
319
+ except OSError:
320
+ mtime = None
321
+ candidates.append(_candidate(
322
+ profile_name,
323
+ kind,
324
+ path,
325
+ bytes_used,
326
+ reason,
327
+ evidence={"modified_at": mtime},
328
+ ))
329
+ old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
330
+ if old_logs and old_logs["bytes"] > 0:
331
+ candidates.append(old_logs)
332
+ candidates.sort(key=lambda item: item["bytes"], reverse=True)
333
+ return candidates
334
+
335
+
336
+ def _format_cleanup_candidates(candidates, use_color=False):
337
+ if not candidates:
338
+ return _dim("No cleanup candidates found.", use_color)
339
+ reclaimable = sum(item["bytes"] for item in candidates)
340
+ lines = [
341
+ _style("Cleanup candidates", "1", use_color),
342
+ f"{_style(_format_bytes(reclaimable), '33', use_color)} reclaimable across {len({item['profile'] for item in candidates})} profile(s)",
343
+ ]
344
+ by_profile = {}
345
+ for item in candidates:
346
+ by_profile.setdefault(item["profile"], []).append(item)
347
+ for profile, items in sorted(by_profile.items(), key=lambda row: sum(i["bytes"] for i in row[1]), reverse=True):
348
+ total = sum(item["bytes"] for item in items)
349
+ lines.extend(["", f"{_style(profile, '1', use_color)} {_style(_format_bytes(total), '33', use_color)}"])
350
+ rows = [[
351
+ _style("SIZE", "1", use_color),
352
+ _style("TYPE", "1", use_color),
353
+ _style("RISK", "1", use_color),
354
+ _style("EVIDENCE", "1", use_color),
355
+ ]]
356
+ for item in items:
357
+ risk_color = "32" if item["risk"] == "safe" else "33"
358
+ rows.append([
359
+ _style(item["size"], "36", use_color),
360
+ item["kind"],
361
+ _style(item["risk"], risk_color, use_color),
362
+ item["reason"],
363
+ ])
364
+ lines.append(_pad_table(rows))
365
+ return "\n".join(lines)
366
+
367
+
368
+ def _format_disk_report(payload, use_color=False):
369
+ title = "CDX profiles" if payload["target"] == "profiles" else "CDX home"
370
+ lines = [
371
+ _style(title, "1", use_color),
372
+ f"Path: {_dim(payload['path'], use_color)}",
373
+ f"Total: {_style(payload['size'], '36', use_color)}",
374
+ ]
375
+ children = payload.get("children") or []
376
+ if children:
377
+ reclaimable_by_profile = {}
378
+ for item in payload.get("candidates") or []:
379
+ reclaimable_by_profile[item["profile"]] = reclaimable_by_profile.get(item["profile"], 0) + item["bytes"]
380
+ headers = ["PROFILE", "SIZE", "SHARE"]
381
+ if "candidates" in payload:
382
+ headers.append("RECLAIMABLE")
383
+ rows = [[_style(header, "1", use_color) for header in headers]]
384
+ for child in children:
385
+ share = (child["bytes"] / payload["bytes"] * 100) if payload["bytes"] else 0
386
+ row = [
387
+ _style(child["name"], "1", use_color),
388
+ _style(child["size"], "36", use_color),
389
+ f"{share:.1f}%",
390
+ ]
391
+ if "candidates" in payload:
392
+ reclaimable = reclaimable_by_profile.get(child["name"], 0)
393
+ row.append(_style(_format_bytes(reclaimable), "33", use_color) if reclaimable else _dim("-", use_color))
394
+ rows.append(row)
395
+ lines.extend(["", _style(f"Profiles ({len(children)})", "1", use_color), _pad_table(rows)])
396
+ if "candidates" in payload:
397
+ lines.extend(["", _format_cleanup_candidates(payload["candidates"], use_color=use_color)])
398
+ return "\n".join(lines)
399
+
400
+
401
+ def _remove_path(path):
402
+ try:
403
+ if os.path.isdir(path):
404
+ size = _directory_size_bytes(path)
405
+ shutil.rmtree(path)
406
+ else:
407
+ size = os.path.getsize(path)
408
+ os.remove(path)
409
+ return size
410
+ except OSError as error:
411
+ raise CdxError(f"Failed to remove cleanup candidate {path}: {error}") from error
412
+
413
+
414
+ def _clean_profile_tmp(profile_path):
415
+ tmp_dir = os.path.join(profile_path, ".tmp")
416
+ targets = [os.path.join(tmp_dir, "marketplaces")]
417
+ try:
418
+ names = os.listdir(tmp_dir)
419
+ except OSError:
420
+ names = []
421
+ targets.extend(os.path.join(tmp_dir, name) for name in names if name.startswith(("plugins-clone-", "plugins-backup-")))
422
+ freed = 0
423
+ removed = []
424
+ for path in targets:
425
+ if os.path.exists(path):
426
+ size = _remove_path(path)
427
+ if size:
428
+ freed += size
429
+ removed.append(path)
430
+ return freed, removed
431
+
432
+
433
+ def _clean_profile_old_logs(profile_path, days, now=None):
434
+ cutoff = (time.time() if now is None else now) - (days * 86400)
435
+ freed = 0
436
+ removed = []
437
+ for root, _, files in os.walk(profile_path):
438
+ if os.path.basename(root) != "log":
439
+ continue
440
+ for filename in files:
441
+ if not filename.endswith(".log"):
442
+ continue
443
+ path = os.path.join(root, filename)
444
+ try:
445
+ stat = os.stat(path)
446
+ except OSError:
447
+ continue
448
+ if stat.st_mtime >= cutoff:
449
+ continue
450
+ try:
451
+ os.remove(path)
452
+ except OSError as error:
453
+ raise CdxError(f"Failed to remove old log {path}: {error}") from error
454
+ freed += stat.st_size
455
+ removed.append(path)
456
+ return freed, removed
457
+
458
+
459
+ def _handle_clean_profiles(args, ctx, json_flag):
460
+ usage = "Usage: cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]"
461
+ yes = "--yes" in args
462
+ args = [arg for arg in args if arg != "--yes"]
463
+ clean_tmp = "--tmp" in args
464
+ old_logs = None
465
+ if "--old-logs" in args:
466
+ index = args.index("--old-logs")
467
+ if index + 1 >= len(args):
468
+ raise CdxError(usage)
469
+ old_logs = _parse_days(args[index + 1], usage)
470
+ args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
471
+ args = [arg for arg in args if arg != "--tmp"]
472
+ if args or clean_tmp == (old_logs is not None):
473
+ raise CdxError(usage)
474
+
475
+ action = "temporary cache" if clean_tmp else f"logs older than {old_logs}d"
476
+ if not yes:
477
+ confirm_fn = ctx["options"].get("confirmProfileCleanup")
478
+ if confirm_fn:
479
+ confirmed = _resolve_confirmation(confirm_fn, action)
480
+ elif not ctx["stdin_is_tty"]:
481
+ raise CdxError("Profile cleanup requires an interactive terminal or --yes in non-interactive mode.")
482
+ else:
483
+ confirmed = _confirm_profile_cleanup(action)
484
+ if not confirmed:
485
+ if json_flag:
486
+ _write_json(ctx, _json_success("clean.profiles", "Cancelled.", cancelled=True, freed_bytes=0, freed_size="0 B", profiles=[]))
487
+ return 0
488
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
489
+ return 0
490
+
491
+ results = []
492
+ now = ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None
493
+ for profile_name, profile_path in _iter_profile_dirs(ctx["service"]["base_dir"]):
494
+ if clean_tmp:
495
+ freed, removed = _clean_profile_tmp(profile_path)
496
+ result_action = "tmp"
497
+ else:
498
+ freed, removed = _clean_profile_old_logs(profile_path, old_logs, now=now)
499
+ result_action = f"old-logs-{old_logs}d"
500
+ if not freed and not removed:
501
+ continue
502
+ results.append({
503
+ "profile": profile_name,
504
+ "action": result_action,
505
+ "freed_bytes": freed,
506
+ "freed_size": _format_bytes(freed),
507
+ "removed_count": len(removed),
508
+ "removed_paths": removed,
509
+ })
510
+ total = sum(item["freed_bytes"] for item in results)
511
+ if json_flag:
512
+ _write_json(ctx, _json_success("clean.profiles", f"Cleaned profiles ({_format_bytes(total)} freed)", cancelled=False, freed_bytes=total, freed_size=_format_bytes(total), profiles=results))
513
+ return 0
514
+ if not results:
515
+ ctx["out"](f"{_dim('No matching profile cleanup candidates found.', ctx['use_color'])}\n")
516
+ return 0
517
+ for item in results:
518
+ message = f"Cleaned {item['profile']} {item['action']} ({item['freed_size']} freed)"
519
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
520
+ ctx["out"](f"{_success(f'Total freed: {_format_bytes(total)}', ctx['use_color'])}\n")
521
+ return 0
522
+
523
+
524
+ def handle_disk(rest, ctx):
525
+ parsed = _parse_flag_args(rest, {
526
+ "--candidates": {"key": "candidates", "type": "bool", "default": False},
527
+ "--json": {"key": "json", "type": "bool", "default": False},
528
+ }, DISK_USAGE, positionals_key="targets", max_positionals=1)
529
+ target = parsed["targets"][0] if parsed["targets"] else "home"
530
+ if target == "home":
531
+ path = ctx["service"]["base_dir"]
532
+ elif target == "profiles":
533
+ path = os.path.join(ctx["service"]["base_dir"], "profiles")
534
+ else:
535
+ raise CdxError(DISK_USAGE)
536
+ stderr = ctx["options"].get("stderr", sys.stderr)
537
+ show_progress = not parsed["json"] and hasattr(stderr, "isatty") and stderr.isatty()
538
+
539
+ def progress(stage, name=None, index=None, total=None):
540
+ if not show_progress:
541
+ return
542
+ if stage == "total":
543
+ label = "CDX profiles" if target == "profiles" else "CDX home"
544
+ ctx["err"](f"Measuring {label} disk usage...\n")
545
+ elif stage == "profile":
546
+ ctx["err"](f"Measuring profile {name} ({index}/{total})...\n")
547
+ elif stage == "candidates":
548
+ ctx["err"](f"Scanning cleanup candidates in {name} ({index}/{total})...\n")
549
+
550
+ progress("total")
551
+ bytes_used = _directory_size_bytes(path, runner=ctx["options"].get("diskUsageRunner"))
552
+ payload = {
553
+ "target": target,
554
+ "path": path,
555
+ "bytes": bytes_used,
556
+ "size": _format_bytes(bytes_used),
557
+ }
558
+ if target == "profiles":
559
+ payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
560
+ if parsed["candidates"]:
561
+ if target != "profiles":
562
+ raise CdxError(DISK_USAGE)
563
+ candidates = _collect_profile_cleanup_candidates(
564
+ ctx["service"]["base_dir"],
565
+ runner=ctx["options"].get("diskUsageRunner"),
566
+ now=ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None,
567
+ progress=progress,
568
+ )
569
+ payload["candidates"] = candidates
570
+ payload["reclaimable_bytes"] = sum(item["bytes"] for item in candidates)
571
+ payload["reclaimable_size"] = _format_bytes(payload["reclaimable_bytes"])
572
+ if parsed["json"]:
573
+ label = "CDX profiles" if target == "profiles" else "CDX home"
574
+ _write_json(ctx, _json_success("disk", f"Measured {label} disk usage: {payload['size']}", disk=payload))
575
+ return 0
576
+ ctx["out"](f"{_format_disk_report(payload, use_color=ctx['use_color'])}\n")
577
+ return 0
578
+
579
+
154
580
  def _extract_claude_oauth_token(text):
155
581
  if not text:
156
582
  return None
@@ -1309,6 +1735,10 @@ def handle_last(rest, ctx):
1309
1735
 
1310
1736
  def handle_clean(rest, ctx):
1311
1737
  json_flag, args = _parse_json_flag(rest)
1738
+ if args[:1] == ["profiles"] and any(arg in ("--tmp", "--old-logs") for arg in args[1:]):
1739
+ return _handle_clean_profiles(args[1:], ctx, json_flag)
1740
+ yes = "--yes" in args
1741
+ args = [arg for arg in args if arg != "--yes"]
1312
1742
  service = ctx["service"]
1313
1743
  if len(args) == 0:
1314
1744
  targets = service["list_sessions"]()
@@ -1318,7 +1748,23 @@ def handle_clean(rest, ctx):
1318
1748
  raise CdxError(f"Unknown session: {args[0]}")
1319
1749
  targets = [session]
1320
1750
  else:
1321
- raise CdxError("Usage: cdx clean [name] [--json]")
1751
+ raise CdxError("Usage: cdx clean [name] [--yes] [--json]")
1752
+
1753
+ if not yes:
1754
+ target = targets[0]["name"] if len(targets) == 1 else f"all {len(targets)} sessions"
1755
+ confirm_fn = ctx["options"].get("confirmClean")
1756
+ if confirm_fn:
1757
+ confirmed = _resolve_confirmation(confirm_fn, target)
1758
+ elif not ctx["stdin_is_tty"]:
1759
+ raise CdxError("Log cleanup requires an interactive terminal or --yes in non-interactive mode.")
1760
+ else:
1761
+ confirmed = _confirm_log_cleanup(target)
1762
+ if not confirmed:
1763
+ if json_flag:
1764
+ _write_json(ctx, _json_success("clean", "Cancelled.", cancelled=True, sessions=[]))
1765
+ return 0
1766
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
1767
+ return 0
1322
1768
 
1323
1769
  cleaned_sessions = []
1324
1770
  for session in targets:
@@ -1373,7 +1819,7 @@ def handle_clean(rest, ctx):
1373
1819
  continue
1374
1820
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
1375
1821
  if json_flag:
1376
- _write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
1822
+ _write_json(ctx, _json_success("clean", "Cleaned session logs", cancelled=False, sessions=cleaned_sessions))
1377
1823
  return 0
1378
1824
 
1379
1825
 
@@ -1667,6 +2113,69 @@ def handle_status(rest, ctx):
1667
2113
  return 0
1668
2114
 
1669
2115
 
2116
+ def handle_reset(rest, ctx):
2117
+ parsed = _parse_flag_args(rest, {
2118
+ "--json": {"key": "json", "type": "bool", "default": False},
2119
+ "--yes": {"key": "yes", "type": "bool", "default": False},
2120
+ }, "Usage: cdx reset <name> [--yes] [--json]", positionals_key="args", max_positionals=1)
2121
+ if len(parsed["args"]) != 1:
2122
+ raise CdxError("Usage: cdx reset <name> [--yes] [--json]")
2123
+ name = parsed["args"][0]
2124
+ session = ctx["service"]["get_session"](name)
2125
+ if not session:
2126
+ raise CdxError(f"Unknown session: {name}")
2127
+ if session["provider"] != PROVIDER_CODEX:
2128
+ raise CdxError("Banked rate-limit resets are only available for Codex sessions.")
2129
+
2130
+ status = ctx["service"]["get_status_row"](name, force_refresh=True)
2131
+ if status.get("reset_credits_available") == 0:
2132
+ raise CdxError(f"No banked Codex reset is available for {name}.")
2133
+ if not parsed["yes"]:
2134
+ confirm_fn = ctx["options"].get("confirmReset")
2135
+ if confirm_fn:
2136
+ confirmed = _resolve_confirmation(confirm_fn, name)
2137
+ elif not ctx["stdin_is_tty"]:
2138
+ raise CdxError("Reset activation requires an interactive terminal or --yes in non-interactive mode.")
2139
+ else:
2140
+ confirmed = _confirm_reset(name)
2141
+ if not confirmed:
2142
+ if parsed["json"]:
2143
+ _write_json(ctx, _json_success("reset", "Cancelled.", cancelled=True, session=name))
2144
+ return 0
2145
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
2146
+ return 0
2147
+
2148
+ credit_rows = status.get("reset_credits") or []
2149
+ credit_id = credit_rows[0].get("id") if credit_rows else None
2150
+ consumer = ctx["options"].get("consumeCodexReset") or consume_codex_rate_limit_reset_credit
2151
+ result = consumer(
2152
+ session,
2153
+ ctx["options"].get("resetIdempotencyKey") or str(uuid.uuid4()),
2154
+ credit_id=credit_id,
2155
+ )
2156
+ if not result.get("ok"):
2157
+ if result.get("reason") == "reset_consume_failed":
2158
+ raise CdxError(
2159
+ f"Unable to activate Codex reset for {name}. "
2160
+ "The installed Codex version or account may not support reset activation."
2161
+ )
2162
+ raise CdxError(f"Unable to activate Codex reset for {name}: {result.get('reason') or 'unknown error'}")
2163
+ outcome = result.get("outcome")
2164
+ if outcome == "noCredit":
2165
+ raise CdxError(f"No banked Codex reset is available for {name}.")
2166
+ if outcome == "nothingToReset":
2167
+ raise CdxError(f"No current Codex rate-limit window is eligible for reset on {name}.")
2168
+ if outcome not in ("reset", "alreadyRedeemed"):
2169
+ raise CdxError(f"Unexpected Codex reset outcome for {name}: {outcome or 'missing'}")
2170
+ refreshed = ctx["service"]["get_status_row"](name, force_refresh=True)
2171
+ message = f"Activated banked Codex reset for {name}"
2172
+ if parsed["json"]:
2173
+ _write_json(ctx, _json_success("reset", message, cancelled=False, outcome=outcome, session=refreshed))
2174
+ return 0
2175
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2176
+ return 0
2177
+
2178
+
1670
2179
  def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
1671
2180
  target_names = set(target_names or [])
1672
2181
  errors = []
@@ -68,6 +68,13 @@ def _update_notice_warning(ctx):
68
68
  def _update_notice_warnings(ctx):
69
69
  warnings = []
70
70
  for notice in ctx.get("update_notices") or []:
71
+ if notice.get("code") == "disk_cleanup_available":
72
+ warnings.append({
73
+ "code": "disk_cleanup_available",
74
+ "message": notice["message"],
75
+ **{key: value for key, value in notice.items() if key not in ("code", "message")},
76
+ })
77
+ continue
71
78
  tool = notice.get("tool") or "cdx-manager"
72
79
  current = notice.get("current_version") or ctx.get("version")
73
80
  command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)