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
package/src/session_service.py
CHANGED
|
@@ -1,89 +1,128 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session service: session CRUD, launch settings, runtime, auth, and assembly.
|
|
3
|
+
|
|
4
|
+
`create_session_service` returns the interface dict every caller uses; the
|
|
5
|
+
functions behind it live here, or in one of the modules this re-exports from.
|
|
6
|
+
|
|
7
|
+
Why the split stops here. `req_027` measured the coupling between the six
|
|
8
|
+
concern groups by transitive closure and cut only where it was real:
|
|
9
|
+
|
|
10
|
+
group entry lines helpers reached only from it
|
|
11
|
+
status 264 16 -> src/session_status.py
|
|
12
|
+
backup 179 9 -> src/session_backup.py
|
|
13
|
+
crud 235 3 stays
|
|
14
|
+
settings 58 1 stays
|
|
15
|
+
runtime 115 0 stays
|
|
16
|
+
auth 10 0 stays
|
|
17
|
+
|
|
18
|
+
`runtime` and `auth` have **no** helper of their own. Made into modules they
|
|
19
|
+
would be thin wrappers over imports from here, which reads worse than leaving
|
|
20
|
+
them in place; `crud` is the core the others build on. If you are about to
|
|
21
|
+
finish the job by splitting the remaining four, re-measure first - that is the
|
|
22
|
+
evidence that says not to.
|
|
23
|
+
|
|
24
|
+
The `# noqa: F401` markers below are load-bearing: without them ruff removes
|
|
25
|
+
the re-exports as unused and callers importing through this module break.
|
|
26
|
+
`test_session_service_facade_still_exposes_every_name_its_callers_import`
|
|
27
|
+
guards that.
|
|
28
|
+
"""
|
|
3
29
|
import json
|
|
4
30
|
import os
|
|
5
31
|
import shutil
|
|
6
|
-
import sys
|
|
7
32
|
import tempfile
|
|
8
33
|
import uuid
|
|
9
|
-
from
|
|
10
|
-
from datetime import datetime, timezone
|
|
11
|
-
from urllib.parse import quote
|
|
34
|
+
from functools import partial
|
|
12
35
|
|
|
13
|
-
from .backup_bundle import decode_bundle, encode_bundle
|
|
14
|
-
from .claude_usage import _decode_jwt_claims
|
|
15
36
|
from .codex_usage import fetch_codex_rate_limits
|
|
16
|
-
from .config import
|
|
37
|
+
from .config import (
|
|
38
|
+
MAX_LAUNCH_BUDGET_USD,
|
|
39
|
+
MAX_LAUNCH_EXTRA_ARGS_LENGTH,
|
|
40
|
+
PERMISSION_INPUT_VALUES,
|
|
41
|
+
PERMISSION_VALUES,
|
|
42
|
+
PROVIDER_CLAUDE,
|
|
43
|
+
PROVIDER_CODEX,
|
|
44
|
+
REASONING_EFFORT_VALUES,
|
|
45
|
+
get_cdx_home,
|
|
46
|
+
normalize_permission,
|
|
47
|
+
split_extra_args,
|
|
48
|
+
)
|
|
17
49
|
from .errors import CdxError
|
|
18
50
|
from .fs_utils import atomic_write, remove_tree
|
|
51
|
+
from .session_backup import ( # noqa: F401 (re-exported for callers and tests)
|
|
52
|
+
_FORCE_IMPORT_PRESERVED_PROFILE_PATHS,
|
|
53
|
+
_auth_bundle_paths,
|
|
54
|
+
_build_export_session_record,
|
|
55
|
+
_collect_auth_files,
|
|
56
|
+
_force_import_preserved_profile_paths,
|
|
57
|
+
_resolve_session_subset,
|
|
58
|
+
_restore_force_import_profile_paths,
|
|
59
|
+
_restore_import_backup,
|
|
60
|
+
_safe_relpath,
|
|
61
|
+
_validate_import_profile_files,
|
|
62
|
+
export_bundle,
|
|
63
|
+
import_bundle,
|
|
64
|
+
)
|
|
65
|
+
from .session_helpers import ( # noqa: F401 (re-exported for src/__init__.py, callers and tests)
|
|
66
|
+
ALLOWED_PROVIDERS,
|
|
67
|
+
DEFAULT_PROVIDER,
|
|
68
|
+
MAX_SESSION_NAME_LENGTH,
|
|
69
|
+
RESERVED_SESSION_NAMES,
|
|
70
|
+
_encode,
|
|
71
|
+
_ensure_private_dir,
|
|
72
|
+
_get_global_codex_home,
|
|
73
|
+
_get_session_auth_home,
|
|
74
|
+
_get_session_root,
|
|
75
|
+
_local_now_iso,
|
|
76
|
+
_normalize_provider,
|
|
77
|
+
_process_is_running,
|
|
78
|
+
_runtime_is_active,
|
|
79
|
+
_session_runtime,
|
|
80
|
+
_validate_new_session_name,
|
|
81
|
+
list_sessions,
|
|
82
|
+
)
|
|
83
|
+
from .session_status import ( # noqa: F401 (re-exported for callers and tests)
|
|
84
|
+
CLAUDE_STATUS_CACHE_TTL_SECONDS,
|
|
85
|
+
CODEX_STATUS_CACHE_TTL_SECONDS,
|
|
86
|
+
MAX_STATUS_WORKERS,
|
|
87
|
+
STATUS_CACHE_TTL_SECONDS,
|
|
88
|
+
_compute_available_pct,
|
|
89
|
+
_fetch_codex_status,
|
|
90
|
+
_is_low_confidence_status_source,
|
|
91
|
+
_is_status_cache_fresh,
|
|
92
|
+
_is_status_newer,
|
|
93
|
+
_merge_status_payload,
|
|
94
|
+
_normalize_pct_value,
|
|
95
|
+
_normalize_status_payload,
|
|
96
|
+
_parse_status_timestamp,
|
|
97
|
+
_read_expected_account_email,
|
|
98
|
+
_resolve_row_session,
|
|
99
|
+
_resolve_session_status,
|
|
100
|
+
_row_priority,
|
|
101
|
+
_row_reasoning_effort,
|
|
102
|
+
_status_cache_hit,
|
|
103
|
+
_status_cache_ttl_seconds,
|
|
104
|
+
_status_has_more_detail,
|
|
105
|
+
_status_row_from_session,
|
|
106
|
+
_to_local_iso,
|
|
107
|
+
format_list_rows,
|
|
108
|
+
get_status_row,
|
|
109
|
+
get_status_rows,
|
|
110
|
+
record_status,
|
|
111
|
+
)
|
|
19
112
|
from .session_store import create_session_store
|
|
20
|
-
from .status_source import
|
|
113
|
+
from .status_source import find_latest_codex_conversation_id
|
|
21
114
|
|
|
22
|
-
DEFAULT_PROVIDER = PROVIDER_CODEX
|
|
23
|
-
ALLOWED_PROVIDERS = set(PROVIDERS)
|
|
24
|
-
MAX_SESSION_NAME_LENGTH = 64
|
|
25
115
|
MAX_SESSION_LABEL_LENGTH = 64
|
|
26
|
-
RESERVED_SESSION_NAMES = {
|
|
27
|
-
"add",
|
|
28
|
-
"can-resume",
|
|
29
|
-
"clean",
|
|
30
|
-
"context",
|
|
31
|
-
"configs",
|
|
32
|
-
"cp",
|
|
33
|
-
"disable",
|
|
34
|
-
"disk",
|
|
35
|
-
"doctor",
|
|
36
|
-
"enable",
|
|
37
|
-
"export",
|
|
38
|
-
"fast",
|
|
39
|
-
"help",
|
|
40
|
-
"handoff",
|
|
41
|
-
"history",
|
|
42
|
-
"import",
|
|
43
|
-
"last",
|
|
44
|
-
"label",
|
|
45
|
-
"login",
|
|
46
|
-
"logout",
|
|
47
|
-
"memory",
|
|
48
|
-
"model",
|
|
49
|
-
"mv",
|
|
50
|
-
"next",
|
|
51
|
-
"notify",
|
|
52
|
-
"perm",
|
|
53
|
-
"power",
|
|
54
|
-
"ready",
|
|
55
|
-
"repair",
|
|
56
|
-
"reset",
|
|
57
|
-
"resume",
|
|
58
|
-
"ren",
|
|
59
|
-
"rename",
|
|
60
|
-
"rmv",
|
|
61
|
-
"run",
|
|
62
|
-
"select",
|
|
63
|
-
"config",
|
|
64
|
-
"set",
|
|
65
|
-
"stats",
|
|
66
|
-
"status",
|
|
67
|
-
"unset",
|
|
68
|
-
"update",
|
|
69
|
-
"view",
|
|
70
|
-
"version",
|
|
71
|
-
"--help",
|
|
72
|
-
"-h",
|
|
73
|
-
"--version",
|
|
74
|
-
"-v",
|
|
75
|
-
}
|
|
76
|
-
STATUS_CACHE_TTL_SECONDS = 60
|
|
77
|
-
CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
|
|
78
116
|
# Each live Codex probe spawns app-server, which can refresh+rotate the OAuth
|
|
79
117
|
# token. Cache longer so routine status calls don't keep triggering refreshes.
|
|
80
|
-
CODEX_STATUS_CACHE_TTL_SECONDS = 5 * 60
|
|
81
|
-
_FORCE_IMPORT_PRESERVED_PROFILE_PATHS = ("plugins",)
|
|
82
118
|
STATUS_PROBE_TIMEOUT_SECONDS = 5
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
# References to the shared definitions in config, not copies. These used to be
|
|
120
|
+
# three separate literals here, validated independently of the ones `cdx run`
|
|
121
|
+
# uses, which is how `cdx set` and `cdx run` came to disagree about the same
|
|
122
|
+
# setting.
|
|
123
|
+
LAUNCH_POWER_VALUES = REASONING_EFFORT_VALUES
|
|
124
|
+
LAUNCH_REASONING_EFFORT_VALUES = REASONING_EFFORT_VALUES
|
|
125
|
+
LAUNCH_PERMISSION_VALUES = PERMISSION_VALUES
|
|
87
126
|
MAX_LAUNCH_MODEL_LENGTH = 128
|
|
88
127
|
MIN_LAUNCH_PRIORITY = 0
|
|
89
128
|
MAX_LAUNCH_PRIORITY = 100
|
|
@@ -93,25 +132,6 @@ DEFAULT_LAUNCH_SETTINGS = {
|
|
|
93
132
|
}
|
|
94
133
|
|
|
95
134
|
|
|
96
|
-
def _encode(name):
|
|
97
|
-
return quote(name, safe="")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _ensure_private_dir(path):
|
|
101
|
-
os.makedirs(path, exist_ok=True)
|
|
102
|
-
if sys.platform == "win32":
|
|
103
|
-
return
|
|
104
|
-
try:
|
|
105
|
-
os.chmod(path, 0o700)
|
|
106
|
-
except OSError:
|
|
107
|
-
pass
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _get_global_codex_home(env=None):
|
|
111
|
-
env = env or os.environ
|
|
112
|
-
return env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
113
|
-
|
|
114
|
-
|
|
115
135
|
def _seed_codex_auth_from_global(auth_home, env=None):
|
|
116
136
|
source_home = _get_global_codex_home(env)
|
|
117
137
|
source_auth = os.path.join(source_home, "auth.json")
|
|
@@ -137,9 +157,17 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
137
157
|
raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
|
|
138
158
|
normalized["reasoning_effort"] = effort
|
|
139
159
|
if "permission" in settings and settings["permission"] is not None:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
160
|
+
# Accepts the provider-native aliases (workspace-write, read-only,
|
|
161
|
+
# danger-full-access) exactly as `cdx run --permission` does, and stores
|
|
162
|
+
# the canonical value, so nothing downstream has to learn the aliases.
|
|
163
|
+
# They used to work on `run` only, so a value copied from one command to
|
|
164
|
+
# the other was rejected.
|
|
165
|
+
permission = normalize_permission(settings["permission"])
|
|
166
|
+
if permission is None:
|
|
167
|
+
allowed = "|".join(PERMISSION_INPUT_VALUES)
|
|
168
|
+
raise CdxError(
|
|
169
|
+
f"Unsupported permission: {settings['permission']} (allowed values: {allowed})"
|
|
170
|
+
)
|
|
143
171
|
normalized["permission"] = permission
|
|
144
172
|
if "fast" in settings and settings["fast"] is not None:
|
|
145
173
|
value = settings["fast"]
|
|
@@ -190,6 +218,40 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
190
218
|
if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
|
|
191
219
|
raise CdxError("Model contains unsupported characters.")
|
|
192
220
|
normalized["model"] = model
|
|
221
|
+
if "fallback_model" in settings and settings["fallback_model"] is not None:
|
|
222
|
+
# Claude accepts a comma-separated list here and tries each in order, so
|
|
223
|
+
# this validates every element rather than the string as one model name.
|
|
224
|
+
raw = str(settings["fallback_model"]).strip()
|
|
225
|
+
if not raw:
|
|
226
|
+
raise CdxError("Fallback model cannot be empty.")
|
|
227
|
+
elements = [element.strip() for element in raw.split(",")]
|
|
228
|
+
if any(not element for element in elements):
|
|
229
|
+
raise CdxError("Fallback model cannot contain an empty entry.")
|
|
230
|
+
for element in elements:
|
|
231
|
+
if len(element) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in element):
|
|
232
|
+
raise CdxError("Fallback model contains unsupported characters.")
|
|
233
|
+
normalized["fallback_model"] = ",".join(elements)
|
|
234
|
+
if "extra_args" in settings and settings["extra_args"] is not None:
|
|
235
|
+
extra = str(settings["extra_args"]).strip()
|
|
236
|
+
if not extra:
|
|
237
|
+
raise CdxError("Extra args cannot be empty.")
|
|
238
|
+
if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in extra):
|
|
239
|
+
raise CdxError("Extra args contain unsupported characters.")
|
|
240
|
+
# Parsed here, not at launch: a string that cannot be split into argv
|
|
241
|
+
# should be rejected when it is set, not when a run fails days later.
|
|
242
|
+
if split_extra_args(extra) is None:
|
|
243
|
+
raise CdxError("Extra args are not a valid argument list (unbalanced quotes).")
|
|
244
|
+
normalized["extra_args"] = extra
|
|
245
|
+
if "budget" in settings and settings["budget"] is not None:
|
|
246
|
+
try:
|
|
247
|
+
budget = float(settings["budget"])
|
|
248
|
+
except (TypeError, ValueError) as error:
|
|
249
|
+
raise CdxError(f"Unsupported budget: {settings['budget']}") from error
|
|
250
|
+
# NaN fails every comparison below, so it has to be rejected explicitly
|
|
251
|
+
# or it would sail through the range check.
|
|
252
|
+
if budget != budget or budget <= 0 or budget > MAX_LAUNCH_BUDGET_USD:
|
|
253
|
+
raise CdxError(f"Unsupported budget: {settings['budget']}")
|
|
254
|
+
normalized["budget"] = budget
|
|
193
255
|
if "priority" in settings and settings["priority"] is not None:
|
|
194
256
|
try:
|
|
195
257
|
priority = int(settings["priority"])
|
|
@@ -201,102 +263,6 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
201
263
|
return normalized
|
|
202
264
|
|
|
203
265
|
|
|
204
|
-
def _local_now_iso():
|
|
205
|
-
return datetime.now().astimezone().isoformat()
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _process_is_running(pid):
|
|
209
|
-
try:
|
|
210
|
-
pid = int(pid)
|
|
211
|
-
except (TypeError, ValueError):
|
|
212
|
-
return False
|
|
213
|
-
if pid <= 0:
|
|
214
|
-
return False
|
|
215
|
-
try:
|
|
216
|
-
os.kill(pid, 0)
|
|
217
|
-
except ProcessLookupError:
|
|
218
|
-
return False
|
|
219
|
-
except PermissionError:
|
|
220
|
-
return True
|
|
221
|
-
except OSError:
|
|
222
|
-
return False
|
|
223
|
-
return True
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def _safe_relpath(path):
|
|
227
|
-
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
228
|
-
if (
|
|
229
|
-
not normalized
|
|
230
|
-
or normalized == "."
|
|
231
|
-
or normalized.startswith("/")
|
|
232
|
-
or (len(normalized) > 1 and normalized[1] == ":")
|
|
233
|
-
or normalized == ".."
|
|
234
|
-
or normalized.startswith("../")
|
|
235
|
-
):
|
|
236
|
-
raise CdxError("Bundle contains an unsafe file path.")
|
|
237
|
-
return normalized
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _to_local_iso(value):
|
|
241
|
-
if not value:
|
|
242
|
-
return value
|
|
243
|
-
try:
|
|
244
|
-
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
245
|
-
except (TypeError, ValueError):
|
|
246
|
-
return value
|
|
247
|
-
if parsed.tzinfo is None:
|
|
248
|
-
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
249
|
-
return parsed.astimezone().isoformat()
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _normalize_pct_value(value):
|
|
253
|
-
if value is None:
|
|
254
|
-
return None
|
|
255
|
-
try:
|
|
256
|
-
return max(0, min(100, round(float(value))))
|
|
257
|
-
except (TypeError, ValueError):
|
|
258
|
-
return None
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def _normalize_status_payload(payload=None):
|
|
262
|
-
if payload is None:
|
|
263
|
-
payload = {}
|
|
264
|
-
now = _local_now_iso()
|
|
265
|
-
return {
|
|
266
|
-
"usage_pct": _normalize_pct_value(payload.get("usage_pct")),
|
|
267
|
-
"remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
|
|
268
|
-
"remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
|
|
269
|
-
"credits": payload.get("credits"),
|
|
270
|
-
"reset_credits_available": payload.get("reset_credits_available"),
|
|
271
|
-
"reset_credits": payload.get("reset_credits"),
|
|
272
|
-
"reset_5h_at": payload.get("reset_5h_at"),
|
|
273
|
-
"reset_week_at": payload.get("reset_week_at"),
|
|
274
|
-
"reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
|
|
275
|
-
"updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
|
|
276
|
-
"raw_status_text": payload.get("raw_status_text"),
|
|
277
|
-
"source_ref": payload.get("source_ref"),
|
|
278
|
-
"structured": bool(payload.get("structured")),
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def _parse_status_timestamp(value):
|
|
283
|
-
if not value:
|
|
284
|
-
return None
|
|
285
|
-
try:
|
|
286
|
-
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
287
|
-
except (TypeError, ValueError):
|
|
288
|
-
return None
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
292
|
-
if ttl_seconds == STATUS_CACHE_TTL_SECONDS:
|
|
293
|
-
if session.get("provider") == PROVIDER_CLAUDE:
|
|
294
|
-
return CLAUDE_STATUS_CACHE_TTL_SECONDS
|
|
295
|
-
if session.get("provider") == PROVIDER_CODEX:
|
|
296
|
-
return CODEX_STATUS_CACHE_TTL_SECONDS
|
|
297
|
-
return ttl_seconds
|
|
298
|
-
|
|
299
|
-
|
|
300
266
|
def _parse_status_timeout_seconds(value):
|
|
301
267
|
if value in (None, ""):
|
|
302
268
|
return None
|
|
@@ -309,131 +275,6 @@ def _parse_status_timeout_seconds(value):
|
|
|
309
275
|
return parsed
|
|
310
276
|
|
|
311
277
|
|
|
312
|
-
def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
313
|
-
status = session.get("lastStatus") or {}
|
|
314
|
-
if _is_low_confidence_status_source(status):
|
|
315
|
-
return False
|
|
316
|
-
updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
|
|
317
|
-
if not updated_at:
|
|
318
|
-
return False
|
|
319
|
-
now = datetime.now(timezone.utc).astimezone()
|
|
320
|
-
ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
|
|
321
|
-
return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _is_status_newer(candidate, current):
|
|
325
|
-
if not candidate:
|
|
326
|
-
return False
|
|
327
|
-
if not current:
|
|
328
|
-
return True
|
|
329
|
-
candidate_at = _parse_status_timestamp(candidate.get("updated_at"))
|
|
330
|
-
current_at = _parse_status_timestamp(current.get("updated_at"))
|
|
331
|
-
if candidate_at and current_at:
|
|
332
|
-
return candidate_at > current_at
|
|
333
|
-
if candidate_at:
|
|
334
|
-
return True
|
|
335
|
-
return False
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _status_has_more_detail(candidate, current):
|
|
339
|
-
if not candidate:
|
|
340
|
-
return False
|
|
341
|
-
if not current:
|
|
342
|
-
return True
|
|
343
|
-
|
|
344
|
-
fields = [
|
|
345
|
-
"usage_pct",
|
|
346
|
-
"remaining_5h_pct",
|
|
347
|
-
"remaining_week_pct",
|
|
348
|
-
"credits",
|
|
349
|
-
"reset_credits_available",
|
|
350
|
-
"reset_credits",
|
|
351
|
-
"reset_5h_at",
|
|
352
|
-
"reset_week_at",
|
|
353
|
-
"reset_at",
|
|
354
|
-
"raw_status_text",
|
|
355
|
-
"source_ref",
|
|
356
|
-
]
|
|
357
|
-
return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _merge_status_payload(current, candidate):
|
|
361
|
-
if not current:
|
|
362
|
-
return candidate
|
|
363
|
-
if not candidate:
|
|
364
|
-
return current
|
|
365
|
-
|
|
366
|
-
merged = dict(current)
|
|
367
|
-
for field in [
|
|
368
|
-
"usage_pct",
|
|
369
|
-
"remaining_5h_pct",
|
|
370
|
-
"remaining_week_pct",
|
|
371
|
-
"credits",
|
|
372
|
-
"reset_credits_available",
|
|
373
|
-
"reset_credits",
|
|
374
|
-
"reset_5h_at",
|
|
375
|
-
"reset_week_at",
|
|
376
|
-
"reset_at",
|
|
377
|
-
"raw_status_text",
|
|
378
|
-
"source_ref",
|
|
379
|
-
]:
|
|
380
|
-
if merged.get(field) is None and candidate.get(field) is not None:
|
|
381
|
-
merged[field] = candidate[field]
|
|
382
|
-
|
|
383
|
-
# The structured marker qualifies the source_ref; keep them in sync when
|
|
384
|
-
# the merge adopts the candidate's ref, else a structured rollout ref
|
|
385
|
-
# would read as low-confidence scraped text.
|
|
386
|
-
if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
|
|
387
|
-
merged["structured"] = bool(candidate.get("structured"))
|
|
388
|
-
|
|
389
|
-
merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
|
|
390
|
-
return merged
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def _compute_available_pct(status):
|
|
394
|
-
if not status:
|
|
395
|
-
return None
|
|
396
|
-
values = [
|
|
397
|
-
_normalize_pct_value(status.get("remaining_5h_pct")),
|
|
398
|
-
_normalize_pct_value(status.get("remaining_week_pct")),
|
|
399
|
-
]
|
|
400
|
-
values = [value for value in values if value is not None]
|
|
401
|
-
if not values:
|
|
402
|
-
return None
|
|
403
|
-
return min(values)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _is_low_confidence_status_source(status):
|
|
407
|
-
if not status:
|
|
408
|
-
return False
|
|
409
|
-
if status.get("structured"):
|
|
410
|
-
# Exact rate_limits API data: trustworthy even when it comes from a
|
|
411
|
-
# sessions/rollout path, which otherwise flags scraped-text noise.
|
|
412
|
-
return False
|
|
413
|
-
source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
|
|
414
|
-
return "/sessions/" in source_ref and "/rollout" in source_ref
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def _read_expected_account_email(auth_home):
|
|
418
|
-
auth_path = os.path.join(auth_home, "auth.json")
|
|
419
|
-
try:
|
|
420
|
-
with open(auth_path, encoding="utf-8") as handle:
|
|
421
|
-
auth = json.load(handle)
|
|
422
|
-
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
423
|
-
return None
|
|
424
|
-
|
|
425
|
-
tokens = auth.get("tokens") or {}
|
|
426
|
-
for token_name in ("id_token", "access_token"):
|
|
427
|
-
claims = _decode_jwt_claims(tokens.get(token_name))
|
|
428
|
-
email = claims.get("email")
|
|
429
|
-
if not email and token_name == "access_token":
|
|
430
|
-
profile = claims.get("https://api.openai.com/profile") or {}
|
|
431
|
-
email = profile.get("email")
|
|
432
|
-
if email:
|
|
433
|
-
return str(email).strip().lower()
|
|
434
|
-
return None
|
|
435
|
-
|
|
436
|
-
|
|
437
278
|
def _ensure_claude_attribution_disabled(auth_home):
|
|
438
279
|
settings_dir = os.path.join(auth_home, ".claude")
|
|
439
280
|
settings_path = os.path.join(settings_dir, "settings.json")
|
|
@@ -455,1151 +296,582 @@ def _ensure_claude_attribution_disabled(auth_home):
|
|
|
455
296
|
return True
|
|
456
297
|
|
|
457
298
|
|
|
458
|
-
def
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def _get_session_root(name):
|
|
479
|
-
return os.path.join(base_dir, "profiles", _encode(name))
|
|
480
|
-
|
|
481
|
-
def _get_session_auth_home(name, provider):
|
|
482
|
-
root = _get_session_root(name)
|
|
483
|
-
if provider == PROVIDER_CLAUDE:
|
|
484
|
-
return os.path.join(root, "claude-home")
|
|
485
|
-
if provider == PROVIDER_ANTIGRAVITY:
|
|
486
|
-
return os.path.join(root, "antigravity-home")
|
|
487
|
-
return root
|
|
488
|
-
|
|
489
|
-
def _normalize_provider(provider):
|
|
490
|
-
value = provider or DEFAULT_PROVIDER
|
|
491
|
-
if value not in ALLOWED_PROVIDERS:
|
|
492
|
-
raise CdxError(f"Unsupported provider: {value}")
|
|
493
|
-
return value
|
|
494
|
-
|
|
495
|
-
def _runtime_is_active(runtime):
|
|
496
|
-
return (
|
|
497
|
-
isinstance(runtime, dict)
|
|
498
|
-
and runtime.get("status") == "running"
|
|
499
|
-
and _process_is_running(runtime.get("pid"))
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
def _session_runtime(name):
|
|
503
|
-
found = {}
|
|
504
|
-
|
|
505
|
-
def updater(state):
|
|
506
|
-
if not state:
|
|
507
|
-
return None
|
|
508
|
-
runtime = state.get("runtime")
|
|
509
|
-
if _runtime_is_active(runtime):
|
|
510
|
-
found["runtime"] = runtime
|
|
511
|
-
return None
|
|
512
|
-
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
513
|
-
return {
|
|
514
|
-
**state,
|
|
515
|
-
"status": "ready",
|
|
516
|
-
"runtime": {
|
|
517
|
-
**runtime,
|
|
518
|
-
"status": "stale",
|
|
519
|
-
"endedAt": _local_now_iso(),
|
|
520
|
-
},
|
|
521
|
-
}
|
|
522
|
-
return None
|
|
299
|
+
def _normalize_session_label(label):
|
|
300
|
+
text = str(label or "").strip()
|
|
301
|
+
if not text:
|
|
302
|
+
raise CdxError("Session label is required")
|
|
303
|
+
if len(text) > MAX_SESSION_LABEL_LENGTH:
|
|
304
|
+
raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
|
|
305
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
|
|
306
|
+
raise CdxError("Session label cannot contain control characters")
|
|
307
|
+
return text
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def set_launch_settings(store, name, settings):
|
|
311
|
+
session = store["get_session"](name)
|
|
312
|
+
if not session:
|
|
313
|
+
raise CdxError(f"Unknown session: {name}")
|
|
314
|
+
updates = _normalize_launch_settings(settings)
|
|
315
|
+
if not updates:
|
|
316
|
+
raise CdxError("At least one launch setting is required.")
|
|
317
|
+
now = _local_now_iso()
|
|
523
318
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
text = str(label or "").strip()
|
|
543
|
-
if not text:
|
|
544
|
-
raise CdxError("Session label is required")
|
|
545
|
-
if len(text) > MAX_SESSION_LABEL_LENGTH:
|
|
546
|
-
raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
|
|
547
|
-
if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
|
|
548
|
-
raise CdxError("Session label cannot contain control characters")
|
|
549
|
-
return text
|
|
550
|
-
|
|
551
|
-
def _build_export_session_record(session):
|
|
319
|
+
def updater(s):
|
|
320
|
+
# Build the new launch dict from the fresh record inside the store
|
|
321
|
+
# lock; a pre-lock snapshot loses a concurrent setting change.
|
|
322
|
+
current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
|
|
323
|
+
launch = {**current, **updates}
|
|
324
|
+
if "power" in updates:
|
|
325
|
+
launch.pop("reasoning_effort", None)
|
|
326
|
+
launch.pop("reasoningEffort", None)
|
|
327
|
+
if "reasoning_effort" in updates:
|
|
328
|
+
launch.pop("power", None)
|
|
329
|
+
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
330
|
+
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
331
|
+
launch["fast"] = False
|
|
332
|
+
launch.pop("fastMode", None)
|
|
333
|
+
if updates.get("fast") is False:
|
|
334
|
+
launch.pop("fastMode", None)
|
|
335
|
+
if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
|
|
336
|
+
launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
|
|
552
337
|
return {
|
|
553
|
-
|
|
554
|
-
"
|
|
555
|
-
"label": session.get("label"),
|
|
556
|
-
"enabled": session.get("enabled", True) is not False,
|
|
557
|
-
"createdAt": session.get("createdAt"),
|
|
558
|
-
"updatedAt": session.get("updatedAt"),
|
|
559
|
-
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
560
|
-
"lastStatusAt": session.get("lastStatusAt"),
|
|
561
|
-
"lastStatus": session.get("lastStatus"),
|
|
562
|
-
"launch": session.get("launch"),
|
|
563
|
-
"auth": session.get("auth"),
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
def _auth_bundle_paths(provider):
|
|
567
|
-
if provider == PROVIDER_CLAUDE:
|
|
568
|
-
return [
|
|
569
|
-
"claude-home/configs/default.json",
|
|
570
|
-
"claude-home/credentials/default.json",
|
|
571
|
-
"claude-home/.claude/.credentials.json",
|
|
572
|
-
"claude-home/.claude.json",
|
|
573
|
-
"claude-home/auth.json",
|
|
574
|
-
]
|
|
575
|
-
return [
|
|
576
|
-
"auth.json",
|
|
577
|
-
]
|
|
578
|
-
|
|
579
|
-
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
|
|
580
|
-
files = []
|
|
581
|
-
total_bytes = 0
|
|
582
|
-
if not os.path.isdir(session_root):
|
|
583
|
-
return {"files": files, "file_count": 0, "bytes": 0}
|
|
584
|
-
for rel_path in _auth_bundle_paths(provider):
|
|
585
|
-
full_path = os.path.join(session_root, rel_path)
|
|
586
|
-
if not os.path.isfile(full_path):
|
|
587
|
-
continue
|
|
588
|
-
with open(full_path, "rb") as handle:
|
|
589
|
-
raw_content = handle.read()
|
|
590
|
-
total_bytes += len(raw_content)
|
|
591
|
-
content = base64.b64encode(raw_content).decode("ascii")
|
|
592
|
-
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
593
|
-
if progress_callback:
|
|
594
|
-
progress_callback({
|
|
595
|
-
"event": "profile_progress",
|
|
596
|
-
"session_name": session_name,
|
|
597
|
-
"file_count": len(files),
|
|
598
|
-
"bytes": total_bytes,
|
|
599
|
-
})
|
|
600
|
-
return {"files": files, "file_count": len(files), "bytes": total_bytes}
|
|
601
|
-
|
|
602
|
-
def _resolve_session_subset(session_names):
|
|
603
|
-
if not session_names:
|
|
604
|
-
return list_sessions()
|
|
605
|
-
by_name = {session["name"]: session for session in list_sessions()}
|
|
606
|
-
selected = []
|
|
607
|
-
for name in session_names:
|
|
608
|
-
session = by_name.get(name)
|
|
609
|
-
if not session:
|
|
610
|
-
raise CdxError(f"Unknown session: {name}")
|
|
611
|
-
selected.append(session)
|
|
612
|
-
return selected
|
|
613
|
-
|
|
614
|
-
def create_session(name, provider=DEFAULT_PROVIDER):
|
|
615
|
-
_validate_new_session_name(name)
|
|
616
|
-
normalized_provider = _normalize_provider(provider)
|
|
617
|
-
session_root = _get_session_root(name)
|
|
618
|
-
auth_home = _get_session_auth_home(name, normalized_provider)
|
|
619
|
-
_ensure_private_dir(base_dir)
|
|
620
|
-
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
621
|
-
_ensure_private_dir(session_root)
|
|
622
|
-
_ensure_private_dir(auth_home)
|
|
623
|
-
if normalized_provider == PROVIDER_CODEX:
|
|
624
|
-
_seed_codex_auth_from_global(auth_home, env=env)
|
|
625
|
-
if normalized_provider == PROVIDER_CLAUDE:
|
|
626
|
-
_ensure_claude_attribution_disabled(auth_home)
|
|
627
|
-
now = _local_now_iso()
|
|
628
|
-
session = {
|
|
629
|
-
"name": name,
|
|
630
|
-
"provider": normalized_provider,
|
|
631
|
-
"enabled": True,
|
|
632
|
-
"sessionRoot": session_root,
|
|
633
|
-
"authHome": auth_home,
|
|
634
|
-
"createdAt": now,
|
|
635
|
-
"updatedAt": now,
|
|
636
|
-
"lastLaunchedAt": None,
|
|
637
|
-
"lastStatusAt": None,
|
|
638
|
-
"lastStatus": None,
|
|
639
|
-
"launch": dict(DEFAULT_LAUNCH_SETTINGS),
|
|
640
|
-
"auth": {
|
|
641
|
-
"status": "unknown",
|
|
642
|
-
"lastCheckedAt": None,
|
|
643
|
-
"lastAuthenticatedAt": None,
|
|
644
|
-
"lastLoggedOutAt": None,
|
|
645
|
-
},
|
|
646
|
-
}
|
|
647
|
-
result = store["add_session"](session)
|
|
648
|
-
if not result["ok"]:
|
|
649
|
-
raise CdxError(f"Session already exists: {name}")
|
|
650
|
-
return result["session"]
|
|
651
|
-
|
|
652
|
-
def remove_session(name):
|
|
653
|
-
session = store["get_session"](name)
|
|
654
|
-
if not session:
|
|
655
|
-
raise CdxError(f"Unknown session: {name}")
|
|
656
|
-
session_root = session.get("sessionRoot") or _get_session_root(name)
|
|
657
|
-
quarantine_root = None
|
|
658
|
-
if os.path.exists(session_root):
|
|
659
|
-
profiles_dir = os.path.dirname(session_root)
|
|
660
|
-
os.makedirs(profiles_dir, exist_ok=True)
|
|
661
|
-
quarantine_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.remove.", dir=profiles_dir)
|
|
662
|
-
os.rmdir(quarantine_root)
|
|
663
|
-
os.rename(session_root, quarantine_root)
|
|
664
|
-
try:
|
|
665
|
-
removed = store["remove_session"](name)
|
|
666
|
-
except Exception:
|
|
667
|
-
if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
|
|
668
|
-
os.rename(quarantine_root, session_root)
|
|
669
|
-
raise
|
|
670
|
-
if not removed:
|
|
671
|
-
if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
|
|
672
|
-
os.rename(quarantine_root, session_root)
|
|
673
|
-
raise CdxError(f"Unknown session: {name}")
|
|
674
|
-
if quarantine_root:
|
|
675
|
-
try:
|
|
676
|
-
remove_tree(quarantine_root)
|
|
677
|
-
except OSError as error:
|
|
678
|
-
raise CdxError(
|
|
679
|
-
f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
|
|
680
|
-
) from error
|
|
681
|
-
return removed
|
|
682
|
-
|
|
683
|
-
def copy_session(source_name, dest_name):
|
|
684
|
-
if source_name == dest_name:
|
|
685
|
-
raise CdxError("Source and destination session names must be different")
|
|
686
|
-
_validate_new_session_name(dest_name)
|
|
687
|
-
source = store["get_session"](source_name)
|
|
688
|
-
if not source:
|
|
689
|
-
raise CdxError(f"Unknown session: {source_name}")
|
|
690
|
-
existing = store["get_session"](dest_name)
|
|
691
|
-
overwritten = False
|
|
692
|
-
source_root = source.get("sessionRoot") or _get_session_root(source_name)
|
|
693
|
-
dest_root = _get_session_root(dest_name)
|
|
694
|
-
if not existing and os.path.exists(dest_root):
|
|
695
|
-
raise CdxError(f"Session profile already exists: {dest_name}")
|
|
696
|
-
dest_auth_home = _get_session_auth_home(dest_name, source["provider"])
|
|
697
|
-
profiles_dir = os.path.dirname(dest_root)
|
|
698
|
-
os.makedirs(profiles_dir, exist_ok=True)
|
|
699
|
-
temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
|
|
700
|
-
temp_root = os.path.join(temp_parent, "profile")
|
|
701
|
-
backup_root = None
|
|
702
|
-
moved_temp = False
|
|
703
|
-
now = _local_now_iso()
|
|
704
|
-
replacement = {
|
|
705
|
-
"name": dest_name,
|
|
706
|
-
"provider": source["provider"],
|
|
707
|
-
"enabled": True,
|
|
708
|
-
"sessionRoot": dest_root,
|
|
709
|
-
"authHome": dest_auth_home,
|
|
710
|
-
"createdAt": now,
|
|
338
|
+
**s,
|
|
339
|
+
"launch": launch,
|
|
711
340
|
"updatedAt": now,
|
|
712
|
-
"lastLaunchedAt": None,
|
|
713
|
-
"lastStatusAt": None,
|
|
714
|
-
"lastStatus": None,
|
|
715
|
-
**({"launch": source.get("launch")} if source.get("launch") else {}),
|
|
716
|
-
**({"label": source.get("label")} if source.get("label") else {}),
|
|
717
|
-
"auth": {
|
|
718
|
-
"status": "unknown",
|
|
719
|
-
"lastCheckedAt": None,
|
|
720
|
-
"lastAuthenticatedAt": None,
|
|
721
|
-
"lastLoggedOutAt": None,
|
|
722
|
-
},
|
|
723
341
|
}
|
|
724
|
-
try:
|
|
725
|
-
shutil.copytree(source_root, temp_root)
|
|
726
|
-
if existing:
|
|
727
|
-
backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
|
|
728
|
-
os.rmdir(backup_root)
|
|
729
|
-
if os.path.exists(dest_root):
|
|
730
|
-
os.rename(dest_root, backup_root)
|
|
731
|
-
os.rename(temp_root, dest_root)
|
|
732
|
-
moved_temp = True
|
|
733
|
-
result = store["replace_session"](dest_name, replacement)
|
|
734
|
-
overwritten = bool(existing)
|
|
735
|
-
except Exception:
|
|
736
|
-
if moved_temp and os.path.exists(dest_root):
|
|
737
|
-
remove_tree(dest_root, ignore_errors=True)
|
|
738
|
-
if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
|
|
739
|
-
os.rename(backup_root, dest_root)
|
|
740
|
-
raise
|
|
741
|
-
finally:
|
|
742
|
-
if backup_root and os.path.exists(backup_root):
|
|
743
|
-
remove_tree(backup_root, ignore_errors=True)
|
|
744
|
-
remove_tree(temp_parent, ignore_errors=True)
|
|
745
|
-
if not result["ok"]:
|
|
746
|
-
raise CdxError(f"Failed to create session: {dest_name}")
|
|
747
|
-
return {"session": result["session"], "overwritten": overwritten}
|
|
748
|
-
|
|
749
|
-
def rename_session(source_name, dest_name):
|
|
750
|
-
if source_name == dest_name:
|
|
751
|
-
raise CdxError("Source and destination session names must be different")
|
|
752
|
-
_validate_new_session_name(dest_name)
|
|
753
|
-
source = store["get_session"](source_name)
|
|
754
|
-
if not source:
|
|
755
|
-
raise CdxError(f"Unknown session: {source_name}")
|
|
756
|
-
if store["get_session"](dest_name):
|
|
757
|
-
raise CdxError(f"Session already exists: {dest_name}")
|
|
758
342
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
343
|
+
return store["update_session"](name, updater)
|
|
344
|
+
|
|
345
|
+
def unset_launch_settings(store, name, keys):
|
|
346
|
+
session = store["get_session"](name)
|
|
347
|
+
if not session:
|
|
348
|
+
raise CdxError(f"Unknown session: {name}")
|
|
349
|
+
if not keys:
|
|
350
|
+
raise CdxError("At least one launch setting is required.")
|
|
351
|
+
allowed = {
|
|
352
|
+
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
|
|
353
|
+
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
354
|
+
}
|
|
355
|
+
unknown = [key for key in keys if key not in allowed]
|
|
356
|
+
if unknown:
|
|
357
|
+
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
358
|
+
now = _local_now_iso()
|
|
763
359
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
360
|
+
def updater(s):
|
|
361
|
+
current = dict(s.get("launch") or {})
|
|
362
|
+
for key in keys:
|
|
363
|
+
current.pop(key, None)
|
|
364
|
+
updated = {**s, "updatedAt": now}
|
|
365
|
+
if current:
|
|
366
|
+
updated["launch"] = current
|
|
767
367
|
else:
|
|
768
|
-
|
|
368
|
+
updated.pop("launch", None)
|
|
369
|
+
return updated
|
|
769
370
|
|
|
770
|
-
|
|
771
|
-
try:
|
|
772
|
-
result = store["rename_session"](source_name, dest_name, lambda s: {
|
|
773
|
-
**s,
|
|
774
|
-
"name": dest_name,
|
|
775
|
-
"sessionRoot": dest_root,
|
|
776
|
-
"authHome": _get_session_auth_home(dest_name, s["provider"]),
|
|
777
|
-
"updatedAt": now,
|
|
778
|
-
})
|
|
779
|
-
except Exception:
|
|
780
|
-
if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
|
|
781
|
-
os.rename(dest_root, source_root)
|
|
782
|
-
raise
|
|
783
|
-
|
|
784
|
-
if not result["ok"]:
|
|
785
|
-
if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
|
|
786
|
-
os.rename(dest_root, source_root)
|
|
787
|
-
if result["reason"] == "exists":
|
|
788
|
-
raise CdxError(f"Session already exists: {dest_name}")
|
|
789
|
-
raise CdxError(f"Unknown session: {source_name}")
|
|
790
|
-
return result["session"]
|
|
791
|
-
|
|
792
|
-
def launch_session(name):
|
|
793
|
-
session = store["get_session"](name)
|
|
794
|
-
if not session:
|
|
795
|
-
raise CdxError(f"Unknown session: {name}")
|
|
796
|
-
if session.get("enabled", True) is False:
|
|
797
|
-
raise CdxError(f"Session is disabled: {name}")
|
|
798
|
-
now = _local_now_iso()
|
|
799
|
-
|
|
800
|
-
def state_updater(state):
|
|
801
|
-
if not state:
|
|
802
|
-
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
803
|
-
return {**state, "rehydratedAt": now}
|
|
804
|
-
|
|
805
|
-
store["update_session_state"](name, state_updater)
|
|
806
|
-
return store["update_session"](name, lambda s: {
|
|
807
|
-
**s, "updatedAt": now, "lastLaunchedAt": now
|
|
808
|
-
})
|
|
371
|
+
return store["update_session"](name, updater)
|
|
809
372
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
373
|
+
def update_auth_state(store, name, updater):
|
|
374
|
+
now = _local_now_iso()
|
|
375
|
+
updated = store["update_session"](name, lambda s: {
|
|
376
|
+
**s,
|
|
377
|
+
"updatedAt": now,
|
|
378
|
+
"auth": updater(s.get("auth") or {}),
|
|
379
|
+
})
|
|
380
|
+
if not updated:
|
|
381
|
+
raise CdxError(f"Unknown session: {name}")
|
|
382
|
+
return updated
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def get_session(store, name):
|
|
386
|
+
return store["get_session"](name)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def ensure_session_state(store, name):
|
|
390
|
+
session = store["get_session"](name)
|
|
391
|
+
if not session:
|
|
392
|
+
raise CdxError(f"Unknown session: {name}")
|
|
393
|
+
result = {}
|
|
394
|
+
|
|
395
|
+
def updater(state):
|
|
396
|
+
if state:
|
|
397
|
+
result["state"] = state
|
|
398
|
+
return None
|
|
399
|
+
repaired = {
|
|
400
|
+
"provider": session["provider"],
|
|
401
|
+
"status": "ready",
|
|
402
|
+
"rehydratedAt": None,
|
|
824
403
|
}
|
|
404
|
+
result["state"] = repaired
|
|
405
|
+
return repaired
|
|
825
406
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
829
|
-
return {
|
|
830
|
-
**state,
|
|
831
|
-
"status": "running",
|
|
832
|
-
"runtime": runtime,
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
store["update_session_state"](name, updater)
|
|
836
|
-
return runtime
|
|
837
|
-
|
|
838
|
-
def finish_session_runtime(name, run_id=None, payload=None):
|
|
839
|
-
session = store["get_session"](name)
|
|
840
|
-
if not session:
|
|
841
|
-
raise CdxError(f"Unknown session: {name}")
|
|
842
|
-
payload = dict(payload or {})
|
|
843
|
-
outcome = {}
|
|
844
|
-
|
|
845
|
-
def updater(state):
|
|
846
|
-
if not state:
|
|
847
|
-
return None
|
|
848
|
-
runtime = state.get("runtime") or {}
|
|
849
|
-
if run_id and runtime.get("runId") != run_id:
|
|
850
|
-
outcome["runtime"] = runtime
|
|
851
|
-
return None
|
|
852
|
-
updated_runtime = {
|
|
853
|
-
**runtime,
|
|
854
|
-
"status": payload.get("status") or "stopped",
|
|
855
|
-
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
856
|
-
}
|
|
857
|
-
if "returncode" in payload:
|
|
858
|
-
updated_runtime["returncode"] = payload["returncode"]
|
|
859
|
-
outcome["runtime"] = updated_runtime
|
|
860
|
-
return {
|
|
861
|
-
**state,
|
|
862
|
-
"status": "ready",
|
|
863
|
-
"runtime": updated_runtime,
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
store["update_session_state"](name, updater)
|
|
867
|
-
return outcome.get("runtime")
|
|
868
|
-
|
|
869
|
-
def ensure_session_state(name):
|
|
870
|
-
session = store["get_session"](name)
|
|
871
|
-
if not session:
|
|
872
|
-
raise CdxError(f"Unknown session: {name}")
|
|
873
|
-
result = {}
|
|
874
|
-
|
|
875
|
-
def updater(state):
|
|
876
|
-
if state:
|
|
877
|
-
result["state"] = state
|
|
878
|
-
return None
|
|
879
|
-
repaired = {
|
|
880
|
-
"provider": session["provider"],
|
|
881
|
-
"status": "ready",
|
|
882
|
-
"rehydratedAt": None,
|
|
883
|
-
}
|
|
884
|
-
result["state"] = repaired
|
|
885
|
-
return repaired
|
|
886
|
-
|
|
887
|
-
store["update_session_state"](name, updater)
|
|
888
|
-
return result["state"]
|
|
889
|
-
|
|
890
|
-
def list_sessions():
|
|
891
|
-
return store["list_sessions"]()
|
|
892
|
-
|
|
893
|
-
def get_session(name):
|
|
894
|
-
return store["get_session"](name)
|
|
895
|
-
|
|
896
|
-
def set_session_enabled(name, enabled):
|
|
897
|
-
session = store["get_session"](name)
|
|
898
|
-
if not session:
|
|
899
|
-
raise CdxError(f"Unknown session: {name}")
|
|
900
|
-
now = _local_now_iso()
|
|
901
|
-
return store["update_session"](name, lambda s: {
|
|
902
|
-
**s,
|
|
903
|
-
"enabled": bool(enabled),
|
|
904
|
-
"updatedAt": now,
|
|
905
|
-
})
|
|
407
|
+
store["update_session_state"](name, updater)
|
|
408
|
+
return result["state"]
|
|
906
409
|
|
|
907
|
-
def set_session_label(name, label):
|
|
908
|
-
session = store["get_session"](name)
|
|
909
|
-
if not session:
|
|
910
|
-
raise CdxError(f"Unknown session: {name}")
|
|
911
|
-
normalized = _normalize_session_label(label)
|
|
912
|
-
now = _local_now_iso()
|
|
913
|
-
return store["update_session"](name, lambda s: {
|
|
914
|
-
**s,
|
|
915
|
-
"label": normalized,
|
|
916
|
-
"updatedAt": now,
|
|
917
|
-
})
|
|
918
410
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
return store["update_session"](name, updater)
|
|
931
|
-
|
|
932
|
-
def set_launch_settings(name, settings):
|
|
933
|
-
session = store["get_session"](name)
|
|
934
|
-
if not session:
|
|
935
|
-
raise CdxError(f"Unknown session: {name}")
|
|
936
|
-
updates = _normalize_launch_settings(settings)
|
|
937
|
-
if not updates:
|
|
938
|
-
raise CdxError("At least one launch setting is required.")
|
|
939
|
-
now = _local_now_iso()
|
|
940
|
-
|
|
941
|
-
def updater(s):
|
|
942
|
-
# Build the new launch dict from the fresh record inside the store
|
|
943
|
-
# lock; a pre-lock snapshot loses a concurrent setting change.
|
|
944
|
-
current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
|
|
945
|
-
launch = {**current, **updates}
|
|
946
|
-
if "power" in updates:
|
|
947
|
-
launch.pop("reasoning_effort", None)
|
|
948
|
-
launch.pop("reasoningEffort", None)
|
|
949
|
-
if "reasoning_effort" in updates:
|
|
950
|
-
launch.pop("power", None)
|
|
951
|
-
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
952
|
-
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
953
|
-
launch["fast"] = False
|
|
954
|
-
launch.pop("fastMode", None)
|
|
955
|
-
if updates.get("fast") is False:
|
|
956
|
-
launch.pop("fastMode", None)
|
|
957
|
-
if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
|
|
958
|
-
launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
|
|
959
|
-
return {
|
|
960
|
-
**s,
|
|
961
|
-
"launch": launch,
|
|
962
|
-
"updatedAt": now,
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
return store["update_session"](name, updater)
|
|
966
|
-
|
|
967
|
-
def unset_launch_settings(name, keys):
|
|
968
|
-
session = store["get_session"](name)
|
|
969
|
-
if not session:
|
|
970
|
-
raise CdxError(f"Unknown session: {name}")
|
|
971
|
-
if not keys:
|
|
972
|
-
raise CdxError("At least one launch setting is required.")
|
|
973
|
-
allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
|
|
974
|
-
unknown = [key for key in keys if key not in allowed]
|
|
975
|
-
if unknown:
|
|
976
|
-
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
977
|
-
now = _local_now_iso()
|
|
978
|
-
|
|
979
|
-
def updater(s):
|
|
980
|
-
current = dict(s.get("launch") or {})
|
|
981
|
-
for key in keys:
|
|
982
|
-
current.pop(key, None)
|
|
983
|
-
updated = {**s, "updatedAt": now}
|
|
984
|
-
if current:
|
|
985
|
-
updated["launch"] = current
|
|
986
|
-
else:
|
|
987
|
-
updated.pop("launch", None)
|
|
988
|
-
return updated
|
|
411
|
+
def set_session_label(store, name, label):
|
|
412
|
+
session = store["get_session"](name)
|
|
413
|
+
if not session:
|
|
414
|
+
raise CdxError(f"Unknown session: {name}")
|
|
415
|
+
normalized = _normalize_session_label(label)
|
|
416
|
+
now = _local_now_iso()
|
|
417
|
+
return store["update_session"](name, lambda s: {
|
|
418
|
+
**s,
|
|
419
|
+
"label": normalized,
|
|
420
|
+
"updatedAt": now,
|
|
421
|
+
})
|
|
989
422
|
|
|
990
|
-
return store["update_session"](name, updater)
|
|
991
423
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
424
|
+
def clear_session_label(store, name):
|
|
425
|
+
session = store["get_session"](name)
|
|
426
|
+
if not session:
|
|
427
|
+
raise CdxError(f"Unknown session: {name}")
|
|
428
|
+
now = _local_now_iso()
|
|
429
|
+
|
|
430
|
+
def updater(s):
|
|
431
|
+
updated = {**s, "updatedAt": now}
|
|
432
|
+
updated.pop("label", None)
|
|
1001
433
|
return updated
|
|
1002
434
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
)
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
)
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
"
|
|
1086
|
-
"
|
|
1087
|
-
"
|
|
1088
|
-
"
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
if
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
435
|
+
return store["update_session"](name, updater)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def set_session_enabled(store, name, enabled):
|
|
439
|
+
session = store["get_session"](name)
|
|
440
|
+
if not session:
|
|
441
|
+
raise CdxError(f"Unknown session: {name}")
|
|
442
|
+
now = _local_now_iso()
|
|
443
|
+
return store["update_session"](name, lambda s: {
|
|
444
|
+
**s,
|
|
445
|
+
"enabled": bool(enabled),
|
|
446
|
+
"updatedAt": now,
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def remove_session(base_dir, store, name):
|
|
451
|
+
session = store["get_session"](name)
|
|
452
|
+
if not session:
|
|
453
|
+
raise CdxError(f"Unknown session: {name}")
|
|
454
|
+
session_root = session.get("sessionRoot") or _get_session_root(base_dir, name)
|
|
455
|
+
quarantine_root = None
|
|
456
|
+
if os.path.exists(session_root):
|
|
457
|
+
profiles_dir = os.path.dirname(session_root)
|
|
458
|
+
os.makedirs(profiles_dir, exist_ok=True)
|
|
459
|
+
quarantine_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.remove.", dir=profiles_dir)
|
|
460
|
+
os.rmdir(quarantine_root)
|
|
461
|
+
os.rename(session_root, quarantine_root)
|
|
462
|
+
try:
|
|
463
|
+
removed = store["remove_session"](name)
|
|
464
|
+
except Exception:
|
|
465
|
+
if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
|
|
466
|
+
os.rename(quarantine_root, session_root)
|
|
467
|
+
raise
|
|
468
|
+
if not removed:
|
|
469
|
+
if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
|
|
470
|
+
os.rename(quarantine_root, session_root)
|
|
471
|
+
raise CdxError(f"Unknown session: {name}")
|
|
472
|
+
if quarantine_root:
|
|
473
|
+
try:
|
|
474
|
+
remove_tree(quarantine_root)
|
|
475
|
+
except OSError as error:
|
|
476
|
+
raise CdxError(
|
|
477
|
+
f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
|
|
478
|
+
) from error
|
|
479
|
+
return removed
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def copy_session(base_dir, store, source_name, dest_name):
|
|
483
|
+
if source_name == dest_name:
|
|
484
|
+
raise CdxError("Source and destination session names must be different")
|
|
485
|
+
_validate_new_session_name(dest_name)
|
|
486
|
+
source = store["get_session"](source_name)
|
|
487
|
+
if not source:
|
|
488
|
+
raise CdxError(f"Unknown session: {source_name}")
|
|
489
|
+
existing = store["get_session"](dest_name)
|
|
490
|
+
overwritten = False
|
|
491
|
+
source_root = source.get("sessionRoot") or _get_session_root(base_dir, source_name)
|
|
492
|
+
dest_root = _get_session_root(base_dir, dest_name)
|
|
493
|
+
if not existing and os.path.exists(dest_root):
|
|
494
|
+
raise CdxError(f"Session profile already exists: {dest_name}")
|
|
495
|
+
dest_auth_home = _get_session_auth_home(base_dir, dest_name, source["provider"])
|
|
496
|
+
profiles_dir = os.path.dirname(dest_root)
|
|
497
|
+
os.makedirs(profiles_dir, exist_ok=True)
|
|
498
|
+
temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
|
|
499
|
+
temp_root = os.path.join(temp_parent, "profile")
|
|
500
|
+
backup_root = None
|
|
501
|
+
moved_temp = False
|
|
502
|
+
now = _local_now_iso()
|
|
503
|
+
replacement = {
|
|
504
|
+
"name": dest_name,
|
|
505
|
+
"provider": source["provider"],
|
|
506
|
+
"enabled": True,
|
|
507
|
+
"sessionRoot": dest_root,
|
|
508
|
+
"authHome": dest_auth_home,
|
|
509
|
+
"createdAt": now,
|
|
510
|
+
"updatedAt": now,
|
|
511
|
+
"lastLaunchedAt": None,
|
|
512
|
+
"lastStatusAt": None,
|
|
513
|
+
"lastStatus": None,
|
|
514
|
+
**({"launch": source.get("launch")} if source.get("launch") else {}),
|
|
515
|
+
**({"label": source.get("label")} if source.get("label") else {}),
|
|
516
|
+
"auth": {
|
|
517
|
+
"status": "unknown",
|
|
518
|
+
"lastCheckedAt": None,
|
|
519
|
+
"lastAuthenticatedAt": None,
|
|
520
|
+
"lastLoggedOutAt": None,
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
try:
|
|
524
|
+
shutil.copytree(source_root, temp_root)
|
|
525
|
+
if existing:
|
|
526
|
+
backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
|
|
527
|
+
os.rmdir(backup_root)
|
|
528
|
+
if os.path.exists(dest_root):
|
|
529
|
+
os.rename(dest_root, backup_root)
|
|
530
|
+
os.rename(temp_root, dest_root)
|
|
531
|
+
moved_temp = True
|
|
532
|
+
result = store["replace_session"](dest_name, replacement)
|
|
533
|
+
overwritten = bool(existing)
|
|
534
|
+
except Exception:
|
|
535
|
+
if moved_temp and os.path.exists(dest_root):
|
|
536
|
+
remove_tree(dest_root, ignore_errors=True)
|
|
537
|
+
if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
|
|
538
|
+
os.rename(backup_root, dest_root)
|
|
539
|
+
raise
|
|
540
|
+
finally:
|
|
541
|
+
if backup_root and os.path.exists(backup_root):
|
|
542
|
+
remove_tree(backup_root, ignore_errors=True)
|
|
543
|
+
remove_tree(temp_parent, ignore_errors=True)
|
|
544
|
+
if not result["ok"]:
|
|
545
|
+
raise CdxError(f"Failed to create session: {dest_name}")
|
|
546
|
+
return {"session": result["session"], "overwritten": overwritten}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def rename_session(base_dir, store, source_name, dest_name):
|
|
550
|
+
if source_name == dest_name:
|
|
551
|
+
raise CdxError("Source and destination session names must be different")
|
|
552
|
+
_validate_new_session_name(dest_name)
|
|
553
|
+
source = store["get_session"](source_name)
|
|
554
|
+
if not source:
|
|
555
|
+
raise CdxError(f"Unknown session: {source_name}")
|
|
556
|
+
if store["get_session"](dest_name):
|
|
557
|
+
raise CdxError(f"Session already exists: {dest_name}")
|
|
558
|
+
|
|
559
|
+
source_root = source.get("sessionRoot") or _get_session_root(base_dir, source_name)
|
|
560
|
+
dest_root = _get_session_root(base_dir, dest_name)
|
|
561
|
+
if os.path.exists(dest_root):
|
|
562
|
+
raise CdxError(f"Session profile already exists: {dest_name}")
|
|
563
|
+
|
|
564
|
+
if os.path.exists(source_root):
|
|
565
|
+
os.rename(source_root, dest_root)
|
|
566
|
+
moved_profile = True
|
|
567
|
+
else:
|
|
568
|
+
moved_profile = False
|
|
569
|
+
|
|
570
|
+
now = _local_now_iso()
|
|
571
|
+
try:
|
|
572
|
+
result = store["rename_session"](source_name, dest_name, lambda s: {
|
|
1105
573
|
**s,
|
|
574
|
+
"name": dest_name,
|
|
575
|
+
"sessionRoot": dest_root,
|
|
576
|
+
"authHome": _get_session_auth_home(base_dir, dest_name, s["provider"]),
|
|
1106
577
|
"updatedAt": now,
|
|
1107
|
-
"auth": updater(s.get("auth") or {}),
|
|
1108
578
|
})
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
579
|
+
except Exception:
|
|
580
|
+
if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
|
|
581
|
+
os.rename(dest_root, source_root)
|
|
582
|
+
raise
|
|
583
|
+
|
|
584
|
+
if not result["ok"]:
|
|
585
|
+
if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
|
|
586
|
+
os.rename(dest_root, source_root)
|
|
587
|
+
if result["reason"] == "exists":
|
|
588
|
+
raise CdxError(f"Session already exists: {dest_name}")
|
|
589
|
+
raise CdxError(f"Unknown session: {source_name}")
|
|
590
|
+
return result["session"]
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
|
|
594
|
+
_validate_new_session_name(name)
|
|
595
|
+
normalized_provider = _normalize_provider(provider)
|
|
596
|
+
session_root = _get_session_root(base_dir, name)
|
|
597
|
+
auth_home = _get_session_auth_home(base_dir, name, normalized_provider)
|
|
598
|
+
_ensure_private_dir(base_dir)
|
|
599
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
600
|
+
_ensure_private_dir(session_root)
|
|
601
|
+
_ensure_private_dir(auth_home)
|
|
602
|
+
if normalized_provider == PROVIDER_CODEX:
|
|
603
|
+
_seed_codex_auth_from_global(auth_home, env=env)
|
|
604
|
+
if normalized_provider == PROVIDER_CLAUDE:
|
|
605
|
+
_ensure_claude_attribution_disabled(auth_home)
|
|
606
|
+
now = _local_now_iso()
|
|
607
|
+
session = {
|
|
608
|
+
"name": name,
|
|
609
|
+
"provider": normalized_provider,
|
|
610
|
+
"enabled": True,
|
|
611
|
+
"sessionRoot": session_root,
|
|
612
|
+
"authHome": auth_home,
|
|
613
|
+
"createdAt": now,
|
|
614
|
+
"updatedAt": now,
|
|
615
|
+
"lastLaunchedAt": None,
|
|
616
|
+
"lastStatusAt": None,
|
|
617
|
+
"lastStatus": None,
|
|
618
|
+
"launch": dict(DEFAULT_LAUNCH_SETTINGS),
|
|
619
|
+
"auth": {
|
|
620
|
+
"status": "unknown",
|
|
621
|
+
"lastCheckedAt": None,
|
|
622
|
+
"lastAuthenticatedAt": None,
|
|
623
|
+
"lastLoggedOutAt": None,
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
result = store["add_session"](session)
|
|
627
|
+
if not result["ok"]:
|
|
628
|
+
raise CdxError(f"Session already exists: {name}")
|
|
629
|
+
return result["session"]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def record_launch_history(store, name, payload):
|
|
633
|
+
session = store["get_session"](name)
|
|
634
|
+
if not session:
|
|
635
|
+
raise CdxError(f"Unknown session: {name}")
|
|
636
|
+
entry = {
|
|
637
|
+
"schema_version": 1,
|
|
638
|
+
"session_name": session["name"],
|
|
639
|
+
"provider": session["provider"],
|
|
640
|
+
"launch": session.get("launch") or {},
|
|
641
|
+
**payload,
|
|
642
|
+
}
|
|
643
|
+
store["append_launch_history"](entry)
|
|
644
|
+
_observe_codex_conversation(store, session)
|
|
645
|
+
return entry
|
|
1112
646
|
|
|
1113
|
-
def _status_cache_hit(s, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS, cache_only=False):
|
|
1114
|
-
return (
|
|
1115
|
-
cache_only
|
|
1116
|
-
or s.get("enabled", True) is False
|
|
1117
|
-
or (
|
|
1118
|
-
s.get("lastStatus")
|
|
1119
|
-
and not force_refresh
|
|
1120
|
-
and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
|
|
1121
|
-
)
|
|
1122
|
-
)
|
|
1123
|
-
|
|
1124
|
-
def _resolve_row_session(
|
|
1125
|
-
s,
|
|
1126
|
-
force_refresh=False,
|
|
1127
|
-
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
1128
|
-
cache_only=False,
|
|
1129
|
-
status_timeout_seconds=None,
|
|
1130
|
-
):
|
|
1131
|
-
status = _resolve_session_status(
|
|
1132
|
-
s,
|
|
1133
|
-
force_refresh=force_refresh,
|
|
1134
|
-
cache_ttl_seconds=cache_ttl_seconds,
|
|
1135
|
-
cache_only=cache_only,
|
|
1136
|
-
status_timeout_seconds=status_timeout_seconds,
|
|
1137
|
-
)
|
|
1138
|
-
return {
|
|
1139
|
-
**s,
|
|
1140
|
-
"lastStatus": status,
|
|
1141
|
-
"lastStatusAt": (status and status.get("updated_at")) or s.get("lastStatusAt"),
|
|
1142
|
-
}
|
|
1143
647
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
648
|
+
def _observe_codex_conversation(store, session):
|
|
649
|
+
"""Read Codex's conversation id back after a run, if it wrote one.
|
|
650
|
+
|
|
651
|
+
Codex mints the id itself, so unlike Claude this can only be observed once
|
|
652
|
+
the rollout exists. Failing to find one is normal and leaves the session on
|
|
653
|
+
the recency resume path; it is never an error worth surfacing to a launch
|
|
654
|
+
that otherwise succeeded.
|
|
655
|
+
"""
|
|
656
|
+
if session.get("provider") != PROVIDER_CODEX:
|
|
657
|
+
return
|
|
658
|
+
auth_home = session.get("authHome")
|
|
659
|
+
if not auth_home:
|
|
660
|
+
return
|
|
661
|
+
try:
|
|
662
|
+
identifier = find_latest_codex_conversation_id(auth_home)
|
|
663
|
+
except OSError:
|
|
664
|
+
return
|
|
665
|
+
if not identifier or identifier == (session.get("conversation") or {}).get("id"):
|
|
666
|
+
return
|
|
667
|
+
store["update_session"](session["name"], lambda s: {
|
|
668
|
+
**s,
|
|
669
|
+
"conversation": {
|
|
670
|
+
"id": identifier,
|
|
671
|
+
"provenance": "observed",
|
|
672
|
+
"recordedAt": _local_now_iso(),
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def get_launch_history(store, name=None, limit=20):
|
|
678
|
+
if name and not store["get_session"](name):
|
|
679
|
+
raise CdxError(f"Unknown session: {name}")
|
|
680
|
+
return store["list_launch_history"](session_name=name, limit=limit)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def start_session_runtime(store, name, payload=None):
|
|
684
|
+
session = store["get_session"](name)
|
|
685
|
+
if not session:
|
|
686
|
+
raise CdxError(f"Unknown session: {name}")
|
|
687
|
+
payload = dict(payload or {})
|
|
688
|
+
now = _local_now_iso()
|
|
689
|
+
runtime = {
|
|
690
|
+
"status": "running",
|
|
691
|
+
"runId": payload.get("runId") or uuid.uuid4().hex,
|
|
692
|
+
"startedAt": payload.get("startedAt") or now,
|
|
693
|
+
"pid": payload.get("pid") or os.getpid(),
|
|
694
|
+
"command": payload.get("command"),
|
|
695
|
+
"label": payload.get("label"),
|
|
696
|
+
"transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
def updater(state):
|
|
700
|
+
if not state:
|
|
701
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
1148
702
|
return {
|
|
1149
|
-
|
|
1150
|
-
"
|
|
1151
|
-
"
|
|
1152
|
-
"enabled": enabled,
|
|
1153
|
-
"active": bool(_session_runtime(s["name"])) if enabled else False,
|
|
1154
|
-
"status": "enabled" if enabled else "disabled",
|
|
1155
|
-
"auth_status": (s.get("auth") or {}).get("status") or "unknown",
|
|
1156
|
-
"auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
|
|
1157
|
-
"remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
|
|
1158
|
-
"remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
|
|
1159
|
-
"credits": row_status.get("credits") if row_status else None,
|
|
1160
|
-
"reset_credits_available": row_status.get("reset_credits_available") if row_status else None,
|
|
1161
|
-
"reset_credits": row_status.get("reset_credits") if row_status else None,
|
|
1162
|
-
"available_pct": _compute_available_pct(row_status),
|
|
1163
|
-
"reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
|
|
1164
|
-
"reset_week_at": row_status.get("reset_week_at") if row_status else None,
|
|
1165
|
-
"reset_at": row_status.get("reset_at") if row_status else None,
|
|
1166
|
-
"updated_at": _to_local_iso(s.get("lastStatusAt")),
|
|
1167
|
-
"last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
|
|
703
|
+
**state,
|
|
704
|
+
"status": "running",
|
|
705
|
+
"runtime": runtime,
|
|
1168
706
|
}
|
|
1169
707
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
})
|
|
1193
|
-
if not cache_hit:
|
|
1194
|
-
progress_callback({
|
|
1195
|
-
"event": "session_started",
|
|
1196
|
-
"session_name": session["name"],
|
|
1197
|
-
"provider": session["provider"],
|
|
1198
|
-
})
|
|
1199
|
-
resolved = _resolve_row_session(
|
|
1200
|
-
session,
|
|
1201
|
-
force_refresh=force_refresh,
|
|
1202
|
-
cache_ttl_seconds=cache_ttl_seconds,
|
|
1203
|
-
cache_only=cache_only,
|
|
1204
|
-
status_timeout_seconds=status_timeout_seconds,
|
|
1205
|
-
)
|
|
1206
|
-
if progress_callback:
|
|
1207
|
-
progress_callback({
|
|
1208
|
-
"event": "session_finished",
|
|
1209
|
-
"session_name": session["name"],
|
|
1210
|
-
"has_status": bool(resolved.get("lastStatus")),
|
|
1211
|
-
"cache_hit": cache_hit,
|
|
1212
|
-
})
|
|
1213
|
-
progress_callback({
|
|
1214
|
-
"event": "status_finished",
|
|
1215
|
-
"row_count": 1,
|
|
1216
|
-
})
|
|
1217
|
-
return _status_row_from_session(resolved)
|
|
1218
|
-
|
|
1219
|
-
def get_status_rows(
|
|
1220
|
-
progress_callback=None,
|
|
1221
|
-
force_refresh=False,
|
|
1222
|
-
cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
|
|
1223
|
-
cache_only=False,
|
|
1224
|
-
status_timeout_seconds=None,
|
|
1225
|
-
):
|
|
1226
|
-
sessions = list_sessions()
|
|
1227
|
-
|
|
1228
|
-
cache_hits = {
|
|
1229
|
-
s["name"]: _status_cache_hit(
|
|
1230
|
-
s,
|
|
1231
|
-
force_refresh=force_refresh,
|
|
1232
|
-
cache_ttl_seconds=cache_ttl_seconds,
|
|
1233
|
-
cache_only=cache_only,
|
|
1234
|
-
)
|
|
1235
|
-
for s in sessions
|
|
1236
|
-
}
|
|
1237
|
-
if progress_callback:
|
|
1238
|
-
progress_callback({
|
|
1239
|
-
"event": "status_started",
|
|
1240
|
-
"session_count": len(sessions),
|
|
1241
|
-
"check_count": sum(1 for cache_hit in cache_hits.values() if not cache_hit),
|
|
1242
|
-
})
|
|
1243
|
-
|
|
1244
|
-
resolved_by_name = {}
|
|
1245
|
-
if sessions:
|
|
1246
|
-
max_workers = min(MAX_STATUS_WORKERS, len(sessions))
|
|
1247
|
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1248
|
-
futures = {}
|
|
1249
|
-
for s in sessions:
|
|
1250
|
-
cache_hit = cache_hits[s["name"]]
|
|
1251
|
-
if progress_callback and not cache_hit:
|
|
1252
|
-
progress_callback({
|
|
1253
|
-
"event": "session_started",
|
|
1254
|
-
"session_name": s["name"],
|
|
1255
|
-
"provider": s["provider"],
|
|
1256
|
-
})
|
|
1257
|
-
futures[executor.submit(
|
|
1258
|
-
_resolve_row_session,
|
|
1259
|
-
s,
|
|
1260
|
-
force_refresh=force_refresh,
|
|
1261
|
-
cache_ttl_seconds=cache_ttl_seconds,
|
|
1262
|
-
cache_only=cache_only,
|
|
1263
|
-
status_timeout_seconds=status_timeout_seconds,
|
|
1264
|
-
)] = s
|
|
1265
|
-
for future in as_completed(futures):
|
|
1266
|
-
s = futures[future]
|
|
1267
|
-
try:
|
|
1268
|
-
resolved = future.result()
|
|
1269
|
-
except CdxError as error:
|
|
1270
|
-
if not str(error).startswith("Unknown session:"):
|
|
1271
|
-
raise
|
|
1272
|
-
continue
|
|
1273
|
-
resolved_by_name[s["name"]] = resolved
|
|
1274
|
-
if progress_callback:
|
|
1275
|
-
progress_callback({
|
|
1276
|
-
"event": "session_finished",
|
|
1277
|
-
"session_name": s["name"],
|
|
1278
|
-
"has_status": bool(resolved.get("lastStatus")),
|
|
1279
|
-
"cache_hit": cache_hits[s["name"]],
|
|
1280
|
-
})
|
|
1281
|
-
resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
|
|
1282
|
-
|
|
1283
|
-
def sort_key(s):
|
|
1284
|
-
at = s.get("lastStatusAt") or ""
|
|
1285
|
-
disabled_rank = 1 if s.get("enabled", True) is False else 0
|
|
1286
|
-
return (disabled_rank, "" if at else "\xff", at, s["name"])
|
|
1287
|
-
|
|
1288
|
-
resolved.sort(key=sort_key)
|
|
1289
|
-
enabled = [s for s in resolved if s.get("enabled", True) is not False]
|
|
1290
|
-
disabled = [s for s in resolved if s.get("enabled", True) is False]
|
|
1291
|
-
enabled.reverse()
|
|
1292
|
-
disabled.sort(key=lambda s: s["name"])
|
|
1293
|
-
resolved = enabled + disabled
|
|
1294
|
-
|
|
1295
|
-
rows = []
|
|
1296
|
-
for s in resolved:
|
|
1297
|
-
rows.append(_status_row_from_session(s))
|
|
1298
|
-
if progress_callback:
|
|
1299
|
-
progress_callback({
|
|
1300
|
-
"event": "status_finished",
|
|
1301
|
-
"row_count": len(rows),
|
|
1302
|
-
})
|
|
1303
|
-
return rows
|
|
1304
|
-
|
|
1305
|
-
def format_list_rows():
|
|
1306
|
-
sessions = list_sessions()
|
|
1307
|
-
providers = {s["provider"] for s in sessions}
|
|
1308
|
-
has_multiple = len(providers) > 1
|
|
1309
|
-
sessions = sorted(
|
|
1310
|
-
sessions,
|
|
1311
|
-
key=lambda s: (
|
|
1312
|
-
1 if s.get("enabled", True) is False else 0,
|
|
1313
|
-
s.get("name", ""),
|
|
1314
|
-
),
|
|
1315
|
-
)
|
|
1316
|
-
return [{
|
|
1317
|
-
"name": s["name"],
|
|
1318
|
-
"label": s.get("label"),
|
|
1319
|
-
"provider": s["provider"] if has_multiple else None,
|
|
1320
|
-
"enabled": s.get("enabled", True) is not False,
|
|
1321
|
-
"active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
|
|
1322
|
-
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
1323
|
-
"status": s.get("lastStatus"),
|
|
1324
|
-
"launch": s.get("launch") or {},
|
|
1325
|
-
"updated_at": _to_local_iso(s.get("updatedAt")),
|
|
1326
|
-
} for s in sessions]
|
|
1327
|
-
|
|
1328
|
-
def get_session_auth_home(name, provider):
|
|
1329
|
-
return _get_session_auth_home(name, provider)
|
|
1330
|
-
|
|
1331
|
-
def get_session_root(name):
|
|
1332
|
-
return _get_session_root(name)
|
|
1333
|
-
|
|
1334
|
-
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
|
|
1335
|
-
if not file_path:
|
|
1336
|
-
raise CdxError("Export path is required.")
|
|
1337
|
-
if os.path.exists(file_path) and not force:
|
|
1338
|
-
raise CdxError(f"Export path already exists: {file_path}")
|
|
1339
|
-
|
|
1340
|
-
sessions = _resolve_session_subset(session_names)
|
|
1341
|
-
payload = {
|
|
1342
|
-
"schema_version": 1,
|
|
1343
|
-
"created_at": _local_now_iso(),
|
|
1344
|
-
"include_auth": bool(include_auth),
|
|
1345
|
-
"sessions": [],
|
|
1346
|
-
"states": {},
|
|
1347
|
-
"profiles": {},
|
|
708
|
+
store["update_session_state"](name, updater)
|
|
709
|
+
return runtime
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def finish_session_runtime(store, name, run_id=None, payload=None):
|
|
713
|
+
session = store["get_session"](name)
|
|
714
|
+
if not session:
|
|
715
|
+
raise CdxError(f"Unknown session: {name}")
|
|
716
|
+
payload = dict(payload or {})
|
|
717
|
+
outcome = {}
|
|
718
|
+
|
|
719
|
+
def updater(state):
|
|
720
|
+
if not state:
|
|
721
|
+
return None
|
|
722
|
+
runtime = state.get("runtime") or {}
|
|
723
|
+
if run_id and runtime.get("runId") != run_id:
|
|
724
|
+
outcome["runtime"] = runtime
|
|
725
|
+
return None
|
|
726
|
+
updated_runtime = {
|
|
727
|
+
**runtime,
|
|
728
|
+
"status": payload.get("status") or "stopped",
|
|
729
|
+
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
1348
730
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
progress_callback({
|
|
1353
|
-
"event": "export_started",
|
|
1354
|
-
"include_auth": bool(include_auth),
|
|
1355
|
-
"session_count": len(sessions),
|
|
1356
|
-
"session_names": [session["name"] for session in sessions],
|
|
1357
|
-
})
|
|
1358
|
-
for session in sessions:
|
|
1359
|
-
if progress_callback:
|
|
1360
|
-
progress_callback({"event": "session_started", "session_name": session["name"]})
|
|
1361
|
-
payload["sessions"].append(_build_export_session_record(session))
|
|
1362
|
-
state = store["read_session_state"](session["name"])
|
|
1363
|
-
if state is not None:
|
|
1364
|
-
payload["states"][session["name"]] = state
|
|
1365
|
-
if include_auth:
|
|
1366
|
-
session_root = session.get("sessionRoot") or _get_session_root(session["name"])
|
|
1367
|
-
profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
|
|
1368
|
-
payload["profiles"][session["name"]] = profile["files"]
|
|
1369
|
-
profile_file_count += profile["file_count"]
|
|
1370
|
-
profile_bytes += profile["bytes"]
|
|
1371
|
-
if progress_callback:
|
|
1372
|
-
progress_callback({"event": "session_finished", "session_name": session["name"]})
|
|
1373
|
-
|
|
1374
|
-
if progress_callback:
|
|
1375
|
-
progress_callback({"event": "encoding_started"})
|
|
1376
|
-
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
1377
|
-
if progress_callback:
|
|
1378
|
-
progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
|
|
1379
|
-
# Atomic + 0o600 from the start: the export path may live in a
|
|
1380
|
-
# world-traversable directory, and a crash must not clobber a
|
|
1381
|
-
# previous good bundle with a truncated one.
|
|
1382
|
-
atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
|
|
731
|
+
if "returncode" in payload:
|
|
732
|
+
updated_runtime["returncode"] = payload["returncode"]
|
|
733
|
+
outcome["runtime"] = updated_runtime
|
|
1383
734
|
return {
|
|
1384
|
-
|
|
1385
|
-
"
|
|
1386
|
-
"
|
|
1387
|
-
"session_count": len(sessions),
|
|
1388
|
-
"profile_file_count": profile_file_count if include_auth else None,
|
|
1389
|
-
"profile_bytes": profile_bytes if include_auth else None,
|
|
1390
|
-
"bundle_size_bytes": len(bundle_bytes),
|
|
735
|
+
**state,
|
|
736
|
+
"status": "ready",
|
|
737
|
+
"runtime": updated_runtime,
|
|
1391
738
|
}
|
|
1392
739
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
if not isinstance(profiles, dict):
|
|
1396
|
-
raise CdxError("Bundle contains invalid profile data.")
|
|
1397
|
-
decoded_profiles = {}
|
|
1398
|
-
for session_payload in imported_sessions:
|
|
1399
|
-
name = session_payload["name"]
|
|
1400
|
-
files = []
|
|
1401
|
-
profile_items = profiles.get(name, [])
|
|
1402
|
-
if not isinstance(profile_items, list):
|
|
1403
|
-
raise CdxError(f"Bundle contains invalid profile data for session {name}.")
|
|
1404
|
-
for item in profile_items:
|
|
1405
|
-
if not isinstance(item, dict):
|
|
1406
|
-
raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
|
|
1407
|
-
rel_path = _safe_relpath(item.get("path"))
|
|
1408
|
-
data_b64 = item.get("data_b64")
|
|
1409
|
-
if not isinstance(data_b64, str):
|
|
1410
|
-
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
|
|
1411
|
-
try:
|
|
1412
|
-
content = base64.b64decode(data_b64.encode("ascii"), validate=True)
|
|
1413
|
-
except (binascii.Error, UnicodeEncodeError) as error:
|
|
1414
|
-
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
1415
|
-
files.append({"path": rel_path, "content": content})
|
|
1416
|
-
decoded_profiles[name] = files
|
|
1417
|
-
return decoded_profiles
|
|
1418
|
-
|
|
1419
|
-
def _restore_import_backup(name, backup_root, session_root, old_record, old_state):
|
|
1420
|
-
if os.path.exists(session_root):
|
|
1421
|
-
remove_tree(session_root, ignore_errors=True)
|
|
1422
|
-
if backup_root and os.path.exists(backup_root):
|
|
1423
|
-
os.rename(backup_root, session_root)
|
|
1424
|
-
if old_record:
|
|
1425
|
-
store["replace_session"](name, old_record)
|
|
1426
|
-
if old_state is not None:
|
|
1427
|
-
store["write_session_state"](name, old_state)
|
|
1428
|
-
else:
|
|
1429
|
-
store["remove_session"](name)
|
|
1430
|
-
|
|
1431
|
-
def _force_import_preserved_profile_paths(session_root):
|
|
1432
|
-
return [rel_path for rel_path in _FORCE_IMPORT_PRESERVED_PROFILE_PATHS if os.path.exists(os.path.join(session_root, rel_path))]
|
|
1433
|
-
|
|
1434
|
-
def _restore_force_import_profile_paths(name, backup_root, session_root, rel_paths):
|
|
1435
|
-
if not backup_root:
|
|
1436
|
-
return
|
|
1437
|
-
for rel_path in rel_paths:
|
|
1438
|
-
source_path = os.path.join(backup_root, rel_path)
|
|
1439
|
-
dest_path = os.path.join(session_root, rel_path)
|
|
1440
|
-
if not os.path.exists(source_path) or os.path.exists(dest_path):
|
|
1441
|
-
continue
|
|
1442
|
-
try:
|
|
1443
|
-
if os.path.isdir(source_path) and not os.path.islink(source_path):
|
|
1444
|
-
shutil.copytree(source_path, dest_path)
|
|
1445
|
-
else:
|
|
1446
|
-
_ensure_private_dir(os.path.dirname(dest_path))
|
|
1447
|
-
shutil.copy2(source_path, dest_path)
|
|
1448
|
-
except Exception as error:
|
|
1449
|
-
raise CdxError(f"Could not restore local plugin state for session {name}: {rel_path}") from error
|
|
1450
|
-
|
|
1451
|
-
def import_bundle(
|
|
1452
|
-
file_path,
|
|
1453
|
-
passphrase=None,
|
|
1454
|
-
session_names=None,
|
|
1455
|
-
force=False,
|
|
1456
|
-
merge=False,
|
|
1457
|
-
allow_authless_force=False,
|
|
1458
|
-
):
|
|
1459
|
-
if not file_path or not os.path.isfile(file_path):
|
|
1460
|
-
raise CdxError(f"Bundle file not found: {file_path}")
|
|
1461
|
-
with open(file_path, "rb") as handle:
|
|
1462
|
-
decoded = decode_bundle(handle.read(), passphrase=passphrase)
|
|
1463
|
-
payload = decoded["payload"]
|
|
1464
|
-
imported_sessions = payload.get("sessions") or []
|
|
1465
|
-
if payload.get("schema_version") != 1:
|
|
1466
|
-
raise CdxError("Unsupported bundle payload schema version.")
|
|
1467
|
-
|
|
1468
|
-
selected_names = set(session_names or [])
|
|
1469
|
-
if selected_names:
|
|
1470
|
-
imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
|
|
1471
|
-
missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
|
|
1472
|
-
if missing_names:
|
|
1473
|
-
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
1474
|
-
names = [item["name"] for item in imported_sessions]
|
|
1475
|
-
|
|
1476
|
-
existing = {session["name"] for session in list_sessions()}
|
|
1477
|
-
conflicts = [name for name in names if name in existing]
|
|
1478
|
-
if conflicts and not force and not merge:
|
|
1479
|
-
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
1480
|
-
if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
|
|
1481
|
-
raise CdxError(
|
|
1482
|
-
"Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
|
|
1483
|
-
"Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
|
|
1484
|
-
)
|
|
740
|
+
store["update_session_state"](name, updater)
|
|
741
|
+
return outcome.get("runtime")
|
|
1485
742
|
|
|
1486
|
-
for session_payload in imported_sessions:
|
|
1487
|
-
_validate_new_session_name(session_payload["name"])
|
|
1488
|
-
_normalize_provider(session_payload["provider"])
|
|
1489
|
-
decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
|
|
1490
|
-
|
|
1491
|
-
for session_payload in imported_sessions:
|
|
1492
|
-
name = session_payload["name"]
|
|
1493
|
-
provider = _normalize_provider(session_payload["provider"])
|
|
1494
|
-
is_existing = name in existing
|
|
1495
|
-
|
|
1496
|
-
session_root = _get_session_root(name)
|
|
1497
|
-
auth_home = _get_session_auth_home(name, provider)
|
|
1498
|
-
_ensure_private_dir(base_dir)
|
|
1499
|
-
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
1500
|
-
backup_root = None
|
|
1501
|
-
old_record = None
|
|
1502
|
-
old_state = None
|
|
1503
|
-
preserved_profile_paths = []
|
|
1504
|
-
if is_existing and force:
|
|
1505
|
-
old_record = store["get_session"](name)
|
|
1506
|
-
old_state = store["read_session_state"](name)
|
|
1507
|
-
preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
|
|
1508
|
-
if os.path.exists(session_root):
|
|
1509
|
-
backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
1510
|
-
os.rmdir(backup_root)
|
|
1511
|
-
os.rename(session_root, backup_root)
|
|
1512
|
-
is_existing = False
|
|
1513
|
-
_ensure_private_dir(session_root)
|
|
1514
|
-
_ensure_private_dir(auth_home)
|
|
1515
|
-
|
|
1516
|
-
existing_state_before = None
|
|
1517
|
-
try:
|
|
1518
|
-
if is_existing and merge:
|
|
1519
|
-
existing_record = store["get_session"](name) or {}
|
|
1520
|
-
# replace_session resets the state file to defaults, so the
|
|
1521
|
-
# local state must be captured before it runs.
|
|
1522
|
-
existing_state_before = store["read_session_state"](name) or {}
|
|
1523
|
-
bundle_record = {
|
|
1524
|
-
**session_payload,
|
|
1525
|
-
"provider": provider,
|
|
1526
|
-
"enabled": session_payload.get("enabled", True) is not False,
|
|
1527
|
-
"sessionRoot": session_root,
|
|
1528
|
-
"authHome": auth_home,
|
|
1529
|
-
}
|
|
1530
|
-
# Existing values take precedence; bundle fills in missing keys only.
|
|
1531
|
-
merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
|
|
1532
|
-
merged_record["sessionRoot"] = session_root
|
|
1533
|
-
merged_record["authHome"] = auth_home
|
|
1534
|
-
store["replace_session"](name, merged_record)
|
|
1535
|
-
else:
|
|
1536
|
-
session_record = {
|
|
1537
|
-
**session_payload,
|
|
1538
|
-
"provider": provider,
|
|
1539
|
-
"enabled": session_payload.get("enabled", True) is not False,
|
|
1540
|
-
"sessionRoot": session_root,
|
|
1541
|
-
"authHome": auth_home,
|
|
1542
|
-
}
|
|
1543
|
-
store["replace_session"](name, session_record)
|
|
1544
|
-
|
|
1545
|
-
state = (payload.get("states") or {}).get(name)
|
|
1546
|
-
if is_existing and merge:
|
|
1547
|
-
merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
|
|
1548
|
-
if merged_state:
|
|
1549
|
-
store["write_session_state"](name, merged_state)
|
|
1550
|
-
elif state is not None:
|
|
1551
|
-
store["write_session_state"](name, state)
|
|
1552
|
-
|
|
1553
|
-
for item in decoded_profiles.get(name, []):
|
|
1554
|
-
dest_path = os.path.join(session_root, item["path"])
|
|
1555
|
-
# In merge mode, skip files that already exist locally.
|
|
1556
|
-
if is_existing and merge and os.path.exists(dest_path):
|
|
1557
|
-
continue
|
|
1558
|
-
_ensure_private_dir(os.path.dirname(dest_path))
|
|
1559
|
-
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
1560
|
-
atomic_write(dest_path, item["content"], mode=0o600)
|
|
1561
|
-
_restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
|
|
1562
|
-
except Exception:
|
|
1563
|
-
_restore_import_backup(name, backup_root, session_root, old_record, old_state)
|
|
1564
|
-
raise
|
|
1565
|
-
finally:
|
|
1566
|
-
if backup_root and os.path.exists(backup_root):
|
|
1567
|
-
remove_tree(backup_root, ignore_errors=True)
|
|
1568
743
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
744
|
+
def launch_session(store, name):
|
|
745
|
+
session = store["get_session"](name)
|
|
746
|
+
if not session:
|
|
747
|
+
raise CdxError(f"Unknown session: {name}")
|
|
748
|
+
if session.get("enabled", True) is False:
|
|
749
|
+
raise CdxError(f"Session is disabled: {name}")
|
|
750
|
+
now = _local_now_iso()
|
|
751
|
+
|
|
752
|
+
def state_updater(state):
|
|
753
|
+
if not state:
|
|
754
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
755
|
+
return {**state, "rehydratedAt": now}
|
|
756
|
+
|
|
757
|
+
store["update_session_state"](name, state_updater)
|
|
758
|
+
|
|
759
|
+
def session_updater(s):
|
|
760
|
+
updated = {**s, "updatedAt": now, "lastLaunchedAt": now}
|
|
761
|
+
conversation = _conversation_identity_for_launch(s)
|
|
762
|
+
if conversation:
|
|
763
|
+
updated["conversation"] = conversation
|
|
764
|
+
return updated
|
|
765
|
+
|
|
766
|
+
return store["update_session"](name, session_updater)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _conversation_identity_for_launch(session):
|
|
770
|
+
"""The conversation id this launch will carry, or None to leave it alone.
|
|
771
|
+
|
|
772
|
+
Claude accepts a caller-supplied `--session-id`, so cdx mints one here,
|
|
773
|
+
before the launch spec is built, and knows the conversation's identity
|
|
774
|
+
without having to find it afterwards. A fresh id per launch rather than one
|
|
775
|
+
reused forever: a launch starts a new conversation, and `cdx resume` should
|
|
776
|
+
mean the last one, so the stored value is always the newest. Reusing a
|
|
777
|
+
single id across launches would also depend on how the provider treats a
|
|
778
|
+
repeated `--session-id`, which is a behaviour cdx does not need to rely on.
|
|
779
|
+
|
|
780
|
+
Codex has no equivalent flag; its identity is read back from the rollout
|
|
781
|
+
after the run, so nothing is minted here.
|
|
782
|
+
"""
|
|
783
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
784
|
+
return None
|
|
785
|
+
return {
|
|
786
|
+
"id": str(uuid.uuid4()),
|
|
787
|
+
"provenance": "imposed",
|
|
788
|
+
"recordedAt": _local_now_iso(),
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def begin_conversation(store, name):
|
|
793
|
+
"""Mint the conversation id a Claude run is about to carry, and store it.
|
|
794
|
+
|
|
795
|
+
`launch_session` does this for interactive launches, but a headless run
|
|
796
|
+
never goes through it, so `cdx run` used to leave Claude sessions with no
|
|
797
|
+
identity at all - `cdx resume` then fell back to recency for Claude while
|
|
798
|
+
working by id for Codex, which records its own after the fact. Same shape
|
|
799
|
+
for both providers now: whatever ran last is what resume names.
|
|
800
|
+
|
|
801
|
+
Returns the updated session, or the unchanged one for providers that mint
|
|
802
|
+
nothing here.
|
|
803
|
+
"""
|
|
804
|
+
session = store["get_session"](name)
|
|
805
|
+
if not session:
|
|
806
|
+
raise CdxError(f"Unknown session: {name}")
|
|
807
|
+
conversation = _conversation_identity_for_launch(session)
|
|
808
|
+
if not conversation:
|
|
809
|
+
return session
|
|
810
|
+
return store["update_session"](name, lambda s: {
|
|
811
|
+
**s, "conversation": conversation, "updatedAt": _local_now_iso(),
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def get_session_root(base_dir, name):
|
|
816
|
+
return _get_session_root(base_dir, name)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def get_session_auth_home(base_dir, name, provider):
|
|
820
|
+
return _get_session_auth_home(base_dir, name, provider)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def create_session_service(options=None):
|
|
824
|
+
if options is None:
|
|
825
|
+
options = {}
|
|
826
|
+
env = options.get("env", os.environ)
|
|
827
|
+
base_dir = options.get("base_dir") or get_cdx_home(env)
|
|
828
|
+
store = options.get("store") or create_session_store(base_dir)
|
|
829
|
+
custom_codex_status_fetcher = options.get("fetchCodexRateLimits")
|
|
830
|
+
codex_status_fetcher = custom_codex_status_fetcher or fetch_codex_rate_limits
|
|
831
|
+
default_status_timeout_seconds = (
|
|
832
|
+
_parse_status_timeout_seconds(options.get("statusTimeoutSeconds"))
|
|
833
|
+
or _parse_status_timeout_seconds(env.get("CDX_STATUS_TIMEOUT_SECONDS"))
|
|
834
|
+
or STATUS_PROBE_TIMEOUT_SECONDS
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
# None when there is no fetcher, so callers test the callable they were
|
|
838
|
+
# given rather than a separate flag they would also have to be passed.
|
|
839
|
+
fetch_codex_status = partial(
|
|
840
|
+
_fetch_codex_status,
|
|
841
|
+
codex_status_fetcher,
|
|
842
|
+
custom_codex_status_fetcher,
|
|
843
|
+
default_status_timeout_seconds,
|
|
844
|
+
) if codex_status_fetcher else None
|
|
1574
845
|
|
|
1575
846
|
return {
|
|
1576
|
-
"create_session": create_session,
|
|
1577
|
-
"remove_session": remove_session,
|
|
1578
|
-
"copy_session": copy_session,
|
|
1579
|
-
"rename_session": rename_session,
|
|
1580
|
-
"launch_session": launch_session,
|
|
1581
|
-
"
|
|
1582
|
-
"
|
|
1583
|
-
"
|
|
1584
|
-
"
|
|
1585
|
-
"
|
|
1586
|
-
"
|
|
1587
|
-
"
|
|
1588
|
-
"
|
|
1589
|
-
"
|
|
1590
|
-
"
|
|
1591
|
-
"
|
|
1592
|
-
"
|
|
1593
|
-
"
|
|
1594
|
-
"
|
|
1595
|
-
"
|
|
1596
|
-
"
|
|
1597
|
-
"
|
|
1598
|
-
"
|
|
1599
|
-
"
|
|
1600
|
-
"
|
|
1601
|
-
"
|
|
1602
|
-
"
|
|
847
|
+
"create_session": partial(create_session, base_dir, env, store),
|
|
848
|
+
"remove_session": partial(remove_session, base_dir, store),
|
|
849
|
+
"copy_session": partial(copy_session, base_dir, store),
|
|
850
|
+
"rename_session": partial(rename_session, base_dir, store),
|
|
851
|
+
"launch_session": partial(launch_session, store),
|
|
852
|
+
"begin_conversation": partial(begin_conversation, store),
|
|
853
|
+
"active_session_runtime": partial(_session_runtime, store),
|
|
854
|
+
"start_session_runtime": partial(start_session_runtime, store),
|
|
855
|
+
"finish_session_runtime": partial(finish_session_runtime, store),
|
|
856
|
+
"ensure_session_state": partial(ensure_session_state, store),
|
|
857
|
+
"list_sessions": partial(list_sessions, store),
|
|
858
|
+
"get_session": partial(get_session, store),
|
|
859
|
+
"set_session_enabled": partial(set_session_enabled, store),
|
|
860
|
+
"set_session_label": partial(set_session_label, store),
|
|
861
|
+
"clear_session_label": partial(clear_session_label, store),
|
|
862
|
+
"set_launch_settings": partial(set_launch_settings, store),
|
|
863
|
+
"unset_launch_settings": partial(unset_launch_settings, store),
|
|
864
|
+
"record_status": partial(record_status, store),
|
|
865
|
+
"record_launch_history": partial(record_launch_history, store),
|
|
866
|
+
"get_launch_history": partial(get_launch_history, store),
|
|
867
|
+
"update_auth_state": partial(update_auth_state, store),
|
|
868
|
+
"get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
|
|
869
|
+
"get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
|
|
870
|
+
"format_list_rows": partial(format_list_rows, store),
|
|
871
|
+
"get_session_auth_home": partial(get_session_auth_home, base_dir),
|
|
872
|
+
"get_session_root": partial(get_session_root, base_dir),
|
|
873
|
+
"export_bundle": partial(export_bundle, base_dir, store),
|
|
874
|
+
"import_bundle": partial(import_bundle, base_dir, store),
|
|
1603
875
|
"base_dir": base_dir,
|
|
1604
876
|
"normalize_provider": _normalize_provider,
|
|
1605
877
|
}
|