cdx-manager 0.9.10 → 0.9.12
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 +1 -1
- package/changelogs/CHANGELOGS_0_9_11.md +44 -0
- package/changelogs/CHANGELOGS_0_9_12.md +36 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -2
- package/src/backup_bundle.py +0 -1
- package/src/claude_usage.py +19 -3
- package/src/cli.py +27 -9
- package/src/cli_args.py +631 -0
- package/src/cli_commands.py +117 -1020
- package/src/cli_helpers.py +385 -0
- package/src/cli_render.py +0 -1
- package/src/cli_view.py +1 -20
- package/src/context_store.py +1 -2
- package/src/logics_view.py +0 -1
- package/src/notify.py +0 -1
- package/src/provider_runtime.py +9 -33
- package/src/run_command.py +1 -1
- package/src/run_registry.py +2 -2
- package/src/run_usage.py +1 -2
- package/src/session_service.py +7 -20
- package/src/session_store.py +2 -2
- package/src/status_source.py +0 -19
- package/src/status_view.py +1 -1
- package/src/update_check.py +1 -2
package/src/cli_commands.py
CHANGED
|
@@ -6,11 +6,69 @@ import re
|
|
|
6
6
|
import shlex
|
|
7
7
|
import sys
|
|
8
8
|
import time
|
|
9
|
-
from datetime import datetime
|
|
9
|
+
from datetime import datetime
|
|
10
10
|
|
|
11
|
+
from .backup_bundle import read_bundle_meta
|
|
11
12
|
from .claude_refresh import _refresh_claude_sessions
|
|
13
|
+
from .cli_args import (
|
|
14
|
+
CAN_RESUME_USAGE,
|
|
15
|
+
CONTEXT_USAGE,
|
|
16
|
+
DOCTOR_USAGE,
|
|
17
|
+
HANDOFF_USAGE,
|
|
18
|
+
LAST_USAGE,
|
|
19
|
+
REPAIR_USAGE,
|
|
20
|
+
RESUME_USAGE,
|
|
21
|
+
RUN_REPORT_USAGE,
|
|
22
|
+
RUN_STATUS_USAGE,
|
|
23
|
+
RUNS_USAGE,
|
|
24
|
+
STATUS_USAGE,
|
|
25
|
+
_filter_history_period,
|
|
26
|
+
_has_history_period,
|
|
27
|
+
_parse_add_args,
|
|
28
|
+
_parse_config_args,
|
|
29
|
+
_parse_configs_args,
|
|
30
|
+
_parse_copy_args,
|
|
31
|
+
_parse_export_args,
|
|
32
|
+
_parse_flag_args,
|
|
33
|
+
_parse_history_args,
|
|
34
|
+
_parse_import_args,
|
|
35
|
+
_parse_json_flag,
|
|
36
|
+
_parse_launch_setting_alias_args,
|
|
37
|
+
_parse_next_args,
|
|
38
|
+
_parse_remove_args,
|
|
39
|
+
_parse_rename_args,
|
|
40
|
+
_parse_run_args,
|
|
41
|
+
_parse_run_id_json_args,
|
|
42
|
+
_parse_runs_args,
|
|
43
|
+
_parse_select_args,
|
|
44
|
+
_parse_set_args,
|
|
45
|
+
_parse_stats_args,
|
|
46
|
+
_parse_timeout_seconds,
|
|
47
|
+
_parse_toggle_args,
|
|
48
|
+
_parse_unset_args,
|
|
49
|
+
_parse_update_args,
|
|
50
|
+
_public_history_period,
|
|
51
|
+
)
|
|
52
|
+
from .cli_helpers import (
|
|
53
|
+
API_SCHEMA_VERSION,
|
|
54
|
+
_build_handoff_context,
|
|
55
|
+
_format_export_report,
|
|
56
|
+
_handoff_launch_prompt,
|
|
57
|
+
_json_failure,
|
|
58
|
+
_json_success,
|
|
59
|
+
_latest_handoff_transcript_path,
|
|
60
|
+
_local_now_iso,
|
|
61
|
+
_make_export_progress,
|
|
62
|
+
_make_notify_progress,
|
|
63
|
+
_make_status_progress,
|
|
64
|
+
_read_handoff_transcript,
|
|
65
|
+
_update_notice_warnings,
|
|
66
|
+
_write_json,
|
|
67
|
+
_write_update_notice,
|
|
68
|
+
)
|
|
12
69
|
from .cli_render import _dim, _info, _style, _success, _warn
|
|
13
|
-
from .
|
|
70
|
+
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
|
|
14
72
|
from .context_store import (
|
|
15
73
|
clear_context,
|
|
16
74
|
edit_context,
|
|
@@ -22,7 +80,6 @@ from .context_store import (
|
|
|
22
80
|
)
|
|
23
81
|
from .errors import CdxError
|
|
24
82
|
from .health import collect_health_report, format_health_report
|
|
25
|
-
from .cli_view import handle_view
|
|
26
83
|
from .notify import (
|
|
27
84
|
format_notify_event,
|
|
28
85
|
format_scheduled_notification,
|
|
@@ -36,992 +93,20 @@ from .provider_runtime import (
|
|
|
36
93
|
_ensure_session_authentication,
|
|
37
94
|
_headless_artifact_paths,
|
|
38
95
|
_list_launch_transcript_paths,
|
|
39
|
-
_normalize_reasoning_effort,
|
|
40
96
|
_probe_provider_auth,
|
|
41
|
-
get_resume_capability,
|
|
42
97
|
_run_headless_provider_command,
|
|
43
98
|
_run_interactive_provider_command,
|
|
99
|
+
get_resume_capability,
|
|
44
100
|
)
|
|
45
101
|
from .repair import format_repair_report, repair_health
|
|
46
|
-
from .
|
|
102
|
+
from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
|
|
47
103
|
from .run_registry import RunRegistry, build_code_review_report
|
|
48
104
|
from .run_usage import extract_run_usage
|
|
49
|
-
from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
|
|
50
105
|
from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
51
106
|
from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
|
|
52
107
|
from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
|
|
53
108
|
|
|
54
109
|
|
|
55
|
-
STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
|
|
56
|
-
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
57
|
-
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
58
|
-
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
59
|
-
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
60
|
-
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
61
|
-
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
62
|
-
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
63
|
-
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
|
|
64
|
-
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]"
|
|
65
|
-
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
66
|
-
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
67
|
-
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
68
|
-
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
69
|
-
STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
70
|
-
LAST_USAGE = "Usage: cdx last [--json]"
|
|
71
|
-
RESUME_USAGE = "Usage: cdx resume <name> [--json]"
|
|
72
|
-
CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
|
|
73
|
-
SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
|
|
74
|
-
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
75
|
-
RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
|
|
76
|
-
RUNS_USAGE = "Usage: cdx runs [--limit N] --json"
|
|
77
|
-
RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
|
|
78
|
-
RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
|
|
79
|
-
API_SCHEMA_VERSION = 1
|
|
80
|
-
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
81
|
-
HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _local_now_iso():
|
|
85
|
-
return datetime.now().astimezone().isoformat()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _json_success(action, message, warnings=None, **extra):
|
|
89
|
-
payload = {
|
|
90
|
-
"schema_version": API_SCHEMA_VERSION,
|
|
91
|
-
"ok": True,
|
|
92
|
-
"action": action,
|
|
93
|
-
"message": message,
|
|
94
|
-
"warnings": warnings or [],
|
|
95
|
-
}
|
|
96
|
-
payload.update(extra)
|
|
97
|
-
return payload
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _json_failure(action, code, message, source="cdx", exit_code=1, warnings=None, **extra):
|
|
101
|
-
payload = {
|
|
102
|
-
"schema_version": API_SCHEMA_VERSION,
|
|
103
|
-
"ok": False,
|
|
104
|
-
"action": action,
|
|
105
|
-
"warnings": warnings or [],
|
|
106
|
-
"error": {
|
|
107
|
-
"source": source,
|
|
108
|
-
"code": code,
|
|
109
|
-
"message": message,
|
|
110
|
-
"exit_code": exit_code,
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
payload.update(extra)
|
|
114
|
-
return payload
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _write_json(ctx, payload):
|
|
118
|
-
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _update_notice_warning(ctx):
|
|
122
|
-
notices = ctx.get("update_notices") or []
|
|
123
|
-
if not notices:
|
|
124
|
-
return None
|
|
125
|
-
warnings = _update_notice_warnings(ctx)
|
|
126
|
-
return warnings[0] if warnings else None
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _update_notice_warnings(ctx):
|
|
130
|
-
warnings = []
|
|
131
|
-
for notice in ctx.get("update_notices") or []:
|
|
132
|
-
tool = notice.get("tool") or "cdx-manager"
|
|
133
|
-
current = notice.get("current_version") or ctx.get("version")
|
|
134
|
-
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|
|
135
|
-
message = f"Update available: {tool} {notice['latest_version']}"
|
|
136
|
-
if current:
|
|
137
|
-
message = f"{message} (current {current})"
|
|
138
|
-
if command:
|
|
139
|
-
message = f"{message}. Run: {command}"
|
|
140
|
-
warnings.append({
|
|
141
|
-
"code": "update_available" if tool == "cdx-manager" else f"{tool.replace('-', '_')}_update_available",
|
|
142
|
-
"message": message,
|
|
143
|
-
"tool": tool,
|
|
144
|
-
"latest_version": notice["latest_version"],
|
|
145
|
-
"current_version": current,
|
|
146
|
-
"update_command": command,
|
|
147
|
-
"url": notice.get("url"),
|
|
148
|
-
})
|
|
149
|
-
return warnings
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _write_update_notice(ctx):
|
|
153
|
-
warnings = _update_notice_warnings(ctx)
|
|
154
|
-
if not warnings:
|
|
155
|
-
return
|
|
156
|
-
for warning in warnings:
|
|
157
|
-
ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _format_bytes(value):
|
|
161
|
-
if value is None:
|
|
162
|
-
return "n/a"
|
|
163
|
-
try:
|
|
164
|
-
amount = float(value)
|
|
165
|
-
except (TypeError, ValueError):
|
|
166
|
-
return str(value)
|
|
167
|
-
units = ["B", "KB", "MB", "GB", "TB"]
|
|
168
|
-
unit = units[0]
|
|
169
|
-
for unit in units:
|
|
170
|
-
if amount < 1024 or unit == units[-1]:
|
|
171
|
-
break
|
|
172
|
-
amount /= 1024
|
|
173
|
-
if unit == "B":
|
|
174
|
-
return f"{int(amount)} B"
|
|
175
|
-
return f"{amount:.1f} {unit}"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _format_export_report(result):
|
|
179
|
-
lines = [
|
|
180
|
-
f"Path: {result['path']}",
|
|
181
|
-
f"Sessions: {', '.join(result['session_names']) or '-'}",
|
|
182
|
-
f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
|
|
183
|
-
f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
|
|
184
|
-
]
|
|
185
|
-
if result.get("include_auth"):
|
|
186
|
-
lines.extend([
|
|
187
|
-
f"Auth files: {result.get('profile_file_count', 0)}",
|
|
188
|
-
f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
|
|
189
|
-
])
|
|
190
|
-
return "\n".join(lines)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _make_export_progress(ctx):
|
|
194
|
-
def progress(event):
|
|
195
|
-
kind = event.get("event")
|
|
196
|
-
if kind == "export_started":
|
|
197
|
-
auth = " with auth" if event.get("include_auth") else ""
|
|
198
|
-
message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
|
|
199
|
-
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
200
|
-
elif kind == "session_started":
|
|
201
|
-
message = f"Collecting {event.get('session_name')}..."
|
|
202
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
203
|
-
elif kind == "profile_progress":
|
|
204
|
-
message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
|
|
205
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
206
|
-
elif kind == "encoding_started":
|
|
207
|
-
ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
|
|
208
|
-
elif kind == "writing_started":
|
|
209
|
-
message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
|
|
210
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
211
|
-
return progress
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _make_status_progress(ctx):
|
|
215
|
-
progress_state = {"checked": 0, "total": 0}
|
|
216
|
-
|
|
217
|
-
def progress(event):
|
|
218
|
-
kind = event.get("event")
|
|
219
|
-
if kind == "status_started":
|
|
220
|
-
progress_state["checked"] = 0
|
|
221
|
-
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
222
|
-
message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
|
|
223
|
-
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
224
|
-
elif kind == "session_started":
|
|
225
|
-
provider = event.get("provider") or "session"
|
|
226
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
227
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
228
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
229
|
-
progress_state["checked"] += 1
|
|
230
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
231
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
232
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
233
|
-
elif kind == "status_finished":
|
|
234
|
-
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
235
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
236
|
-
return progress
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _make_notify_progress(ctx):
|
|
240
|
-
progress_state = {"checked": 0, "total": 0}
|
|
241
|
-
|
|
242
|
-
def progress(event):
|
|
243
|
-
kind = event.get("event")
|
|
244
|
-
if kind == "notify_check_started":
|
|
245
|
-
target = event.get("session_name") or "next ready session"
|
|
246
|
-
ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
|
|
247
|
-
elif kind == "status_started":
|
|
248
|
-
progress_state["checked"] = 0
|
|
249
|
-
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
250
|
-
message = f"Loading status for {event.get('session_count', 0)} session(s)..."
|
|
251
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
252
|
-
elif kind == "session_started":
|
|
253
|
-
provider = event.get("provider") or "session"
|
|
254
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
255
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
256
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
257
|
-
progress_state["checked"] += 1
|
|
258
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
259
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
260
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
261
|
-
elif kind == "notify_waiting":
|
|
262
|
-
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
263
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
264
|
-
return progress
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _latest_launch_transcript_path(session):
|
|
268
|
-
paths = _list_launch_transcript_paths(session)
|
|
269
|
-
if not paths:
|
|
270
|
-
return None
|
|
271
|
-
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _get_session_home(session):
|
|
275
|
-
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _safe_stat(path):
|
|
279
|
-
try:
|
|
280
|
-
return os.stat(path)
|
|
281
|
-
except OSError:
|
|
282
|
-
return None
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _sort_recent_paths(paths):
|
|
286
|
-
stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
|
|
287
|
-
return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _collect_native_handoff_transcript_paths(session):
|
|
291
|
-
root = _get_session_home(session)
|
|
292
|
-
if not root:
|
|
293
|
-
return []
|
|
294
|
-
candidates = []
|
|
295
|
-
direct = [
|
|
296
|
-
os.path.join(root, "history.jsonl"),
|
|
297
|
-
os.path.join(root, "session_index.jsonl"),
|
|
298
|
-
]
|
|
299
|
-
candidates.extend(path for path in direct if _safe_stat(path))
|
|
300
|
-
|
|
301
|
-
scan_roots = [
|
|
302
|
-
os.path.join(root, "sessions"),
|
|
303
|
-
os.path.join(root, ".claude", "projects"),
|
|
304
|
-
os.path.join(root, "projects"),
|
|
305
|
-
]
|
|
306
|
-
skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
|
|
307
|
-
for scan_root in scan_roots:
|
|
308
|
-
if not os.path.isdir(scan_root):
|
|
309
|
-
continue
|
|
310
|
-
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
311
|
-
dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
|
|
312
|
-
for filename in filenames:
|
|
313
|
-
if filename.endswith(".jsonl") or filename.endswith(".log"):
|
|
314
|
-
candidates.append(os.path.join(dirpath, filename))
|
|
315
|
-
return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _latest_handoff_transcript_path(session):
|
|
319
|
-
launch_path = _latest_launch_transcript_path(session)
|
|
320
|
-
if launch_path:
|
|
321
|
-
return launch_path
|
|
322
|
-
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
323
|
-
return native_paths[0] if native_paths else None
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _collect_handoff_text_fragments(value):
|
|
327
|
-
fragments = []
|
|
328
|
-
if isinstance(value, str):
|
|
329
|
-
text = value.strip()
|
|
330
|
-
if text:
|
|
331
|
-
fragments.append(text)
|
|
332
|
-
elif isinstance(value, list):
|
|
333
|
-
for item in value:
|
|
334
|
-
fragments.extend(_collect_handoff_text_fragments(item))
|
|
335
|
-
elif isinstance(value, dict):
|
|
336
|
-
if isinstance(value.get("text"), str):
|
|
337
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("text")))
|
|
338
|
-
if isinstance(value.get("content"), (str, list, dict)):
|
|
339
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("content")))
|
|
340
|
-
if isinstance(value.get("message"), dict):
|
|
341
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("message")))
|
|
342
|
-
if isinstance(value.get("payload"), dict):
|
|
343
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
|
|
344
|
-
return fragments
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def _jsonl_record_to_handoff_text(record):
|
|
348
|
-
if not isinstance(record, dict):
|
|
349
|
-
return None
|
|
350
|
-
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
351
|
-
role = (
|
|
352
|
-
message.get("role")
|
|
353
|
-
or record.get("role")
|
|
354
|
-
or record.get("type")
|
|
355
|
-
or record.get("sender")
|
|
356
|
-
or "entry"
|
|
357
|
-
)
|
|
358
|
-
text_sources = []
|
|
359
|
-
for key in ("content", "text", "payload"):
|
|
360
|
-
if key in record:
|
|
361
|
-
text_sources.append(record.get(key))
|
|
362
|
-
for key in ("content", "text"):
|
|
363
|
-
if key in message:
|
|
364
|
-
text_sources.append(message.get(key))
|
|
365
|
-
fragments = []
|
|
366
|
-
for value in text_sources:
|
|
367
|
-
fragments.extend(_collect_handoff_text_fragments(value))
|
|
368
|
-
text = "\n".join(fragment for fragment in fragments if fragment).strip()
|
|
369
|
-
if not text:
|
|
370
|
-
return None
|
|
371
|
-
role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
|
|
372
|
-
return f"[{role}]\n{text}"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _read_jsonl_handoff_transcript(path):
|
|
376
|
-
entries = []
|
|
377
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
378
|
-
for line in handle:
|
|
379
|
-
line = line.strip()
|
|
380
|
-
if not line:
|
|
381
|
-
continue
|
|
382
|
-
try:
|
|
383
|
-
text = _jsonl_record_to_handoff_text(json.loads(line))
|
|
384
|
-
except json.JSONDecodeError:
|
|
385
|
-
text = None
|
|
386
|
-
if text:
|
|
387
|
-
entries.append(text)
|
|
388
|
-
if entries:
|
|
389
|
-
return "\n\n".join(entries)
|
|
390
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
391
|
-
return handle.read()
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def _read_handoff_transcript(path):
|
|
395
|
-
if path.endswith(".jsonl"):
|
|
396
|
-
content = _read_jsonl_handoff_transcript(path)
|
|
397
|
-
else:
|
|
398
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
399
|
-
content = handle.read()
|
|
400
|
-
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
401
|
-
return content, False
|
|
402
|
-
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
|
|
406
|
-
truncation_note = (
|
|
407
|
-
"Only the tail of the previous transcript is included because the log was large."
|
|
408
|
-
if truncated else
|
|
409
|
-
"The previous transcript is included below."
|
|
410
|
-
)
|
|
411
|
-
return "\n".join([
|
|
412
|
-
"# Shared Context",
|
|
413
|
-
"",
|
|
414
|
-
"## Goal",
|
|
415
|
-
f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
|
|
416
|
-
"",
|
|
417
|
-
"## Current State",
|
|
418
|
-
f"- Source session: `{source['name']}` ({source['provider']})",
|
|
419
|
-
f"- Target session: `{target['name']}` ({target['provider']})",
|
|
420
|
-
f"- Transcript source: `{transcript_path}`",
|
|
421
|
-
f"- {truncation_note}",
|
|
422
|
-
"",
|
|
423
|
-
"## Previous Session Transcript",
|
|
424
|
-
transcript.rstrip(),
|
|
425
|
-
"",
|
|
426
|
-
"## Next Steps",
|
|
427
|
-
"- Read the transcript above.",
|
|
428
|
-
"- Continue the task from the latest actionable state.",
|
|
429
|
-
"- Preserve the target account authentication; do not copy source credentials.",
|
|
430
|
-
])
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def _handoff_launch_prompt(session, install=None):
|
|
434
|
-
if session.get("provider") == PROVIDER_CODEX:
|
|
435
|
-
context_ref = "$CODEX_HOME/shared-context.md"
|
|
436
|
-
else:
|
|
437
|
-
context_ref = (install or {}).get("target_path") or os.path.join(
|
|
438
|
-
session.get("authHome") or session.get("sessionRoot") or "~",
|
|
439
|
-
"shared-context.md",
|
|
440
|
-
)
|
|
441
|
-
return (
|
|
442
|
-
f"Read {context_ref} first, then resume the previous session "
|
|
443
|
-
"from the latest actionable state. Do not ask me to paste the context again."
|
|
444
|
-
)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def _parse_json_flag(args):
|
|
448
|
-
json_flag = "--json" in args
|
|
449
|
-
cleaned = [arg for arg in args if arg != "--json"]
|
|
450
|
-
return json_flag, cleaned
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
|
|
454
|
-
parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
|
|
455
|
-
positionals = []
|
|
456
|
-
index = 0
|
|
457
|
-
while index < len(args):
|
|
458
|
-
arg = args[index]
|
|
459
|
-
if arg in schema:
|
|
460
|
-
spec = schema[arg]
|
|
461
|
-
if spec["type"] == "bool":
|
|
462
|
-
parsed[spec["key"]] = True
|
|
463
|
-
index += 1
|
|
464
|
-
continue
|
|
465
|
-
value, index = _read_option_value(args, index, usage)
|
|
466
|
-
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
467
|
-
continue
|
|
468
|
-
if arg.startswith("--") and "=" in arg:
|
|
469
|
-
flag, value = arg.split("=", 1)
|
|
470
|
-
spec = schema.get(flag)
|
|
471
|
-
if not spec or spec["type"] == "bool":
|
|
472
|
-
raise CdxError(usage)
|
|
473
|
-
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
474
|
-
index += 1
|
|
475
|
-
continue
|
|
476
|
-
if arg.startswith("-"):
|
|
477
|
-
raise CdxError(usage)
|
|
478
|
-
positionals.append(arg)
|
|
479
|
-
if len(positionals) > max_positionals:
|
|
480
|
-
raise CdxError(usage)
|
|
481
|
-
index += 1
|
|
482
|
-
|
|
483
|
-
if positionals_key is not None:
|
|
484
|
-
parsed[positionals_key] = positionals
|
|
485
|
-
return parsed
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def _parse_add_args(args):
|
|
489
|
-
parsed = _parse_flag_args(
|
|
490
|
-
args,
|
|
491
|
-
{"--model": {"key": "model", "type": "str", "default": None}},
|
|
492
|
-
"Usage: cdx add [provider] <name> [--model MODEL] [--json]",
|
|
493
|
-
positionals_key="values",
|
|
494
|
-
max_positionals=2,
|
|
495
|
-
)
|
|
496
|
-
values = parsed["values"]
|
|
497
|
-
if len(values) == 1:
|
|
498
|
-
return {"provider": PROVIDER_CODEX, "name": values[0], "model": parsed["model"]}
|
|
499
|
-
if len(values) == 2:
|
|
500
|
-
return {"provider": values[0], "name": values[1], "model": parsed["model"]}
|
|
501
|
-
raise CdxError("Usage: cdx add [provider] <name> [--model MODEL] [--json]")
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def _parse_copy_args(args):
|
|
505
|
-
if len(args) != 2:
|
|
506
|
-
raise CdxError("Usage: cdx cp <source> <dest> [--json]")
|
|
507
|
-
return {"source": args[0], "dest": args[1]}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def _parse_rename_args(args):
|
|
511
|
-
if len(args) != 2:
|
|
512
|
-
raise CdxError("Usage: cdx ren <source> <dest> [--json]")
|
|
513
|
-
return {"source": args[0], "dest": args[1]}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def _parse_remove_args(args):
|
|
517
|
-
parsed = _parse_flag_args(args, {
|
|
518
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
519
|
-
}, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
|
|
520
|
-
if len(parsed["names"]) != 1:
|
|
521
|
-
raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
|
|
522
|
-
return {"name": parsed["names"][0], "force": parsed["force"]}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
def _parse_toggle_args(args, usage):
|
|
526
|
-
parsed = _parse_flag_args(args, {
|
|
527
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
528
|
-
}, usage, positionals_key="names", max_positionals=1)
|
|
529
|
-
if len(parsed["names"]) != 1:
|
|
530
|
-
raise CdxError(usage)
|
|
531
|
-
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _parse_fast_value(value):
|
|
535
|
-
text = str(value).strip().lower()
|
|
536
|
-
if text in ("on", "true", "1", "yes"):
|
|
537
|
-
return True
|
|
538
|
-
if text in ("off", "false", "0", "no"):
|
|
539
|
-
return False
|
|
540
|
-
raise CdxError(SET_USAGE)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _parse_priority_value(value):
|
|
544
|
-
try:
|
|
545
|
-
priority = int(value)
|
|
546
|
-
except (TypeError, ValueError) as error:
|
|
547
|
-
raise CdxError(SET_USAGE) from error
|
|
548
|
-
if priority < 0 or priority > 100:
|
|
549
|
-
raise CdxError(SET_USAGE)
|
|
550
|
-
return priority
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _parse_set_args(args):
|
|
554
|
-
parsed = _parse_flag_args(args, {
|
|
555
|
-
"--power": {"key": "power", "type": "str", "default": None},
|
|
556
|
-
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
557
|
-
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
558
|
-
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
559
|
-
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
560
|
-
"--model": {"key": "model", "type": "str", "default": None},
|
|
561
|
-
"--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
|
|
562
|
-
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
563
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
|
|
564
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
565
|
-
}, SET_USAGE, positionals_key="names", max_positionals=1)
|
|
566
|
-
if len(parsed["names"]) > 1:
|
|
567
|
-
raise CdxError(SET_USAGE)
|
|
568
|
-
if parsed["names"] and parsed["sessions"]:
|
|
569
|
-
raise CdxError(SET_USAGE)
|
|
570
|
-
if parsed["names"] and parsed["provider"]:
|
|
571
|
-
raise CdxError(SET_USAGE)
|
|
572
|
-
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
573
|
-
raise CdxError(SET_USAGE)
|
|
574
|
-
settings = {
|
|
575
|
-
key: parsed[key]
|
|
576
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority")
|
|
577
|
-
if parsed[key] is not None
|
|
578
|
-
}
|
|
579
|
-
if not settings:
|
|
580
|
-
raise CdxError(SET_USAGE)
|
|
581
|
-
return {
|
|
582
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
583
|
-
"sessions": parsed["sessions"],
|
|
584
|
-
"provider": parsed["provider"],
|
|
585
|
-
"settings": settings,
|
|
586
|
-
"json": parsed["json"],
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def _parse_unset_args(args):
|
|
591
|
-
parsed = _parse_flag_args(args, {
|
|
592
|
-
"--power": {"key": "power", "type": "bool", "default": False},
|
|
593
|
-
"--permission": {"key": "permission", "type": "bool", "default": False},
|
|
594
|
-
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
595
|
-
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
596
|
-
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
597
|
-
"--model": {"key": "model", "type": "bool", "default": False},
|
|
598
|
-
"--priority": {"key": "priority", "type": "bool", "default": False},
|
|
599
|
-
"--all": {"key": "all", "type": "bool", "default": False},
|
|
600
|
-
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
601
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, UNSET_USAGE)},
|
|
602
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
603
|
-
}, UNSET_USAGE, positionals_key="names", max_positionals=1)
|
|
604
|
-
if len(parsed["names"]) > 1:
|
|
605
|
-
raise CdxError(UNSET_USAGE)
|
|
606
|
-
if parsed["names"] and parsed["sessions"]:
|
|
607
|
-
raise CdxError(UNSET_USAGE)
|
|
608
|
-
if parsed["names"] and parsed["provider"]:
|
|
609
|
-
raise CdxError(UNSET_USAGE)
|
|
610
|
-
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
611
|
-
raise CdxError(UNSET_USAGE)
|
|
612
|
-
keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
|
|
613
|
-
key for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority") if parsed[key]
|
|
614
|
-
]
|
|
615
|
-
if not keys:
|
|
616
|
-
raise CdxError(UNSET_USAGE)
|
|
617
|
-
return {
|
|
618
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
619
|
-
"sessions": parsed["sessions"],
|
|
620
|
-
"provider": parsed["provider"],
|
|
621
|
-
"keys": keys,
|
|
622
|
-
"json": parsed["json"],
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
def _parse_config_args(args):
|
|
627
|
-
parsed = _parse_flag_args(args, {
|
|
628
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
629
|
-
}, CONFIG_USAGE, positionals_key="names", max_positionals=1)
|
|
630
|
-
if len(parsed["names"]) != 1:
|
|
631
|
-
raise CdxError(CONFIG_USAGE)
|
|
632
|
-
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
def _parse_configs_args(args):
|
|
636
|
-
parsed = _parse_flag_args(args, {
|
|
637
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
638
|
-
}, CONFIGS_USAGE)
|
|
639
|
-
return {"json": parsed["json"]}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def _parse_select_args(args):
|
|
643
|
-
parsed = _parse_flag_args(args, {
|
|
644
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SELECT_USAGE)},
|
|
645
|
-
"--min-reasoning-effort": {"key": "min_reasoning_effort", "type": "str", "default": None},
|
|
646
|
-
"--min-power": {"key": "min_power", "type": "str", "default": None},
|
|
647
|
-
"--require-ready": {"key": "require_ready", "type": "bool", "default": False},
|
|
648
|
-
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
649
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
650
|
-
}, SELECT_USAGE)
|
|
651
|
-
if not parsed["provider"] or not parsed["json"]:
|
|
652
|
-
raise CdxError(SELECT_USAGE)
|
|
653
|
-
effort = _normalize_reasoning_effort(
|
|
654
|
-
reasoning_effort=parsed["min_reasoning_effort"],
|
|
655
|
-
power=parsed["min_power"],
|
|
656
|
-
usage=SELECT_USAGE,
|
|
657
|
-
).get("reasoning_effort")
|
|
658
|
-
return {
|
|
659
|
-
"provider": parsed["provider"],
|
|
660
|
-
"min_reasoning_effort": effort,
|
|
661
|
-
"require_ready": parsed["require_ready"],
|
|
662
|
-
"refresh": parsed["refresh"],
|
|
663
|
-
"json": parsed["json"],
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def _parse_next_args(args):
|
|
668
|
-
parsed = _parse_flag_args(args, {
|
|
669
|
-
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
670
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
671
|
-
}, NEXT_USAGE)
|
|
672
|
-
return {"json": parsed["json"], "refresh": parsed["refresh"]}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def _parse_timeout_seconds(value, usage=RUN_USAGE):
|
|
676
|
-
try:
|
|
677
|
-
parsed = float(value)
|
|
678
|
-
except (TypeError, ValueError) as error:
|
|
679
|
-
raise CdxError(usage) from error
|
|
680
|
-
if parsed <= 0:
|
|
681
|
-
raise CdxError(usage)
|
|
682
|
-
return parsed
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
def _normalize_run_permission(value):
|
|
686
|
-
if value is None:
|
|
687
|
-
return None
|
|
688
|
-
text = str(value).strip().lower()
|
|
689
|
-
aliases = {
|
|
690
|
-
"workspace-write": "default",
|
|
691
|
-
"read-only": "review",
|
|
692
|
-
"danger-full-access": "full",
|
|
693
|
-
}
|
|
694
|
-
text = aliases.get(text, text)
|
|
695
|
-
if text not in ("review", "default", "auto", "full"):
|
|
696
|
-
raise CdxError(RUN_USAGE)
|
|
697
|
-
return text
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def _parse_run_args(args):
|
|
701
|
-
parsed = _parse_flag_args(args, {
|
|
702
|
-
"--cwd": {"key": "cwd", "type": "str", "default": None},
|
|
703
|
-
"--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
|
|
704
|
-
"--prompt": {"key": "prompt", "type": "str", "default": None},
|
|
705
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, RUN_USAGE)},
|
|
706
|
-
"--model": {"key": "model", "type": "str", "default": None},
|
|
707
|
-
"--kind": {"key": "kind", "type": "str", "default": "assistant"},
|
|
708
|
-
"--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
|
|
709
|
-
"--power": {"key": "power", "type": "str", "default": None},
|
|
710
|
-
"--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
|
|
711
|
-
"--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_timeout_seconds},
|
|
712
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
713
|
-
}, RUN_USAGE, positionals_key="names", max_positionals=1)
|
|
714
|
-
if not parsed["json"]:
|
|
715
|
-
raise CdxError(RUN_USAGE)
|
|
716
|
-
name = parsed["names"][0] if parsed["names"] else None
|
|
717
|
-
if name and parsed["provider"]:
|
|
718
|
-
raise CdxError(RUN_USAGE)
|
|
719
|
-
if not name and not parsed["provider"]:
|
|
720
|
-
raise CdxError(RUN_USAGE)
|
|
721
|
-
if not parsed["cwd"]:
|
|
722
|
-
raise CdxError(RUN_USAGE)
|
|
723
|
-
if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
|
|
724
|
-
raise CdxError(RUN_USAGE)
|
|
725
|
-
if parsed["kind"] not in ("assistant", "code-review"):
|
|
726
|
-
raise CdxError(RUN_USAGE)
|
|
727
|
-
effort = _normalize_reasoning_effort(
|
|
728
|
-
reasoning_effort=parsed["reasoning_effort"],
|
|
729
|
-
power=parsed["power"],
|
|
730
|
-
usage=RUN_USAGE,
|
|
731
|
-
)
|
|
732
|
-
return {
|
|
733
|
-
"name": name,
|
|
734
|
-
"provider": parsed["provider"],
|
|
735
|
-
"cwd": parsed["cwd"],
|
|
736
|
-
"prompt_file": parsed["prompt_file"],
|
|
737
|
-
"prompt": parsed["prompt"],
|
|
738
|
-
"kind": parsed["kind"],
|
|
739
|
-
"model": parsed["model"],
|
|
740
|
-
"permission": parsed["permission"],
|
|
741
|
-
"timeout_seconds": parsed["timeout_seconds"],
|
|
742
|
-
"reasoning_effort": effort.get("reasoning_effort"),
|
|
743
|
-
"power": effort.get("power"),
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def _parse_positive_int(value):
|
|
748
|
-
try:
|
|
749
|
-
parsed = int(value)
|
|
750
|
-
except (TypeError, ValueError) as error:
|
|
751
|
-
raise CdxError(HISTORY_USAGE) from error
|
|
752
|
-
if parsed < 1:
|
|
753
|
-
raise CdxError(HISTORY_USAGE)
|
|
754
|
-
return parsed
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
def _local_timezone():
|
|
758
|
-
return datetime.now().astimezone().tzinfo
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def _parse_datetime_value(value, usage, end_of_day=False):
|
|
762
|
-
text = str(value or "").strip()
|
|
763
|
-
if not text:
|
|
764
|
-
raise CdxError(usage)
|
|
765
|
-
if text.endswith("Z"):
|
|
766
|
-
text = f"{text[:-1]}+00:00"
|
|
767
|
-
is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
|
|
768
|
-
try:
|
|
769
|
-
parsed = datetime.fromisoformat(text)
|
|
770
|
-
except ValueError as error:
|
|
771
|
-
raise CdxError(usage) from error
|
|
772
|
-
if is_date_only and end_of_day:
|
|
773
|
-
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
774
|
-
if parsed.tzinfo is None:
|
|
775
|
-
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
776
|
-
return parsed.astimezone()
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def _parse_since_value(value, now, usage):
|
|
780
|
-
text = str(value or "").strip().lower()
|
|
781
|
-
if not text:
|
|
782
|
-
raise CdxError(usage)
|
|
783
|
-
if text == "today":
|
|
784
|
-
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
785
|
-
if text == "yesterday":
|
|
786
|
-
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
787
|
-
return today - timedelta(days=1)
|
|
788
|
-
unit = text[-1:]
|
|
789
|
-
amount_text = text[:-1]
|
|
790
|
-
if unit in ("m", "h", "d", "w") and amount_text.isdigit():
|
|
791
|
-
amount = int(amount_text)
|
|
792
|
-
if amount < 1:
|
|
793
|
-
raise CdxError(usage)
|
|
794
|
-
multipliers = {
|
|
795
|
-
"m": timedelta(minutes=amount),
|
|
796
|
-
"h": timedelta(hours=amount),
|
|
797
|
-
"d": timedelta(days=amount),
|
|
798
|
-
"w": timedelta(weeks=amount),
|
|
799
|
-
}
|
|
800
|
-
return now - multipliers[unit]
|
|
801
|
-
return _parse_datetime_value(value, usage, end_of_day=False)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _format_period_datetime(value):
|
|
805
|
-
if value is None:
|
|
806
|
-
return None
|
|
807
|
-
return value.isoformat(timespec="seconds")
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
def _parse_history_period(parsed, now):
|
|
811
|
-
since = parsed.get("since")
|
|
812
|
-
from_value = parsed.get("from")
|
|
813
|
-
to_value = parsed.get("to")
|
|
814
|
-
if since and from_value:
|
|
815
|
-
raise CdxError("Usage: cdx history cannot combine --since and --from.")
|
|
816
|
-
start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
|
|
817
|
-
if from_value:
|
|
818
|
-
start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
|
|
819
|
-
end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
|
|
820
|
-
if start and end and start.timestamp() > end.timestamp():
|
|
821
|
-
raise CdxError("Usage: cdx history period start must be before period end.")
|
|
822
|
-
return {
|
|
823
|
-
"from": _format_period_datetime(start),
|
|
824
|
-
"to": _format_period_datetime(end),
|
|
825
|
-
"from_ts": start.timestamp() if start else None,
|
|
826
|
-
"to_ts": end.timestamp() if end else None,
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def _has_history_period(period):
|
|
831
|
-
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def _parse_entry_timestamp(entry):
|
|
835
|
-
value = entry.get("started_at")
|
|
836
|
-
if not value:
|
|
837
|
-
return None
|
|
838
|
-
text = str(value)
|
|
839
|
-
if text.endswith("Z"):
|
|
840
|
-
text = f"{text[:-1]}+00:00"
|
|
841
|
-
try:
|
|
842
|
-
parsed = datetime.fromisoformat(text)
|
|
843
|
-
except ValueError:
|
|
844
|
-
return None
|
|
845
|
-
if parsed.tzinfo is None:
|
|
846
|
-
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
847
|
-
return parsed.timestamp()
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
def _filter_history_period(entries, period):
|
|
851
|
-
if not _has_history_period(period):
|
|
852
|
-
return entries
|
|
853
|
-
filtered = []
|
|
854
|
-
start = period.get("from_ts")
|
|
855
|
-
end = period.get("to_ts")
|
|
856
|
-
for entry in entries:
|
|
857
|
-
timestamp = _parse_entry_timestamp(entry)
|
|
858
|
-
if timestamp is None:
|
|
859
|
-
continue
|
|
860
|
-
if start is not None and timestamp < start:
|
|
861
|
-
continue
|
|
862
|
-
if end is not None and timestamp > end:
|
|
863
|
-
continue
|
|
864
|
-
filtered.append(entry)
|
|
865
|
-
return filtered
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
def _public_history_period(period):
|
|
869
|
-
return {
|
|
870
|
-
"from": period.get("from"),
|
|
871
|
-
"to": period.get("to"),
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
def _parse_history_args(args, now=None):
|
|
876
|
-
now = now or datetime.now().astimezone()
|
|
877
|
-
parsed = _parse_flag_args(args, {
|
|
878
|
-
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
879
|
-
"--summary": {"key": "summary", "type": "bool", "default": False},
|
|
880
|
-
"--since": {"key": "since", "type": "str", "default": None},
|
|
881
|
-
"--from": {"key": "from", "type": "str", "default": None},
|
|
882
|
-
"--to": {"key": "to", "type": "str", "default": None},
|
|
883
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
884
|
-
}, HISTORY_USAGE, positionals_key="names", max_positionals=1)
|
|
885
|
-
period = _parse_history_period(parsed, now)
|
|
886
|
-
return {
|
|
887
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
888
|
-
"limit": parsed["limit"],
|
|
889
|
-
"summary": parsed["summary"],
|
|
890
|
-
"period": period,
|
|
891
|
-
"json": parsed["json"],
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
def _parse_stats_args(args, now=None):
|
|
896
|
-
now = now or datetime.now().astimezone()
|
|
897
|
-
parsed = _parse_flag_args(args, {
|
|
898
|
-
"--since": {"key": "since", "type": "str", "default": None},
|
|
899
|
-
"--from": {"key": "from", "type": "str", "default": None},
|
|
900
|
-
"--to": {"key": "to", "type": "str", "default": None},
|
|
901
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
902
|
-
}, STATS_USAGE, positionals_key="names", max_positionals=1)
|
|
903
|
-
period = _parse_history_period(parsed, now)
|
|
904
|
-
return {
|
|
905
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
906
|
-
"period": period,
|
|
907
|
-
"json": parsed["json"],
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
def _read_option_value(args, index, usage):
|
|
912
|
-
if index + 1 >= len(args):
|
|
913
|
-
raise CdxError(usage)
|
|
914
|
-
return args[index + 1], index + 2
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
def _parse_session_names(value):
|
|
918
|
-
if value is None:
|
|
919
|
-
return None
|
|
920
|
-
names = [item.strip() for item in value.split(",") if item.strip()]
|
|
921
|
-
if not names:
|
|
922
|
-
raise CdxError("At least one session name is required in --sessions.")
|
|
923
|
-
return names
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
def _parse_set_unset_sessions(value):
|
|
927
|
-
text = str(value or "").strip()
|
|
928
|
-
if not text:
|
|
929
|
-
raise CdxError("At least one session name is required in --sessions.")
|
|
930
|
-
if text.lower() == "all":
|
|
931
|
-
return "all"
|
|
932
|
-
return _parse_session_names(text)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
def _parse_provider_filter(value, usage):
|
|
936
|
-
provider = str(value or "").strip()
|
|
937
|
-
if provider not in PROVIDERS:
|
|
938
|
-
raise CdxError(usage)
|
|
939
|
-
return provider
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
def _parse_launch_setting_target(value, usage):
|
|
943
|
-
text = str(value or "").strip()
|
|
944
|
-
if not text:
|
|
945
|
-
raise CdxError(usage)
|
|
946
|
-
if text == "all":
|
|
947
|
-
return {"name": None, "sessions": "all", "provider": None}
|
|
948
|
-
if text.startswith("provider:"):
|
|
949
|
-
return {"name": None, "sessions": None, "provider": _parse_provider_filter(text.split(":", 1)[1], usage)}
|
|
950
|
-
if "," in text:
|
|
951
|
-
return {"name": None, "sessions": _parse_session_names(text), "provider": None}
|
|
952
|
-
return {"name": text, "sessions": None, "provider": None}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
def _parse_launch_setting_alias_args(kind, args):
|
|
956
|
-
json_flag, cleaned = _parse_json_flag(args)
|
|
957
|
-
if len(cleaned) != 2:
|
|
958
|
-
raise CdxError(SETTING_ALIAS_USAGE)
|
|
959
|
-
target = _parse_launch_setting_target(cleaned[0], SETTING_ALIAS_USAGE)
|
|
960
|
-
value = cleaned[1]
|
|
961
|
-
field = "permission" if kind == "perm" else kind
|
|
962
|
-
if value == "default":
|
|
963
|
-
return {**target, "keys": [field], "json": json_flag, "alias": kind}
|
|
964
|
-
if field == "fast":
|
|
965
|
-
value = _parse_fast_value(value)
|
|
966
|
-
return {**target, "settings": {field: value}, "json": json_flag, "alias": kind}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
def _parse_update_args(args):
|
|
970
|
-
parsed = _parse_flag_args(args, {
|
|
971
|
-
"--check": {"key": "check", "type": "bool", "default": False},
|
|
972
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
973
|
-
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
974
|
-
"--version": {"key": "version", "type": "str", "default": None},
|
|
975
|
-
}, UPDATE_USAGE)
|
|
976
|
-
if parsed["check"] and parsed["version"]:
|
|
977
|
-
raise CdxError("Usage: cdx update --check cannot be combined with --version.")
|
|
978
|
-
if parsed["version"] is not None and not parsed["version"].strip():
|
|
979
|
-
raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
|
|
980
|
-
return parsed
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
def _parse_export_args(args):
|
|
984
|
-
parsed = _parse_flag_args(args, {
|
|
985
|
-
"--include-auth": {"key": "include_auth", "type": "bool", "default": False},
|
|
986
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
987
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
988
|
-
"--sessions": {
|
|
989
|
-
"key": "session_names",
|
|
990
|
-
"type": "str",
|
|
991
|
-
"default": None,
|
|
992
|
-
"transform": _parse_session_names,
|
|
993
|
-
},
|
|
994
|
-
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
995
|
-
}, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
996
|
-
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
997
|
-
if not parsed["file_path"]:
|
|
998
|
-
raise CdxError(EXPORT_USAGE)
|
|
999
|
-
if parsed["passphrase_env"] and not parsed["include_auth"]:
|
|
1000
|
-
raise CdxError("--passphrase-env requires --include-auth for export.")
|
|
1001
|
-
return parsed
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
def _parse_import_args(args):
|
|
1005
|
-
parsed = _parse_flag_args(args, {
|
|
1006
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
1007
|
-
"--merge": {"key": "merge", "type": "bool", "default": False},
|
|
1008
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1009
|
-
"--sessions": {
|
|
1010
|
-
"key": "session_names",
|
|
1011
|
-
"type": "str",
|
|
1012
|
-
"default": None,
|
|
1013
|
-
"transform": _parse_session_names,
|
|
1014
|
-
},
|
|
1015
|
-
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
1016
|
-
}, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
1017
|
-
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
1018
|
-
if not parsed["file_path"]:
|
|
1019
|
-
raise CdxError(IMPORT_USAGE)
|
|
1020
|
-
if parsed["force"] and parsed["merge"]:
|
|
1021
|
-
raise CdxError("--force and --merge are mutually exclusive.")
|
|
1022
|
-
return parsed
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
110
|
def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
|
|
1026
111
|
env = ctx.get("env", {})
|
|
1027
112
|
if env_var:
|
|
@@ -1112,7 +197,7 @@ def _bootstrap_claude_setup_token(session, ctx):
|
|
|
1112
197
|
"Claude setup-token completed, but cdx could not capture the token. "
|
|
1113
198
|
"Run claude setup-token and save the token under credentials/default.json."
|
|
1114
199
|
)
|
|
1115
|
-
with open(transcript_path,
|
|
200
|
+
with open(transcript_path, encoding="utf-8", errors="replace") as handle:
|
|
1116
201
|
transcript = handle.read()
|
|
1117
202
|
token = _extract_claude_oauth_token(transcript)
|
|
1118
203
|
if not token:
|
|
@@ -1124,7 +209,14 @@ def _bootstrap_claude_setup_token(session, ctx):
|
|
|
1124
209
|
os.remove(transcript_path)
|
|
1125
210
|
except OSError:
|
|
1126
211
|
pass
|
|
1127
|
-
|
|
212
|
+
auth_home = session.get("authHome") or ""
|
|
213
|
+
cred_path = _write_claude_oauth_token(auth_home, token)
|
|
214
|
+
# ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
|
|
215
|
+
try:
|
|
216
|
+
os.remove(os.path.join(auth_home, ".claude", ".credentials.json"))
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
return cred_path
|
|
1128
220
|
|
|
1129
221
|
|
|
1130
222
|
def handle_add(rest, ctx):
|
|
@@ -1138,16 +230,20 @@ def handle_add(rest, ctx):
|
|
|
1138
230
|
if parsed.get("model"):
|
|
1139
231
|
session = ctx["service"]["set_launch_settings"](parsed["name"], {"model": parsed["model"]})
|
|
1140
232
|
message = f"Created session {parsed['name']} ({parsed['provider']})"
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
ctx
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
233
|
+
if session["provider"] == PROVIDER_CLAUDE and ctx["stdin_is_tty"]:
|
|
234
|
+
# ponytail: mint a ~1yr setup-token instead of the ~daily claude login token
|
|
235
|
+
_bootstrap_claude_setup_token(session, ctx)
|
|
236
|
+
else:
|
|
237
|
+
_ensure_session_authentication(
|
|
238
|
+
session,
|
|
239
|
+
ctx["service"],
|
|
240
|
+
spawn=ctx.get("spawn"),
|
|
241
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
242
|
+
env_override=ctx.get("env"),
|
|
243
|
+
stdin_is_tty=ctx["stdin_is_tty"],
|
|
244
|
+
behavior="bootstrap",
|
|
245
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
246
|
+
)
|
|
1151
247
|
now = _local_now_iso()
|
|
1152
248
|
ctx["service"]["update_auth_state"](parsed["name"], lambda auth: {
|
|
1153
249
|
**auth,
|
|
@@ -1354,22 +450,6 @@ def handle_next(rest, ctx):
|
|
|
1354
450
|
return 0
|
|
1355
451
|
|
|
1356
452
|
|
|
1357
|
-
def _parse_runs_args(rest):
|
|
1358
|
-
return _parse_flag_args(rest, {
|
|
1359
|
-
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
1360
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1361
|
-
}, RUNS_USAGE, max_positionals=0)
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
def _parse_run_id_json_args(rest, usage):
|
|
1365
|
-
parsed = _parse_flag_args(rest, {
|
|
1366
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1367
|
-
}, usage, positionals_key="ids", max_positionals=1)
|
|
1368
|
-
if not parsed["json"] or len(parsed["ids"]) != 1:
|
|
1369
|
-
raise CdxError(usage)
|
|
1370
|
-
return {"run_id": parsed["ids"][0], "json": True}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
453
|
def _run_registry(ctx):
|
|
1374
454
|
return RunRegistry(ctx["service"]["base_dir"])
|
|
1375
455
|
|
|
@@ -2595,7 +1675,7 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
|
|
|
2595
1675
|
env_override=env_override,
|
|
2596
1676
|
)
|
|
2597
1677
|
now = _local_now_iso()
|
|
2598
|
-
service["update_auth_state"](session["name"], lambda auth: {
|
|
1678
|
+
service["update_auth_state"](session["name"], lambda auth, authenticated=authenticated, now=now: {
|
|
2599
1679
|
**auth,
|
|
2600
1680
|
"status": "authenticated" if authenticated else "logged_out",
|
|
2601
1681
|
"lastCheckedAt": now,
|
|
@@ -2740,6 +1820,7 @@ def handle_import(rest, ctx):
|
|
|
2740
1820
|
|
|
2741
1821
|
|
|
2742
1822
|
def handle_login(rest, ctx):
|
|
1823
|
+
rest = [arg for arg in rest if arg != "--setup-token"] # accepted for back-compat; setup-token is now the default for Claude
|
|
2743
1824
|
json_flag, args = _parse_json_flag(rest)
|
|
2744
1825
|
if len(args) != 1:
|
|
2745
1826
|
raise CdxError("Usage: cdx login <name> [--json]")
|
|
@@ -2748,10 +1829,14 @@ def handle_login(rest, ctx):
|
|
|
2748
1829
|
session = ctx["service"]["get_session"](args[0])
|
|
2749
1830
|
if not session:
|
|
2750
1831
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
1832
|
+
if session["provider"] == PROVIDER_CLAUDE:
|
|
1833
|
+
# ponytail: setup-token mints a ~1yr token; claude login mints a ~daily one that forces reconnects
|
|
1834
|
+
_bootstrap_claude_setup_token(session, ctx)
|
|
1835
|
+
else:
|
|
1836
|
+
_run_interactive_provider_command(
|
|
1837
|
+
session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
1838
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
1839
|
+
)
|
|
2755
1840
|
auth_probe = _ensure_session_authentication(
|
|
2756
1841
|
session,
|
|
2757
1842
|
ctx["service"],
|
|
@@ -2760,16 +1845,6 @@ def handle_login(rest, ctx):
|
|
|
2760
1845
|
behavior="probe-only",
|
|
2761
1846
|
trust_local_credentials=False,
|
|
2762
1847
|
)
|
|
2763
|
-
if not auth_probe.get("authenticated") and session["provider"] == PROVIDER_CLAUDE:
|
|
2764
|
-
_bootstrap_claude_setup_token(session, ctx)
|
|
2765
|
-
auth_probe = _ensure_session_authentication(
|
|
2766
|
-
session,
|
|
2767
|
-
ctx["service"],
|
|
2768
|
-
spawn_sync=ctx.get("spawn_sync"),
|
|
2769
|
-
env_override=ctx.get("env"),
|
|
2770
|
-
behavior="probe-only",
|
|
2771
|
-
trust_local_credentials=False,
|
|
2772
|
-
)
|
|
2773
1848
|
if not auth_probe.get("authenticated"):
|
|
2774
1849
|
raise CdxError(
|
|
2775
1850
|
f"Login command completed, but session {session['name']} is still not authenticated."
|
|
@@ -2985,11 +2060,33 @@ def handle_resume(rest, ctx):
|
|
|
2985
2060
|
return handle_launch(args[0], ctx, resume=True, force_json=json_flag)
|
|
2986
2061
|
|
|
2987
2062
|
|
|
2063
|
+
def _warn_if_session_already_running(name, ctx):
|
|
2064
|
+
# ponytail: concurrent sessions on one profile share its single OAuth
|
|
2065
|
+
# .credentials.json; both refresh near token expiry and the rotated refresh
|
|
2066
|
+
# token invalidates the other session -> surprise logout. Warn before adding
|
|
2067
|
+
# a second live session on the same profile.
|
|
2068
|
+
active = ctx["service"].get("active_session_runtime")
|
|
2069
|
+
runtime = active(name) if active else None
|
|
2070
|
+
if not runtime:
|
|
2071
|
+
return
|
|
2072
|
+
pid = runtime.get("pid")
|
|
2073
|
+
ctx["out"](f"{_warn(f'A session named {name} is already running (pid {pid}).', ctx['use_color'])}\n")
|
|
2074
|
+
ctx["out"](
|
|
2075
|
+
f"{_dim('Two sessions on one profile share its login and can log each other out. Use a different profile instead.', ctx['use_color'])}\n"
|
|
2076
|
+
)
|
|
2077
|
+
if not ctx["stdin_is_tty"]:
|
|
2078
|
+
return
|
|
2079
|
+
answer = input(f"Launch a second {name} session anyway? [y/N] ")
|
|
2080
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
2081
|
+
raise CdxError("Launch cancelled.")
|
|
2082
|
+
|
|
2083
|
+
|
|
2988
2084
|
def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None):
|
|
2989
2085
|
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
2990
2086
|
if force_json is not None:
|
|
2991
2087
|
json_flag = force_json
|
|
2992
2088
|
warnings = _update_notice_warnings(ctx)
|
|
2089
|
+
_warn_if_session_already_running(command, ctx)
|
|
2993
2090
|
session = ctx["service"]["launch_session"](command)
|
|
2994
2091
|
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
2995
2092
|
if capability and not capability["resumable"]:
|