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 +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_9.md +16 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +50 -41
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/conversation.py +25 -8
- package/src/process_conversation.py +72 -13
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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` | `
|
|
714
|
-
| `resolution_method` | `
|
|
715
|
-
| `conversation_id` | `
|
|
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` |
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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.
|
|
758
|
+
provider/platform fails closed. Reasons include `unrelated_user`,
|
|
742
759
|
`unrelated_process`, `process_not_running`, `process_identity_changed`,
|
|
743
|
-
`native_evidence_unavailable`, `
|
|
744
|
-
`unsupported_process_resolution`. No argv, environment,
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
package/pyproject.toml
CHANGED
package/src/conversation.py
CHANGED
|
@@ -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
|
-
#
|
|
416
|
-
#
|
|
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
|
|
419
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
132
|
+
evidence = _rollout(pid, auth_home)
|
|
133
|
+
if _process(pid) != identity or _rollout(pid, auth_home) != evidence:
|
|
73
134
|
raise _Unknown("evidence_changed")
|
|
74
|
-
|
|
75
|
-
|
|
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
|