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.
- package/README.md +214 -19
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +324 -39
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +190 -2
- package/src/cli_render.py +17 -0
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +849 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +713 -0
- package/src/config.py +70 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +142 -15
- package/src/run_command.py +112 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +127 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +698 -1426
- package/src/session_status.py +550 -0
- package/src/status_source.py +45 -0
- package/src/status_view.py +15 -34
|
@@ -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")
|