cdx-manager 0.15.1 → 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.
@@ -7,9 +7,11 @@ 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
 
14
+ from .agent_notify import launch_notify_env, notifications_enabled
13
15
  from .claude_usage import _clean_oauth_token, _decode_jwt_claims
14
16
  from .codex_usage import codex_auth_lock
15
17
  from .config import (
@@ -23,6 +25,18 @@ from .config import (
23
25
  from .errors import CdxError
24
26
 
25
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]")
26
40
  AUTH_PROBE_TIMEOUT_SECONDS = 15
27
41
  CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
28
42
  AUTH_PROBE_AUTHENTICATED = "authenticated"
@@ -517,6 +531,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
517
531
  cwd = cwd or os.getcwd()
518
532
  env_override = env_override or {}
519
533
  env = {**os.environ, **env_override}
534
+ env.update(launch_notify_env(session, notifications_enabled(session)))
520
535
  initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
521
536
  _validate_initial_prompt(initial_prompt)
522
537
  if session["provider"] == PROVIDER_CLAUDE:
@@ -724,6 +739,9 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
724
739
  _validate_initial_prompt(initial_prompt)
725
740
  cwd = cwd or os.getcwd()
726
741
  env = {**os.environ, **(env_override or {})}
742
+ # Headless runs read the same home, and so the same hooks, as an interactive
743
+ # launch. Their caller already learns of completion from the return value.
744
+ env.update(launch_notify_env(session, enabled=False))
727
745
  initial_prompt = _with_launch_preferences(session, initial_prompt, env=env)
728
746
  _validate_initial_prompt(initial_prompt)
729
747
  launch = session.get("launch") or {}
@@ -1115,12 +1133,112 @@ def _signal_name(sig):
1115
1133
  return str(sig)
1116
1134
 
1117
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
+
1118
1234
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
1119
1235
  env_override=None, signal_emitter=None,
1120
- initial_prompt=None, lifecycle_callback=None):
1236
+ initial_prompt=None, lifecycle_callback=None,
1237
+ title_stream=None, title_enabled=True):
1121
1238
  kwargs = dict(spawn=spawn, cwd=cwd, env_override=env_override,
1122
1239
  signal_emitter=signal_emitter, initial_prompt=initial_prompt,
1123
- lifecycle_callback=lifecycle_callback)
1240
+ lifecycle_callback=lifecycle_callback, title_stream=title_stream,
1241
+ title_enabled=title_enabled)
1124
1242
  if session.get("provider") != PROVIDER_CODEX:
1125
1243
  return _run_interactive_provider_command_impl(session, action, **kwargs)
1126
1244
  # Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
@@ -1137,7 +1255,28 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
1137
1255
 
1138
1256
  def _run_interactive_provider_command_impl(session, action, spawn=None, cwd=None,
1139
1257
  env_override=None, signal_emitter=None,
1140
- 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):
1141
1280
  spawn = spawn or subprocess.Popen
1142
1281
  if action == "launch":
1143
1282
  spec = _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
@@ -211,6 +211,18 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
211
211
  normalized["logics"] = False
212
212
  else:
213
213
  raise CdxError(f"Unsupported logics value: {settings['logics']}")
214
+ if "notify" in settings and settings["notify"] is not None:
215
+ value = settings["notify"]
216
+ if isinstance(value, bool):
217
+ normalized["notify"] = value
218
+ else:
219
+ text = str(value).strip().lower()
220
+ if text in ("on", "true", "1", "yes"):
221
+ normalized["notify"] = True
222
+ elif text in ("off", "false", "0", "no"):
223
+ normalized["notify"] = False
224
+ else:
225
+ raise CdxError(f"Unsupported notify value: {settings['notify']}")
214
226
  if "model" in settings and settings["model"] is not None:
215
227
  model = str(settings["model"]).strip()
216
228
  if not model:
@@ -349,7 +361,7 @@ def unset_launch_settings(store, name, keys):
349
361
  if not keys:
350
362
  raise CdxError("At least one launch setting is required.")
351
363
  allowed = {
352
- "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
364
+ "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify",
353
365
  "model", "fallback_model", "budget", "extra_args", "priority",
354
366
  }
355
367
  unknown = [key for key in keys if key not in allowed]