cdx-manager 0.16.0 → 0.17.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 +7 -1
- package/changelogs/CHANGELOGS_0_17_0.md +64 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +2 -2
- package/src/commands/launch.py +3 -0
- package/src/provider_runtime.py +137 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
6
6
|
|
|
@@ -421,6 +421,12 @@ cdx model provider:ollama default
|
|
|
421
421
|
|
|
422
422
|
`--model` maps to Codex `--model`, Claude `--model`, and Ollama `ollama run <model>`. `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`; supported values are `minimal`, `low`, `medium`, `high`, and `xhigh`. `--permission` maps to provider-native permission flags. Codex Fast mode is separate from reasoning effort: `--fast on` opts a Codex session into the Codex Fast service tier, while new sessions, `--fast off`, and default launches force the non-Fast `flex` tier. Use `--power low` when you want low reasoning effort without enabling Codex Fast credits. Existing legacy sessions that stored `fast=on` before this split continue to behave as low effort unless the user explicitly sets `--fast on` again. `--priority` is a 0..100 selector preference used as a tie-breaker after readiness and availability. `--rtk on` injects a launch instruction that encourages assistants to use RTK (`rtk <command>`) for noisy terminal commands when RTK is available, while keeping raw commands for exact output. Logics guidance is auto-enabled when `logics-manager` is available; use `--logics off` to disable that guidance for a session, or `--logics on` to pin it explicitly.
|
|
423
423
|
|
|
424
|
+
### Terminal Titles
|
|
425
|
+
|
|
426
|
+
Interactive launches and resumes set the terminal window title to `session — folder`, where `folder` is the basename of the launch directory - for example `work — cdx-manager`. The convention is identical for Claude, Codex, and Antigravity: it comes from the shared launch runtime, not from provider flags, and cdx keeps re-asserting it so a provider TUI cannot take the title back mid-session.
|
|
427
|
+
|
|
428
|
+
The title is only written when stdout is a terminal. Nothing is emitted for `--json` launches, redirected or piped output, `cdx run` and other headless runs, login flows, or Ollama sessions. Session names and directories are stripped of escape and control characters before the title is emitted, and cdx does not restore the previous title when the session ends.
|
|
429
|
+
|
|
424
430
|
### Launch History
|
|
425
431
|
|
|
426
432
|
Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# CDX Manager 0.17.0
|
|
2
|
+
|
|
3
|
+
> A terminal running four cdx sessions gave you four windows titled by whatever
|
|
4
|
+
> each provider felt like calling itself. The title now says which session it
|
|
5
|
+
> is and which repository it is in, and it says it the same way for Claude,
|
|
6
|
+
> Codex and Antigravity.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
### Interactive sessions title the terminal `session — folder`
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
work — cdx-manager
|
|
14
|
+
perso — logics-manager
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every interactive `cdx <name>` and `cdx resume <name>` sets the window title to
|
|
18
|
+
its session name and the basename of the launch directory. Both halves matter:
|
|
19
|
+
the session is the account you are spending, the folder is the work. Neither
|
|
20
|
+
alone tells you which window you are looking at when several are open.
|
|
21
|
+
|
|
22
|
+
The convention comes from cdx's own launch runtime rather than from provider
|
|
23
|
+
flags, which is why it is identical across Claude, Codex and Antigravity. No
|
|
24
|
+
provider argument changed, and no undocumented option is relied on — Codex and
|
|
25
|
+
Antigravity do not expose one, and depending on a private flag would make this
|
|
26
|
+
break on their next release.
|
|
27
|
+
|
|
28
|
+
A title written once at launch does not survive: a provider TUI sets its own
|
|
29
|
+
whenever it repaints. cdx keeps re-asserting it for as long as the session
|
|
30
|
+
runs, and releases it when the session ends — on the error and interrupt paths
|
|
31
|
+
as well as the normal one.
|
|
32
|
+
|
|
33
|
+
Nothing is emitted where there is no terminal to emit to. `--json` launches,
|
|
34
|
+
redirected or piped output, `cdx run` and other headless runs, and login flows
|
|
35
|
+
all stay byte-for-byte as they were; a title sequence in the middle of a JSON
|
|
36
|
+
document its caller has to parse is a bug, not a feature. Ollama is left alone
|
|
37
|
+
as a local model runner rather than a cdx-managed coding session.
|
|
38
|
+
|
|
39
|
+
Session names and directories are your data, so both are stripped of escape
|
|
40
|
+
and control characters before the title is written. A directory named with a
|
|
41
|
+
raw `ESC` would otherwise end the title string early and leave the rest of the
|
|
42
|
+
name to be read by the terminal as commands.
|
|
43
|
+
|
|
44
|
+
cdx does not restore the previous title when a session ends. Terminals do not
|
|
45
|
+
reliably report the title they had, so restoring it means guessing, and
|
|
46
|
+
guessing wrong is worse than leaving the last true statement in place.
|
|
47
|
+
|
|
48
|
+
## Validation
|
|
49
|
+
|
|
50
|
+
- `ruff check .`
|
|
51
|
+
- `python3 -m pytest` — 679 tests.
|
|
52
|
+
- Verified against a real pty rather than a mocked stream: the launch emits
|
|
53
|
+
`\x1b]0;work — cdx-manager\x07`.
|
|
54
|
+
- The non-TTY, `--json`, login and Ollama paths are each pinned by a test that
|
|
55
|
+
asserts nothing at all is written.
|
|
56
|
+
|
|
57
|
+
## Known limits
|
|
58
|
+
|
|
59
|
+
- The em dash is written as UTF-8. A terminal whose stdout encoding cannot
|
|
60
|
+
represent it — `LC_ALL=C`, for instance — gets no title rather than a
|
|
61
|
+
mangled one or a crash, since every title write is best-effort.
|
|
62
|
+
- The refresh interval is fixed at five seconds and is not configurable. A
|
|
63
|
+
provider that repaints its title more aggressively than that would show its
|
|
64
|
+
own for up to that long before cdx takes it back.
|
|
@@ -180,6 +180,10 @@
|
|
|
180
180
|
"v0.15.1": {
|
|
181
181
|
"github_tarball_sha256": "1dc7ea87600844e4853ab12c9f123dc67548ae904f511fea9a58ddf7b3eacfef",
|
|
182
182
|
"github_zip_sha256": "7ad41c63adffdd1ef12d02a72c86fb1b6a64cbcddd9970b0741ba5516f241707"
|
|
183
|
+
},
|
|
184
|
+
"v0.16.0": {
|
|
185
|
+
"github_tarball_sha256": "bdcd2808922410b20567ba86589d49166c5c2b9404325195b28457df80ca3dad",
|
|
186
|
+
"github_zip_sha256": "e89cb8c964304f0805d06da9d355d543f8a590e0febbf07e585a692ccf73ef99"
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
189
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -60,7 +60,7 @@ def read_hook_payload(args, stdin_text=None):
|
|
|
60
60
|
|
|
61
61
|
def compose_notification(payload, env=None, cwd=None):
|
|
62
62
|
"""Title and message for this event, or None if we should stay quiet."""
|
|
63
|
-
env = env
|
|
63
|
+
env = os.environ if env is None else env
|
|
64
64
|
if env.get(ENABLED_ENV) == "0":
|
|
65
65
|
# Headless runs share the session's home, so they read the same hooks.
|
|
66
66
|
# Their caller already learns of completion from the return value.
|
|
@@ -84,7 +84,7 @@ def handle_notify(rest, ctx):
|
|
|
84
84
|
reader = ctx.get("prompt_stdin") or sys.stdin
|
|
85
85
|
if reader is not None and not ctx.get("stdin_is_tty"):
|
|
86
86
|
stdin_text = reader.read()
|
|
87
|
-
env = ctx.get("env")
|
|
87
|
+
env = os.environ if ctx.get("env") is None else ctx["env"]
|
|
88
88
|
composed = compose_notification(read_hook_payload(rest, stdin_text), env, ctx.get("cwd"))
|
|
89
89
|
if composed:
|
|
90
90
|
send_desktop_notification(*composed, spawn_sync=ctx.get("spawn_sync"), env=env)
|
package/src/commands/launch.py
CHANGED
|
@@ -147,6 +147,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
147
147
|
session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
|
|
148
148
|
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
|
149
149
|
lifecycle_callback=runtime_lifecycle,
|
|
150
|
+
# A JSON run owns stdout: a terminal title sequence would land in
|
|
151
|
+
# the middle of the document its caller has to parse.
|
|
152
|
+
title_enabled=not json_flag,
|
|
150
153
|
)
|
|
151
154
|
except CdxError as error:
|
|
152
155
|
run_info = getattr(error, "run_info", {}) or {}
|
package/src/provider_runtime.py
CHANGED
|
@@ -7,6 +7,7 @@ import shutil
|
|
|
7
7
|
import signal
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
|
+
import threading
|
|
10
11
|
import uuid
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
|
|
@@ -24,6 +25,18 @@ from .config import (
|
|
|
24
25
|
from .errors import CdxError
|
|
25
26
|
|
|
26
27
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
28
|
+
TERMINAL_TITLE_SEPARATOR = " — "
|
|
29
|
+
# Providers redraw their own title while their TUI runs, so a single write at
|
|
30
|
+
# launch does not survive. Re-asserting on this interval takes the title back
|
|
31
|
+
# without being frequent enough to matter to the terminal.
|
|
32
|
+
TERMINAL_TITLE_REFRESH_SECONDS = 5.0
|
|
33
|
+
TERMINAL_TITLE_ACTIONS = ("launch", "resume")
|
|
34
|
+
# Ollama is deliberately absent: it is a local model runner, not a cdx-managed
|
|
35
|
+
# coding session, and its window title is left to it.
|
|
36
|
+
TERMINAL_TITLE_PROVIDERS = (PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_ANTIGRAVITY)
|
|
37
|
+
# ESC plus the C0/C1 control ranges: anything that could close the OSC string
|
|
38
|
+
# early or start a new escape sequence from session or directory names.
|
|
39
|
+
_TERMINAL_TITLE_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
27
40
|
AUTH_PROBE_TIMEOUT_SECONDS = 15
|
|
28
41
|
CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
|
|
29
42
|
AUTH_PROBE_AUTHENTICATED = "authenticated"
|
|
@@ -1120,12 +1133,112 @@ def _signal_name(sig):
|
|
|
1120
1133
|
return str(sig)
|
|
1121
1134
|
|
|
1122
1135
|
|
|
1136
|
+
def _sanitize_terminal_title_component(value):
|
|
1137
|
+
"""Strip anything that could escape the OSC title string.
|
|
1138
|
+
|
|
1139
|
+
Session names and directories are user data, so they are the injection
|
|
1140
|
+
vector here: a raw ESC or BEL inside either would end the title string and
|
|
1141
|
+
let the rest be read as terminal commands.
|
|
1142
|
+
"""
|
|
1143
|
+
if value is None:
|
|
1144
|
+
return ""
|
|
1145
|
+
return _TERMINAL_TITLE_CONTROL_CHARS.sub("", str(value)).strip()
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def format_terminal_title(session_name, cwd=None):
|
|
1149
|
+
"""Build the `session -- folder` title shared by every interactive provider.
|
|
1150
|
+
|
|
1151
|
+
The folder is the basename of the effective launch cwd, which is what
|
|
1152
|
+
identifies the work; the full path would not fit a title bar. Missing parts
|
|
1153
|
+
are dropped rather than rendered as an empty half.
|
|
1154
|
+
"""
|
|
1155
|
+
session_part = _sanitize_terminal_title_component(session_name)
|
|
1156
|
+
directory = os.path.abspath(cwd or os.getcwd())
|
|
1157
|
+
folder_part = _sanitize_terminal_title_component(os.path.basename(directory) or directory)
|
|
1158
|
+
parts = [part for part in (session_part, folder_part) if part]
|
|
1159
|
+
return TERMINAL_TITLE_SEPARATOR.join(parts)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _stream_is_tty(stream):
|
|
1163
|
+
try:
|
|
1164
|
+
return bool(stream is not None and stream.isatty())
|
|
1165
|
+
except (AttributeError, ValueError, OSError):
|
|
1166
|
+
return False
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class _TerminalTitleKeeper:
|
|
1170
|
+
"""Holds the terminal title for as long as the provider process runs.
|
|
1171
|
+
|
|
1172
|
+
A provider TUI sets its own title whenever it repaints, so this rewrites the
|
|
1173
|
+
OSC sequence on an interval instead of once. Every write is best-effort: a
|
|
1174
|
+
closed or unwritable stream ends the loop rather than the session.
|
|
1175
|
+
"""
|
|
1176
|
+
|
|
1177
|
+
def __init__(self, title, stream, interval=None):
|
|
1178
|
+
self.title = title
|
|
1179
|
+
self._sequence = f"\033]0;{title}\007"
|
|
1180
|
+
self._stream = stream
|
|
1181
|
+
self._interval = TERMINAL_TITLE_REFRESH_SECONDS if interval is None else interval
|
|
1182
|
+
self._stop_event = threading.Event()
|
|
1183
|
+
self._thread = None
|
|
1184
|
+
|
|
1185
|
+
def _write(self):
|
|
1186
|
+
try:
|
|
1187
|
+
self._stream.write(self._sequence)
|
|
1188
|
+
self._stream.flush()
|
|
1189
|
+
except Exception:
|
|
1190
|
+
return False
|
|
1191
|
+
return True
|
|
1192
|
+
|
|
1193
|
+
def start(self):
|
|
1194
|
+
if not self._write():
|
|
1195
|
+
return self
|
|
1196
|
+
if self._interval and self._interval > 0:
|
|
1197
|
+
self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
|
1198
|
+
self._thread.start()
|
|
1199
|
+
return self
|
|
1200
|
+
|
|
1201
|
+
def _refresh_loop(self):
|
|
1202
|
+
while not self._stop_event.wait(self._interval):
|
|
1203
|
+
if not self._write():
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
def stop(self):
|
|
1207
|
+
self._stop_event.set()
|
|
1208
|
+
thread = self._thread
|
|
1209
|
+
self._thread = None
|
|
1210
|
+
if thread is not None and thread is not threading.current_thread():
|
|
1211
|
+
thread.join(timeout=1)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _start_terminal_title(session, action, cwd=None, stream=None, enabled=True):
|
|
1215
|
+
"""Start title upkeep for an interactive launch/resume, or return None.
|
|
1216
|
+
|
|
1217
|
+
Returns None for every case that has no terminal to write to - auth
|
|
1218
|
+
actions, non-TTY stdout, JSON output, captured test streams - so callers can
|
|
1219
|
+
stop() unconditionally.
|
|
1220
|
+
"""
|
|
1221
|
+
if not enabled or action not in TERMINAL_TITLE_ACTIONS:
|
|
1222
|
+
return None
|
|
1223
|
+
if session.get("provider") not in TERMINAL_TITLE_PROVIDERS:
|
|
1224
|
+
return None
|
|
1225
|
+
stream = sys.stdout if stream is None else stream
|
|
1226
|
+
if not _stream_is_tty(stream):
|
|
1227
|
+
return None
|
|
1228
|
+
title = format_terminal_title(session.get("name"), cwd)
|
|
1229
|
+
if not title:
|
|
1230
|
+
return None
|
|
1231
|
+
return _TerminalTitleKeeper(title, stream).start()
|
|
1232
|
+
|
|
1233
|
+
|
|
1123
1234
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
1124
1235
|
env_override=None, signal_emitter=None,
|
|
1125
|
-
initial_prompt=None, lifecycle_callback=None
|
|
1236
|
+
initial_prompt=None, lifecycle_callback=None,
|
|
1237
|
+
title_stream=None, title_enabled=True):
|
|
1126
1238
|
kwargs = dict(spawn=spawn, cwd=cwd, env_override=env_override,
|
|
1127
1239
|
signal_emitter=signal_emitter, initial_prompt=initial_prompt,
|
|
1128
|
-
lifecycle_callback=lifecycle_callback
|
|
1240
|
+
lifecycle_callback=lifecycle_callback, title_stream=title_stream,
|
|
1241
|
+
title_enabled=title_enabled)
|
|
1129
1242
|
if session.get("provider") != PROVIDER_CODEX:
|
|
1130
1243
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
1131
1244
|
# Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
|
|
@@ -1142,7 +1255,28 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
1142
1255
|
|
|
1143
1256
|
def _run_interactive_provider_command_impl(session, action, spawn=None, cwd=None,
|
|
1144
1257
|
env_override=None, signal_emitter=None,
|
|
1145
|
-
initial_prompt=None, lifecycle_callback=None
|
|
1258
|
+
initial_prompt=None, lifecycle_callback=None,
|
|
1259
|
+
title_stream=None, title_enabled=True):
|
|
1260
|
+
# The title lives exactly as long as the provider process, including the
|
|
1261
|
+
# error and signal paths below, which is why it is held here rather than
|
|
1262
|
+
# inside the run itself.
|
|
1263
|
+
title_keeper = _start_terminal_title(
|
|
1264
|
+
session, action, cwd=cwd, stream=title_stream, enabled=title_enabled
|
|
1265
|
+
)
|
|
1266
|
+
try:
|
|
1267
|
+
return _run_interactive_provider_process(
|
|
1268
|
+
session, action, spawn=spawn, cwd=cwd, env_override=env_override,
|
|
1269
|
+
signal_emitter=signal_emitter, initial_prompt=initial_prompt,
|
|
1270
|
+
lifecycle_callback=lifecycle_callback,
|
|
1271
|
+
)
|
|
1272
|
+
finally:
|
|
1273
|
+
if title_keeper is not None:
|
|
1274
|
+
title_keeper.stop()
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _run_interactive_provider_process(session, action, spawn=None, cwd=None,
|
|
1278
|
+
env_override=None, signal_emitter=None,
|
|
1279
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
1146
1280
|
spawn = spawn or subprocess.Popen
|
|
1147
1281
|
if action == "launch":
|
|
1148
1282
|
spec = _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|