cdx-manager 0.4.4 → 0.5.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 +16 -4
- package/changelogs/CHANGELOGS_0_5_0.md +45 -0
- package/changelogs/CHANGELOGS_0_5_1.md +33 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +1 -0
- package/src/cli.py +25 -2
- package/src/cli_commands.py +162 -1
- package/src/cli_render.py +7 -0
- package/src/codex_usage.py +168 -0
- package/src/context_store.py +127 -0
- package/src/session_service.py +48 -2
- package/src/status_view.py +16 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -36,7 +36,9 @@ One command to launch any session. Zero auth juggling.
|
|
|
36
36
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
37
37
|
- **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
|
|
38
38
|
- **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, and last-updated timestamps for every session in one aligned table.
|
|
39
|
-
- **
|
|
39
|
+
- **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
|
|
40
|
+
- **Shared handoff context.** Keep a per-workspace Markdown context and install it into another assistant session before switching providers or accounts.
|
|
41
|
+
- **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
|
|
40
42
|
- **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
|
|
41
43
|
- **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
|
|
42
44
|
|
|
@@ -52,10 +54,12 @@ One command to launch any session. Zero auth juggling.
|
|
|
52
54
|
- Persistence:
|
|
53
55
|
- Session registry at `~/.cdx/sessions.json` (versioned JSON store).
|
|
54
56
|
- Per-session state at `~/.cdx/state/<name>.json`.
|
|
57
|
+
- Per-workspace shared context at `~/.cdx/contexts/<workspace-hash>/context.md`.
|
|
55
58
|
- Auth and provider data under `~/.cdx/profiles/<name>/`.
|
|
56
59
|
- All paths are URL-encoded to support arbitrary session names.
|
|
57
60
|
- Status resolution pipeline:
|
|
58
61
|
- Primary source: recorded status fields on the session record.
|
|
62
|
+
- Codex live source: `codex app-server` JSON-RPC `account/rateLimits/read`, normalized into 5-hour, weekly, reset, credit, and plan fields.
|
|
59
63
|
- 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.
|
|
60
64
|
- Claude status refreshes are cached briefly by default; pass `--refresh` to force a live rate-limit probe.
|
|
61
65
|
- If `script` is unavailable, Codex launch falls back to running without transcript capture.
|
|
@@ -122,7 +126,7 @@ For a specific version:
|
|
|
122
126
|
|
|
123
127
|
```bash
|
|
124
128
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
125
|
-
CDX_VERSION=v0.
|
|
129
|
+
CDX_VERSION=v0.5.1 sh install.sh
|
|
126
130
|
```
|
|
127
131
|
|
|
128
132
|
From source:
|
|
@@ -242,6 +246,10 @@ cdx status
|
|
|
242
246
|
| `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
|
|
243
247
|
| `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
|
|
244
248
|
| `cdx logout <name> [--json]` | Log out of a session |
|
|
249
|
+
| `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
|
|
250
|
+
| `cdx enable <name> [--json]` | Re-enable a disabled session |
|
|
251
|
+
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
252
|
+
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
245
253
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
246
254
|
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
247
255
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
@@ -277,6 +285,10 @@ Commands with machine-readable output:
|
|
|
277
285
|
- `cdx import ... --json`
|
|
278
286
|
- `cdx login ... --json`
|
|
279
287
|
- `cdx logout ... --json`
|
|
288
|
+
- `cdx disable ... --json`
|
|
289
|
+
- `cdx enable ... --json`
|
|
290
|
+
- `cdx context ... --json`
|
|
291
|
+
- `cdx handoff ... --json`
|
|
280
292
|
- `cdx doctor --json`
|
|
281
293
|
- `cdx repair --json`
|
|
282
294
|
- `cdx update --json`
|
|
@@ -435,7 +447,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
|
|
|
435
447
|
- **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
|
|
436
448
|
- **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
|
|
437
449
|
- **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
|
|
438
|
-
- **Status shows `n/a` for all fields** — the
|
|
450
|
+
- **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.
|
|
439
451
|
- **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
|
|
440
452
|
- **`cdx login` hangs** — the provider's login flow requires a browser or device code. Follow the on-screen instructions in the terminal that opened.
|
|
441
453
|
- **`make install` says `npm link` is not found** — ensure Node.js and npm are installed and in your PATH.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog (`0.4.4 -> 0.5.0`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added session disable/enable controls so an assistant can be kept in the registry while temporarily removed from launch rotation.
|
|
8
|
+
- Added shared workspace context commands to make provider/account handoff explicit when moving between Codex and Claude sessions.
|
|
9
|
+
- Prepared the package metadata for the 0.5.0 release across npm, PyPI, CLI version output, and README install examples.
|
|
10
|
+
|
|
11
|
+
## Session Disable Toggle
|
|
12
|
+
|
|
13
|
+
- Added `cdx disable <name> [--json]` and `cdx enable <name> [--json]`.
|
|
14
|
+
- Disabled sessions remain visible in the main list and status views with a `disabled` status.
|
|
15
|
+
- Disabled sessions sort after enabled sessions and are excluded from status priority recommendations.
|
|
16
|
+
- Launching a disabled session now fails with a clear `Session is disabled: <name>` error.
|
|
17
|
+
- Claude live status refresh skips disabled sessions.
|
|
18
|
+
|
|
19
|
+
## Shared Context Handoff
|
|
20
|
+
|
|
21
|
+
- Added per-workspace shared context storage at `~/.cdx/contexts/<workspace-hash>/context.md`.
|
|
22
|
+
- Added `cdx context show`, `path`, `init`, `edit`, `clear`, and `set`.
|
|
23
|
+
- Added `cdx handoff <name>` to install the current workspace context into the target session as `shared-context.md` before launching it.
|
|
24
|
+
- Added `cdx handoff <name> --json` for non-interactive integrations that only need to install the context and inspect the resulting paths.
|
|
25
|
+
- Reserved `context` and `handoff` as session names to avoid command collisions.
|
|
26
|
+
|
|
27
|
+
## Packaging
|
|
28
|
+
|
|
29
|
+
- Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.0`.
|
|
30
|
+
- Updated the README version badge, pinned installer example, command table, JSON command list, and data layout notes.
|
|
31
|
+
- Removed the accidental npm self-dependency from `package.json` and regenerated the lockfile.
|
|
32
|
+
|
|
33
|
+
## Validation and Regression Coverage
|
|
34
|
+
|
|
35
|
+
- Added CLI coverage for context storage, workspace isolation, and handoff installation.
|
|
36
|
+
- Added session-service coverage for disabled-session ordering, launch blocking, and new reserved command names.
|
|
37
|
+
- Kept the full Python test suite green.
|
|
38
|
+
|
|
39
|
+
## Validation and Regression Evidence
|
|
40
|
+
|
|
41
|
+
- `python -m pytest`
|
|
42
|
+
- `npm run lint`
|
|
43
|
+
- `npm test`
|
|
44
|
+
- `node bin/cdx.js --version`
|
|
45
|
+
- `npm pack --dry-run`
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog (`0.5.0 -> 0.5.1`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-22
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Replaced Codex status scraping with a structured local Codex app-server rate-limit probe.
|
|
8
|
+
- Kept transcript and JSONL status parsing as a legacy fallback when the local Codex app-server is unavailable.
|
|
9
|
+
- Prepared the package metadata for the 0.5.1 release across npm, PyPI, CLI version output, and README install examples.
|
|
10
|
+
|
|
11
|
+
## Codex Status Resolution
|
|
12
|
+
|
|
13
|
+
- Added a Codex app-server JSON-RPC client that calls `account/rateLimits/read` for each isolated Codex session profile.
|
|
14
|
+
- Normalized app-server rate-limit snapshots into the existing `cdx status` output fields: 5-hour remaining percentage, weekly remaining percentage, reset times, credits, and source metadata.
|
|
15
|
+
- Preserved multi-account isolation by running each probe with the session-specific `CODEX_HOME`.
|
|
16
|
+
- Updated status and launch tips so `/status` transcript capture is no longer presented as the normal Codex refresh path.
|
|
17
|
+
|
|
18
|
+
## Packaging
|
|
19
|
+
|
|
20
|
+
- Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.1`.
|
|
21
|
+
- Updated the README version badge and pinned installer example for `v0.5.1`.
|
|
22
|
+
|
|
23
|
+
## Validation and Regression Coverage
|
|
24
|
+
|
|
25
|
+
- Added runtime coverage for Codex app-server JSON-RPC probing and rate-limit normalization.
|
|
26
|
+
- Added session-service coverage proving app-server status is preferred over legacy transcript artifacts.
|
|
27
|
+
- Updated CLI coverage for the revised Codex status guidance.
|
|
28
|
+
|
|
29
|
+
## Validation and Regression Evidence
|
|
30
|
+
|
|
31
|
+
- `python3 -m unittest discover -s test -p 'test_*_py.py'`
|
|
32
|
+
- `python logics/skills/logics.py lint --require-status`
|
|
33
|
+
- `python3 bin/cdx status --json`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Terminal session manager for Codex and Claude accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexandre Agostini",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"checksums",
|
|
29
29
|
"changelogs",
|
|
30
30
|
"src",
|
|
31
|
+
"!src/__pycache__",
|
|
32
|
+
"!src/**/*.pyc",
|
|
31
33
|
"install.sh",
|
|
32
34
|
"install.ps1",
|
|
33
35
|
"pyproject.toml",
|
package/pyproject.toml
CHANGED
package/src/claude_refresh.py
CHANGED
|
@@ -36,6 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
36
36
|
claude_sessions = [
|
|
37
37
|
s for s in sessions
|
|
38
38
|
if s["provider"] == "claude"
|
|
39
|
+
and s.get("enabled", True) is not False
|
|
39
40
|
and (not target_names or s["name"] in target_names)
|
|
40
41
|
and (force or _is_stale(s, ttl_seconds=ttl_seconds))
|
|
41
42
|
]
|
package/src/cli.py
CHANGED
|
@@ -9,10 +9,14 @@ from .cli_commands import (
|
|
|
9
9
|
STATUS_USAGE,
|
|
10
10
|
handle_add,
|
|
11
11
|
handle_clean,
|
|
12
|
+
handle_context,
|
|
12
13
|
handle_copy,
|
|
13
14
|
handle_doctor,
|
|
15
|
+
handle_disable,
|
|
16
|
+
handle_enable,
|
|
14
17
|
handle_export,
|
|
15
18
|
handle_import,
|
|
19
|
+
handle_handoff,
|
|
16
20
|
handle_launch,
|
|
17
21
|
handle_login,
|
|
18
22
|
handle_logout,
|
|
@@ -45,7 +49,7 @@ from .status_view import (
|
|
|
45
49
|
)
|
|
46
50
|
from .update_check import check_for_update
|
|
47
51
|
|
|
48
|
-
VERSION = "0.
|
|
52
|
+
VERSION = "0.5.1"
|
|
49
53
|
|
|
50
54
|
|
|
51
55
|
# ---------------------------------------------------------------------------
|
|
@@ -62,11 +66,15 @@ def _print_help(use_color=False):
|
|
|
62
66
|
f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
|
|
63
67
|
f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
|
|
64
68
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
69
|
+
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
70
|
+
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
65
71
|
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
66
72
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
67
73
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
|
68
74
|
f" {_style('cdx login <name> [--json]', '36', use_color)}",
|
|
69
75
|
f" {_style('cdx logout <name> [--json]', '36', use_color)}",
|
|
76
|
+
f" {_style('cdx disable <name> [--json]', '36', use_color)}",
|
|
77
|
+
f" {_style('cdx enable <name> [--json]', '36', use_color)}",
|
|
70
78
|
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
71
79
|
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
72
80
|
f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
@@ -101,6 +109,8 @@ def format_json_error(error):
|
|
|
101
109
|
code = "unknown_command"
|
|
102
110
|
elif message.startswith("Session already exists:"):
|
|
103
111
|
code = "session_exists"
|
|
112
|
+
elif message.startswith("Session is disabled:"):
|
|
113
|
+
code = "session_disabled"
|
|
104
114
|
elif "requires an interactive terminal" in message or "requires confirmation" in message:
|
|
105
115
|
code = "interactive_terminal_required"
|
|
106
116
|
return json.dumps({
|
|
@@ -208,8 +218,9 @@ def main(argv, options=None):
|
|
|
208
218
|
"spawn_sync": spawn_sync,
|
|
209
219
|
"stdin_is_tty": stdin_is_tty,
|
|
210
220
|
"version": VERSION,
|
|
221
|
+
"cwd": options.get("cwd") or os.getcwd(),
|
|
211
222
|
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
212
|
-
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "login", "logout", "export", "import", "help", "version"
|
|
223
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
|
|
213
224
|
) else None,
|
|
214
225
|
"use_color": use_color,
|
|
215
226
|
}
|
|
@@ -226,6 +237,12 @@ def main(argv, options=None):
|
|
|
226
237
|
if command == "rmv":
|
|
227
238
|
return handle_remove(rest, ctx)
|
|
228
239
|
|
|
240
|
+
if command == "disable":
|
|
241
|
+
return handle_disable(rest, ctx)
|
|
242
|
+
|
|
243
|
+
if command == "enable":
|
|
244
|
+
return handle_enable(rest, ctx)
|
|
245
|
+
|
|
229
246
|
if command == "clean":
|
|
230
247
|
return handle_clean(rest, ctx)
|
|
231
248
|
|
|
@@ -247,6 +264,12 @@ def main(argv, options=None):
|
|
|
247
264
|
if command == "notify":
|
|
248
265
|
return handle_notify(rest, ctx)
|
|
249
266
|
|
|
267
|
+
if command == "context":
|
|
268
|
+
return handle_context(rest, ctx)
|
|
269
|
+
|
|
270
|
+
if command == "handoff":
|
|
271
|
+
return handle_handoff(rest, ctx)
|
|
272
|
+
|
|
250
273
|
if command == "status":
|
|
251
274
|
return handle_status(rest, ctx)
|
|
252
275
|
|
package/src/cli_commands.py
CHANGED
|
@@ -2,10 +2,20 @@ import asyncio
|
|
|
2
2
|
import getpass
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
7
8
|
from .claude_refresh import _refresh_claude_sessions
|
|
8
9
|
from .cli_render import _dim, _info, _success, _warn
|
|
10
|
+
from .context_store import (
|
|
11
|
+
clear_context,
|
|
12
|
+
edit_context,
|
|
13
|
+
get_context_path,
|
|
14
|
+
init_context,
|
|
15
|
+
install_context_for_session,
|
|
16
|
+
read_context,
|
|
17
|
+
write_context,
|
|
18
|
+
)
|
|
9
19
|
from .errors import CdxError
|
|
10
20
|
from .health import collect_health_report, format_health_report
|
|
11
21
|
from .notify import (
|
|
@@ -32,6 +42,8 @@ REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
|
32
42
|
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
33
43
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
34
44
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
45
|
+
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
46
|
+
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json]"
|
|
35
47
|
API_SCHEMA_VERSION = 1
|
|
36
48
|
|
|
37
49
|
|
|
@@ -90,6 +102,13 @@ def _parse_remove_args(args):
|
|
|
90
102
|
return {"name": names[0], "force": force}
|
|
91
103
|
|
|
92
104
|
|
|
105
|
+
def _parse_toggle_args(args, usage):
|
|
106
|
+
json_flag, cleaned = _parse_json_flag(args)
|
|
107
|
+
if len(cleaned) != 1:
|
|
108
|
+
raise CdxError(usage)
|
|
109
|
+
return {"name": cleaned[0], "json": json_flag}
|
|
110
|
+
|
|
111
|
+
|
|
93
112
|
def _read_option_value(args, index, usage):
|
|
94
113
|
if index + 1 >= len(args):
|
|
95
114
|
raise CdxError(usage)
|
|
@@ -371,6 +390,28 @@ def handle_remove(rest, ctx):
|
|
|
371
390
|
return 0
|
|
372
391
|
|
|
373
392
|
|
|
393
|
+
def handle_disable(rest, ctx):
|
|
394
|
+
parsed = _parse_toggle_args(rest, "Usage: cdx disable <name> [--json]")
|
|
395
|
+
session = ctx["service"]["set_session_enabled"](parsed["name"], False)
|
|
396
|
+
message = f"Disabled session {parsed['name']}"
|
|
397
|
+
if parsed["json"]:
|
|
398
|
+
_write_json(ctx, _json_success("disable", message, session=session))
|
|
399
|
+
return 0
|
|
400
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def handle_enable(rest, ctx):
|
|
405
|
+
parsed = _parse_toggle_args(rest, "Usage: cdx enable <name> [--json]")
|
|
406
|
+
session = ctx["service"]["set_session_enabled"](parsed["name"], True)
|
|
407
|
+
message = f"Enabled session {parsed['name']}"
|
|
408
|
+
if parsed["json"]:
|
|
409
|
+
_write_json(ctx, _json_success("enable", message, session=session))
|
|
410
|
+
return 0
|
|
411
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
374
415
|
def handle_clean(rest, ctx):
|
|
375
416
|
json_flag, args = _parse_json_flag(rest)
|
|
376
417
|
service = ctx["service"]
|
|
@@ -507,6 +548,103 @@ def handle_notify(rest, ctx):
|
|
|
507
548
|
return 0
|
|
508
549
|
|
|
509
550
|
|
|
551
|
+
def handle_context(rest, ctx):
|
|
552
|
+
json_flag, args = _parse_json_flag(rest)
|
|
553
|
+
if not args:
|
|
554
|
+
args = ["show"]
|
|
555
|
+
action = args[0]
|
|
556
|
+
base_dir = ctx["service"]["base_dir"]
|
|
557
|
+
cwd = ctx.get("cwd")
|
|
558
|
+
|
|
559
|
+
if action == "show":
|
|
560
|
+
if len(args) != 1:
|
|
561
|
+
raise CdxError(CONTEXT_USAGE)
|
|
562
|
+
content = read_context(base_dir, cwd)
|
|
563
|
+
payload = {
|
|
564
|
+
"path": get_context_path(base_dir, cwd),
|
|
565
|
+
"exists": bool(content.strip()),
|
|
566
|
+
"content": content,
|
|
567
|
+
}
|
|
568
|
+
if json_flag:
|
|
569
|
+
_write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
|
|
570
|
+
return 0
|
|
571
|
+
if content.strip():
|
|
572
|
+
ctx["out"](content if content.endswith("\n") else f"{content}\n")
|
|
573
|
+
else:
|
|
574
|
+
ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
if action == "path":
|
|
578
|
+
if len(args) != 1:
|
|
579
|
+
raise CdxError(CONTEXT_USAGE)
|
|
580
|
+
path = get_context_path(base_dir, cwd)
|
|
581
|
+
if json_flag:
|
|
582
|
+
_write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
|
|
583
|
+
return 0
|
|
584
|
+
ctx["out"](f"{path}\n")
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
if action == "init":
|
|
588
|
+
if len(args) != 1:
|
|
589
|
+
raise CdxError(CONTEXT_USAGE)
|
|
590
|
+
result = init_context(base_dir, cwd)
|
|
591
|
+
message = "Created shared context" if result.get("created") else "Shared context already exists"
|
|
592
|
+
if json_flag:
|
|
593
|
+
_write_json(ctx, _json_success("context.init", message, context=result))
|
|
594
|
+
return 0
|
|
595
|
+
text = f"{message}: {result['path']}"
|
|
596
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
if action == "edit":
|
|
600
|
+
if len(args) != 1:
|
|
601
|
+
raise CdxError(CONTEXT_USAGE)
|
|
602
|
+
result = edit_context(
|
|
603
|
+
base_dir,
|
|
604
|
+
cwd,
|
|
605
|
+
env=ctx.get("env"),
|
|
606
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
607
|
+
)
|
|
608
|
+
if json_flag:
|
|
609
|
+
_write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
|
|
610
|
+
return 0
|
|
611
|
+
text = f"Edited shared context: {result['path']}"
|
|
612
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
if action == "clear":
|
|
616
|
+
if len(args) != 1:
|
|
617
|
+
raise CdxError(CONTEXT_USAGE)
|
|
618
|
+
result = clear_context(base_dir, cwd)
|
|
619
|
+
message = "Cleared shared context" if result["removed"] else "No shared context to clear"
|
|
620
|
+
if json_flag:
|
|
621
|
+
_write_json(ctx, _json_success("context.clear", message, context=result))
|
|
622
|
+
return 0
|
|
623
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
if action == "set":
|
|
627
|
+
content_args = args[1:]
|
|
628
|
+
if content_args:
|
|
629
|
+
content = " ".join(content_args)
|
|
630
|
+
else:
|
|
631
|
+
stdin = ctx["options"].get("stdin_data")
|
|
632
|
+
if stdin is None and not ctx.get("stdin_is_tty"):
|
|
633
|
+
stdin = sys.stdin.read()
|
|
634
|
+
if stdin is None:
|
|
635
|
+
raise CdxError("Usage: cdx context set <text> [--json]")
|
|
636
|
+
content = stdin
|
|
637
|
+
result = write_context(base_dir, content, cwd)
|
|
638
|
+
if json_flag:
|
|
639
|
+
_write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
|
|
640
|
+
return 0
|
|
641
|
+
text = f"Saved shared context: {result['path']}"
|
|
642
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
643
|
+
return 0
|
|
644
|
+
|
|
645
|
+
raise CdxError(CONTEXT_USAGE)
|
|
646
|
+
|
|
647
|
+
|
|
510
648
|
def handle_status(rest, ctx):
|
|
511
649
|
json_flag = "--json" in rest
|
|
512
650
|
small_flag = "--small" in rest or "-s" in rest
|
|
@@ -808,7 +946,7 @@ def handle_launch(command, ctx):
|
|
|
808
946
|
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
809
947
|
if session["provider"] == "codex":
|
|
810
948
|
if not json_flag:
|
|
811
|
-
ctx["out"](f"{_dim('Tip:
|
|
949
|
+
ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
|
|
812
950
|
_run_interactive_provider_command(
|
|
813
951
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
814
952
|
signal_emitter=ctx.get("signal_emitter")
|
|
@@ -816,3 +954,26 @@ def handle_launch(command, ctx):
|
|
|
816
954
|
if json_flag:
|
|
817
955
|
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
818
956
|
return 0
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def handle_handoff(rest, ctx):
|
|
960
|
+
json_flag, args = _parse_json_flag(rest)
|
|
961
|
+
if len(args) != 1:
|
|
962
|
+
raise CdxError(HANDOFF_USAGE)
|
|
963
|
+
name = args[0]
|
|
964
|
+
session = ctx["service"]["get_session"](name)
|
|
965
|
+
if not session:
|
|
966
|
+
raise CdxError(f"Unknown session: {name}")
|
|
967
|
+
install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
|
|
968
|
+
if json_flag:
|
|
969
|
+
_write_json(ctx, _json_success(
|
|
970
|
+
"handoff",
|
|
971
|
+
f"Installed shared context for {name}",
|
|
972
|
+
context=install,
|
|
973
|
+
session=session,
|
|
974
|
+
))
|
|
975
|
+
return 0
|
|
976
|
+
text = f"Shared context installed for {name}: {install['target_path']}"
|
|
977
|
+
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
978
|
+
ctx["out"](f"{_dim('Ask the assistant to read shared-context.md if it does not pick it up automatically.', ctx['use_color'])}\n")
|
|
979
|
+
return handle_launch(name, ctx)
|
package/src/cli_render.py
CHANGED
|
@@ -108,6 +108,7 @@ def _format_sessions(service, use_color=False):
|
|
|
108
108
|
headers = ["SESSION"]
|
|
109
109
|
if has_provider:
|
|
110
110
|
headers.append("PROVIDER")
|
|
111
|
+
headers.append("STATUS")
|
|
111
112
|
headers.append("UPDATED")
|
|
112
113
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
113
114
|
table_rows = []
|
|
@@ -115,6 +116,8 @@ def _format_sessions(service, use_color=False):
|
|
|
115
116
|
parts = [r["name"]]
|
|
116
117
|
if has_provider:
|
|
117
118
|
parts.append(r.get("provider") or "n/a")
|
|
119
|
+
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
120
|
+
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
118
121
|
parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
|
|
119
122
|
table_rows.append(parts)
|
|
120
123
|
lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
|
|
@@ -124,6 +127,10 @@ def _format_sessions(service, use_color=False):
|
|
|
124
127
|
f" {_style('cdx <name>', '36', use_color)}",
|
|
125
128
|
f" {_style('cdx login <name>', '36', use_color)}",
|
|
126
129
|
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
130
|
+
f" {_style('cdx context show', '36', use_color)}",
|
|
131
|
+
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
132
|
+
f" {_style('cdx disable <name>', '36', use_color)}",
|
|
133
|
+
f" {_style('cdx enable <name>', '36', use_color)}",
|
|
127
134
|
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
128
135
|
f" {_style('cdx rmv <name>', '36', use_color)}",
|
|
129
136
|
f" {_style('cdx status', '36', use_color)}",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
9
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_reset_date(unix_seconds):
|
|
13
|
+
if unix_seconds is None:
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
dt = datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone()
|
|
17
|
+
except (TypeError, ValueError, OSError):
|
|
18
|
+
return None
|
|
19
|
+
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _remaining_from_used_percent(value):
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
return max(0, min(100, round(100 - float(value))))
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_window(snapshot, duration_mins):
|
|
32
|
+
for key in ("primary", "secondary"):
|
|
33
|
+
window = snapshot.get(key) or {}
|
|
34
|
+
if window.get("windowDurationMins") == duration_mins:
|
|
35
|
+
return window
|
|
36
|
+
if window.get("window_minutes") == duration_mins:
|
|
37
|
+
return window
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_codex_rate_limit_snapshot(snapshot):
|
|
42
|
+
if not snapshot:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
five_hour = _get_window(snapshot, 300)
|
|
46
|
+
weekly = _get_window(snapshot, 10080)
|
|
47
|
+
credits = snapshot.get("credits")
|
|
48
|
+
credit_balance = None
|
|
49
|
+
if isinstance(credits, dict):
|
|
50
|
+
credit_balance = credits.get("balance")
|
|
51
|
+
if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
|
|
52
|
+
credit_balance = None
|
|
53
|
+
elif credits is not None:
|
|
54
|
+
credit_balance = credits
|
|
55
|
+
|
|
56
|
+
reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
|
|
57
|
+
reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
|
|
58
|
+
|
|
59
|
+
raw_status_text = json.dumps(snapshot, sort_keys=True)
|
|
60
|
+
return {
|
|
61
|
+
"remaining_5h_pct": _remaining_from_used_percent(
|
|
62
|
+
five_hour.get("usedPercent", five_hour.get("used_percent"))
|
|
63
|
+
),
|
|
64
|
+
"remaining_week_pct": _remaining_from_used_percent(
|
|
65
|
+
weekly.get("usedPercent", weekly.get("used_percent"))
|
|
66
|
+
),
|
|
67
|
+
"credits": credit_balance,
|
|
68
|
+
"reset_5h_at": reset_5h_at,
|
|
69
|
+
"reset_week_at": reset_week_at,
|
|
70
|
+
"reset_at": reset_week_at or reset_5h_at,
|
|
71
|
+
"updated_at": datetime.now().astimezone().isoformat(),
|
|
72
|
+
"raw_status_text": raw_status_text,
|
|
73
|
+
"source_ref": "api:codex-app-server-rate-limits",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _reader_thread(stream, output):
|
|
78
|
+
try:
|
|
79
|
+
for line in stream:
|
|
80
|
+
output.put(line)
|
|
81
|
+
finally:
|
|
82
|
+
output.put(None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_response(output, request_id, timeout):
|
|
86
|
+
deadline = datetime.now().timestamp() + timeout
|
|
87
|
+
while datetime.now().timestamp() < deadline:
|
|
88
|
+
remaining = max(0.01, deadline - datetime.now().timestamp())
|
|
89
|
+
try:
|
|
90
|
+
line = output.get(timeout=remaining)
|
|
91
|
+
except queue.Empty:
|
|
92
|
+
break
|
|
93
|
+
if line is None:
|
|
94
|
+
break
|
|
95
|
+
try:
|
|
96
|
+
message = json.loads(line)
|
|
97
|
+
except (TypeError, json.JSONDecodeError):
|
|
98
|
+
continue
|
|
99
|
+
if message.get("id") == request_id:
|
|
100
|
+
return message
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_json_line(process, payload):
|
|
105
|
+
process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
106
|
+
process.stdin.flush()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
|
|
110
|
+
auth_home = session.get("authHome")
|
|
111
|
+
if not auth_home:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
env = os.environ.copy()
|
|
115
|
+
env["CODEX_HOME"] = auth_home
|
|
116
|
+
popen_factory = popen_factory or subprocess.Popen
|
|
117
|
+
process = None
|
|
118
|
+
output = queue.Queue()
|
|
119
|
+
try:
|
|
120
|
+
process = popen_factory(
|
|
121
|
+
["codex", "app-server", "--listen", "stdio://"],
|
|
122
|
+
stdin=subprocess.PIPE,
|
|
123
|
+
stdout=subprocess.PIPE,
|
|
124
|
+
stderr=subprocess.PIPE,
|
|
125
|
+
env=env,
|
|
126
|
+
text=True,
|
|
127
|
+
bufsize=1,
|
|
128
|
+
)
|
|
129
|
+
thread = threading.Thread(target=_reader_thread, args=(process.stdout, output), daemon=True)
|
|
130
|
+
thread.start()
|
|
131
|
+
_write_json_line(process, {
|
|
132
|
+
"jsonrpc": "2.0",
|
|
133
|
+
"id": 1,
|
|
134
|
+
"method": "initialize",
|
|
135
|
+
"params": {
|
|
136
|
+
"clientInfo": {"name": "cdx-manager", "version": "0"},
|
|
137
|
+
"capabilities": {"experimentalApi": True},
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
initialized = _read_response(output, 1, timeout)
|
|
141
|
+
if not initialized or initialized.get("error"):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
_write_json_line(process, {
|
|
145
|
+
"jsonrpc": "2.0",
|
|
146
|
+
"id": 2,
|
|
147
|
+
"method": "account/rateLimits/read",
|
|
148
|
+
"params": None,
|
|
149
|
+
})
|
|
150
|
+
response = _read_response(output, 2, timeout)
|
|
151
|
+
if not response or response.get("error"):
|
|
152
|
+
return None
|
|
153
|
+
result = response.get("result") or {}
|
|
154
|
+
by_limit = result.get("rateLimitsByLimitId") or {}
|
|
155
|
+
snapshot = by_limit.get("codex") or result.get("rateLimits")
|
|
156
|
+
return normalize_codex_rate_limit_snapshot(snapshot)
|
|
157
|
+
except (OSError, ValueError, BrokenPipeError):
|
|
158
|
+
return None
|
|
159
|
+
finally:
|
|
160
|
+
if process is not None:
|
|
161
|
+
try:
|
|
162
|
+
process.terminate()
|
|
163
|
+
process.wait(timeout=1)
|
|
164
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
165
|
+
try:
|
|
166
|
+
process.kill()
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .errors import CdxError
|
|
8
|
+
from .session_store import _ensure_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
## Current State
|
|
16
|
+
|
|
17
|
+
## Decisions
|
|
18
|
+
|
|
19
|
+
## Files Changed
|
|
20
|
+
|
|
21
|
+
## Open Questions
|
|
22
|
+
|
|
23
|
+
## Next Steps
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _local_now_iso():
|
|
28
|
+
return datetime.now().astimezone().isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _workspace_key(cwd):
|
|
32
|
+
normalized = os.path.realpath(cwd or os.getcwd())
|
|
33
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
34
|
+
return digest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _context_dir(base_dir, cwd):
|
|
38
|
+
return os.path.join(base_dir, "contexts", _workspace_key(cwd))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_context_path(base_dir, cwd=None):
|
|
42
|
+
return os.path.join(_context_dir(base_dir, cwd), "context.md")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_context_meta_path(base_dir, cwd=None):
|
|
46
|
+
return os.path.join(_context_dir(base_dir, cwd), "meta.json")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_context(base_dir, cwd=None):
|
|
50
|
+
path = get_context_path(base_dir, cwd)
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
53
|
+
return handle.read()
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_context(base_dir, content, cwd=None):
|
|
59
|
+
path = get_context_path(base_dir, cwd)
|
|
60
|
+
_ensure_dir(os.path.dirname(path))
|
|
61
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
62
|
+
handle.write(content.rstrip() + "\n")
|
|
63
|
+
return {
|
|
64
|
+
"path": path,
|
|
65
|
+
"updated_at": _local_now_iso(),
|
|
66
|
+
"bytes": os.path.getsize(path),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def init_context(base_dir, cwd=None):
|
|
71
|
+
current = read_context(base_dir, cwd)
|
|
72
|
+
if current.strip():
|
|
73
|
+
return {
|
|
74
|
+
"path": get_context_path(base_dir, cwd),
|
|
75
|
+
"created": False,
|
|
76
|
+
"bytes": len(current.encode("utf-8")),
|
|
77
|
+
}
|
|
78
|
+
result = write_context(base_dir, DEFAULT_CONTEXT_TEMPLATE, cwd)
|
|
79
|
+
return {**result, "created": True}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_context(base_dir, cwd=None):
|
|
83
|
+
path = get_context_path(base_dir, cwd)
|
|
84
|
+
try:
|
|
85
|
+
os.remove(path)
|
|
86
|
+
removed = True
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
removed = False
|
|
89
|
+
return {"path": path, "removed": removed}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
|
|
93
|
+
result = init_context(base_dir, cwd)
|
|
94
|
+
path = result["path"]
|
|
95
|
+
env = env or os.environ
|
|
96
|
+
editor = editor or env.get("VISUAL") or env.get("EDITOR")
|
|
97
|
+
if not editor:
|
|
98
|
+
raise CdxError("Context edit requires VISUAL or EDITOR.")
|
|
99
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
100
|
+
argv = shlex.split(editor) + [path]
|
|
101
|
+
if spawn_sync is subprocess.run:
|
|
102
|
+
completed = subprocess.run(argv)
|
|
103
|
+
returncode = completed.returncode
|
|
104
|
+
else:
|
|
105
|
+
completed = spawn_sync(argv[0], argv[1:], {"env": env})
|
|
106
|
+
returncode = completed.get("returncode", 0) if isinstance(completed, dict) else getattr(completed, "returncode", 0)
|
|
107
|
+
if returncode not in (0, None):
|
|
108
|
+
raise CdxError(f"Context editor exited with code {returncode}")
|
|
109
|
+
return {"path": path, "edited": True}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_context_for_session(base_dir, session, cwd=None):
|
|
113
|
+
content = read_context(base_dir, cwd)
|
|
114
|
+
if not content.strip():
|
|
115
|
+
raise CdxError("No shared context for this workspace. Run: cdx context init or cdx context set <text>")
|
|
116
|
+
auth_home = session.get("authHome") or session.get("sessionRoot")
|
|
117
|
+
if not auth_home:
|
|
118
|
+
raise CdxError(f"Session auth home missing for {session['name']}")
|
|
119
|
+
target_path = os.path.join(auth_home, "shared-context.md")
|
|
120
|
+
_ensure_dir(os.path.dirname(target_path))
|
|
121
|
+
with open(target_path, "w", encoding="utf-8") as handle:
|
|
122
|
+
handle.write(content.rstrip() + "\n")
|
|
123
|
+
return {
|
|
124
|
+
"source_path": get_context_path(base_dir, cwd),
|
|
125
|
+
"target_path": target_path,
|
|
126
|
+
"bytes": os.path.getsize(target_path),
|
|
127
|
+
}
|
package/src/session_service.py
CHANGED
|
@@ -9,6 +9,7 @@ from urllib.parse import quote
|
|
|
9
9
|
|
|
10
10
|
from .backup_bundle import decode_bundle, encode_bundle
|
|
11
11
|
from .config import get_cdx_home
|
|
12
|
+
from .codex_usage import fetch_codex_rate_limits
|
|
12
13
|
from .errors import CdxError
|
|
13
14
|
from .session_store import create_session_store
|
|
14
15
|
from .status_source import find_latest_status_artifact
|
|
@@ -19,10 +20,14 @@ MAX_SESSION_NAME_LENGTH = 64
|
|
|
19
20
|
RESERVED_SESSION_NAMES = {
|
|
20
21
|
"add",
|
|
21
22
|
"clean",
|
|
23
|
+
"context",
|
|
22
24
|
"cp",
|
|
25
|
+
"disable",
|
|
23
26
|
"doctor",
|
|
27
|
+
"enable",
|
|
24
28
|
"export",
|
|
25
29
|
"help",
|
|
30
|
+
"handoff",
|
|
26
31
|
"import",
|
|
27
32
|
"login",
|
|
28
33
|
"logout",
|
|
@@ -241,6 +246,7 @@ def create_session_service(options=None):
|
|
|
241
246
|
env = options.get("env", os.environ)
|
|
242
247
|
base_dir = options.get("base_dir") or get_cdx_home(env)
|
|
243
248
|
store = options.get("store") or create_session_store(base_dir)
|
|
249
|
+
codex_status_fetcher = options.get("fetchCodexRateLimits") or fetch_codex_rate_limits
|
|
244
250
|
|
|
245
251
|
def _get_session_root(name):
|
|
246
252
|
return os.path.join(base_dir, "profiles", _encode(name))
|
|
@@ -273,6 +279,7 @@ def create_session_service(options=None):
|
|
|
273
279
|
return {
|
|
274
280
|
"name": session["name"],
|
|
275
281
|
"provider": session["provider"],
|
|
282
|
+
"enabled": session.get("enabled", True) is not False,
|
|
276
283
|
"createdAt": session.get("createdAt"),
|
|
277
284
|
"updatedAt": session.get("updatedAt"),
|
|
278
285
|
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
@@ -325,6 +332,7 @@ def create_session_service(options=None):
|
|
|
325
332
|
session = {
|
|
326
333
|
"name": name,
|
|
327
334
|
"provider": normalized_provider,
|
|
335
|
+
"enabled": True,
|
|
328
336
|
"sessionRoot": session_root,
|
|
329
337
|
"authHome": auth_home,
|
|
330
338
|
"createdAt": now,
|
|
@@ -399,6 +407,7 @@ def create_session_service(options=None):
|
|
|
399
407
|
replacement = {
|
|
400
408
|
"name": dest_name,
|
|
401
409
|
"provider": source["provider"],
|
|
410
|
+
"enabled": True,
|
|
402
411
|
"sessionRoot": dest_root,
|
|
403
412
|
"authHome": dest_auth_home,
|
|
404
413
|
"createdAt": now,
|
|
@@ -485,6 +494,8 @@ def create_session_service(options=None):
|
|
|
485
494
|
session = store["get_session"](name)
|
|
486
495
|
if not session:
|
|
487
496
|
raise CdxError(f"Unknown session: {name}")
|
|
497
|
+
if session.get("enabled", True) is False:
|
|
498
|
+
raise CdxError(f"Session is disabled: {name}")
|
|
488
499
|
state = store["read_session_state"](name)
|
|
489
500
|
if not state:
|
|
490
501
|
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
@@ -515,6 +526,17 @@ def create_session_service(options=None):
|
|
|
515
526
|
def get_session(name):
|
|
516
527
|
return store["get_session"](name)
|
|
517
528
|
|
|
529
|
+
def set_session_enabled(name, enabled):
|
|
530
|
+
session = store["get_session"](name)
|
|
531
|
+
if not session:
|
|
532
|
+
raise CdxError(f"Unknown session: {name}")
|
|
533
|
+
now = _local_now_iso()
|
|
534
|
+
return store["update_session"](name, lambda s: {
|
|
535
|
+
**s,
|
|
536
|
+
"enabled": bool(enabled),
|
|
537
|
+
"updatedAt": now,
|
|
538
|
+
})
|
|
539
|
+
|
|
518
540
|
def record_status(name, payload):
|
|
519
541
|
normalized = _normalize_status_payload(payload)
|
|
520
542
|
updated = store["update_session"](name, lambda s: {
|
|
@@ -531,6 +553,12 @@ def create_session_service(options=None):
|
|
|
531
553
|
source_root = session.get("authHome") or _get_session_auth_home(
|
|
532
554
|
session["name"], session["provider"]
|
|
533
555
|
)
|
|
556
|
+
if session["provider"] == "codex" and codex_status_fetcher:
|
|
557
|
+
live_status = codex_status_fetcher({**session, "authHome": source_root})
|
|
558
|
+
if live_status:
|
|
559
|
+
record_status(session["name"], live_status)
|
|
560
|
+
return live_status
|
|
561
|
+
|
|
534
562
|
expected_account_email = (
|
|
535
563
|
_read_expected_account_email(source_root)
|
|
536
564
|
if session["provider"] == "codex"
|
|
@@ -605,10 +633,15 @@ def create_session_service(options=None):
|
|
|
605
633
|
|
|
606
634
|
def sort_key(s):
|
|
607
635
|
at = s.get("lastStatusAt") or ""
|
|
608
|
-
|
|
636
|
+
disabled_rank = 1 if s.get("enabled", True) is False else 0
|
|
637
|
+
return (disabled_rank, "" if at else "\xff", at, s["name"])
|
|
609
638
|
|
|
610
639
|
resolved.sort(key=sort_key)
|
|
611
|
-
resolved.
|
|
640
|
+
enabled = [s for s in resolved if s.get("enabled", True) is not False]
|
|
641
|
+
disabled = [s for s in resolved if s.get("enabled", True) is False]
|
|
642
|
+
enabled.reverse()
|
|
643
|
+
disabled.sort(key=lambda s: s["name"])
|
|
644
|
+
resolved = enabled + disabled
|
|
612
645
|
|
|
613
646
|
rows = []
|
|
614
647
|
for s in resolved:
|
|
@@ -616,6 +649,8 @@ def create_session_service(options=None):
|
|
|
616
649
|
rows.append({
|
|
617
650
|
"session_name": s["name"],
|
|
618
651
|
"provider": s["provider"],
|
|
652
|
+
"enabled": s.get("enabled", True) is not False,
|
|
653
|
+
"status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
619
654
|
"remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
|
|
620
655
|
"remaining_week_pct": status.get("remaining_week_pct") if status else None,
|
|
621
656
|
"credits": status.get("credits") if status else None,
|
|
@@ -631,9 +666,18 @@ def create_session_service(options=None):
|
|
|
631
666
|
sessions = list_sessions()
|
|
632
667
|
providers = {s["provider"] for s in sessions}
|
|
633
668
|
has_multiple = len(providers) > 1
|
|
669
|
+
sessions = sorted(
|
|
670
|
+
sessions,
|
|
671
|
+
key=lambda s: (
|
|
672
|
+
1 if s.get("enabled", True) is False else 0,
|
|
673
|
+
s.get("name", ""),
|
|
674
|
+
),
|
|
675
|
+
)
|
|
634
676
|
return [{
|
|
635
677
|
"name": s["name"],
|
|
636
678
|
"provider": s["provider"] if has_multiple else None,
|
|
679
|
+
"enabled": s.get("enabled", True) is not False,
|
|
680
|
+
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
637
681
|
"status": s.get("lastStatus"),
|
|
638
682
|
"updated_at": _to_local_iso(s.get("updatedAt")),
|
|
639
683
|
} for s in sessions]
|
|
@@ -723,6 +767,7 @@ def create_session_service(options=None):
|
|
|
723
767
|
session_record = {
|
|
724
768
|
**session_payload,
|
|
725
769
|
"provider": provider,
|
|
770
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
726
771
|
"sessionRoot": session_root,
|
|
727
772
|
"authHome": auth_home,
|
|
728
773
|
}
|
|
@@ -763,6 +808,7 @@ def create_session_service(options=None):
|
|
|
763
808
|
"ensure_session_state": ensure_session_state,
|
|
764
809
|
"list_sessions": list_sessions,
|
|
765
810
|
"get_session": get_session,
|
|
811
|
+
"set_session_enabled": set_session_enabled,
|
|
766
812
|
"record_status": record_status,
|
|
767
813
|
"update_auth_state": update_auth_state,
|
|
768
814
|
"get_status_rows": get_status_rows,
|
package/src/status_view.py
CHANGED
|
@@ -55,22 +55,29 @@ def _style_reset_time(value, use_color=False):
|
|
|
55
55
|
def _format_status_rows(rows, use_color=False, small=False):
|
|
56
56
|
has_provider = len({r["provider"] for r in rows}) > 1 and not small
|
|
57
57
|
if small:
|
|
58
|
-
headers = ["SESSION", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
|
|
58
|
+
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
|
|
59
59
|
elif has_provider:
|
|
60
|
-
headers = ["SESSION", "PROV.", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
60
|
+
headers = ["SESSION", "PROV.", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
61
61
|
else:
|
|
62
|
-
headers = ["SESSION", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
62
|
+
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
63
63
|
if not rows:
|
|
64
64
|
if small:
|
|
65
|
-
return "SESSION OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
66
|
-
return "SESSION OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
65
|
+
return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
66
|
+
return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
67
67
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
68
|
-
|
|
68
|
+
active_rows = [r for r in rows if r.get("enabled", True) is not False]
|
|
69
|
+
disabled_rows = sorted(
|
|
70
|
+
[r for r in rows if r.get("enabled", True) is False],
|
|
71
|
+
key=lambda r: r.get("session_name") or "",
|
|
72
|
+
)
|
|
73
|
+
priority = _recommend_priority_sessions(active_rows)
|
|
69
74
|
table_rows = []
|
|
70
|
-
for r in priority:
|
|
75
|
+
for r in priority + disabled_rows:
|
|
71
76
|
base = [r["session_name"]]
|
|
72
77
|
if has_provider:
|
|
73
78
|
base.append(r.get("provider") or "n/a")
|
|
79
|
+
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
80
|
+
base.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
74
81
|
usage_columns = [
|
|
75
82
|
_style_pct(r.get("available_pct"), use_color),
|
|
76
83
|
_style_pct(r.get("remaining_5h_pct"), use_color),
|
|
@@ -101,7 +108,7 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
101
108
|
_pad_table([headers] + table_rows),
|
|
102
109
|
"",
|
|
103
110
|
_style(priority_line, "1", use_color),
|
|
104
|
-
_style("Tip:
|
|
111
|
+
_style("Tip: Codex status uses the local app-server rate-limit API when available; Claude sessions auto-refresh, use --refresh to force.", "2", use_color),
|
|
105
112
|
])
|
|
106
113
|
|
|
107
114
|
|
|
@@ -258,6 +265,7 @@ def _format_status_detail(row, use_color=False):
|
|
|
258
265
|
lines = [
|
|
259
266
|
f"{_style('Session:', '1', use_color)} {row['session_name']}",
|
|
260
267
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
268
|
+
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
261
269
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|
|
262
270
|
f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
|
|
263
271
|
f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
|