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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.16.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.17.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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 or os.environ
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") or os.environ
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)
@@ -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 {}
@@ -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)