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.
- package/README.md +47 -6
- package/changelogs/CHANGELOGS_0_16_0.md +97 -0
- package/changelogs/CHANGELOGS_0_17_0.md +64 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +324 -0
- package/src/cli.py +4 -6
- package/src/cli_args.py +4 -2
- package/src/cli_commands.py +1 -1
- package/src/cli_helpers.py +5 -2
- package/src/commands/auth.py +35 -52
- package/src/commands/launch.py +23 -1
- package/src/notify.py +125 -149
- package/src/provider_runtime.py +142 -3
- package/src/session_service.py +13 -1
package/src/provider_runtime.py
CHANGED
|
@@ -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)
|
package/src/session_service.py
CHANGED
|
@@ -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]
|