cdx-manager 0.26.8 → 0.26.9

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.8-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.26.9-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.8
1
+ 0.26.9
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.26.9
2
+
3
+ Release date: 2026-11-08
4
+
5
+ ## Fixes
6
+
7
+ - Resolve the conversation of a live interactive Codex process from the
8
+ rollout file it holds open (#126, `abb19e0`). `cdx conversation <profile>
9
+ --pid PID` no longer answers `native_conversation_identity_unavailable`
10
+ for an interactive Codex session whose native process writes an exact
11
+ rollout; it still refuses workspace or recency guesses.
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.26.8 (`e04fae8`).
16
+ - Record release archive checksums for v0.26.8 (`f41f2b3`).
@@ -881,6 +881,17 @@
881
881
  "x86_64-pc-windows-msvc": "098c27b535f041b72b6aac89d2c34464a986178be881336b89f99c8dfb5fdb5d",
882
882
  "x86_64-unknown-linux-musl": "195737be7f25dc30bab676f53aed2c426612aee55de4cfc95a55a5b2d8ca9b76"
883
883
  }
884
+ },
885
+ "v0.26.8": {
886
+ "github_tarball_sha256": "528b9147a815bbe8565e63dc7089951691427cdbf0ec7c2c4a5740029c68d612",
887
+ "github_zip_sha256": "1f829aa5f889663e4b974d5ad91a3160418252d9349afd5899afaa27084cf4cb",
888
+ "tray_assets": {
889
+ "aarch64-apple-darwin": "947ca5e15d9ef9db080eb33e1a09376420118e4af0c9fc049c12d2e8ae9649c5",
890
+ "aarch64-unknown-linux-musl": "004e423dcff66cef815140ca96d516089306bc6e8e940a4d2f6f657eeb2f9f6b",
891
+ "x86_64-apple-darwin": "b5780dcff73502155fc8c24f1b3bdd9f18cea129c7a24c017d40168ee5ba8867",
892
+ "x86_64-pc-windows-msvc": "f68d4fb19c4e94b31662cd46eb9ae8aa2b3e4bf53d3bde4fce1b540df7719e26",
893
+ "x86_64-unknown-linux-musl": "ada7fcbd0c9202cad8a5e55b60a6b85af34210157a4d7352451bcb95bbb103cd"
894
+ }
884
895
  }
885
896
  }
886
897
  }
package/docs/cli.md CHANGED
@@ -710,48 +710,57 @@ Process-selected pages expose:
710
710
 
711
711
  | Field | Current contract |
712
712
  | --- | --- |
713
- | `resolution_status` | `unknown` until native selected-conversation evidence is available |
714
- | `resolution_method` | `null`; kernel process identity alone is not a conversation resolution method |
715
- | `conversation_id` | `null`; no candidate or guessed ID is exposed |
713
+ | `resolution_status` | `resolved` for verified native rollout evidence, otherwise `unknown` |
714
+ | `resolution_method` | `process_open_rollout` when resolved, otherwise `null` |
715
+ | `conversation_id` | Native `session_meta.payload.id`, checked against the open rollout filename |
716
716
  | `process_identity` | Opaque Linux lifetime guard when process validation succeeds |
717
- | `reason` | Explicit evidence boundary or process validation failure |
718
-
719
- **Current measured boundary:** process-to-selected-conversation resolution is not
720
- available. Even a sole open writable rollout proves only that a process owns a
721
- thread. During a TUI conversation switch it may belong to a retained thread while
722
- the selected thread has no rollout yet. Resume argv, session/profile state, cwd,
723
- mtime, a unique new transcript, and fuzzy text also cannot prove the current
724
- selection. This implementation deliberately returns
725
- `native_conversation_identity_unavailable` after validating a live Codex process;
726
- it does not claim that an open file establishes the selected conversation.
727
- Remote-ops must treat this as an explicit unknown, never retry via recency.
728
-
729
- Process mode returns `status: "unavailable"`, empty entries, null conversation ID
730
- and no cursors. With `--cursor` or `--before`, it returns `reset_required` and no
731
- entries: a cursor from an explicit-ID or recency lookup cannot cross into process
732
- mode. No process cursor is issued until it can be bound to both a verified process
733
- lifetime and a native conversation selection/generation. Existing explicit-ID,
734
- recency, forward, tail and backward pagination contracts remain unchanged.
735
-
736
- Only Linux `/proc` process identity is currently supported. Each validation reads
737
- at most 8 KiB each from process `stat`, `status` and kernel `boot_id`, twice, plus
738
- the executable symlink/stat. The guard includes PID, start ticks, boot ID, all
717
+ | `reason` | `null` on success; explicit evidence boundary or process validation failure otherwise |
718
+
719
+ On Linux, CDX inspects only the supplied PID's open descriptors. A unique writable
720
+ `rollout-…-UUID.jsonl` under the selected profile's `sessions/` directory supplies
721
+ the native identity. Its first record must be a complete `session_meta` with a
722
+ matching UUID and `source: "cli"`. Duplicate descriptors for the same file count
723
+ once; read-only descriptors do not establish the active writer. Multiple distinct
724
+ writable rollouts return `ambiguous_native_rollouts`, even if one is newer. A
725
+ rollout outside the selected profile returns `profile_boundary_mismatch` before
726
+ its contents are read. Resolved symlinks must remain inside the profile, including
727
+ the `sessions/` root. No workspace, mtime, saved resume ID or recency fallback is
728
+ used, even if `fallback_to_recency=True` is supplied through Python.
729
+
730
+ This is a snapshot of the process's sole native rollout writer. A rollout must
731
+ already exist and remain open for writing; a fresh thread before its first
732
+ rollout returns `native_conversation_identity_unavailable`. A retained writer
733
+ and another open writer are ambiguous. The resolver does not infer TUI focus
734
+ from file timestamps or claim to observe selection changes without a writer
735
+ change. Consumers must treat unknown as unknown and must not retry via recency.
736
+
737
+ The exact open file's device/inode is checked against its profile path before
738
+ reading. CDX rechecks the lifetime guard and native rollout evidence before and
739
+ after the message read. A detected exit, PID reuse, switch, replacement or lost
740
+ proof discards all entries. These checks are snapshots, not an atomic provider
741
+ selection subscription; the process can change after the final check.
742
+
743
+ Resolved requests return `status: "ok"` and normalized entries, including support
744
+ for `--tail`. Unknown requests return `status: "unavailable"`, no entries and a
745
+ null conversation ID. Process pagination remains disabled: no cursors are issued,
746
+ `has_more` is false, and `--cursor` or `--before` returns `reset_required` without
747
+ entries. Poll again using the retained process identity and `--tail`; an
748
+ explicit-ID or recency cursor cannot cross into process mode. Existing explicit-ID
749
+ and recency pagination contracts are unchanged.
750
+
751
+ Only Linux `/proc` and interactive Codex are supported. Each evidence pass reads
752
+ at most 8 KiB each from `stat`, `status`, kernel `boot_id` and each candidate's
753
+ `fdinfo`, enumerates at most 512 descriptors, and reads at most 64 KiB of each
754
+ candidate's metadata header. Message reads use the existing bounded conversation
755
+ reader and redaction. The lifetime guard includes PID, start ticks, boot ID, all
739
756
  four target UIDs and executable device/inode. Wrong user, a non-`codex` executable,
740
757
  dead/reused PID, inaccessible/oversized/malformed evidence, or unsupported
741
- provider/platform fails closed. Other reasons include `unrelated_user`,
758
+ provider/platform fails closed. Reasons include `unrelated_user`,
742
759
  `unrelated_process`, `process_not_running`, `process_identity_changed`,
743
- `native_evidence_unavailable`, `evidence_limit`, and
744
- `unsupported_process_resolution`. No argv, environment, credential store,
745
- transcript file, directory scan or history database is read in process mode.
746
- A process identity does not prove profile ownership; missing native profile and
747
- thread association therefore remains unknown even for an existing profile.
748
-
749
- The smallest required launch-time tracking is a provider-native event containing
750
- the selected conversation ID on fresh start, resume and every selection change.
751
- The launcher must bind each event atomically to the child's boot ID/PID/start ticks,
752
- profile and a monotonic selection generation; invalidate it on exit. A future
753
- resolver can consume that bounded record and bind cursors to the same generation.
754
- An original resume ID or launch timestamp alone is insufficient. No launch/resume
755
- flags or dependencies are changed here; implementing this requires a verified
756
- provider lifecycle event source, which the inspected interactive interface does
757
- not supply. See the mission fragment for the local native probe and Tower checks.
760
+ `native_evidence_unavailable`, `invalid_native_rollout`, `evidence_changed`,
761
+ `evidence_limit`, and `unsupported_process_resolution`. No argv, environment,
762
+ credential store, descendant traversal, profile transcript search or history
763
+ database participates in process resolution. No launch flags or dependencies
764
+ change. Tower's authenticated TUI still requires a measured comparison of the
765
+ returned UUID with its selected native conversation after launch, resume and
766
+ switch, especially with concurrent sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.26.8",
3
+ "version": "0.26.9",
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.26.8"
7
+ version = "0.26.9"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
@@ -407,20 +407,33 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
407
407
  raise CdxError("Process selection cannot be combined with conversation, cwd, since or terminal transcript selection.")
408
408
  from .process_conversation import resolve_process_conversation
409
409
 
410
- resolution = resolve_process_conversation(provider, auth_home, pid=pid, process_identity=process_identity)
410
+ resolution, evidence = resolve_process_conversation(provider, auth_home, pid=pid, process_identity=process_identity)
411
411
  page = {"status": "unavailable", "provider": provider, "entries": [], "next_cursor": None,
412
412
  "has_more": False, "resolution_scope": None, **resolution}
413
413
  if tail is not None or before is not None:
414
414
  page["before_cursor"] = None
415
- # No process-bound cursor is issued until native selected-thread evidence
416
- # exists. Never replay an explicit-ID or recency cursor through this mode.
415
+ # Process pagination remains disabled: an unbound explicit-ID or recency
416
+ # cursor must never cross into this mode.
417
417
  if cursor is not None or before is not None:
418
- page["status"] = "reset_required"
419
- return page
418
+ return {**page, "status": "reset_required"}
419
+ if evidence is None:
420
+ return page
421
+ selected = _read_conversation_page(provider, auth_home, limit=limit, tail=tail,
422
+ _transcript=evidence[1], _file_identity=evidence[2])
423
+ checked, current = resolve_process_conversation(provider, auth_home, process_identity=resolution["process_identity"])
424
+ if current != evidence or checked != resolution:
425
+ return {**page, **checked, "resolution_status": "unknown", "resolution_method": None,
426
+ "conversation_id": None, "reason": checked["reason"] or "evidence_changed"}
427
+ if selected["status"] != "ok":
428
+ return {**page, "resolution_status": "unknown", "resolution_method": None,
429
+ "conversation_id": None, "reason": selected["reason"]}
430
+ return {**selected, **resolution, "reason": None, "next_cursor": None, "before_cursor": None,
431
+ "has_more": False}
420
432
 
421
433
 
422
434
  def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, started_at=None,
423
- terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None):
435
+ terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None,
436
+ _transcript=None, _file_identity=None):
424
437
  """Return a JSON-serializable page; pass next_cursor back unchanged to poll.
425
438
 
426
439
  Entries follow file/block order. Cursors bind a byte offset and block index
@@ -440,7 +453,7 @@ def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, s
440
453
  datetime.fromisoformat(started_at.replace("Z", "+00:00"))
441
454
  except (ValueError, AttributeError, TypeError):
442
455
  raise CdxError("Conversation --since must be an ISO timestamp.") from None
443
- scope = {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
456
+ scope = None if _transcript is not None else {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
444
457
  page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
445
458
  "resolution_method": None, "resolution_scope": None}
446
459
  if tail is not None or before is not None:
@@ -448,7 +461,9 @@ def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, s
448
461
  if provider not in PROVIDERS or provider == "ollama":
449
462
  return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
450
463
  # An explicit conversation must never fall back to an unrelated recent file.
451
- if conversation_id:
464
+ if _transcript is not None:
465
+ path = _transcript
466
+ elif conversation_id:
452
467
  path = conversation_transcript(provider, auth_home, conversation_id)
453
468
  if path:
454
469
  page["resolution_method"] = "conversation_id"
@@ -464,6 +479,8 @@ def _read_conversation_page(provider, auth_home=None, *, conversation_id=None, s
464
479
  try:
465
480
  with open(path, "rb") as handle:
466
481
  stat = os.fstat(handle.fileno())
482
+ if _file_identity is not None and (stat.st_dev, stat.st_ino) != _file_identity:
483
+ return {**page, "status": "unavailable", "reason": "evidence_changed"}
467
484
  identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
468
485
  if tail is not None or before is not None:
469
486
  return _read_tail(handle, stat, identity, provider, page, tail if tail is not None else limit, before)
@@ -1,12 +1,14 @@
1
- """Bounded Linux process identity, without guessing a selected conversation.
1
+ """Bounded Linux process identity and profile-scoped native rollout evidence.
2
2
 
3
3
  No argv, environment, credential stores, transcript searches or parent/child
4
4
  heuristics participate in identity resolution.
5
5
  """
6
6
 
7
7
  import hashlib
8
+ import json
8
9
  import os
9
10
  import re
11
+ import stat
10
12
  import sys
11
13
  from pathlib import Path
12
14
 
@@ -46,8 +48,66 @@ def _process(pid):
46
48
  return f"linux:{pid}:{digest}"
47
49
 
48
50
 
51
+ def _rollout(pid, auth_home):
52
+ """Identify a sole writable rollout; multiple native writers are ambiguous."""
53
+ home = Path(auth_home).resolve()
54
+ sessions = (home / "sessions").resolve()
55
+ if not sessions.is_relative_to(home):
56
+ raise _Unknown("profile_boundary_mismatch")
57
+ candidates = set()
58
+ root = PROC_ROOT / str(pid)
59
+ with os.scandir(root / "fd") as descriptors:
60
+ for count, descriptor in enumerate(descriptors):
61
+ if count >= 512:
62
+ raise _Unknown("evidence_limit")
63
+ try:
64
+ target = os.readlink(descriptor.path)
65
+ except FileNotFoundError:
66
+ continue # Unrelated descriptors can close during enumeration.
67
+ name = Path(target).name
68
+ if not name.startswith("rollout-") or ".jsonl" not in name:
69
+ continue
70
+ flags = next(line.split()[1] for line in _bounded(root / "fdinfo" / descriptor.name).splitlines()
71
+ if line.startswith("flags:"))
72
+ if int(flags, 8) & os.O_ACCMODE not in (os.O_WRONLY, os.O_RDWR):
73
+ continue
74
+ path = Path(target).resolve()
75
+ if not path.is_relative_to(sessions):
76
+ raise _Unknown("profile_boundary_mismatch")
77
+ match = re.fullmatch(r"rollout-.*(" + _UUID + r")\.jsonl", name)
78
+ if not match:
79
+ raise _Unknown("invalid_native_rollout")
80
+ file_stat = os.stat(descriptor.path)
81
+ path_stat = path.stat()
82
+ file_identity = (file_stat.st_dev, file_stat.st_ino)
83
+ if not stat.S_ISREG(file_stat.st_mode) or file_identity != (path_stat.st_dev, path_stat.st_ino):
84
+ raise _Unknown("evidence_changed")
85
+ # Read only the metadata header of the exact open file, never a
86
+ # transcript found by scanning the profile or matching a workspace.
87
+ with open(descriptor.path, "rb") as handle:
88
+ opened = os.fstat(handle.fileno())
89
+ if (opened.st_dev, opened.st_ino) != file_identity:
90
+ raise _Unknown("evidence_changed")
91
+ header = handle.readline(65537)
92
+ if len(header) > 65536:
93
+ raise _Unknown("evidence_limit")
94
+ if not header.endswith(b"\n"):
95
+ raise _Unknown("invalid_native_rollout")
96
+ metadata = json.loads(header)
97
+ payload = metadata.get("payload")
98
+ if (metadata.get("type") != "session_meta" or not isinstance(payload, dict)
99
+ or payload.get("id") != match[1] or payload.get("source") != "cli"):
100
+ raise _Unknown("invalid_native_rollout")
101
+ candidates.add((match[1], path, file_identity))
102
+ if len(candidates) > 1:
103
+ raise _Unknown("ambiguous_native_rollouts")
104
+ if not candidates:
105
+ raise _Unknown("native_conversation_identity_unavailable")
106
+ return candidates.pop()
107
+
108
+
49
109
  def resolve_process_conversation(provider, auth_home, *, pid=None, process_identity=None):
50
- """Return explicit unknown when the native selected-thread link is absent.
110
+ """Return public resolution fields and private exact-file evidence, if present.
51
111
 
52
112
  A bare PID means the process alive *now*. Persist process_identity to reject
53
113
  reuse on subsequent calls. The identity is an opaque guard, not a secret or
@@ -56,26 +116,25 @@ def resolve_process_conversation(provider, auth_home, *, pid=None, process_ident
56
116
  result = {"resolution_status": "unknown", "resolution_method": None,
57
117
  "conversation_id": None, "process_identity": None, "reason": None}
58
118
  if provider != "codex" or sys.platform != "linux":
59
- return {**result, "reason": "unsupported_process_resolution"}
119
+ return {**result, "reason": "unsupported_process_resolution"}, None
60
120
  if process_identity is not None:
61
121
  match = _IDENTITY.fullmatch(process_identity) if isinstance(process_identity, str) and len(process_identity) <= 100 else None
62
122
  if not match or pid is not None:
63
- return {**result, "reason": "invalid_process_identity"}
123
+ return {**result, "reason": "invalid_process_identity"}, None
64
124
  pid = int(match[1])
65
125
  if type(pid) is not int or not 0 < pid < 2**31 or not auth_home:
66
- return {**result, "reason": "invalid_process_identity"}
126
+ return {**result, "reason": "invalid_process_identity"}, None
67
127
  try:
68
128
  identity = _process(pid)
69
129
  if process_identity is not None and identity != process_identity:
70
130
  raise _Unknown("process_identity_changed")
71
131
  result["process_identity"] = identity
72
- if _process(pid) != identity:
132
+ evidence = _rollout(pid, auth_home)
133
+ if _process(pid) != identity or _rollout(pid, auth_home) != evidence:
73
134
  raise _Unknown("evidence_changed")
74
- # Even a sole writable rollout may belong to a retained thread while
75
- # another thread is selected but has not created its rollout. Neither
76
- # that descriptor nor resume argv is a native selected-thread event.
77
- return {**result, "reason": "native_conversation_identity_unavailable"}
135
+ return {**result, "resolution_status": "resolved", "resolution_method": "process_open_rollout",
136
+ "conversation_id": evidence[0]}, evidence
78
137
  except _Unknown as exc:
79
- return {**result, "reason": str(exc)}
80
- except (OSError, ValueError, KeyError, IndexError, TypeError, StopIteration, UnicodeError, RecursionError):
81
- return {**result, "reason": "native_evidence_unavailable"}
138
+ return {**result, "reason": str(exc)}, None
139
+ except (OSError, ValueError, KeyError, IndexError, TypeError, StopIteration, UnicodeError, RecursionError, AttributeError):
140
+ return {**result, "reason": "native_evidence_unavailable"}, None