cdx-manager 0.6.0 → 0.6.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -67,6 +67,7 @@ One command to launch any session. Zero auth juggling.
67
67
  - Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
68
68
  - Fallback: `status-source` scans provider JSONL history files and terminal log transcripts, strips ANSI/OSC sequences, and extracts `usage%`, `5h remaining%`, and `week remaining%` via pattern matching.
69
69
  - Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
70
+ - On Linux, transcript capture uses the `util-linux` `script -c` command form.
70
71
  - If `script` is unavailable, Codex launch falls back to running without transcript capture.
71
72
  - On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
72
73
  - Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
@@ -131,7 +132,7 @@ For a specific version:
131
132
 
132
133
  ```bash
133
134
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
134
- CDX_VERSION=v0.6.0 sh install.sh
135
+ CDX_VERSION=v0.6.1 sh install.sh
135
136
  ```
136
137
 
137
138
  From source:
@@ -0,0 +1,30 @@
1
+ # Changelog (`0.6.0 -> 0.6.1`)
2
+
3
+ Release date: 2026-05-28
4
+
5
+ ## Handoff Reliability
6
+
7
+ - Fixed `cdx handoff <source> <target>` when a source session has no `cdx-session*.log` launch transcript.
8
+ - Added fallback discovery for native provider histories, including Claude project JSONL files under the isolated session home.
9
+ - Converted JSONL message records into readable role-prefixed handoff context instead of requiring raw terminal logs.
10
+
11
+ ## Linux Transcript Capture
12
+
13
+ - Fixed default transcript capture on Linux and Arch Linux by using the `util-linux` `script -q -F -c "<command>" <transcript>` form.
14
+ - Kept the existing BSD/macOS `script` invocation unchanged.
15
+ - Preserved custom `CDX_SCRIPT_ARGS` behavior for users who explicitly configure their wrapper.
16
+
17
+ ## Release Metadata and Documentation
18
+
19
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.1`.
20
+ - Documented the Linux-specific transcript capture behavior in the README.
21
+
22
+ ## Validation and Regression Coverage
23
+
24
+ - Added regression coverage for Claude-to-Claude handoff from native `.claude/projects/*.jsonl` history when no launch log exists.
25
+ - Added runtime coverage for the Linux `script` invocation shape.
26
+
27
+ ## Validation and Regression Evidence
28
+
29
+ - `npm run lint`
30
+ - `npm test`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.6.0"
7
+ version = "0.6.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -54,7 +54,7 @@ from .status_view import (
54
54
  )
55
55
  from .update_check import check_for_update
56
56
 
57
- VERSION = "0.6.0"
57
+ VERSION = "0.6.1"
58
58
 
59
59
 
60
60
  # ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import asyncio
2
2
  import getpass
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import sys
6
7
  import time
7
8
  from datetime import datetime, timedelta
@@ -37,7 +38,7 @@ from .provider_runtime import (
37
38
  from .repair import format_repair_report, repair_health
38
39
  from .backup_bundle import read_bundle_meta
39
40
  from .status_view import _format_status_detail, _format_status_rows
40
- from .update_check import fetch_latest_release, is_newer_version
41
+ from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
41
42
  from .update_manager import build_update_plan, format_update_failure, run_update_plan
42
43
 
43
44
 
@@ -56,6 +57,7 @@ HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|t
56
57
  LAST_USAGE = "Usage: cdx last [--json]"
57
58
  API_SCHEMA_VERSION = 1
58
59
  HANDOFF_TRANSCRIPT_CHARS = 120000
60
+ HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
59
61
 
60
62
 
61
63
  def _local_now_iso():
@@ -194,9 +196,132 @@ def _latest_launch_transcript_path(session):
194
196
  return max(paths, key=lambda path: (os.path.getmtime(path), path))
195
197
 
196
198
 
197
- def _read_handoff_transcript(path):
199
+ def _get_session_home(session):
200
+ return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
201
+
202
+
203
+ def _safe_stat(path):
204
+ try:
205
+ return os.stat(path)
206
+ except OSError:
207
+ return None
208
+
209
+
210
+ def _sort_recent_paths(paths):
211
+ stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
212
+ return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
213
+
214
+
215
+ def _collect_native_handoff_transcript_paths(session):
216
+ root = _get_session_home(session)
217
+ if not root:
218
+ return []
219
+ candidates = []
220
+ direct = [
221
+ os.path.join(root, "history.jsonl"),
222
+ os.path.join(root, "session_index.jsonl"),
223
+ ]
224
+ candidates.extend(path for path in direct if _safe_stat(path))
225
+
226
+ scan_roots = [
227
+ os.path.join(root, "sessions"),
228
+ os.path.join(root, ".claude", "projects"),
229
+ os.path.join(root, "projects"),
230
+ ]
231
+ skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
232
+ for scan_root in scan_roots:
233
+ if not os.path.isdir(scan_root):
234
+ continue
235
+ for dirpath, dirnames, filenames in os.walk(scan_root):
236
+ dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
237
+ for filename in filenames:
238
+ if filename.endswith(".jsonl") or filename.endswith(".log"):
239
+ candidates.append(os.path.join(dirpath, filename))
240
+ return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
241
+
242
+
243
+ def _latest_handoff_transcript_path(session):
244
+ launch_path = _latest_launch_transcript_path(session)
245
+ if launch_path:
246
+ return launch_path
247
+ native_paths = _collect_native_handoff_transcript_paths(session)
248
+ return native_paths[0] if native_paths else None
249
+
250
+
251
+ def _collect_handoff_text_fragments(value):
252
+ fragments = []
253
+ if isinstance(value, str):
254
+ text = value.strip()
255
+ if text:
256
+ fragments.append(text)
257
+ elif isinstance(value, list):
258
+ for item in value:
259
+ fragments.extend(_collect_handoff_text_fragments(item))
260
+ elif isinstance(value, dict):
261
+ if isinstance(value.get("text"), str):
262
+ fragments.extend(_collect_handoff_text_fragments(value.get("text")))
263
+ if isinstance(value.get("content"), (str, list, dict)):
264
+ fragments.extend(_collect_handoff_text_fragments(value.get("content")))
265
+ if isinstance(value.get("message"), dict):
266
+ fragments.extend(_collect_handoff_text_fragments(value.get("message")))
267
+ if isinstance(value.get("payload"), dict):
268
+ fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
269
+ return fragments
270
+
271
+
272
+ def _jsonl_record_to_handoff_text(record):
273
+ if not isinstance(record, dict):
274
+ return None
275
+ message = record.get("message") if isinstance(record.get("message"), dict) else {}
276
+ role = (
277
+ message.get("role")
278
+ or record.get("role")
279
+ or record.get("type")
280
+ or record.get("sender")
281
+ or "entry"
282
+ )
283
+ text_sources = []
284
+ for key in ("content", "text", "payload"):
285
+ if key in record:
286
+ text_sources.append(record.get(key))
287
+ for key in ("content", "text"):
288
+ if key in message:
289
+ text_sources.append(message.get(key))
290
+ fragments = []
291
+ for value in text_sources:
292
+ fragments.extend(_collect_handoff_text_fragments(value))
293
+ text = "\n".join(fragment for fragment in fragments if fragment).strip()
294
+ if not text:
295
+ return None
296
+ role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
297
+ return f"[{role}]\n{text}"
298
+
299
+
300
+ def _read_jsonl_handoff_transcript(path):
301
+ entries = []
302
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
303
+ for line in handle:
304
+ line = line.strip()
305
+ if not line:
306
+ continue
307
+ try:
308
+ text = _jsonl_record_to_handoff_text(json.loads(line))
309
+ except json.JSONDecodeError:
310
+ text = None
311
+ if text:
312
+ entries.append(text)
313
+ if entries:
314
+ return "\n\n".join(entries)
198
315
  with open(path, "r", encoding="utf-8", errors="replace") as handle:
199
- content = handle.read()
316
+ return handle.read()
317
+
318
+
319
+ def _read_handoff_transcript(path):
320
+ if path.endswith(".jsonl"):
321
+ content = _read_jsonl_handoff_transcript(path)
322
+ else:
323
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
324
+ content = handle.read()
200
325
  if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
201
326
  return content, False
202
327
  return content[-HANDOFF_TRANSCRIPT_CHARS:], True
@@ -1437,7 +1562,14 @@ def handle_update(rest, ctx):
1437
1562
  if parsed["version"] is not None:
1438
1563
  target_version = str(parsed["version"]).strip().lstrip("v")
1439
1564
  else:
1440
- latest = release_fetcher()
1565
+ try:
1566
+ latest = (
1567
+ release_fetcher()
1568
+ if ctx["options"].get("fetchLatestRelease")
1569
+ else fetch_latest_release_or_raise(env=ctx.get("env"))
1570
+ )
1571
+ except LatestReleaseCheckError as error:
1572
+ raise CdxError(str(error)) from error
1441
1573
  if not latest:
1442
1574
  raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
1443
1575
  target_version = str(latest.get("latest_version") or "").strip()
@@ -1613,9 +1745,9 @@ def handle_handoff(rest, ctx):
1613
1745
  target = ctx["service"]["get_session"](target_name)
1614
1746
  if not target:
1615
1747
  raise CdxError(f"Unknown session: {target_name}")
1616
- transcript_path = _latest_launch_transcript_path(source)
1748
+ transcript_path = _latest_handoff_transcript_path(source)
1617
1749
  if not transcript_path:
1618
- raise CdxError(f"No launch transcript found for session: {source_name}")
1750
+ raise CdxError(f"No transcript found for session: {source_name}")
1619
1751
  transcript, truncated = _read_handoff_transcript(transcript_path)
1620
1752
  context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
1621
1753
  write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
@@ -131,7 +131,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
131
131
  args = args + [transcript_path]
132
132
  args = args + [spec["command"]] + spec["args"]
133
133
  else:
134
- args = ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
134
+ args = _default_script_args(transcript_path, spec)
135
135
  return {
136
136
  "command": script_bin,
137
137
  "args": args,
@@ -142,6 +142,13 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
142
142
  }
143
143
 
144
144
 
145
+ def _default_script_args(transcript_path, spec):
146
+ if sys.platform.startswith("linux"):
147
+ command = shlex.join([spec["command"]] + spec["args"])
148
+ return ["-q", "-F", "-c", command, transcript_path]
149
+ return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
150
+
151
+
145
152
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
146
153
  if initial_prompt is not None:
147
154
  if not isinstance(initial_prompt, str):
@@ -9,6 +9,10 @@ UPDATE_CHECK_TTL_SECONDS = 12 * 60 * 60
9
9
  LATEST_RELEASE_URL = "https://api.github.com/repos/AlexAgo83/cdx-manager/releases/latest"
10
10
 
11
11
 
12
+ class LatestReleaseCheckError(Exception):
13
+ pass
14
+
15
+
12
16
  def _parse_version(value):
13
17
  raw = str(value or "").strip().lstrip("v")
14
18
  parts = raw.split(".")
@@ -51,13 +55,22 @@ def _write_cache(path, payload):
51
55
  handle.write("\n")
52
56
 
53
57
 
54
- def _fetch_latest_release():
58
+ def _github_token(env=None):
59
+ env = env or os.environ
60
+ return env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")
61
+
62
+
63
+ def _fetch_latest_release(env=None):
64
+ headers = {
65
+ "Accept": "application/vnd.github+json",
66
+ "User-Agent": "cdx-manager-update-check",
67
+ }
68
+ token = _github_token(env)
69
+ if token:
70
+ headers["Authorization"] = f"Bearer {token}"
55
71
  request = urllib.request.Request(
56
72
  LATEST_RELEASE_URL,
57
- headers={
58
- "Accept": "application/vnd.github+json",
59
- "User-Agent": "cdx-manager-update-check",
60
- },
73
+ headers=headers,
61
74
  )
62
75
  with urllib.request.urlopen(request, timeout=5) as response:
63
76
  payload = json.loads(response.read().decode("utf-8"))
@@ -67,13 +80,39 @@ def _fetch_latest_release():
67
80
  }
68
81
 
69
82
 
70
- def fetch_latest_release():
83
+ def _format_fetch_error(error):
84
+ if isinstance(error, urllib.error.HTTPError):
85
+ if error.code == 403:
86
+ return (
87
+ "GitHub API rate limit reached while checking the latest cdx-manager release. "
88
+ "Try again later, set GH_TOKEN or GITHUB_TOKEN, or run cdx update --version <version>."
89
+ )
90
+ if error.code == 404:
91
+ return "Unable to find the latest cdx-manager release on GitHub."
92
+ return f"GitHub returned HTTP {error.code} while checking the latest cdx-manager release."
93
+ if isinstance(error, urllib.error.URLError):
94
+ reason = getattr(error, "reason", None)
95
+ suffix = f" ({reason})" if reason else ""
96
+ return f"Unable to reach GitHub while checking the latest cdx-manager release{suffix}."
97
+ if isinstance(error, TimeoutError):
98
+ return "Timed out while checking the latest cdx-manager release."
99
+ return "Unable to check for the latest cdx-manager release."
100
+
101
+
102
+ def fetch_latest_release(env=None):
71
103
  try:
72
- return _fetch_latest_release()
104
+ return _fetch_latest_release(env=env)
73
105
  except (urllib.error.URLError, TimeoutError, ValueError, OSError):
74
106
  return None
75
107
 
76
108
 
109
+ def fetch_latest_release_or_raise(env=None):
110
+ try:
111
+ return _fetch_latest_release(env=env)
112
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error:
113
+ raise LatestReleaseCheckError(_format_fetch_error(error)) from error
114
+
115
+
77
116
  def check_for_update(base_dir, current_version, env=None, now_fn=None):
78
117
  env = env or os.environ
79
118
  now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
@@ -94,7 +133,7 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
94
133
  }
95
134
  return None
96
135
 
97
- latest = fetch_latest_release()
136
+ latest = fetch_latest_release(env=env)
98
137
  if not latest:
99
138
  return None
100
139