cdx-manager 0.12.4 → 0.15.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.
@@ -0,0 +1,713 @@
1
+ """Status domain commands: status, next, config, configs, last, history, stats.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import os
7
+ import time
8
+ from datetime import datetime
9
+
10
+ from ..claude_refresh import _refresh_claude_sessions
11
+ from ..cli_args import (
12
+ LAST_USAGE,
13
+ STATUS_USAGE,
14
+ _filter_history_period,
15
+ _has_history_period,
16
+ _parse_config_args,
17
+ _parse_configs_args,
18
+ _parse_flag_args,
19
+ _parse_history_args,
20
+ _parse_json_flag,
21
+ _parse_next_args,
22
+ _parse_stats_args,
23
+ _parse_timeout_seconds,
24
+ _public_history_period,
25
+ )
26
+ from ..cli_helpers import (
27
+ _format_launch_config,
28
+ _format_launch_setting_value,
29
+ _format_launch_settings_hint,
30
+ _json_failure,
31
+ _json_success,
32
+ _local_now_iso,
33
+ _make_status_progress,
34
+ _update_notice_warnings,
35
+ _write_json,
36
+ _write_update_notice,
37
+ )
38
+ from ..cli_render import _dim, _info, _pad_table, _style, _warn
39
+ from ..commands.launch import handle_launch
40
+ from ..config import PROVIDER_CLAUDE
41
+ from ..errors import CdxError
42
+ from ..provider_runtime import AUTH_PROBE_AUTHENTICATED, AUTH_PROBE_DEGRADED, _probe_provider_auth_status
43
+ from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
44
+
45
+
46
+ def _format_next_pct(value):
47
+ return "n/a" if value is None else f"{value}%"
48
+
49
+ def _next_action(row):
50
+ instruction = format_priority_instruction(row, "first")
51
+ return "refresh" if instruction.startswith("refresh ") else "use"
52
+
53
+ def _format_next_selection(session, row, use_color=False):
54
+
55
+ action = _next_action(row)
56
+ command = f"cdx status {session['name']} --refresh" if action == "refresh" else f"cdx {session['name']}"
57
+ rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROVIDER", "OK", "5H", "WEEK", "REASON"]]]
58
+ rows.append([
59
+ _style(session["name"], "36", use_color),
60
+ _dim(session.get("provider") or "-", use_color),
61
+ _format_next_pct(row.get("available_pct")),
62
+ _format_next_pct(row.get("remaining_5h_pct")),
63
+ _format_next_pct(row.get("remaining_week_pct")),
64
+ format_priority_instruction(row, "first"),
65
+ ])
66
+ return "\n".join([
67
+ _style("Next assistant:", "1", use_color),
68
+ _pad_table(rows),
69
+ "",
70
+ f"{_style('Run:', '1', use_color)} {_style(command, '36', use_color)}",
71
+ ])
72
+
73
+ def handle_next(rest, ctx):
74
+ parsed = _parse_next_args(rest)
75
+ rows = ctx["service"]["get_status_rows"](force_refresh=parsed["refresh"])
76
+ priority = recommend_priority_rows(rows)
77
+ if not priority:
78
+ message = "No usable session status yet."
79
+ if parsed["json"]:
80
+ _write_json(ctx, _json_failure("next", "no_suitable_session", message, selection_policy="status_priority"))
81
+ return 1
82
+ ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
83
+ return 1
84
+ row = priority[0]
85
+ session_name = row.get("session_name")
86
+ session = ctx["service"]["get_session"](session_name)
87
+ if not session:
88
+ message = f"Selected status row has no matching session: {session_name}"
89
+ if parsed["json"]:
90
+ _write_json(ctx, _json_failure("next", "missing_session", message, selection_policy="status_priority", row=row))
91
+ return 1
92
+ raise CdxError(message)
93
+ action = _next_action(row)
94
+ message = f"Selected next session {session['name']}"
95
+ if parsed["json"]:
96
+ _write_json(ctx, _json_success(
97
+ "next",
98
+ message,
99
+ session=session,
100
+ row=row,
101
+ recommended_action=action,
102
+ command=f"cdx status {session['name']} --refresh" if action == "refresh" else f"cdx {session['name']}",
103
+ reason=format_priority_instruction(row, "first"),
104
+ selection_policy="status_priority",
105
+ ))
106
+ return 0
107
+ ctx["out"](f"{_format_next_selection(session, row, ctx['use_color'])}\n")
108
+ return 0
109
+
110
+ def _format_launch_configs(sessions, use_color=False):
111
+ from ..cli_render import _dim, _style
112
+
113
+ if not sessions:
114
+ return "\n".join([
115
+ _style("Launch settings:", "1", use_color),
116
+ "No sessions.",
117
+ "",
118
+ _dim(_format_launch_settings_hint(), use_color),
119
+ ])
120
+ # The table is already nine columns wide, so the two headless-only settings
121
+ # follow the LABEL convention from the session list: shown once somebody
122
+ # uses them, absent otherwise.
123
+ has_headless = any(
124
+ (session.get("launch") or {}).get(key) is not None
125
+ for session in sessions
126
+ for key in ("fallback_model", "budget")
127
+ )
128
+ has_extra = any((session.get("launch") or {}).get("extra_args") for session in sessions)
129
+ headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL"]
130
+ if has_headless:
131
+ headers += ["FALLBACK", "BUDGET"]
132
+ if has_extra:
133
+ headers.append("EXTRA ARGS")
134
+ headers.append("PRIORITY")
135
+ rows = [[_style(value, "1", use_color) for value in headers]]
136
+ for session in sessions:
137
+ launch = session.get("launch") or {}
138
+ row = [
139
+ _style(session["name"], "36", use_color),
140
+ _dim(session.get("provider") or "-", use_color),
141
+ _format_launch_setting_value(launch, "power", use_color),
142
+ _format_launch_setting_value(launch, "permission", use_color),
143
+ _format_launch_setting_value(launch, "fast", use_color),
144
+ _format_launch_setting_value(launch, "rtk", use_color),
145
+ _format_launch_setting_value(launch, "logics", use_color),
146
+ _format_launch_setting_value(launch, "model", use_color),
147
+ ]
148
+ if has_headless:
149
+ row.append(_format_launch_setting_value(launch, "fallback_model", use_color))
150
+ row.append(_format_launch_setting_value(launch, "budget", use_color))
151
+ if has_extra:
152
+ row.append(_format_launch_setting_value(launch, "extra_args", use_color))
153
+ row.append(_format_launch_setting_value(launch, "priority", use_color))
154
+ rows.append(row)
155
+ lines = [
156
+ _style("Launch settings:", "1", use_color),
157
+ _pad_table(rows),
158
+ "",
159
+ ]
160
+ if has_headless:
161
+ lines.append(_dim(
162
+ "FALLBACK and BUDGET apply to headless Claude runs only "
163
+ "(cdx run); the provider accepts them with --print alone.",
164
+ use_color,
165
+ ))
166
+ if has_extra:
167
+ lines.append(_dim(
168
+ "EXTRA ARGS are passed to the provider unchanged and unvalidated. "
169
+ "cdx does not check them, and cdx schema does not describe them.",
170
+ use_color,
171
+ ))
172
+ lines.append(_dim(_format_launch_settings_hint(), use_color))
173
+ return "\n".join(lines)
174
+
175
+ def _format_duration_ms(value):
176
+ if value is None:
177
+ return "-"
178
+ try:
179
+ amount = int(value)
180
+ except (TypeError, ValueError):
181
+ return str(value)
182
+ if amount < 1000:
183
+ return f"{amount}ms"
184
+ seconds = amount / 1000
185
+ if seconds < 60:
186
+ return f"{seconds:.1f}s"
187
+ minutes = int(seconds // 60)
188
+ remaining = int(seconds % 60)
189
+ if minutes < 60:
190
+ return f"{minutes}m {remaining:02d}s"
191
+ hours = minutes // 60
192
+ remaining_minutes = minutes % 60
193
+ if hours < 24:
194
+ return f"{hours}h {remaining_minutes:02d}m"
195
+ days = hours // 24
196
+ remaining_hours = hours % 24
197
+ return f"{days}d {remaining_hours:02d}h"
198
+
199
+ def _summarize_history(entries):
200
+ by_session = {}
201
+ for entry in entries:
202
+ name = entry.get("session_name") or "-"
203
+ row = by_session.setdefault(name, {
204
+ "session_name": name,
205
+ "provider": entry.get("provider") or "-",
206
+ "launches": 0,
207
+ "successes": 0,
208
+ "failures": 0,
209
+ "duration_ms": 0,
210
+ "last_started_at": None,
211
+ })
212
+ row["launches"] += 1
213
+ if entry.get("status") == "success":
214
+ row["successes"] += 1
215
+ elif entry.get("status") == "failed":
216
+ row["failures"] += 1
217
+ try:
218
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
219
+ except (TypeError, ValueError):
220
+ pass
221
+ started = entry.get("started_at")
222
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
223
+ row["last_started_at"] = started
224
+ row["provider"] = entry.get("provider") or row["provider"]
225
+ return sorted(
226
+ by_session.values(),
227
+ key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
228
+ reverse=True,
229
+ )
230
+
231
+ def _token_value(usage, key):
232
+ if not isinstance(usage, dict):
233
+ return None
234
+ value = usage.get(key)
235
+ try:
236
+ parsed = int(value)
237
+ except (TypeError, ValueError):
238
+ return None
239
+ return parsed if parsed >= 0 else None
240
+
241
+ def _summarize_stats(entries):
242
+ rows = {}
243
+ for entry in entries:
244
+ name = entry.get("session_name") or "-"
245
+ row = rows.setdefault(name, {
246
+ "session_name": name,
247
+ "provider": entry.get("provider") or "-",
248
+ "launches": 0,
249
+ "successes": 0,
250
+ "failures": 0,
251
+ "duration_ms": 0,
252
+ "usage_runs": 0,
253
+ "input_tokens": 0,
254
+ "output_tokens": 0,
255
+ "reasoning_tokens": 0,
256
+ "total_tokens": 0,
257
+ "last_started_at": None,
258
+ })
259
+ row["launches"] += 1
260
+ if entry.get("status") == "success":
261
+ row["successes"] += 1
262
+ elif entry.get("status") == "failed":
263
+ row["failures"] += 1
264
+ try:
265
+ row["duration_ms"] += int(entry.get("duration_ms") or 0)
266
+ except (TypeError, ValueError):
267
+ pass
268
+ usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
269
+ parsed_usage = {
270
+ key: _token_value(usage, key)
271
+ for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
272
+ }
273
+ if any(value is not None for value in parsed_usage.values()):
274
+ row["usage_runs"] += 1
275
+ for key, value in parsed_usage.items():
276
+ row[key] += value or 0
277
+ started = entry.get("started_at")
278
+ if started and (not row["last_started_at"] or started > row["last_started_at"]):
279
+ row["last_started_at"] = started
280
+ row["provider"] = entry.get("provider") or row["provider"]
281
+ return sorted(
282
+ rows.values(),
283
+ key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
284
+ reverse=True,
285
+ )
286
+
287
+ def _stats_totals(rows):
288
+ return {
289
+ "sessions": len(rows),
290
+ "launches": sum(row["launches"] for row in rows),
291
+ "successes": sum(row["successes"] for row in rows),
292
+ "failures": sum(row["failures"] for row in rows),
293
+ "duration_ms": sum(row["duration_ms"] for row in rows),
294
+ "usage_runs": sum(row["usage_runs"] for row in rows),
295
+ "input_tokens": sum(row["input_tokens"] for row in rows),
296
+ "output_tokens": sum(row["output_tokens"] for row in rows),
297
+ "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
298
+ "total_tokens": sum(row["total_tokens"] for row in rows),
299
+ }
300
+
301
+ def _format_history_period(period):
302
+ if not _has_history_period(period or {}):
303
+ return None
304
+ start = _format_period_display(period.get("from")) or "beginning"
305
+ end = _format_period_display(period.get("to")) or "now"
306
+ return f"Period: {start} -> {end}"
307
+
308
+ def _format_period_display(value):
309
+ if not value:
310
+ return None
311
+ try:
312
+ parsed = datetime.fromisoformat(str(value))
313
+ except ValueError:
314
+ return str(value)
315
+ return parsed.strftime("%Y-%m-%d %H:%M")
316
+
317
+ def _format_history_summary(entries, period=None, use_color=False, active_sessions=None):
318
+ from ..cli_render import _format_relative_age
319
+
320
+ active_sessions = active_sessions or set()
321
+ summary = _summarize_history(entries)
322
+ if not summary:
323
+ return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
324
+ rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]]
325
+ for row in summary:
326
+ session_name = row["session_name"]
327
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
328
+ rows.append([
329
+ _style(display_name, "36", use_color),
330
+ _dim(row["provider"], use_color),
331
+ _style(str(row["launches"]), "1", use_color),
332
+ _style(str(row["successes"]), "32" if row["successes"] else "2", use_color),
333
+ _style(str(row["failures"]), "31" if row["failures"] else "2", use_color),
334
+ _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
335
+ _dim(_format_relative_age(row.get("last_started_at")), use_color),
336
+ ])
337
+ lines = [_style("Assistant time:", "1", use_color)]
338
+ period_line = _format_history_period(period or {})
339
+ if period_line:
340
+ lines.extend([_dim(period_line, use_color), ""])
341
+ lines.append(_pad_table(rows))
342
+ return "\n".join(lines)
343
+
344
+ def _format_history(entries, use_color=False, active_sessions=None):
345
+ from ..cli_render import _format_relative_age
346
+
347
+ active_sessions = active_sessions or set()
348
+ if not entries:
349
+ return "No launch history."
350
+ rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]]
351
+ for entry in entries:
352
+ transcript_path = entry.get("transcript_path")
353
+ session_name = entry.get("session_name") or "-"
354
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
355
+ status = entry.get("status") or "-"
356
+ status_color = "32" if status == "success" else "31" if status == "failed" else "2"
357
+ rows.append([
358
+ _style(display_name, "36", use_color),
359
+ _dim(entry.get("provider") or "-", use_color),
360
+ _style(status, status_color, use_color),
361
+ _style(_format_duration_ms(entry.get("duration_ms")), "33" if entry.get("duration_ms") else "2", use_color),
362
+ _dim(_format_relative_age(entry.get("started_at")), use_color),
363
+ _dim(os.path.basename(transcript_path) if transcript_path else "-", use_color),
364
+ ])
365
+ return "\n".join([
366
+ _style("Recent launches:", "1", use_color),
367
+ _pad_table(rows),
368
+ "",
369
+ _dim("Full transcript paths and cwd are available with --json.", use_color),
370
+ ])
371
+
372
+ def _format_token_count(value):
373
+ try:
374
+ amount = int(value)
375
+ except (TypeError, ValueError):
376
+ return "-"
377
+ if amount >= 1000000:
378
+ return f"{amount / 1000000:.1f}M"
379
+ if amount >= 1000:
380
+ return f"{amount / 1000:.1f}K"
381
+ return str(amount)
382
+
383
+ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None):
384
+ from ..cli_render import _format_relative_age
385
+
386
+ active_sessions = active_sessions or set()
387
+ if not rows:
388
+ return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
389
+ table = [[_style(value, "1", use_color) for value in [
390
+ "SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
391
+ ]]]
392
+ for row in rows:
393
+ session_name = row["session_name"]
394
+ display_name = f"{session_name}*" if session_name in active_sessions else session_name
395
+ table.append([
396
+ _style(display_name, "36", use_color),
397
+ _dim(row["provider"], use_color),
398
+ _style(str(row["launches"]), "1", use_color),
399
+ _style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
400
+ _style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
401
+ _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
402
+ _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
403
+ _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
404
+ _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
405
+ _dim(_format_relative_age(row.get("last_started_at")), use_color),
406
+ ])
407
+ lines = [_style("Assistant stats:", "1", use_color)]
408
+ period_line = _format_history_period(period or {})
409
+ if period_line:
410
+ lines.extend([_dim(period_line, use_color), ""])
411
+ lines.append(_pad_table(table))
412
+ lines.extend([
413
+ "",
414
+ _dim(
415
+ "Totals: "
416
+ f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
417
+ f"{_format_token_count(totals['total_tokens'])} tokens, "
418
+ f"{_format_duration_ms(totals['duration_ms'])}.",
419
+ use_color,
420
+ ),
421
+ ])
422
+ return "\n".join(lines)
423
+
424
+ def _active_session_names(ctx):
425
+ return {
426
+ row["name"]
427
+ for row in ctx["service"]["format_list_rows"]()
428
+ if row.get("active")
429
+ }
430
+
431
+ def handle_config(rest, ctx):
432
+ parsed = _parse_config_args(rest)
433
+ session = ctx["service"]["get_session"](parsed["name"])
434
+ if not session:
435
+ raise CdxError(
436
+ f"Unknown session: {parsed['name']}. Run cdx configs to inspect existing sessions or "
437
+ f"cdx add {parsed['name']} to create it."
438
+ )
439
+ message = f"Launch settings for {parsed['name']}"
440
+ if parsed["json"]:
441
+ _write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
442
+ return 0
443
+ ctx["out"](f"{_format_launch_config(session, ctx['use_color'])}\n")
444
+ return 0
445
+
446
+ def handle_configs(rest, ctx):
447
+ parsed = _parse_configs_args(rest)
448
+ sessions = ctx["service"]["list_sessions"]()
449
+ message = f"Listed launch settings for {len(sessions)} session{'s' if len(sessions) != 1 else ''}"
450
+ if parsed["json"]:
451
+ _write_json(ctx, _json_success("configs", message, count=len(sessions), sessions=sessions))
452
+ return 0
453
+ ctx["out"](f"{_format_launch_configs(sessions, ctx['use_color'])}\n")
454
+ return 0
455
+
456
+ def handle_history(rest, ctx):
457
+ now_fn = ctx["options"].get("now") or time.time
458
+ now = datetime.fromtimestamp(now_fn()).astimezone()
459
+ parsed = _parse_history_args(rest, now=now)
460
+ has_period = _has_history_period(parsed["period"])
461
+ limit = 0 if parsed["summary"] or has_period else parsed["limit"]
462
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
463
+ entries = _filter_history_period(entries, parsed["period"])
464
+ if has_period and not parsed["summary"]:
465
+ entries = entries[:parsed["limit"]]
466
+ message = (
467
+ f"Listed launch history for {parsed['name']}"
468
+ if parsed["name"]
469
+ else "Listed launch history"
470
+ )
471
+ if parsed["json"]:
472
+ payload = {"history": entries, "period": _public_history_period(parsed["period"])}
473
+ if parsed["summary"]:
474
+ payload["summary"] = _summarize_history(entries)
475
+ _write_json(ctx, _json_success("history", message, **payload))
476
+ return 0
477
+ active_sessions = _active_session_names(ctx)
478
+ if parsed["summary"]:
479
+ ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
480
+ return 0
481
+ ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
482
+ return 0
483
+
484
+ def handle_stats(rest, ctx):
485
+ now_fn = ctx["options"].get("now") or time.time
486
+ now = datetime.fromtimestamp(now_fn()).astimezone()
487
+ parsed = _parse_stats_args(rest, now=now)
488
+ entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
489
+ entries = _filter_history_period(entries, parsed["period"])
490
+ rows = _summarize_stats(entries)
491
+ totals = _stats_totals(rows)
492
+ message = (
493
+ f"Calculated stats for {parsed['name']}"
494
+ if parsed["name"]
495
+ else "Calculated stats"
496
+ )
497
+ if parsed["json"]:
498
+ _write_json(ctx, _json_success(
499
+ "stats",
500
+ message,
501
+ period=_public_history_period(parsed["period"]),
502
+ stats=rows,
503
+ totals=totals,
504
+ ))
505
+ return 0
506
+ active_sessions = _active_session_names(ctx)
507
+ ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
508
+ return 0
509
+
510
+ def _resolve_last_launch_session(ctx):
511
+ for entry in ctx["service"]["get_launch_history"](limit=0):
512
+ name = entry.get("session_name")
513
+ if not name:
514
+ continue
515
+ session = ctx["service"]["get_session"](name)
516
+ if session:
517
+ return session
518
+ raise CdxError("No launch history yet. Run cdx <name> first.")
519
+
520
+ def handle_last(rest, ctx):
521
+ json_flag, args = _parse_json_flag(rest)
522
+ if args:
523
+ raise CdxError(LAST_USAGE)
524
+ session = _resolve_last_launch_session(ctx)
525
+ if not json_flag:
526
+ message = f"Launching last session: {session['name']}"
527
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
528
+ return handle_launch(session["name"], ctx)
529
+
530
+ def handle_status(rest, ctx):
531
+ parsed = _parse_flag_args(rest, {
532
+ "--json": {"key": "json", "type": "bool", "default": False},
533
+ "--small": {"key": "small", "type": "bool", "default": False},
534
+ "-s": {"key": "small", "type": "bool", "default": False},
535
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
536
+ "--cached": {"key": "cached", "type": "bool", "default": False},
537
+ "--timeout": {"key": "timeout", "type": "str", "default": None, "transform": lambda value: _parse_timeout_seconds(value, STATUS_USAGE)},
538
+ }, STATUS_USAGE, positionals_key="args", max_positionals=1)
539
+ if parsed["json"] and parsed["small"]:
540
+ raise CdxError(STATUS_USAGE)
541
+ if parsed["refresh"] and parsed["cached"]:
542
+ raise CdxError(STATUS_USAGE)
543
+ args = parsed["args"]
544
+ if len(args) == 1 and parsed["small"]:
545
+ raise CdxError(STATUS_USAGE)
546
+
547
+ auth_refresh = (
548
+ {"errors": []}
549
+ if parsed["cached"]
550
+ else _refresh_claude_auth_states(
551
+ ctx["service"],
552
+ target_names=args if len(args) == 1 else None,
553
+ spawn_sync=ctx.get("spawn_sync"),
554
+ env_override=ctx.get("env"),
555
+ )
556
+ )
557
+ warnings = [
558
+ {
559
+ "code": "claude_auth_probe_failed",
560
+ "session": item.get("session") or "unknown",
561
+ "message": item.get("error") or "unknown error",
562
+ }
563
+ for item in auth_refresh.get("errors", [])
564
+ ]
565
+ refresh_result = (
566
+ {"errors": []}
567
+ if parsed["cached"]
568
+ else _refresh_claude_sessions(
569
+ ctx["service"],
570
+ ctx.get("refresh_fn"),
571
+ target_names=args if len(args) == 1 else None,
572
+ force=parsed["refresh"],
573
+ )
574
+ )
575
+ refresh_errors = [
576
+ {
577
+ "session": item.get("session"),
578
+ "error": str(item.get("error")),
579
+ }
580
+ for item in refresh_result.get("errors", [])
581
+ ]
582
+ status_progress = None if parsed["json"] else _make_status_progress(ctx)
583
+ if len(args) == 1:
584
+ row = ctx["service"]["get_status_row"](
585
+ args[0],
586
+ progress_callback=status_progress,
587
+ force_refresh=parsed["refresh"],
588
+ cache_only=parsed["cached"],
589
+ status_timeout_seconds=parsed["timeout"],
590
+ )
591
+ output_warnings = [
592
+ *warnings,
593
+ *_refresh_warning_payloads(refresh_errors, [row]),
594
+ *_update_notice_warnings(ctx),
595
+ ]
596
+ if parsed["json"]:
597
+ _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=output_warnings, session=row))
598
+ return 0
599
+ ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
600
+ _write_refresh_warnings(refresh_errors, ctx, rows=[row])
601
+ _write_update_notice(ctx)
602
+ return 0
603
+
604
+ rows = ctx["service"]["get_status_rows"](
605
+ progress_callback=status_progress,
606
+ force_refresh=parsed["refresh"],
607
+ cache_only=parsed["cached"],
608
+ status_timeout_seconds=parsed["timeout"],
609
+ )
610
+ output_warnings = [
611
+ *warnings,
612
+ *_refresh_warning_payloads(refresh_errors, rows),
613
+ *_update_notice_warnings(ctx),
614
+ ]
615
+ if parsed["json"]:
616
+ _write_json(ctx, _json_success("status", "Collected session status rows", warnings=output_warnings, rows=rows))
617
+ return 0
618
+ ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
619
+ _write_refresh_warnings(refresh_errors, ctx, rows=rows)
620
+ _write_update_notice(ctx)
621
+ return 0
622
+
623
+ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
624
+ target_names = set(target_names or [])
625
+ errors = []
626
+ updated = []
627
+ for session in service["list_sessions"]():
628
+ if session["provider"] != PROVIDER_CLAUDE:
629
+ continue
630
+ if session.get("enabled", True) is False:
631
+ continue
632
+ if target_names and session["name"] not in target_names:
633
+ continue
634
+ if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
635
+ continue
636
+ try:
637
+ auth_status = _probe_provider_auth_status(
638
+ session,
639
+ spawn_sync=spawn_sync,
640
+ env_override=env_override,
641
+ )
642
+ if auth_status == AUTH_PROBE_DEGRADED:
643
+ errors.append({
644
+ "session": session["name"],
645
+ "error": "Auth probe timed out; authentication status is degraded.",
646
+ })
647
+ continue
648
+ now = _local_now_iso()
649
+ service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
650
+ **auth,
651
+ "status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
652
+ "lastCheckedAt": now,
653
+ **({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
654
+ })
655
+ updated.append(session["name"])
656
+ except Exception as error:
657
+ errors.append({"session": session["name"], "error": str(error)})
658
+ return {"updated": updated, "errors": errors}
659
+
660
+ def _rows_by_session(rows):
661
+ return {
662
+ row.get("session_name"): row
663
+ for row in (rows or [])
664
+ if row.get("session_name")
665
+ }
666
+
667
+ def _is_invalid_claude_usage_auth(error):
668
+ text = str(error or "").lower()
669
+ return "http 401" in text and "invalid authentication credentials" in text
670
+
671
+ def _has_valid_local_claude_auth(item, rows_by_name):
672
+ session = item.get("session")
673
+ row = rows_by_name.get(session)
674
+ return bool(row and row.get("provider") == PROVIDER_CLAUDE and row.get("auth_status") == "authenticated")
675
+
676
+ def _refresh_warning_payloads(refresh_errors, rows=None):
677
+ rows_by_name = _rows_by_session(rows)
678
+ warnings = []
679
+ for item in refresh_errors:
680
+ session = item.get("session") or "unknown"
681
+ error = item.get("error") or "unknown error"
682
+ warning = {
683
+ "code": "claude_refresh_failed",
684
+ "session": session,
685
+ "message": error,
686
+ }
687
+ if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
688
+ warning.update({
689
+ "auth_status": "authenticated",
690
+ "status_freshness": "stale",
691
+ "message": (
692
+ "Claude quota refresh failed, but local Claude auth is still valid; "
693
+ f"cached quota may be stale. Original error: {error}"
694
+ ),
695
+ })
696
+ warnings.append(warning)
697
+ return warnings
698
+
699
+ def _format_refresh_warning(item, rows_by_name):
700
+ session = item.get("session") or "unknown"
701
+ error = item.get("error") or "unknown error"
702
+ if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
703
+ return (
704
+ f"Warning: Claude quota refresh failed for {session}, but local auth is still valid; "
705
+ f"cached quota may be stale. ({error})"
706
+ )
707
+ return f"Warning: Claude refresh failed for {session}: {error}"
708
+
709
+ def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
710
+ write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
711
+ rows_by_name = _rows_by_session(rows)
712
+ for item in refresh_errors:
713
+ write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")