cdx-manager 0.11.1 → 0.12.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 +15 -4
- package/changelogs/CHANGELOGS_0_11_2.md +27 -0
- package/changelogs/CHANGELOGS_0_12_0.md +42 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +5 -2
- package/src/cli_args.py +2 -1
- package/src/cli_commands.py +230 -0
- package/src/context_store.py +66 -7
- package/src/health.py +193 -4
- package/src/provider_runtime.py +14 -0
- package/src/session_service.py +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -361,7 +361,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
361
361
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
362
362
|
| `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
|
|
363
363
|
| `cdx can-resume <name> [--json]` | Check whether a session supports native resume without launching the provider |
|
|
364
|
-
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
364
|
+
| `cdx context show\|path\|init\|edit\|clear\|set\|append [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
365
|
+
| `cdx memory [--global\|--project NAME_OR_PATH] [show\|view\|path\|init\|edit\|clear\|set\|append\|list] [text...] [--json]` | Manage explicit user-controlled memory for the current workspace, global scope, or a named/path project |
|
|
365
366
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
366
367
|
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports cross-provider handoff |
|
|
367
368
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
@@ -371,7 +372,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
371
372
|
| `cdx reset <name> [--yes] [--json]` | Explicitly consume one available banked Codex rate-limit reset; confirmation is required unless `--yes` is supplied |
|
|
372
373
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
373
374
|
| `cdx import <file> [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
|
|
374
|
-
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
375
|
+
| `cdx doctor [--json]` | Inspect CLI dependencies, provider CLI versions/capability hints, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
375
376
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
376
377
|
| `cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--tls-cert <path>] [--tls-key <path>] [--open] [--no-open]` | Open the Logics browser/focus viewer by delegating to `logics-manager view`; all viewer flags are forwarded; JSON mode reports diagnostics without launching it |
|
|
377
378
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
@@ -414,6 +415,7 @@ Commands with machine-readable output:
|
|
|
414
415
|
- `cdx disable ... --json`
|
|
415
416
|
- `cdx enable ... --json`
|
|
416
417
|
- `cdx context ... --json`
|
|
418
|
+
- `cdx memory ... --json`
|
|
417
419
|
- `cdx handoff ... --json`
|
|
418
420
|
- `cdx history ... --json`
|
|
419
421
|
- `cdx stats ... --json`
|
|
@@ -558,6 +560,7 @@ Notes:
|
|
|
558
560
|
- if a compatible `script` command is available and exposed via `CDX_SCRIPT_BIN`, `cdx` uses it
|
|
559
561
|
- otherwise Codex launches without transcript capture and the session still works normally
|
|
560
562
|
- `cdx doctor` reports the transcript-capture fallback explicitly so missing `script` on Windows is visible without being treated as a hard failure.
|
|
563
|
+
- `cdx doctor --json` includes detected `codex --version` and `claude --version` output when available, plus conservative capability hints for recent provider memory, model, and headless diagnostic surfaces. These hints are informational; `cdx` does not edit provider-private memory stores.
|
|
561
564
|
|
|
562
565
|
---
|
|
563
566
|
|
|
@@ -605,6 +608,10 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
605
608
|
state/
|
|
606
609
|
<encoded-name>.json # Per-session rehydration state
|
|
607
610
|
launch_history.jsonl # Append-only launch history
|
|
611
|
+
contexts/
|
|
612
|
+
<workspace-hash>/context.md # Current-workspace shared context / memory
|
|
613
|
+
global/context.md # Global cdx memory
|
|
614
|
+
projects/<encoded-name>/context.md # Named project memory from cdx memory --project NAME
|
|
608
615
|
profiles/
|
|
609
616
|
<encoded-name>/ # Codex session: CODEX_HOME points here
|
|
610
617
|
log/
|
|
@@ -617,6 +624,10 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
617
624
|
|
|
618
625
|
Session names are URL-encoded when used as directory or file names. CLI command names such as `add`, `disk`, `status`, and `login` are reserved and cannot be used as new session names.
|
|
619
626
|
|
|
627
|
+
`cdx memory` is the user-facing interface for explicit memory. `--project NAME` uses a stable free-form project name; `--project /path/to/repo` resolves to the same workspace-hash memory that repo would use as the current workspace. `append` adds the text as-is with a clean newline boundary; it does not add dates or headings. `list` reports global memory, named project memories, and the current workspace memory when present; it does not scan your filesystem for repos.
|
|
628
|
+
|
|
629
|
+
Provider-private memory files, such as Codex SQLite memory databases or Claude Code project memory files, are not edited by `cdx memory`.
|
|
630
|
+
|
|
620
631
|
### Disk maintenance
|
|
621
632
|
|
|
622
633
|
Use `cdx disk` for a total `CDX_HOME` measurement, and `cdx disk profiles` for the `profiles/` total plus a per-profile breakdown.
|
|
@@ -683,7 +694,7 @@ Without `--yes`, an interactive confirmation is required. `cdx` sends an idempot
|
|
|
683
694
|
## Troubleshooting
|
|
684
695
|
|
|
685
696
|
- **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
|
|
686
|
-
- **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file` and `
|
|
697
|
+
- **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
|
|
687
698
|
- **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
|
|
688
699
|
- **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
|
|
689
700
|
- **Status shows `n/a` for all fields** — the Codex app-server rate-limit probe may be unavailable, the session may not be authenticated, and no legacy transcript/history status has been captured yet.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CDX Manager 0.11.2
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Improved Codex Business auth diagnostics without changing login or launch behavior.
|
|
6
|
+
- Added stale-auth log warnings to help repair the affected profile only.
|
|
7
|
+
- Documented the Business `account_id` ambiguity and the `cdx login <name>` repair path.
|
|
8
|
+
|
|
9
|
+
## Changes
|
|
10
|
+
|
|
11
|
+
### Codex Business auth diagnostics
|
|
12
|
+
|
|
13
|
+
`cdx doctor` now reports shared Codex `tokens.account_id` values as workspace-level evidence on Business plans, not proof that two profiles are using the same user account. When recent `/status` transcript output exposes different observed users for profiles that share the same account id, doctor marks that as expected instead of a duplicate-user warning.
|
|
14
|
+
|
|
15
|
+
Codex auth diagnostics now include a masked `account_id` and an observed account from recent logs when available. Raw tokens remain excluded from JSON output.
|
|
16
|
+
|
|
17
|
+
### Stale auth hints
|
|
18
|
+
|
|
19
|
+
`cdx doctor` scans recent Codex profile logs for common expired-auth markers such as `401`, `token_expired`, and `authentication token is expired`. When found, it emits `codex_stale_auth_logs` with a direct `cdx login <name>` recommendation for the affected isolated profile.
|
|
20
|
+
|
|
21
|
+
## Validation
|
|
22
|
+
|
|
23
|
+
- `npm run lint`
|
|
24
|
+
- `npm test`
|
|
25
|
+
- `logics-manager health`
|
|
26
|
+
- `logics-manager lint --require-status`
|
|
27
|
+
- `logics-manager audit --group-by-doc`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# CDX Manager 0.12.0
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Added explicit `cdx memory` management for current workspace, global, and project-scoped memory.
|
|
6
|
+
- Added append/list workflows for user-controlled Markdown memory without touching provider-private memory stores.
|
|
7
|
+
- Improved `cdx doctor` provider visibility with Codex and Claude CLI versions plus conservative capability hints.
|
|
8
|
+
|
|
9
|
+
## Changes
|
|
10
|
+
|
|
11
|
+
### Scoped memory
|
|
12
|
+
|
|
13
|
+
`cdx memory` now manages explicit local memory using the existing shared context store:
|
|
14
|
+
|
|
15
|
+
- `cdx memory append <text>` appends to the current workspace memory.
|
|
16
|
+
- `cdx memory --global append <text>` appends to a global operator memory.
|
|
17
|
+
- `cdx memory --project <name> append <text>` appends to a stable named project memory.
|
|
18
|
+
- `cdx memory --project <path> ...` resolves to the same workspace-hash memory used by that repo path.
|
|
19
|
+
- `cdx memory list [--json]` lists global memory, named project memories, and the current workspace memory when present.
|
|
20
|
+
|
|
21
|
+
Append is intentionally plain text with a clean newline boundary; it does not add dates or headings automatically.
|
|
22
|
+
|
|
23
|
+
### Context compatibility
|
|
24
|
+
|
|
25
|
+
`cdx context append <text>` was added for the existing workspace context command. Existing `cdx context` and `cdx handoff` behavior remains compatible.
|
|
26
|
+
|
|
27
|
+
### Doctor provider versions
|
|
28
|
+
|
|
29
|
+
`cdx doctor` now records provider CLI versions when available:
|
|
30
|
+
|
|
31
|
+
- `codex --version`
|
|
32
|
+
- `claude --version`
|
|
33
|
+
|
|
34
|
+
JSON output includes conservative capability hints for recent provider memory and diagnostic surfaces. These hints are informational; `cdx` still does not read or write Codex SQLite memory, Claude Code project memory, `CLAUDE.md`, or provider plugin files.
|
|
35
|
+
|
|
36
|
+
## Validation
|
|
37
|
+
|
|
38
|
+
- `PYTHONPATH=src python3 -m pytest -q`
|
|
39
|
+
- `python3 -m ruff check src test`
|
|
40
|
+
- `logics-manager flow validate req_013_add_a_small_workspace_memory_command_to_cdx`
|
|
41
|
+
- `logics-manager lint --require-status`
|
|
42
|
+
- `logics-manager audit --group-by-doc`
|
|
@@ -140,6 +140,14 @@
|
|
|
140
140
|
"v0.11.0": {
|
|
141
141
|
"github_tarball_sha256": "bc42f83f20ba1c6ff81f824c382801457f93cf8cb166acbe952c12d009560a70",
|
|
142
142
|
"github_zip_sha256": "1c03b4f03f53c46d0a5ba580fc8ade7b26fa3cb1bb5f029f703681f136b9ac1f"
|
|
143
|
+
},
|
|
144
|
+
"v0.11.1": {
|
|
145
|
+
"github_tarball_sha256": "33ee5a76cd217007468ea620fa725644dfd62179585551b5703129c00a5094d5",
|
|
146
|
+
"github_zip_sha256": "8b53832ad2af109c22a898782bd36e1408356fc2bd0c3f99ca2ffb1262e2325b"
|
|
147
|
+
},
|
|
148
|
+
"v0.11.2": {
|
|
149
|
+
"github_tarball_sha256": "513d7c586a662da5ac5daf792fc76f746e924bbd95157a9d0cbbeba34f2991e0",
|
|
150
|
+
"github_zip_sha256": "d6d633efcbdc28818f68bd87efe3f246fe2983a9f4aa24d03868c978645c593e"
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -32,6 +32,7 @@ from .cli_commands import (
|
|
|
32
32
|
handle_launch_setting_alias,
|
|
33
33
|
handle_login,
|
|
34
34
|
handle_logout,
|
|
35
|
+
handle_memory,
|
|
35
36
|
handle_next,
|
|
36
37
|
handle_notify,
|
|
37
38
|
handle_remove,
|
|
@@ -77,7 +78,7 @@ from .status_view import (
|
|
|
77
78
|
)
|
|
78
79
|
from .update_check import check_for_update, check_logics_manager_for_update
|
|
79
80
|
|
|
80
|
-
VERSION = "0.
|
|
81
|
+
VERSION = "0.12.0"
|
|
81
82
|
|
|
82
83
|
# Public surface: this module is a facade. Names below are imported above
|
|
83
84
|
# purely to be re-exported (consumed by tests and external callers); listing
|
|
@@ -117,6 +118,7 @@ _COMMAND_HANDLERS = {
|
|
|
117
118
|
"last": handle_last,
|
|
118
119
|
"login": handle_login,
|
|
119
120
|
"logout": handle_logout,
|
|
121
|
+
"memory": handle_memory,
|
|
120
122
|
"next": handle_next,
|
|
121
123
|
"notify": handle_notify,
|
|
122
124
|
"repair": handle_repair,
|
|
@@ -185,7 +187,8 @@ def _print_help(use_color=False):
|
|
|
185
187
|
f" {_style('cdx runs [--limit N] --json', '36', use_color)}",
|
|
186
188
|
f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
|
|
187
189
|
f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
|
|
188
|
-
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
190
|
+
f" {_style('cdx context show|path|init|edit|clear|set|append [text...] [--json]', '36', use_color)}",
|
|
191
|
+
f" {_style('cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]', '36', use_color)}",
|
|
189
192
|
f" {_style('cdx disk [profiles] [--candidates] [--json]', '36', use_color)}",
|
|
190
193
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
191
194
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -18,7 +18,8 @@ REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
|
18
18
|
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
19
19
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
20
20
|
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
21
|
-
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
21
|
+
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text...] [--json]"
|
|
22
|
+
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
22
23
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
23
24
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
24
25
|
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
|
package/src/cli_commands.py
CHANGED
|
@@ -20,6 +20,7 @@ from .cli_args import (
|
|
|
20
20
|
DOCTOR_USAGE,
|
|
21
21
|
HANDOFF_USAGE,
|
|
22
22
|
LAST_USAGE,
|
|
23
|
+
MEMORY_USAGE,
|
|
23
24
|
REPAIR_USAGE,
|
|
24
25
|
RESUME_USAGE,
|
|
25
26
|
RUN_REPORT_USAGE,
|
|
@@ -77,13 +78,23 @@ from .cli_view import handle_view as handle_view # re-export for cli.py / tests
|
|
|
77
78
|
from .codex_usage import consume_codex_rate_limit_reset_credit
|
|
78
79
|
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
79
80
|
from .context_store import (
|
|
81
|
+
append_context,
|
|
82
|
+
append_context_path,
|
|
80
83
|
clear_context,
|
|
84
|
+
clear_context_path,
|
|
81
85
|
edit_context,
|
|
86
|
+
edit_context_path,
|
|
82
87
|
get_context_path,
|
|
88
|
+
get_global_context_path,
|
|
89
|
+
get_named_project_context_path,
|
|
83
90
|
init_context,
|
|
91
|
+
init_context_path,
|
|
84
92
|
install_context_for_session,
|
|
93
|
+
list_named_project_contexts,
|
|
85
94
|
read_context,
|
|
95
|
+
read_context_path,
|
|
86
96
|
write_context,
|
|
97
|
+
write_context_path,
|
|
87
98
|
)
|
|
88
99
|
from .errors import CdxError
|
|
89
100
|
from .fs_utils import atomic_write
|
|
@@ -2038,9 +2049,228 @@ def handle_context(rest, ctx):
|
|
|
2038
2049
|
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
2039
2050
|
return 0
|
|
2040
2051
|
|
|
2052
|
+
if action == "append":
|
|
2053
|
+
note = " ".join(args[1:])
|
|
2054
|
+
if not note.strip():
|
|
2055
|
+
raise CdxError("Usage: cdx context append <text> [--json]")
|
|
2056
|
+
result = append_context(base_dir, note, cwd)
|
|
2057
|
+
if json_flag:
|
|
2058
|
+
_write_json(ctx, _json_success("context.append", "Appended shared context", context=result))
|
|
2059
|
+
return 0
|
|
2060
|
+
text = f"Appended shared context: {result['path']}"
|
|
2061
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
2062
|
+
return 0
|
|
2063
|
+
|
|
2041
2064
|
raise CdxError(CONTEXT_USAGE)
|
|
2042
2065
|
|
|
2043
2066
|
|
|
2067
|
+
def _parse_memory_args(rest):
|
|
2068
|
+
json_flag, args = _parse_json_flag(rest)
|
|
2069
|
+
scope = "current"
|
|
2070
|
+
project = None
|
|
2071
|
+
cleaned = []
|
|
2072
|
+
index = 0
|
|
2073
|
+
while index < len(args):
|
|
2074
|
+
arg = args[index]
|
|
2075
|
+
if arg == "--global":
|
|
2076
|
+
if scope != "current":
|
|
2077
|
+
raise CdxError(MEMORY_USAGE)
|
|
2078
|
+
scope = "global"
|
|
2079
|
+
index += 1
|
|
2080
|
+
continue
|
|
2081
|
+
if arg == "--project":
|
|
2082
|
+
if scope != "current" or index + 1 >= len(args) or not args[index + 1].strip():
|
|
2083
|
+
raise CdxError(MEMORY_USAGE)
|
|
2084
|
+
scope = "project"
|
|
2085
|
+
project = args[index + 1].strip()
|
|
2086
|
+
index += 2
|
|
2087
|
+
continue
|
|
2088
|
+
if arg.startswith("--project="):
|
|
2089
|
+
if scope != "current":
|
|
2090
|
+
raise CdxError(MEMORY_USAGE)
|
|
2091
|
+
project = arg.split("=", 1)[1].strip()
|
|
2092
|
+
if not project:
|
|
2093
|
+
raise CdxError(MEMORY_USAGE)
|
|
2094
|
+
scope = "project"
|
|
2095
|
+
index += 1
|
|
2096
|
+
continue
|
|
2097
|
+
cleaned.append(arg)
|
|
2098
|
+
index += 1
|
|
2099
|
+
if not cleaned:
|
|
2100
|
+
cleaned = ["show"]
|
|
2101
|
+
if cleaned[0] == "view":
|
|
2102
|
+
cleaned[0] = "show"
|
|
2103
|
+
return json_flag, scope, project, cleaned
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _memory_target(base_dir, cwd, scope, project):
|
|
2107
|
+
if scope == "global":
|
|
2108
|
+
return {"scope": "global", "path": get_global_context_path(base_dir)}
|
|
2109
|
+
if scope == "project":
|
|
2110
|
+
if os.path.exists(project):
|
|
2111
|
+
return {
|
|
2112
|
+
"scope": "project",
|
|
2113
|
+
"project": project,
|
|
2114
|
+
"project_kind": "path",
|
|
2115
|
+
"path": get_context_path(base_dir, project),
|
|
2116
|
+
}
|
|
2117
|
+
return {
|
|
2118
|
+
"scope": "project",
|
|
2119
|
+
"project": project,
|
|
2120
|
+
"project_kind": "name",
|
|
2121
|
+
"path": get_named_project_context_path(base_dir, project),
|
|
2122
|
+
}
|
|
2123
|
+
return {"scope": "current", "path": get_context_path(base_dir, cwd)}
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def _memory_payload(target, content=None):
|
|
2127
|
+
payload = {
|
|
2128
|
+
"scope": target["scope"],
|
|
2129
|
+
"path": target["path"],
|
|
2130
|
+
"exists": os.path.isfile(target["path"]),
|
|
2131
|
+
}
|
|
2132
|
+
if target.get("project") is not None:
|
|
2133
|
+
payload["project"] = target["project"]
|
|
2134
|
+
payload["project_kind"] = target.get("project_kind")
|
|
2135
|
+
if payload["exists"]:
|
|
2136
|
+
payload["bytes"] = os.path.getsize(target["path"])
|
|
2137
|
+
if content is not None:
|
|
2138
|
+
payload["content"] = content
|
|
2139
|
+
return payload
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def _list_memory_entries(base_dir, cwd):
|
|
2143
|
+
entries = []
|
|
2144
|
+
global_path = get_global_context_path(base_dir)
|
|
2145
|
+
if os.path.isfile(global_path):
|
|
2146
|
+
entries.append({"scope": "global", "path": global_path, "bytes": os.path.getsize(global_path)})
|
|
2147
|
+
entries.extend(list_named_project_contexts(base_dir))
|
|
2148
|
+
current_path = get_context_path(base_dir, cwd)
|
|
2149
|
+
if os.path.isfile(current_path):
|
|
2150
|
+
entries.append({"scope": "current", "path": current_path, "bytes": os.path.getsize(current_path)})
|
|
2151
|
+
return entries
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def handle_memory(rest, ctx):
|
|
2155
|
+
json_flag, scope, project, args = _parse_memory_args(rest)
|
|
2156
|
+
action = args[0]
|
|
2157
|
+
base_dir = ctx["service"]["base_dir"]
|
|
2158
|
+
cwd = ctx.get("cwd")
|
|
2159
|
+
|
|
2160
|
+
if action == "list":
|
|
2161
|
+
if len(args) != 1 or scope != "current":
|
|
2162
|
+
raise CdxError(MEMORY_USAGE)
|
|
2163
|
+
entries = _list_memory_entries(base_dir, cwd)
|
|
2164
|
+
if json_flag:
|
|
2165
|
+
_write_json(ctx, _json_success("memory.list", "Listed memory scopes", memories=entries))
|
|
2166
|
+
return 0
|
|
2167
|
+
if not entries:
|
|
2168
|
+
ctx["out"](f"{_dim('No cdx memory files found.', ctx['use_color'])}\n")
|
|
2169
|
+
return 0
|
|
2170
|
+
rows = [["SCOPE", "PROJECT", "SIZE", "PATH"]]
|
|
2171
|
+
for entry in entries:
|
|
2172
|
+
rows.append([
|
|
2173
|
+
entry["scope"],
|
|
2174
|
+
entry.get("project") or "-",
|
|
2175
|
+
_format_bytes(entry.get("bytes") or 0),
|
|
2176
|
+
entry["path"],
|
|
2177
|
+
])
|
|
2178
|
+
ctx["out"](_pad_table(rows) + "\n")
|
|
2179
|
+
return 0
|
|
2180
|
+
|
|
2181
|
+
target = _memory_target(base_dir, cwd, scope, project)
|
|
2182
|
+
path = target["path"]
|
|
2183
|
+
|
|
2184
|
+
if action == "show":
|
|
2185
|
+
if len(args) != 1:
|
|
2186
|
+
raise CdxError(MEMORY_USAGE)
|
|
2187
|
+
content = read_context_path(path)
|
|
2188
|
+
payload = _memory_payload(target, content=content)
|
|
2189
|
+
if json_flag:
|
|
2190
|
+
_write_json(ctx, _json_success("memory.show", "Loaded memory", memory=payload))
|
|
2191
|
+
return 0
|
|
2192
|
+
if content.strip():
|
|
2193
|
+
ctx["out"](content if content.endswith("\n") else f"{content}\n")
|
|
2194
|
+
else:
|
|
2195
|
+
ctx["out"](f"{_dim('No memory for this scope. Run: cdx memory append <text>', ctx['use_color'])}\n")
|
|
2196
|
+
return 0
|
|
2197
|
+
|
|
2198
|
+
if action == "path":
|
|
2199
|
+
if len(args) != 1:
|
|
2200
|
+
raise CdxError(MEMORY_USAGE)
|
|
2201
|
+
if json_flag:
|
|
2202
|
+
_write_json(ctx, _json_success("memory.path", "Resolved memory path", memory=_memory_payload(target)))
|
|
2203
|
+
return 0
|
|
2204
|
+
ctx["out"](f"{path}\n")
|
|
2205
|
+
return 0
|
|
2206
|
+
|
|
2207
|
+
if action == "init":
|
|
2208
|
+
if len(args) != 1:
|
|
2209
|
+
raise CdxError(MEMORY_USAGE)
|
|
2210
|
+
result = init_context_path(path)
|
|
2211
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2212
|
+
message = "Created memory" if result.get("created") else "Memory already exists"
|
|
2213
|
+
if json_flag:
|
|
2214
|
+
_write_json(ctx, _json_success("memory.init", message, memory=result))
|
|
2215
|
+
return 0
|
|
2216
|
+
ctx["out"](f"{_success(f'{message}: {path}', ctx['use_color'])}\n")
|
|
2217
|
+
return 0
|
|
2218
|
+
|
|
2219
|
+
if action == "edit":
|
|
2220
|
+
if len(args) != 1:
|
|
2221
|
+
raise CdxError(MEMORY_USAGE)
|
|
2222
|
+
result = edit_context_path(path, env=ctx.get("env"), spawn_sync=ctx.get("spawn_sync"))
|
|
2223
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2224
|
+
if json_flag:
|
|
2225
|
+
_write_json(ctx, _json_success("memory.edit", "Edited memory", memory=result))
|
|
2226
|
+
return 0
|
|
2227
|
+
ctx["out"](f"{_success(f'Edited memory: {path}', ctx['use_color'])}\n")
|
|
2228
|
+
return 0
|
|
2229
|
+
|
|
2230
|
+
if action == "clear":
|
|
2231
|
+
if len(args) != 1:
|
|
2232
|
+
raise CdxError(MEMORY_USAGE)
|
|
2233
|
+
result = clear_context_path(path)
|
|
2234
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2235
|
+
message = "Cleared memory" if result["removed"] else "No memory to clear"
|
|
2236
|
+
if json_flag:
|
|
2237
|
+
_write_json(ctx, _json_success("memory.clear", message, memory=result))
|
|
2238
|
+
return 0
|
|
2239
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
2240
|
+
return 0
|
|
2241
|
+
|
|
2242
|
+
if action == "set":
|
|
2243
|
+
content_args = args[1:]
|
|
2244
|
+
if content_args:
|
|
2245
|
+
content = " ".join(content_args)
|
|
2246
|
+
else:
|
|
2247
|
+
stdin = ctx["options"].get("stdin_data")
|
|
2248
|
+
if stdin is None and not ctx.get("stdin_is_tty"):
|
|
2249
|
+
stdin = sys.stdin.read()
|
|
2250
|
+
if stdin is None:
|
|
2251
|
+
raise CdxError("Usage: cdx memory set <text> [--json]")
|
|
2252
|
+
content = stdin
|
|
2253
|
+
result = write_context_path(path, content)
|
|
2254
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2255
|
+
if json_flag:
|
|
2256
|
+
_write_json(ctx, _json_success("memory.set", "Saved memory", memory=result))
|
|
2257
|
+
return 0
|
|
2258
|
+
ctx["out"](f"{_success(f'Saved memory: {path}', ctx['use_color'])}\n")
|
|
2259
|
+
return 0
|
|
2260
|
+
|
|
2261
|
+
if action == "append":
|
|
2262
|
+
note = " ".join(args[1:])
|
|
2263
|
+
result = append_context_path(path, note)
|
|
2264
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2265
|
+
if json_flag:
|
|
2266
|
+
_write_json(ctx, _json_success("memory.append", "Appended memory", memory=result))
|
|
2267
|
+
return 0
|
|
2268
|
+
ctx["out"](f"{_success(f'Appended memory: {path}', ctx['use_color'])}\n")
|
|
2269
|
+
return 0
|
|
2270
|
+
|
|
2271
|
+
raise CdxError(MEMORY_USAGE)
|
|
2272
|
+
|
|
2273
|
+
|
|
2044
2274
|
def handle_status(rest, ctx):
|
|
2045
2275
|
parsed = _parse_flag_args(rest, {
|
|
2046
2276
|
"--json": {"key": "json", "type": "bool", "default": False},
|
package/src/context_store.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import shlex
|
|
4
4
|
import subprocess
|
|
5
5
|
from datetime import datetime
|
|
6
|
+
from urllib.parse import quote, unquote
|
|
6
7
|
|
|
7
8
|
from .errors import CdxError
|
|
8
9
|
from .fs_utils import atomic_write
|
|
@@ -42,12 +43,43 @@ def get_context_path(base_dir, cwd=None):
|
|
|
42
43
|
return os.path.join(_context_dir(base_dir, cwd), "context.md")
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def get_global_context_path(base_dir):
|
|
47
|
+
return os.path.join(base_dir, "contexts", "global", "context.md")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_named_project_context_path(base_dir, project):
|
|
51
|
+
return os.path.join(base_dir, "contexts", "projects", quote(project, safe=""), "context.md")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_named_project_contexts(base_dir):
|
|
55
|
+
root = os.path.join(base_dir, "contexts", "projects")
|
|
56
|
+
try:
|
|
57
|
+
names = sorted(os.listdir(root))
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return []
|
|
60
|
+
rows = []
|
|
61
|
+
for name in names:
|
|
62
|
+
path = os.path.join(root, name, "context.md")
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
continue
|
|
65
|
+
rows.append({
|
|
66
|
+
"scope": "project",
|
|
67
|
+
"project": unquote(name),
|
|
68
|
+
"path": path,
|
|
69
|
+
"bytes": os.path.getsize(path),
|
|
70
|
+
})
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
|
|
45
74
|
def get_context_meta_path(base_dir, cwd=None):
|
|
46
75
|
return os.path.join(_context_dir(base_dir, cwd), "meta.json")
|
|
47
76
|
|
|
48
77
|
|
|
49
78
|
def read_context(base_dir, cwd=None):
|
|
50
|
-
|
|
79
|
+
return read_context_path(get_context_path(base_dir, cwd))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_context_path(path):
|
|
51
83
|
try:
|
|
52
84
|
with open(path, encoding="utf-8") as handle:
|
|
53
85
|
return handle.read()
|
|
@@ -56,7 +88,10 @@ def read_context(base_dir, cwd=None):
|
|
|
56
88
|
|
|
57
89
|
|
|
58
90
|
def write_context(base_dir, content, cwd=None):
|
|
59
|
-
|
|
91
|
+
return write_context_path(get_context_path(base_dir, cwd), content)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_context_path(path, content):
|
|
60
95
|
_ensure_dir(os.path.dirname(path))
|
|
61
96
|
atomic_write(path, content.rstrip() + "\n", mode=0o644)
|
|
62
97
|
return {
|
|
@@ -66,20 +101,40 @@ def write_context(base_dir, content, cwd=None):
|
|
|
66
101
|
}
|
|
67
102
|
|
|
68
103
|
|
|
104
|
+
def append_context(base_dir, note, cwd=None):
|
|
105
|
+
return append_context_path(get_context_path(base_dir, cwd), note)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def append_context_path(path, note):
|
|
109
|
+
note = str(note or "").strip()
|
|
110
|
+
if not note:
|
|
111
|
+
raise CdxError("Memory append requires text.")
|
|
112
|
+
current = read_context_path(path).rstrip()
|
|
113
|
+
content = f"{current}\n{note}" if current else note
|
|
114
|
+
return write_context_path(path, content)
|
|
115
|
+
|
|
116
|
+
|
|
69
117
|
def init_context(base_dir, cwd=None):
|
|
70
|
-
|
|
118
|
+
return init_context_path(get_context_path(base_dir, cwd))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def init_context_path(path):
|
|
122
|
+
current = read_context_path(path)
|
|
71
123
|
if current.strip():
|
|
72
124
|
return {
|
|
73
|
-
"path":
|
|
125
|
+
"path": path,
|
|
74
126
|
"created": False,
|
|
75
127
|
"bytes": len(current.encode("utf-8")),
|
|
76
128
|
}
|
|
77
|
-
result =
|
|
129
|
+
result = write_context_path(path, DEFAULT_CONTEXT_TEMPLATE)
|
|
78
130
|
return {**result, "created": True}
|
|
79
131
|
|
|
80
132
|
|
|
81
133
|
def clear_context(base_dir, cwd=None):
|
|
82
|
-
|
|
134
|
+
return clear_context_path(get_context_path(base_dir, cwd))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def clear_context_path(path):
|
|
83
138
|
try:
|
|
84
139
|
os.remove(path)
|
|
85
140
|
removed = True
|
|
@@ -89,7 +144,11 @@ def clear_context(base_dir, cwd=None):
|
|
|
89
144
|
|
|
90
145
|
|
|
91
146
|
def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
|
|
92
|
-
|
|
147
|
+
return edit_context_path(get_context_path(base_dir, cwd), editor=editor, env=env, spawn_sync=spawn_sync)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def edit_context_path(path, editor=None, env=None, spawn_sync=None):
|
|
151
|
+
result = init_context_path(path)
|
|
93
152
|
path = result["path"]
|
|
94
153
|
env = env or os.environ
|
|
95
154
|
editor = editor or env.get("VISUAL") or env.get("EDITOR")
|
package/src/health.py
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import shutil
|
|
5
|
+
import subprocess
|
|
4
6
|
import sys
|
|
5
7
|
import tempfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
6
9
|
from urllib.parse import quote, unquote
|
|
7
10
|
|
|
8
11
|
from .cli_render import _pad_table, _style
|
|
9
12
|
from .config import PROVIDER_CODEX
|
|
10
13
|
from .provider_runtime import codex_auth_diagnostic
|
|
14
|
+
from .status_source import _extract_account_identity
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
def _encode(name):
|
|
@@ -40,6 +44,8 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
40
44
|
path = shutil.which(command, path=env.get("PATH"))
|
|
41
45
|
status = "OK" if path else "WARN"
|
|
42
46
|
issues.append(_issue(status, f"{command}_cli", f"{command} CLI {'found' if path else 'not found'}", path))
|
|
47
|
+
if path:
|
|
48
|
+
issues.append(_provider_cli_version_issue(command, path, env, spawn_sync))
|
|
43
49
|
|
|
44
50
|
rtk_path = shutil.which("rtk", path=env.get("PATH"))
|
|
45
51
|
issues.append(_issue(
|
|
@@ -73,6 +79,7 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
73
79
|
issues.append(_check_cdx_home(base_dir))
|
|
74
80
|
sessions = service["list_sessions"]()
|
|
75
81
|
session_names = {session["name"] for session in sessions}
|
|
82
|
+
codex_diagnostics = []
|
|
76
83
|
for session in sessions:
|
|
77
84
|
name = session["name"]
|
|
78
85
|
root = session.get("sessionRoot") or service["get_session_root"](name)
|
|
@@ -82,16 +89,79 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
82
89
|
if not os.path.isfile(state_path):
|
|
83
90
|
issues.append(_issue("FAIL", "missing_state", f"session {name} state file is missing", state_path, True))
|
|
84
91
|
if session.get("provider") == PROVIDER_CODEX:
|
|
85
|
-
|
|
92
|
+
diag = codex_auth_diagnostic(session, spawn_sync=spawn_sync, env_override=env)
|
|
93
|
+
diag["observed_account"] = _recent_codex_observed_account(diag.get("auth_home"))
|
|
94
|
+
codex_diagnostics.append((session, diag))
|
|
95
|
+
issues.extend(_codex_auth_issues(session, diag))
|
|
86
96
|
|
|
97
|
+
issues.extend(_codex_shared_account_id_issues(codex_diagnostics))
|
|
87
98
|
issues.extend(_collect_profile_issues(base_dir, session_names))
|
|
88
99
|
return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
|
|
89
100
|
|
|
90
101
|
|
|
91
|
-
def
|
|
102
|
+
def _provider_cli_version_issue(command, path, env, spawn_sync=None):
|
|
103
|
+
result = _run_version_command(command, env, spawn_sync)
|
|
104
|
+
raw = (result.get("stdout") or result.get("stderr") or "").strip()
|
|
105
|
+
version = _extract_version(raw)
|
|
106
|
+
detail = {
|
|
107
|
+
"command": command,
|
|
108
|
+
"path": path,
|
|
109
|
+
"version": version,
|
|
110
|
+
"raw": raw,
|
|
111
|
+
"capabilities": _provider_capability_hints(command),
|
|
112
|
+
}
|
|
113
|
+
if version:
|
|
114
|
+
return _issue("OK", f"{command}_cli_version", f"{command} CLI version detected: {version}", detail)
|
|
115
|
+
return _issue("WARN", f"{command}_cli_version", f"{command} CLI version could not be detected", detail)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run_version_command(command, env, spawn_sync=None):
|
|
119
|
+
try:
|
|
120
|
+
if spawn_sync:
|
|
121
|
+
result = spawn_sync(command, ["--version"], {"env": env, "timeout": 5})
|
|
122
|
+
if isinstance(result, dict):
|
|
123
|
+
return result
|
|
124
|
+
return {
|
|
125
|
+
"stdout": getattr(result, "stdout", "") or "",
|
|
126
|
+
"stderr": getattr(result, "stderr", "") or "",
|
|
127
|
+
}
|
|
128
|
+
completed = subprocess.run(
|
|
129
|
+
[command, "--version"],
|
|
130
|
+
env=env,
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=5,
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
return {"stdout": completed.stdout or "", "stderr": completed.stderr or ""}
|
|
137
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
138
|
+
return {"stdout": "", "stderr": ""}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_version(text):
|
|
142
|
+
match = re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text or "")
|
|
143
|
+
return match.group(1) if match else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _provider_capability_hints(command):
|
|
147
|
+
if command == "codex":
|
|
148
|
+
return [
|
|
149
|
+
"version_inventory",
|
|
150
|
+
"provider_memory_import_surfaces_may_exist_in_recent_codex",
|
|
151
|
+
"provider_memory_is_not_modified_by_cdx_memory",
|
|
152
|
+
]
|
|
153
|
+
if command == "claude":
|
|
154
|
+
return [
|
|
155
|
+
"version_inventory",
|
|
156
|
+
"project_memory_and_stream_json_diagnostics_may_exist_in_recent_claude_code",
|
|
157
|
+
"provider_memory_is_not_modified_by_cdx_memory",
|
|
158
|
+
]
|
|
159
|
+
return ["version_inventory"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _codex_auth_issues(session, diag):
|
|
92
163
|
name = session["name"]
|
|
93
|
-
|
|
94
|
-
identity = diag.get("account_email") or "unknown account"
|
|
164
|
+
identity = diag.get("account_email") or diag.get("observed_account") or "unknown account"
|
|
95
165
|
issues = [
|
|
96
166
|
_issue(
|
|
97
167
|
"OK" if diag.get("auth_json_exists") else "WARN",
|
|
@@ -103,6 +173,8 @@ def _codex_auth_issues(session, spawn_sync=None, env=None):
|
|
|
103
173
|
"auth_json_exists": diag.get("auth_json_exists"),
|
|
104
174
|
"local_tokens_present": diag.get("local_tokens_present"),
|
|
105
175
|
"account_email": diag.get("account_email"),
|
|
176
|
+
"observed_account": diag.get("observed_account"),
|
|
177
|
+
"account_id": _mask_identifier(diag.get("account_id")),
|
|
106
178
|
},
|
|
107
179
|
)
|
|
108
180
|
]
|
|
@@ -120,9 +192,126 @@ def _codex_auth_issues(session, spawn_sync=None, env=None):
|
|
|
120
192
|
"live_error": diag.get("live_error"),
|
|
121
193
|
},
|
|
122
194
|
))
|
|
195
|
+
stale_auth = _recent_codex_auth_error(diag.get("auth_home"))
|
|
196
|
+
if stale_auth:
|
|
197
|
+
issues.append(_issue(
|
|
198
|
+
"WARN",
|
|
199
|
+
"codex_stale_auth_logs",
|
|
200
|
+
f"session {name} recent Codex logs mention expired auth; run cdx login {name} if disconnects continue",
|
|
201
|
+
{
|
|
202
|
+
"session": name,
|
|
203
|
+
"auth_home": diag.get("auth_home"),
|
|
204
|
+
"source": stale_auth["source"],
|
|
205
|
+
"mtime": stale_auth["mtime"],
|
|
206
|
+
"markers": stale_auth["markers"],
|
|
207
|
+
},
|
|
208
|
+
))
|
|
209
|
+
return issues
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _codex_shared_account_id_issues(codex_diagnostics):
|
|
213
|
+
by_account_id = {}
|
|
214
|
+
for session, diag in codex_diagnostics:
|
|
215
|
+
account_id = diag.get("account_id")
|
|
216
|
+
if account_id:
|
|
217
|
+
by_account_id.setdefault(account_id, []).append((session, diag))
|
|
218
|
+
|
|
219
|
+
issues = []
|
|
220
|
+
for account_id, rows in sorted(by_account_id.items()):
|
|
221
|
+
if len(rows) < 2:
|
|
222
|
+
continue
|
|
223
|
+
identities = sorted({
|
|
224
|
+
_identity_user_part(diag.get("account_email") or diag.get("observed_account"))
|
|
225
|
+
for _session, diag in rows
|
|
226
|
+
if _identity_user_part(diag.get("account_email") or diag.get("observed_account"))
|
|
227
|
+
})
|
|
228
|
+
sessions = [session["name"] for session, _diag in rows]
|
|
229
|
+
status = "OK" if len(identities) > 1 else "WARN"
|
|
230
|
+
message = (
|
|
231
|
+
"Codex profiles share a Business account_id but observed different users; account_id is not a user identity"
|
|
232
|
+
if status == "OK"
|
|
233
|
+
else "Codex profiles share account_id; on Business plans this may be a workspace id, not proof of duplicate user auth"
|
|
234
|
+
)
|
|
235
|
+
issues.append(_issue(
|
|
236
|
+
status,
|
|
237
|
+
"codex_shared_account_id",
|
|
238
|
+
message,
|
|
239
|
+
{
|
|
240
|
+
"sessions": sessions,
|
|
241
|
+
"account_id": _mask_identifier(account_id),
|
|
242
|
+
"observed_identities": identities,
|
|
243
|
+
},
|
|
244
|
+
))
|
|
123
245
|
return issues
|
|
124
246
|
|
|
125
247
|
|
|
248
|
+
def _identity_user_part(value):
|
|
249
|
+
if not value:
|
|
250
|
+
return None
|
|
251
|
+
return str(value).strip().lower().split()[0].split("(", 1)[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _mask_identifier(value):
|
|
255
|
+
if not value:
|
|
256
|
+
return None
|
|
257
|
+
text = str(value)
|
|
258
|
+
return text if len(text) <= 8 else f"{text[:6]}...{text[-4:]}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _recent_codex_observed_account(auth_home):
|
|
262
|
+
for path in _recent_codex_log_paths(auth_home):
|
|
263
|
+
text = _read_tail(path)
|
|
264
|
+
account = _extract_account_identity(text)
|
|
265
|
+
if account:
|
|
266
|
+
return account
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _recent_codex_auth_error(auth_home):
|
|
271
|
+
markers = ("token_expired", "authentication token is expired", "http 401")
|
|
272
|
+
for path in _recent_codex_log_paths(auth_home):
|
|
273
|
+
text = _read_tail(path).lower()
|
|
274
|
+
found = [marker for marker in markers if marker in text]
|
|
275
|
+
if found:
|
|
276
|
+
try:
|
|
277
|
+
mtime = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
|
|
278
|
+
except OSError:
|
|
279
|
+
mtime = None
|
|
280
|
+
return {"source": path, "mtime": mtime, "markers": found}
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _recent_codex_log_paths(auth_home):
|
|
285
|
+
if not auth_home:
|
|
286
|
+
return []
|
|
287
|
+
candidates = []
|
|
288
|
+
for root in (os.path.join(auth_home, "log"), os.path.join(auth_home, "sessions")):
|
|
289
|
+
if not os.path.isdir(root):
|
|
290
|
+
continue
|
|
291
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
292
|
+
dirnames[:] = [name for name in dirnames if name not in {"cache", "plugins", "skills", "sqlite"}]
|
|
293
|
+
for filename in filenames:
|
|
294
|
+
if filename.endswith((".log", ".jsonl")):
|
|
295
|
+
path = os.path.join(dirpath, filename)
|
|
296
|
+
try:
|
|
297
|
+
stat = os.stat(path)
|
|
298
|
+
except OSError:
|
|
299
|
+
continue
|
|
300
|
+
candidates.append((stat.st_mtime, path))
|
|
301
|
+
return [path for _mtime, path in sorted(candidates, reverse=True)[:16]]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _read_tail(path, max_bytes=128 * 1024):
|
|
305
|
+
try:
|
|
306
|
+
with open(path, "rb") as handle:
|
|
307
|
+
handle.seek(0, os.SEEK_END)
|
|
308
|
+
size = handle.tell()
|
|
309
|
+
handle.seek(max(0, size - max_bytes))
|
|
310
|
+
return handle.read().decode("utf-8", errors="replace")
|
|
311
|
+
except OSError:
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
|
|
126
315
|
def _check_cdx_home(base_dir):
|
|
127
316
|
try:
|
|
128
317
|
os.makedirs(base_dir, exist_ok=True)
|
package/src/provider_runtime.py
CHANGED
|
@@ -166,6 +166,19 @@ def _read_codex_account_email(auth_home):
|
|
|
166
166
|
return None
|
|
167
167
|
|
|
168
168
|
|
|
169
|
+
def _read_codex_account_id(auth_home):
|
|
170
|
+
try:
|
|
171
|
+
with open(os.path.join(auth_home, "auth.json"), encoding="utf-8") as handle:
|
|
172
|
+
auth = json.load(handle)
|
|
173
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
174
|
+
return None
|
|
175
|
+
tokens = auth.get("tokens") if isinstance(auth, dict) else {}
|
|
176
|
+
account_id = tokens.get("account_id") if isinstance(tokens, dict) else None
|
|
177
|
+
if not account_id:
|
|
178
|
+
return None
|
|
179
|
+
return str(account_id).strip() or None
|
|
180
|
+
|
|
181
|
+
|
|
169
182
|
def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
|
|
170
183
|
auth_home = _get_auth_home(session)
|
|
171
184
|
auth_path = os.path.join(auth_home, "auth.json")
|
|
@@ -174,6 +187,7 @@ def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
|
|
|
174
187
|
"auth_json_exists": os.path.isfile(auth_path),
|
|
175
188
|
"local_tokens_present": _has_local_codex_auth(auth_home),
|
|
176
189
|
"account_email": _read_codex_account_email(auth_home),
|
|
190
|
+
"account_id": _read_codex_account_id(auth_home),
|
|
177
191
|
"live_status": "unknown",
|
|
178
192
|
"live_error": None,
|
|
179
193
|
}
|