diff --git a/README.md b/README.md
index 5624f30c..b6ad77d6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,33 @@
# Codex++
+
+ Codex++ 图标
+
+
Codex++ 是一个面向 Codex App 的外部增强启动器。它不修改 Codex App 原始安装文件,而是通过外部 launcher 启动 Codex,并使用 Chromium DevTools Protocol 向渲染进程注入增强脚本。
+## 目录
+
+- [功能](#功能)
+- [痛点](#痛点)
+- [解决效果](#解决效果)
+- [讨论交流](#讨论交流)
+- [友情链接](#友情链接)
+- [工作方式](#工作方式)
+- [环境要求](#环境要求)
+- [Windows 使用](#windows-使用)
+ - [图形菜单安装/卸载](#图形菜单安装卸载)
+ - [命令行安装](#命令行安装)
+ - [命令行卸载](#命令行卸载)
+- [macOS 使用](#macos-使用)
+ - [安装](#安装)
+ - [卸载](#卸载)
+- [直接启动](#直接启动)
+- [数据与备份](#数据与备份)
+- [常见问题](#常见问题)
+
+## 功能
+
当前功能:
- 在会话列表悬停显示"删除"按钮
@@ -39,7 +65,7 @@ Codex++ 启动后会解锁插件入口,并在会话列表悬停时显示删除
欢迎扫码加入 Codex++ 交流群,反馈问题、交流使用体验或提出新功能建议:
-Codex++ 交流群二维码
+[画像:f5885733b2d29c3cf56f70b625343189]
## 友情链接
@@ -193,6 +219,57 @@ Codex++ 默认读取 Codex 本地数据库:
~/.codex-session-delete/launcher.log
```
+## Windows 自动接管(可选)
+
+默认情况下 Codex++ 只在你**从 `Codex++` 快捷方式启动时**生效。如果你从开始菜单、任务栏或系统原生入口直接启动 Codex,那一次不会有注入,`Codex++` 菜单和插件解锁都不会出现。
+
+Windows 可以注册一个常驻 watcher 解决这个问题。它会每 3 秒探测一次本机 CDP 端口,发现 Codex 在跑但 CDP 没起来,就把这一批 Codex 进程杀掉、通过 launcher 重拉一次带注入的版本。这样不管你从哪里打开 Codex,都会被自动接管。
+
+注意代价:
+
+- 每次 Codex 通过原生路径启动,都会先打开一瞬间,再被 kill,再被 launcher 带 CDP 重开。视觉上是 1 ~ 2 秒的"打开→关闭→重开"闪烁。
+- watcher 以 `pythonw.exe` 常驻运行,登录时自动启动(通过 `HKCU\...\Run` 和 Startup 文件夹双路径注册,后者防止某些注册表清理工具干扰)。
+
+### 安装
+
+```bash
+python -m codex_session_delete watch-install
+```
+
+安装完成后 watcher 会立即启动,无需重启。
+
+### 卸载
+
+```bash
+python -m codex_session_delete watch-remove
+```
+
+`remove` / 系统卸载 Codex++ 时会自动连带执行 `watch-remove`,不需要手动处理。
+
+### 临时开关
+
+保留自启项、但让 watcher 不再自动接管:
+
+```bash
+python -m codex_session_delete watch-disable
+python -m codex_session_delete watch-enable
+```
+
+### 日志
+
+```text
+%USERPROFILE%\.codex-session-delete\watcher.log
+```
+
+示例:
+
+```text
+[...] watcher started (interval=3.0s)
+[...] Codex running without CDP (pids=[...]); attempting takeover
+[...] takeover: killing 4 codex pid(s): [...]
+[...] takeover: CDP is up on 9229 (launcher pid=...)
+```
+
## 常见问题
### 双击 Codex++ 没反应
@@ -248,6 +325,7 @@ codex_session_delete/
storage_adapter.py 本地 SQLite 删除/撤销
windows_installer.py Windows 快捷方式与卸载项
macos_installer.py macOS app bundle 安装
+ watcher.py Windows 常驻 watcher(可选,原生启动接管)
inject/renderer-inject.js
tests/ 自动化测试
diff --git a/codex_session_delete/__init__.py b/codex_session_delete/__init__.py
index 3dc1f76b..92192eed 100644
--- a/codex_session_delete/__init__.py
+++ b/codex_session_delete/__init__.py
@@ -1 +1 @@
-__version__ = "0.1.0"
+__version__ = "1.0.4"
diff --git a/codex_session_delete/app_paths.py b/codex_session_delete/app_paths.py
index b413b139..802b3d19 100644
--- a/codex_session_delete/app_paths.py
+++ b/codex_session_delete/app_paths.py
@@ -3,6 +3,7 @@
import os
import re
import sys
+import subprocess
from pathlib import Path
@@ -16,13 +17,33 @@ def _version_tuple(path: Path) -> tuple[int, ...]:
return tuple(int(part) for part in match.group(1).split(".") if part.isdigit())
-def find_latest_codex_app_dir(windows_apps_dir: Path | None = None) -> Path | None:
- root = windows_apps_dir or Path("C:/Program Files/WindowsApps")
- candidates = [path / "app" for path in root.glob("OpenAI.Codex_*_x64__*") if (path / "app").is_dir()]
- if not candidates:
- return None
- return max(candidates, key=lambda app_dir: _version_tuple(app_dir.parent))
+def find_latest_codex_app_dir(root: Path | None = None) -> Path | None:
+ if root is not None:
+ matches = [path for path in root.iterdir() if path.is_dir() and _version_tuple(path)]
+ if not matches:
+ return None
+ latest = max(matches, key=_version_tuple)
+ app = latest / "app"
+ return app if app.is_dir() else latest
+ cmd = 'Get-AppxPackage -Name "OpenAI.Codex" | Select-Object -ExpandProperty InstallLocation'
+ try:
+ r = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", cmd],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=8,
+ check=False,
+ )
+ if r.returncode != 0 or not (p := r.stdout.strip()):
+ return None
+ root = Path(p)
+ app = root / "app"
+ return app if app.is_dir() else root
+ except (OSError, subprocess.SubprocessError):
+ return None
def user_data_candidates() -> list[Path]:
candidates: list[Path] = []
diff --git a/codex_session_delete/assets/codex-plus-plus.ico b/codex_session_delete/assets/codex-plus-plus.ico
new file mode 100644
index 00000000..52f40f61
Binary files /dev/null and b/codex_session_delete/assets/codex-plus-plus.ico differ
diff --git a/codex_session_delete/assets/codex-plus-plus.png b/codex_session_delete/assets/codex-plus-plus.png
new file mode 100644
index 00000000..600fe358
Binary files /dev/null and b/codex_session_delete/assets/codex-plus-plus.png differ
diff --git a/codex_session_delete/backup_store.py b/codex_session_delete/backup_store.py
index c235700a..3a6fe996 100644
--- a/codex_session_delete/backup_store.py
+++ b/codex_session_delete/backup_store.py
@@ -14,6 +14,7 @@ def __init__(self, backup_dir: Path):
def write_backup(self, session_id: str, source_db: str, tables: dict[str, list[dict[str, Any]]]) -> str:
token = f"{int(time.time())}-{uuid.uuid4().hex}"
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
path = self.path_for(token)
payload = {
"token": token,
diff --git a/codex_session_delete/cdp.py b/codex_session_delete/cdp.py
index 00a8eefc..bcc39dec 100644
--- a/codex_session_delete/cdp.py
+++ b/codex_session_delete/cdp.py
@@ -10,6 +10,7 @@
BridgeHandler = Callable[[str, dict[str, object]], dict[str, object]]
+BRIDGE_BINDING_NAME = "codexSessionDeleteV2"
def list_targets(port: int) -> list[dict[str, object]]:
@@ -36,7 +37,7 @@ def evaluate_script(websocket_url: str, script: str) -> dict[str, object]:
payload = {
"id": 1,
"method": "Runtime.evaluate",
- "params": {"expression": script, "awaitPromise": False},
+ "params": {"expression": script, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True},
}
ws.send(json.dumps(payload))
while True:
@@ -73,13 +74,11 @@ def build_bridge_script(binding_name: str) -> str:
}});
}})();
"""
-
-
def install_bridge(websocket_url: str, binding_name: str, handler: BridgeHandler) -> websocket.WebSocket:
ws = websocket.create_connection(websocket_url, timeout=5)
ws.send(json.dumps({"id": 1, "method": "Runtime.addBinding", "params": {"name": binding_name}}))
_wait_for_id(ws, 1)
- ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": build_bridge_script(binding_name), "awaitPromise": False}}))
+ ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": build_bridge_script(binding_name), "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}}))
_wait_for_id(ws, 2)
thread = threading.Thread(target=_bridge_loop, args=(ws, handler), daemon=True)
thread.start()
@@ -90,13 +89,12 @@ def inject_file(port: int, script_path: Path, helper_port: int, handler: BridgeH
targets = list_targets(port)
target = pick_page_target(targets)
websocket_url = str(target["webSocketDebuggerUrl"])
- bridge_socket = install_bridge(websocket_url, "codexSessionDelete", handler) if handler else None
+ bridge_socket = install_bridge(websocket_url, BRIDGE_BINDING_NAME, handler) if handler else None
script = script_path.read_text(encoding="utf-8")
prefix = f"window.__CODEX_SESSION_DELETE_HELPER__ = 'http://127.0.0.1:{helper_port}';\n"
result = evaluate_script(websocket_url, prefix + script)
return bridge_socket or result
-
def _bridge_loop(ws: websocket.WebSocket, handler: BridgeHandler) -> None:
while True:
try:
@@ -121,12 +119,12 @@ def _bridge_loop(ws: websocket.WebSocket, handler: BridgeHandler) -> None:
def _resolve_bridge(ws: websocket.WebSocket, request_id: str, result: dict[str, object]) -> None:
expression = f"window.__codexSessionDeleteResolve({json.dumps(request_id)}, {json.dumps(result)})"
- ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False}}))
+ ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}}))
def _reject_bridge(ws: websocket.WebSocket, request_id: str, message: str) -> None:
expression = f"window.__codexSessionDeleteReject({json.dumps(request_id)}, {json.dumps(message)})"
- ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False}}))
+ ws.send(json.dumps({"id": _next_id(), "method": "Runtime.evaluate", "params": {"expression": expression, "awaitPromise": False, "allowUnsafeEvalBlockedByCSP": True}}))
def _wait_for_id(ws: websocket.WebSocket, message_id: int) -> dict[str, object]:
diff --git a/codex_session_delete/cli.py b/codex_session_delete/cli.py
index b9bfe459..21df1b0e 100644
--- a/codex_session_delete/cli.py
+++ b/codex_session_delete/cli.py
@@ -1,13 +1,16 @@
from __future__ import annotations
import argparse
+import os
+import subprocess
+import sys
import traceback
-import time
from pathlib import Path
from codex_session_delete.helper_server import HelperServer
from codex_session_delete.installers import InstallOptions, install_codex_plus_plus, uninstall_codex_plus_plus
-from codex_session_delete.launcher import launch_and_inject
+from codex_session_delete.launcher import launch_and_inject, shutdown_helper
+from codex_session_delete import watcher
def add_launch_arguments(parser: argparse.ArgumentParser) -> None:
@@ -40,6 +43,17 @@ def build_parser() -> argparse.ArgumentParser:
remove_parser.add_argument("--install-root", type=Path, default=None)
remove_parser.add_argument("--remove-data", action="store_true")
+ watch_parser = subparsers.add_parser("watch", help="Run the Codex watcher loop (auto-reinject when Codex is launched normally)")
+ watch_parser.add_argument("--debug-port", type=int, default=9229)
+
+ watch_install_parser = subparsers.add_parser("watch-install", help="Register the watcher to run at Windows logon")
+ watch_install_parser.add_argument("--debug-port", type=int, default=9229)
+
+ subparsers.add_parser("watch-remove", help="Unregister the watcher logon task")
+
+ subparsers.add_parser("watch-enable", help="Re-enable the watcher loop after it was disabled")
+ subparsers.add_parser("watch-disable", help="Disable the watcher loop without removing the logon task")
+
add_launch_arguments(parser)
return parser
@@ -56,26 +70,169 @@ def log_launch_failure(exc: BaseException) -> None:
path.write_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), encoding="utf-8")
-def wait_for_shutdown(server: HelperServer) -> None:
+def wait_for_windows_process_id(process_id: int) -> None:
+ if sys.platform != "win32":
+ return
+ import ctypes
+
+ synchronize = 0x00100000
+ infinite = 0xFFFFFFFF
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
+ kernel32.OpenProcess.restype = ctypes.c_void_p
+ kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
+ kernel32.WaitForSingleObject.restype = ctypes.c_ulong
+ kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
+ kernel32.CloseHandle.restype = ctypes.c_int
+
+ handle = kernel32.OpenProcess(synchronize, False, process_id)
+ if not handle:
+ return
try:
- while True:
- time.sleep(3600)
+ kernel32.WaitForSingleObject(handle, infinite)
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+def wait_for_shutdown(server: HelperServer, codex_proc) -> None:
+ try:
+ if isinstance(codex_proc, int):
+ wait_for_windows_process_id(codex_proc)
+ elif codex_proc is None and sys.platform == "darwin":
+ import subprocess as _sp
+ import time as _time
+ while True:
+ result = _sp.run(["pgrep", "-f", "^/Applications/Codex\\.app/Contents/MacOS/Codex"], capture_output=True)
+ if result.returncode != 0:
+ break
+ _time.sleep(2)
+ elif codex_proc is not None:
+ codex_proc.wait()
except KeyboardInterrupt:
- server.shutdown()
+ pass
+ finally:
+ shutdown_helper(server)
+
+
+def stop_existing_windows_launchers() -> None:
+ if sys.platform != "win32":
+ return
+ current_pid = os.getpid()
+ script = (
+ "Get-CimInstance Win32_Process | "
+ "Where-Object { $_.ProcessId -ne $env:CODEX_PLUS_PLUS_PID -and "
+ "$_.CommandLine -match 'pythonw?(.exe)?\"?\\s+-m\\s+codex_session_delete\\s+launch(\\s|$)' } | "
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
+ )
+ env = {**os.environ, "CODEX_PLUS_PLUS_PID": str(current_pid)}
+ subprocess.run(["powershell", "-NoProfile", "-Command", script], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
def run_launch(args: argparse.Namespace) -> int:
+ stop_existing_windows_launchers()
try:
- server = launch_and_inject(args.app_dir, args.db, args.backup_dir, args.debug_port, args.helper_port)
+ server, codex_proc = launch_and_inject(args.app_dir, args.db, args.backup_dir, args.debug_port, args.helper_port)
except Exception as exc:
log_launch_failure(exc)
raise
print(f"Codex session delete helper running on http://127.0.0.1:{server.port}")
print("Keep this terminal open while using the delete buttons. Press Ctrl+C to stop.")
- wait_for_shutdown(server)
+ wait_for_shutdown(server, codex_proc)
return 0
+WATCHER_RUN_NAME = "CodexPlusPlusWatcher"
+WATCHER_RUN_KEY = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"
+WATCHER_STARTUP_SHORTCUT_NAME = "CodexPlusPlusWatcher.lnk"
+
+
+def _watcher_command(debug_port: int) -> tuple[str, str, str]:
+ python = sys.executable
+ pythonw = Path(python).with_name("pythonw.exe")
+ exe = str(pythonw if pythonw.exists() else python)
+ arguments = f"-m codex_session_delete watch --debug-port {debug_port}"
+ full = f'"{exe}" {arguments}'
+ return exe, arguments, full
+
+
+def _ps_quote(value: str) -> str:
+ return "'" + value.replace("'", "''") + "'"
+
+
+def install_watcher_logon_task(debug_port: int) -> None:
+ if sys.platform != "win32":
+ raise RuntimeError("watch-install is only supported on Windows")
+ exe, arguments, full_command = _watcher_command(debug_port)
+ project_root = str(Path(__file__).resolve().parent.parent)
+ script = f"""
+$ErrorActionPreference = 'Stop'
+$Exe = {_ps_quote(exe)}
+$Args = {_ps_quote(arguments)}
+$RunFullCommand = {_ps_quote(full_command)}
+$ProjectRoot = {_ps_quote(project_root)}
+$ShortcutName = {_ps_quote(WATCHER_STARTUP_SHORTCUT_NAME)}
+# 1) HKCU Run value
+New-Item -Path '{WATCHER_RUN_KEY}' -Force | Out-Null
+Set-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}' -Value $RunFullCommand
+# 2) Startup folder .lnk (survives registry cleanups)
+$Startup = [Environment]::GetFolderPath('Startup')
+New-Item -ItemType Directory -Force -Path $Startup | Out-Null
+$Shell = New-Object -ComObject WScript.Shell
+$ShortcutPath = Join-Path $Startup $ShortcutName
+$Shortcut = $Shell.CreateShortcut($ShortcutPath)
+$Shortcut.TargetPath = $Exe
+$Shortcut.Arguments = $Args
+$Shortcut.WorkingDirectory = $ProjectRoot
+$Shortcut.WindowStyle = 7
+$Shortcut.Description = 'Codex++ watcher (auto-inject Codex on start)'
+$Shortcut.Save()
+# 3) Echo what was written for verification
+$runValue = (Get-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}').'{WATCHER_RUN_NAME}'
+Write-Output ("watch-install: HKCU Run = " + $runValue)
+Write-Output ("watch-install: Startup shortcut = " + $ShortcutPath)
+""".strip()
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if result.stdout:
+ print(result.stdout.strip())
+ # Start the watcher right now as well.
+ subprocess.Popen(
+ [exe, "-m", "codex_session_delete", "watch", "--debug-port", str(debug_port)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ close_fds=True,
+ creationflags=(
+ subprocess.CREATE_NEW_PROCESS_GROUP
+ | getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0)
+ ),
+ )
+ print("watch-install: watcher process spawned")
+
+
+def uninstall_watcher_logon_task() -> None:
+ if sys.platform != "win32":
+ return
+ script = f"""
+Remove-ItemProperty -Path '{WATCHER_RUN_KEY}' -Name '{WATCHER_RUN_NAME}' -ErrorAction SilentlyContinue | Out-Null
+$Startup = [Environment]::GetFolderPath('Startup')
+$ShortcutPath = Join-Path $Startup {_ps_quote(WATCHER_STARTUP_SHORTCUT_NAME)}
+if (Test-Path $ShortcutPath) {{ Remove-Item $ShortcutPath -Force -ErrorAction SilentlyContinue }}
+Get-CimInstance Win32_Process -Filter "Name='pythonw.exe' OR Name='python.exe'" | Where-Object {{ $_.CommandLine -match 'codex_session_delete\\s+watch' }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
+""".strip()
+ subprocess.run(
+ ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
+ check=False,
+ )
+
+
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command in {"install", "setup"}:
@@ -83,6 +240,21 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.command in {"uninstall", "remove"}:
uninstall_codex_plus_plus(InstallOptions(install_root=args.install_root, remove_data=args.remove_data))
+ uninstall_watcher_logon_task()
+ return 0
+ if args.command == "watch":
+ return watcher.watch_loop(debug_port=args.debug_port)
+ if args.command == "watch-install":
+ install_watcher_logon_task(args.debug_port)
+ return 0
+ if args.command == "watch-remove":
+ uninstall_watcher_logon_task()
+ return 0
+ if args.command == "watch-enable":
+ watcher.enable_watcher()
+ return 0
+ if args.command == "watch-disable":
+ watcher.disable_watcher()
return 0
return run_launch(args)
diff --git a/codex_session_delete/helper_server.py b/codex_session_delete/helper_server.py
index 04da4975..0b5c753a 100644
--- a/codex_session_delete/helper_server.py
+++ b/codex_session_delete/helper_server.py
@@ -10,6 +10,7 @@
class DeleteService(Protocol):
def delete(self, session: SessionRef) -> DeleteResult: ...
def undo(self, token: str) -> DeleteResult: ...
+ def find_archived_thread_by_title(self, title: str) -> SessionRef | None: ...
class HelperServer(ThreadingHTTPServer):
@@ -45,6 +46,10 @@ def do_POST(self) -> None:
token = str(payload.get("undo_token", ""))
self._send_json(self.server.service.undo(token).to_dict())
return
+ if self.path == "/archived-thread":
+ session = self.server.service.find_archived_thread_by_title(str(payload.get("title", "")))
+ self._send_json({"session_id": session.session_id, "title": session.title} if session else {"session_id": "", "title": ""})
+ return
self._send_json({"error": "not found"}, status=404)
except Exception as exc:
result = DeleteResult(DeleteStatus.FAILED, str(payload.get("session_id", "")) if "payload" in locals() else "", str(exc))
diff --git a/codex_session_delete/inject/renderer-inject.js b/codex_session_delete/inject/renderer-inject.js
index 8738ff72..15d20448 100644
--- a/codex_session_delete/inject/renderer-inject.js
+++ b/codex_session_delete/inject/renderer-inject.js
@@ -2,13 +2,20 @@
const helperBase = window.__CODEX_SESSION_DELETE_HELPER__ || "http://127.0.0.1:57321";
const buttonClass = "codex-delete-button";
const styleId = "codex-delete-style";
+ const codexDeleteStyleVersion = "4";
const codexPlusMenuId = "codex-plus-menu";
+ const codexDeleteVersion = "5";
+ const codexArchiveDeleteAllVersion = "2";
+ const codexPlusVersion = "1.0.4";
const codexPlusSettingsKey = "codexPlusSettings";
function installStyle() {
- if (document.getElementById(styleId)) return;
+ const existingStyle = document.getElementById(styleId);
+ if (existingStyle?.dataset.codexDeleteStyleVersion === codexDeleteStyleVersion) return;
+ existingStyle?.remove();
const style = document.createElement("style");
style.id = styleId;
+ style.dataset.codexDeleteStyleVersion = codexDeleteStyleVersion;
style.textContent = `
.${buttonClass} {
position: absolute;
@@ -27,33 +34,108 @@
cursor: pointer;
}
[data-codex-delete-row="true"]:hover .${buttonClass} { opacity: 1; }
+ [data-codex-delete-row="true"].codex-archive-confirm-visible .${buttonClass} { right: 66px; }
+ .codex-archive-delete-all {
+ border: 1px solid #ef4444;
+ border-radius: 7px;
+ background: #fee2e2;
+ color: #991b1b;
+ font: 12px system-ui, sans-serif;
+ line-height: 16px;
+ padding: 3px 8px;
+ cursor: pointer;
+ }
+ .codex-archive-action-bar {
+ position: fixed;
+ right: 28px;
+ top: 86px;
+ z-index: 2147482999;
+ box-shadow: 0 8px 24px rgba(0,0,0,.18);
+ }
.codex-delete-toast {
position: fixed;
right: 18px;
bottom: 18px;
- z-index: 2147483647;
+ z-index: 2147483000;
padding: 10px 12px;
border-radius: 8px;
background: #111827;
color: white;
font: 13px system-ui, sans-serif;
box-shadow: 0 8px 30px rgba(0,0,0,.25);
+ pointer-events: none;
+ }
+ .codex-delete-toast button { margin-left: 10px; pointer-events: auto; }
+ .codex-delete-confirm-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(15,23,42,.28);
+ }
+ .codex-delete-confirm-content {
+ width: min(420px, calc(100vw - 48px));
+ border: 1px solid rgba(15,23,42,.12);
+ border-radius: 12px;
+ background: #ffffff;
+ color: #111827;
+ font: 14px system-ui, sans-serif;
+ box-shadow: 0 24px 80px rgba(15,23,42,.22);
+ padding: 18px;
+ }
+ .codex-delete-confirm-title { font-size: 16px; font-weight: 650; }
+ .codex-delete-confirm-message { margin-top: 8px; color: #4b5563; line-height: 1.45; }
+ .codex-delete-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 18px;
+ }
+ .codex-delete-confirm-actions button {
+ border: 1px solid #d1d5db;
+ border-radius: 7px;
+ padding: 6px 12px;
+ background: #ffffff;
+ color: #111827;
+ font: 13px system-ui, sans-serif;
+ }
+ .codex-delete-confirm-actions [data-codex-delete-confirm="true"] {
+ border-color: #ef4444;
+ background: #dc2626;
}
- .codex-delete-toast button { margin-left: 10px; }
#${codexPlusMenuId}.codex-plus-menu-floating {
position: fixed;
top: 0;
- left: 240px;
+ right: 140px;
+ left: auto;
z-index: 2147483645;
height: 30px;
color: #d1d5db;
font: 13px system-ui, sans-serif;
+ text-align: right;
+ pointer-events: auto;
+ -webkit-app-region: no-drag;
}
#${codexPlusMenuId} {
display: inline-flex;
align-items: center;
height: 100%;
flex: 0 0 auto;
+ pointer-events: auto;
+ -webkit-app-region: no-drag;
+ }
+ .codex-plus-trigger {
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ height: 100%;
+ padding: 0 8px;
+ cursor: pointer;
+ pointer-events: auto;
+ -webkit-app-region: no-drag;
}
.codex-plus-modal-overlay {
position: fixed;
@@ -123,7 +205,7 @@
}
function defaultCodexPlusSettings() {
- return { pluginEntryUnlock: true, forcePluginInstall: true, sessionDelete: true };
+ return { pluginEntryUnlock: true, forcePluginInstall: true, sessionDelete: true, nativeMenuPlacement: true };
}
function codexPlusSettings() {
@@ -156,7 +238,7 @@
overlay.innerHTML = `
-
Codex++
+
Codex++ ${codexPlusVersion}
+
+
原生菜单栏位置
把 Codex++ 菜单插入顶部原生菜单栏;默认关闭以避免页面重渲染冲突。
+
+
+
+
提出问题
打开 GitHub Issues 反馈问题或建议。
+
+
`;
@@ -183,6 +273,12 @@
overlay.remove();
return;
}
+ const issueButton = event.target.closest("[data-codex-plus-issue]");
+ if (issueButton) {
+ const issueUrl = "https://github.com/BigPizzaV3/CodexPlusPlus/issues";
+ window.open(issueUrl, "_blank");
+ return;
+ }
const toggle = event.target.closest("[data-codex-plus-setting]");
if (!toggle) return;
const key = toggle.getAttribute("data-codex-plus-setting");
@@ -193,6 +289,7 @@
}
function findNativeMenuInsertionPoint() {
+ if (!codexPlusSettings().nativeMenuPlacement) return null;
const header = document.querySelector(".app-header-tint");
const menuBar = header?.querySelector(".flex.items-center.gap-0\\.5") || header?.querySelector('[class*="flex items-center gap-0.5"]');
if (!menuBar) return null;
@@ -205,7 +302,7 @@
if (node !== keep) node.remove();
});
Array.from(document.querySelectorAll("button")).forEach((button) => {
- if ((button.textContent || "").trim() === "Codex++" && !button.closest(`#${codexPlusMenuId}`)) {
+ if ((button.textContent || "").trim() === `Codex++ ${codexPlusVersion}` && !button.closest(`#${codexPlusMenuId}`)) {
button.remove();
}
});
@@ -241,7 +338,7 @@
menu.dataset.codexPlusMenuVersion = "5";
const trigger = document.createElement("button");
trigger.type = "button";
- trigger.textContent = "Codex++";
+ trigger.textContent = `Codex++ ${codexPlusVersion}`;
const nativeButtonClass = insertionPoint?.nativeButtonClass || "codex-plus-trigger";
configureCodexPlusTrigger(menu, trigger, nativeButtonClass);
menu.appendChild(trigger);
@@ -282,7 +379,7 @@
function enablePluginEntry() {
if (!codexPlusSettings().pluginEntryUnlock) return;
const buttons = Array.from(document.querySelectorAll("button"));
- const pluginButton = buttons.find((element) => (element.textContent || "").trim() === "插件");
+ const pluginButton = buttons.find((element) => /^(插件|Plugins)$/i.test((element.textContent || "").trim()));
if (!pluginButton) return;
spoofChatGPTAuthMethod(pluginButton);
pluginButton.disabled = false;
@@ -323,17 +420,54 @@
});
}
- function sessionRows() {
+ let cachedSessionRows = [];
+ let cachedSessionRowsAt = 0;
+
+ function sessionRows(forceRefresh = false) {
+ const now = Date.now();
+ if (!forceRefresh && now - cachedSessionRowsAt < 150) { + cachedSessionRows = cachedSessionRows.filter((row) => row.isConnected);
+ if (cachedSessionRows.length> 0) return cachedSessionRows;
+ }
+
const codexThreads = Array.from(document.querySelectorAll('[data-app-action-sidebar-thread-id]'));
- if (codexThreads.length> 0) return codexThreads;
+ if (codexThreads.length> 0) {
+ cachedSessionRows = codexThreads;
+ cachedSessionRowsAt = now;
+ return cachedSessionRows;
+ }
- const candidates = Array.from(document.querySelectorAll("a, button, [role='button'], [data-testid], li, div"));
- return candidates.filter((element) => {
+ const candidates = Array.from(document.querySelectorAll("a[href*='session'], a[href*='conversation'], a[href*='thread'], button[data-testid*='session'], button[data-testid*='conversation'], button[data-testid*='thread'], [role='button'][data-testid*='session'], [role='button'][data-testid*='conversation'], [role='button'][data-testid*='thread'], li[data-testid*='session'], li[data-testid*='conversation'], li[data-testid*='thread']"));
+ cachedSessionRows = candidates.filter((element) => {
const text = (element.textContent || "").trim();
const href = element.getAttribute("href") || "";
- const hasSessionHint = /session|conversation|thread/i.test(href + " " + element.outerHTML.slice(0, 400));
+ const testId = element.getAttribute("data-testid") || "";
+ const hasSessionHint = /session|conversation|thread/i.test(href + " " + testId);
return text.length> 0 && text.length < 200 && hasSessionHint; }); + cachedSessionRowsAt = now; + return cachedSessionRows; + } + + function archivedPageRows() { + const rows = Array.from(document.querySelectorAll("button")).filter((button) => (button.textContent || "").trim() === "取消归档").map((button) => button.closest(".flex.w-full.items-center.justify-between") || button.parentElement).filter(Boolean);
+ rows.forEach((row) => {
+ row.dataset.codexArchivePageRow = "true";
+ row.setAttribute("data-codex-archive-page-row", "true");
+ });
+ return rows;
+ }
+
+ function archivedSessionRows() {
+ return sessionRows().filter((row) => row.querySelector('button[aria-label="取消归档对话"]') || row.outerHTML.includes("取消归档") || row.outerHTML.includes("unarchive"));
+ }
+
+ function archivedRows() {
+ return [...archivedSessionRows(), ...archivedPageRows()];
+ }
+
+ function archivedPageVisible() {
+ return archivedRows().length> 0 || window.location.href.includes("archive") || (document.body.textContent || "").includes("已归档对话");
}
function sessionRefFromRow(row) {
@@ -373,58 +507,397 @@
setTimeout(() => toast.remove(), 10000);
}
+ function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<") + .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
+
+ function confirmDelete(title) {
+ document.querySelectorAll(".codex-delete-confirm-overlay").forEach((node) => node.remove());
+ return new Promise((resolve) => {
+ const overlay = document.createElement("div");
+ overlay.className = "codex-delete-confirm-overlay";
+ overlay.innerHTML = `
+
+
删除会话
+
删除"${escapeHtml(title)}"?
+
+
+
+
+
+ `;
+ const finish = (value, event) => {
+ event?.preventDefault();
+ event?.stopPropagation();
+ event?.target?.blur?.();
+ overlay.remove();
+ resolve(value);
+ };
+ overlay.addEventListener("click", (event) => {
+ if (event.target === overlay || event.target.closest("[data-codex-delete-cancel]")) {
+ finish(false, event);
+ return;
+ }
+ if (event.target.closest("[data-codex-delete-confirm]")) {
+ finish(true, event);
+ }
+ }, true);
+ overlay.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") finish(false, event);
+ }, true);
+ document.body.appendChild(overlay);
+ overlay.querySelector("[data-codex-delete-cancel]")?.focus();
+ });
+ }
+
+ function rowHref(row) {
+ return row.getAttribute("href") || row.querySelector("a")?.getAttribute("href") || "";
+ }
+
+ function isCurrentSessionRow(row, ref) {
+ if (row.getAttribute("aria-current") === "page" || row.getAttribute("aria-current") === "true") return true;
+ const href = rowHref(row);
+ if (href) {
+ try {
+ const url = new URL(href, window.location.href);
+ if (url.href === window.location.href || url.pathname === window.location.pathname) return true;
+ } catch {
+ if (window.location.href.includes(href)) return true;
+ }
+ }
+ return !!ref.session_id && window.location.href.includes(ref.session_id);
+ }
+
+ function releaseDeleteFocus(row, button) {
+ button.blur();
+ if (row.contains(document.activeElement)) {
+ document.activeElement.blur();
+ }
+ }
+
+ function removeDeletedRow(row, button, ref) {
+ releaseDeleteFocus(row, button);
+ const shouldReload = isCurrentSessionRow(row, ref);
+ row.remove();
+ if (shouldReload) {
+ window.location.reload();
+ }
+ }
+
+ function updateDeleteButtonOffsets() {
+ sessionRows().forEach((row) => {
+ const hasArchiveConfirm = Array.from(row.querySelectorAll("button")).some((button) => {
+ const rect = button.getBoundingClientRect();
+ const label = button.getAttribute("aria-label") || "";
+ const text = (button.textContent || "").trim();
+ if (button.classList.contains(buttonClass) || label === "归档对话" || label === "置顶对话") return false;
+ return text === "确认" || (text.length> 0 && rect.width> 0 && rect.width <= 36 && rect.x> row.getBoundingClientRect().right - 50);
+ });
+ row.classList.toggle("codex-archive-confirm-visible", hasArchiveConfirm);
+ });
+ }
+
+ function openDeleteConfirmForRow(row, button, ref, event) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation?.();
+ releaseDeleteFocus(row, button);
+ confirmDelete(ref.title).then(async (confirmed) => {
+ if (!confirmed) return;
+ releaseDeleteFocus(row, button);
+ const result = await postJson("/delete", ref);
+ if (result.status === "server_deleted" || result.status === "local_deleted") {
+ removeDeletedRow(row, button, ref);
+ showToast(result.message || "Deleted", result.undo_token);
+ } else {
+ showToast(result.message || "Delete failed", null);
+ }
+ });
+ }
+
+ function installDeleteButtonEventDelegation() {
+ document.removeEventListener("pointerup", window.__codexSessionDeleteDocumentDeleteHandler, true);
+ document.removeEventListener("click", window.__codexSessionDeleteDocumentDeleteHandler, true);
+ const handler = (event) => {
+ const button = event.target?.closest?.(`.${buttonClass}`);
+ const row = button?.closest?.("[data-app-action-sidebar-thread-id]");
+ if (!button || !row) return;
+ const ref = sessionRefFromRow(row);
+ if (!ref.session_id) return;
+ openDeleteConfirmForRow(row, button, ref, event);
+ };
+ window.__codexSessionDeleteDocumentDeleteHandler = handler;
+ document.addEventListener("pointerup", handler, true);
+ document.addEventListener("click", handler, true);
+ }
+
function attachButton(row) {
if (!codexPlusSettings().sessionDelete) return;
- if (row.dataset.codexDeleteRow === "true") return;
+ const existingDeleteButtons = Array.from(row.querySelectorAll(`.${buttonClass}`));
+ if (existingDeleteButtons.length === 1 && existingDeleteButtons[0].dataset.codexDeleteVersion === codexDeleteVersion) return;
+ existingDeleteButtons.forEach((button) => button.remove());
+ row.dataset.codexDeleteRow = "false";
const ref = sessionRefFromRow(row);
if (!ref.session_id) return;
row.dataset.codexDeleteRow = "true";
const button = document.createElement("button");
button.type = "button";
button.className = buttonClass;
+ button.dataset.codexDeleteVersion = codexDeleteVersion;
button.textContent = "删除";
+ const stopDeleteButtonEvent = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation?.();
+ releaseDeleteFocus(row, button);
+ };
+ ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => {
+ button.addEventListener(eventName, stopDeleteButtonEvent, true);
+ });
+ const openDeleteConfirm = (event) => openDeleteConfirmForRow(row, button, ref, event);
+ button.addEventListener("pointerup", openDeleteConfirm, true);
+ button.addEventListener("click", openDeleteConfirm, true);
+ row.appendChild(button);
+ const refreshDeleteButton = (originalButton) => {
+ if (!originalButton.isConnected) return;
+ const replacement = originalButton.cloneNode(true);
+ ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => {
+ replacement.addEventListener(eventName, stopDeleteButtonEvent, true);
+ });
+ replacement.addEventListener("pointerup", openDeleteConfirm, true);
+ replacement.addEventListener("click", openDeleteConfirm, true);
+ originalButton.replaceWith(replacement);
+ };
+ setTimeout(() => refreshDeleteButton(button), 0);
+ }
+
+ function tryAttachButton(row) {
+ try {
+ attachButton(row);
+ } catch (error) {
+ window.__codexSessionDeleteAttachButtonFailures = window.__codexSessionDeleteAttachButtonFailures || [];
+ window.__codexSessionDeleteAttachButtonFailures.push(String(error?.stack || error));
+ }
+ }
+
+ function reactArchivedThreadFromNode(node) {
+ const reactKey = Object.keys(node).find((key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$"));
+ let fiber = reactKey ? node[reactKey] : null;
+ for (let depth = 0; fiber && depth < 20; depth += 1, fiber = fiber.return) { + const props = fiber.memoizedProps || fiber.pendingProps || {}; + if (props.archivedThread?.id) return props.archivedThread; + const childThread = props.children?.props?.archivedThread; + if (childThread?.id) return childThread; + } + return null; + } + + function archivedThreadFromRow(row) { + for (const node of [row, ...row.querySelectorAll("*")]) { + const thread = reactArchivedThreadFromNode(node); + if (thread?.id || thread?.sessionId) return thread; + } + return null; + } + + function archivedRefFromRow(row) { + const archivedThread = archivedThreadFromRow(row); + if (archivedThread?.id || archivedThread?.sessionId) { + return { session_id: archivedThread.id || archivedThread.sessionId, title: archivedThread.title || row.querySelector(".truncate.text-base")?.textContent?.trim() || "Untitled session" }; + } + const sidebarRef = sessionRefFromRow(row); + if (sidebarRef.session_id) return sidebarRef; + const titleNode = row.querySelector(".truncate.text-base, [data-thread-title], a, div"); + const title = ((titleNode || row).textContent || "Untitled session") + .replace("取消归档", "") + .replace("删除", "") + .replace(/\d{4}年\d{1,2}月\d{1,2}日.*$/, "") + .replace(/\s+·\s+.*$/, "") + .trim() + .slice(0, 160); + return { session_id: "", title }; + } + + async function resolveArchivedThread(row) { + const ref = archivedRefFromRow(row); + if (ref.session_id) return ref; + const resolved = await postJson("/archived-thread", { title: ref.title }); + return resolved?.session_id ? resolved : ref; + } + + function stopArchivedButtonEvent(event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + } + + function archiveTitleContainer() { + return Array.from(document.querySelectorAll("h1, h2, h3, div, span")) + .find((element) => (element.textContent || "").trim() === "已归档对话" && element.getBoundingClientRect().x> 350);
+ }
+
+ async function deleteArchivedSessions(rows) {
+ let deleted = 0;
+ for (const row of rows) {
+ const ref = await resolveArchivedThread(row);
+ if (!ref.session_id) continue;
+ const result = await postJson("/delete", ref);
+ if (result.status === "server_deleted" || result.status === "local_deleted") {
+ row.remove();
+ deleted += 1;
+ }
+ }
+ showToast(`已删除 ${deleted} 个归档会话`, null);
+ }
+
+ function attachArchivedPageDeleteButton(row) {
+ if (!codexPlusSettings().sessionDelete) return;
+ if (row.dataset.codexArchiveDeleteRow === "true") return;
+ row.dataset.codexArchiveDeleteRow = "true";
+ const unarchiveButton = Array.from(row.querySelectorAll("button")).find((button) => (button.textContent || "").trim() === "取消归档");
+ if (!unarchiveButton) return;
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "codex-archive-delete-all";
+ button.textContent = "删除";
+ ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => {
+ button.addEventListener(eventName, stopArchivedButtonEvent, true);
+ });
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
- if (!confirm(`删除会话"${ref.title}"?`)) return;
+ event.stopImmediatePropagation?.();
+ const ref = await resolveArchivedThread(row);
+ if (!ref.session_id) {
+ showToast("Delete failed: archived session id not found", null);
+ return;
+ }
+ if (!(await confirmDelete(ref.title))) return;
const result = await postJson("/delete", ref);
if (result.status === "server_deleted" || result.status === "local_deleted") {
- row.style.display = "none";
+ row.remove();
showToast(result.message || "Deleted", result.undo_token);
} else {
showToast(result.message || "Delete failed", null);
}
+ }, true);
+ unarchiveButton.insertAdjacentElement("afterend", button);
+ }
+
+ function installArchivedDeleteAllButton() {
+ const existingButton = document.querySelector("[data-codex-archive-delete-all]");
+ if (!codexPlusSettings().sessionDelete || !archivedPageVisible()) {
+ existingButton?.remove();
+ return;
+ }
+ const rows = archivedRows();
+ if (rows.length === 0) {
+ existingButton?.remove();
+ return;
+ }
+ if (existingButton?.dataset.codexArchiveDeleteAllVersion === codexArchiveDeleteAllVersion) return;
+ existingButton?.remove();
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "codex-archive-delete-all codex-archive-action-bar";
+ Object.assign(button.style, {
+ position: "static",
+ marginLeft: "12px",
+ verticalAlign: "middle",
+ zIndex: "2147482999",
+ cursor: "pointer",
+ pointerEvents: "auto",
+ maxWidth: "fit-content",
+ alignSelf: "flex-start",
});
- row.appendChild(button);
+ button.dataset.codexArchiveDeleteAll = "true";
+ button.dataset.codexArchiveDeleteAllVersion = codexArchiveDeleteAllVersion;
+ button.textContent = "删除全部归档";
+ ["pointerdown", "mousedown", "mouseup", "touchstart"].forEach((eventName) => {
+ button.addEventListener(eventName, stopArchivedButtonEvent, true);
+ });
+ const openArchivedDeleteAllConfirm = async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation?.();
+ const currentRows = archivedRows();
+ if (currentRows.length === 0) return;
+ if (!(await confirmDelete(`全部 ${currentRows.length} 个归档会话`))) return;
+ await deleteArchivedSessions(currentRows);
+ };
+ button.addEventListener("pointerup", openArchivedDeleteAllConfirm, true);
+ button.addEventListener("click", openArchivedDeleteAllConfirm, true);
+ const title = archiveTitleContainer();
+ if (title) {
+ title.insertAdjacentElement("afterend", button);
+ } else {
+ document.body.appendChild(button);
+ }
}
function scanLightweight() {
installStyle();
installCodexPlusMenu();
+ installDeleteButtonEventDelegation();
}
function scanDeferred() {
enablePluginEntry();
unblockPluginInstallButtons();
- sessionRows().forEach(attachButton);
+ sessionRows().forEach(tryAttachButton);
+ updateDeleteButtonOffsets();
+ archivedPageRows().forEach(attachArchivedPageDeleteButton);
+ installArchivedDeleteAllButton();
+ }
+
+ function runScanStep(step) {
+ try {
+ step();
+ } catch (error) {
+ window.__codexSessionDeleteScanFailures = window.__codexSessionDeleteScanFailures || [];
+ window.__codexSessionDeleteScanFailures.push(String(error?.stack || error));
+ }
}
function scan() {
- scanLightweight();
- requestAnimationFrame(scanDeferred);
+ runScanStep(scanLightweight);
+ requestAnimationFrame(() => runScanStep(scanDeferred));
+ }
+
+ function shouldScheduleScan(mutations) {
+ if (!mutations) return true;
+ return mutations.some((mutation) => {
+ const target = mutation.target;
+ if (target?.closest?.(".codex-delete-toast, .codex-delete-confirm-overlay, .codex-plus-modal-overlay, #codex-plus-menu")) return false;
+ return Array.from(mutation.addedNodes).some((node) => {
+ if (node.nodeType !== 1) return false;
+ if (node.closest?.(".codex-delete-toast, .codex-delete-confirm-overlay, .codex-plus-modal-overlay, #codex-plus-menu")) return false;
+ return true;
+ }) || Array.from(mutation.removedNodes).some((node) => node.nodeType === 1);
+ });
}
- function scheduleScan() {
+ function runScheduledScan() {
+ window.__codexSessionDeleteScanPending = false;
+ clearTimeout(window.__codexSessionDeleteScanTimer);
+ window.__codexSessionDeleteScanTimer = null;
+ scan();
+ }
+
+ function scheduleScan(mutations) {
+ if (!shouldScheduleScan(mutations)) return;
if (window.__codexSessionDeleteScanPending) return;
window.__codexSessionDeleteScanPending = true;
- requestAnimationFrame(() => {
- window.__codexSessionDeleteScanPending = false;
- scan();
- });
+ window.__codexSessionDeleteScanTimer = setTimeout(runScheduledScan, 200);
}
- scheduleScan();
+ scan();
window.__codexSessionDeleteObserver?.disconnect();
window.__codexSessionDeleteObserver = new MutationObserver(scheduleScan);
- window.__codexSessionDeleteObserver.observe(document.documentElement, { childList: true, subtree: true });
+ window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
})();
diff --git a/codex_session_delete/launcher.py b/codex_session_delete/launcher.py
index f5d400e2..f16b8316 100644
--- a/codex_session_delete/launcher.py
+++ b/codex_session_delete/launcher.py
@@ -1,8 +1,12 @@
from __future__ import annotations
+import ctypes
+import socket
import subprocess
+import sys
import threading
import time
+import uuid
from pathlib import Path
from typing import Any
@@ -33,24 +37,161 @@ def undo(self, token: str) -> DeleteResult:
return DeleteResult(DeleteStatus.FAILED, "", "No local backup adapter configured", undo_token=token)
return self.local_adapter.undo(token)
+ def find_archived_thread_by_title(self, title: str) -> SessionRef | None:
+ if self.local_adapter is None:
+ return None
+ return self.local_adapter.find_archived_thread_by_title(title)
+
class InjectedHelperServer(HelperServer):
bridge_socket: Any = None
-def build_codex_command(app_dir: Path, debug_port: int) -> list[str]:
- if app_dir.suffix == ".app":
- exe = app_dir / "Contents" / "MacOS" / "Codex"
- else:
- candidates = [app_dir / "Codex.exe", app_dir / "codex.exe"]
- exe = next((path for path in candidates if path.exists()), candidates[-1])
+def _can_bind_loopback_port(port: int) -> bool:
+ if port == 0:
+ return True
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
+ probe.bind(("127.0.0.1", port))
+ return True
+ except OSError:
+ return False
+
+
+def _find_available_loopback_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
+ probe.bind(("127.0.0.1", 0))
+ return int(probe.getsockname()[1])
+
+
+def select_windows_loopback_port(requested_port: int) -> int:
+ if sys.platform != "win32" or _can_bind_loopback_port(requested_port):
+ return requested_port
+ return _find_available_loopback_port()
+
+
+def build_codex_arguments(debug_port: int) -> list[str]:
return [
- str(exe),
f"--remote-debugging-port={debug_port}",
f"--remote-allow-origins=http://127.0.0.1:{debug_port}",
]
+def build_codex_executable(app_dir: Path) -> Path:
+ if app_dir.suffix == ".app":
+ return app_dir / "Contents" / "MacOS" / "Codex"
+ candidates = [app_dir / "Codex.exe", app_dir / "codex.exe"]
+ return next((path for path in candidates if path.exists()), candidates[-1])
+
+
+def build_codex_command(app_dir: Path, debug_port: int) -> list[str]:
+ return [str(build_codex_executable(app_dir)), *build_codex_arguments(debug_port)]
+
+
+def packaged_app_user_model_id(app_dir: Path) -> str | None:
+ package_dir = app_dir.parent if app_dir.name.lower() == "app" else app_dir
+ if not package_dir.name.startswith("OpenAI.Codex_") or "__" not in package_dir.name:
+ return None
+ identity_name = package_dir.name.split("_", 1)[0]
+ publisher_id = package_dir.name.rsplit("__", 1)[1]
+ if not publisher_id:
+ return None
+ return f"{identity_name}_{publisher_id}!App"
+
+
+class _GUID(ctypes.Structure):
+ _fields_ = [
+ ("Data1", ctypes.c_uint32),
+ ("Data2", ctypes.c_uint16),
+ ("Data3", ctypes.c_uint16),
+ ("Data4", ctypes.c_ubyte * 8),
+ ]
+
+ def __init__(self, value: str):
+ parsed = uuid.UUID(value)
+ data4 = bytes([parsed.clock_seq_hi_variant, parsed.clock_seq_low]) + parsed.node.to_bytes(6, "big")
+ super().__init__(parsed.time_low, parsed.time_mid, parsed.time_hi_version, (ctypes.c_ubyte * 8)(*data4))
+
+
+def _raise_for_hresult(hr: int, operation: str) -> None:
+ if hr < 0: + raise OSError(f"{operation} failed with HRESULT 0x{hr & 0xFFFFFFFF:08X}") + + +def activate_packaged_app(app_user_model_id: str, arguments: str) -> int:
+ if sys.platform != "win32":
+ raise RuntimeError("Packaged app activation is only supported on Windows")
+
+ ole32 = ctypes.OleDLL("ole32")
+ ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
+ ole32.CoInitializeEx.restype = ctypes.c_long
+ ole32.CoUninitialize.argtypes = []
+ ole32.CoUninitialize.restype = None
+ ole32.CoCreateInstance.argtypes = [
+ ctypes.POINTER(_GUID),
+ ctypes.c_void_p,
+ ctypes.c_ulong,
+ ctypes.POINTER(_GUID),
+ ctypes.POINTER(ctypes.c_void_p),
+ ]
+ ole32.CoCreateInstance.restype = ctypes.c_long
+
+ coinit_hr = ole32.CoInitializeEx(None, 2)
+ should_uninitialize = coinit_hr>= 0
+ if coinit_hr < 0 and coinit_hr != -2147417850: # RPC_E_CHANGED_MODE + _raise_for_hresult(coinit_hr, "CoInitializeEx") + + activation_manager = ctypes.c_void_p() + try: + clsid = _GUID("45BA127D-10A8-46EA-8AB7-56EA9078943C") + iid = _GUID("2e941141-7f97-4756-ba1d-9decde894a3d") + _raise_for_hresult( + ole32.CoCreateInstance(ctypes.byref(clsid), None, 1, ctypes.byref(iid), ctypes.byref(activation_manager)), + "CoCreateInstance(ApplicationActivationManager)", + ) + + activate_application_type = ctypes.WINFUNCTYPE( + ctypes.c_long, + ctypes.c_void_p, + ctypes.c_wchar_p, + ctypes.c_wchar_p, + ctypes.c_ulong, + ctypes.POINTER(ctypes.c_ulong), + ) + + vtable = ctypes.cast(activation_manager, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents + activate_application = activate_application_type(vtable[3]) + + process_id = ctypes.c_ulong() + _raise_for_hresult( + activate_application(activation_manager, app_user_model_id, arguments, 0, ctypes.byref(process_id)), + "ActivateApplication", + ) + return int(process_id.value) + finally: + if activation_manager.value: + release = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)( + ctypes.cast(activation_manager, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents[2] + ) + release(activation_manager) + if should_uninitialize: + ole32.CoUninitialize() + + +def launch_codex_app(app_dir: Path, debug_port: int) -> Any:
+ app_user_model_id = packaged_app_user_model_id(app_dir) if sys.platform == "win32" else None
+ if app_user_model_id:
+ return activate_packaged_app(app_user_model_id, subprocess.list2cmdline(build_codex_arguments(debug_port)))
+ if app_dir.suffix == ".app":
+ subprocess.run(["open", "-a", str(app_dir), "--args", *build_codex_arguments(debug_port)], check=True)
+ return None
+ return subprocess.Popen(build_codex_command(app_dir, debug_port))
+
+
def start_helper(service, host: str = "127.0.0.1", port: int = 57321) -> HelperServer:
server = InjectedHelperServer(host, port, service)
thread = threading.Thread(target=server.serve_forever, daemon=True)
@@ -58,6 +199,11 @@ def start_helper(service, host: str = "127.0.0.1", port: int = 57321) -> HelperS
return server
+def shutdown_helper(server: HelperServer) -> None:
+ server.shutdown()
+ server.server_close()
+
+
def inject_with_retry(debug_port: int, script_path: Path, helper_port: int, service: ApiFirstDeleteService, attempts: int = 20, delay: float = 0.5) -> Any:
last_error: Exception | None = None
for _ in range(attempts):
@@ -71,16 +217,44 @@ def inject_with_retry(debug_port: int, script_path: Path, helper_port: int, serv
raise RuntimeError("Codex injection failed")
-def launch_and_inject(app_dir: Path | None, db_path: Path | None, backup_dir: Path, debug_port: int, helper_port: int) -> HelperServer:
+def launch_and_inject(app_dir: Path | None, db_path: Path | None, backup_dir: Path, debug_port: int, helper_port: int) -> tuple[HelperServer, Any]:
resolved_app_dir = resolve_codex_app_dir(app_dir)
if resolved_app_dir is None:
raise RuntimeError("Codex App directory not found")
+ debug_port = select_windows_loopback_port(debug_port)
+ helper_port = select_windows_loopback_port(helper_port)
service = ApiFirstDeleteService(UnavailableApiAdapter(), db_path, backup_dir)
server = start_helper(service, port=helper_port)
- subprocess.Popen(build_codex_command(resolved_app_dir, debug_port))
- script_path = Path(__file__).parent / "inject" / "renderer-inject.js"
- server.bridge_socket = inject_with_retry(debug_port, script_path, server.port, service)
- return server
+ codex_proc = None
+ try:
+ codex_proc = launch_codex_app(resolved_app_dir, debug_port)
+ script_path = Path(__file__).parent / "inject" / "renderer-inject.js"
+ server.bridge_socket = inject_with_retry(debug_port, script_path, server.port, service)
+ return server, codex_proc
+ except Exception:
+ shutdown_helper(server)
+ # Kill any Codex process we just activated so the next attempt starts from a clean state
+ # instead of staring at a half-rendered white window.
+ if sys.platform == "win32":
+ try:
+ subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ "Get-CimInstance Win32_Process -Filter \"Name='Codex.exe' OR Name='codex.exe'\" | "
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
+ ],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=6,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+ except (OSError, subprocess.SubprocessError):
+ pass
+ raise
def handle_bridge_request(service: ApiFirstDeleteService, path: str, payload: dict[str, object]) -> dict[str, object]:
@@ -89,4 +263,7 @@ def handle_bridge_request(service: ApiFirstDeleteService, path: str, payload: di
return service.delete(session).to_dict()
if path == "/undo":
return service.undo(str(payload.get("undo_token", ""))).to_dict()
+ if path == "/archived-thread":
+ session = service.find_archived_thread_by_title(str(payload.get("title", "")))
+ return {"session_id": session.session_id, "title": session.title} if session else {"session_id": "", "title": ""}
return {"status": DeleteStatus.FAILED.value, "session_id": str(payload.get("session_id", "")), "message": "Unknown bridge path"}
diff --git a/codex_session_delete/macos_installer.py b/codex_session_delete/macos_installer.py
index 517f9c50..0f8cfc6f 100644
--- a/codex_session_delete/macos_installer.py
+++ b/codex_session_delete/macos_installer.py
@@ -7,6 +7,10 @@
from pathlib import Path
from typing import TYPE_CHECKING
+from codex_session_delete.app_paths import find_macos_codex_app
+
+ICON_ASSET = Path(__file__).resolve().parent / "assets" / "codex-plus-plus.png"
+
if TYPE_CHECKING:
from codex_session_delete.installers import InstallOptions
@@ -38,10 +42,12 @@ def install_macos_app(options: "InstallOptions") -> None:
"CFBundleName": "Codex++",
"CFBundleDisplayName": "Codex++",
"CFBundleIdentifier": "com.bigpizzav3.codexplusplus",
- "CFBundleVersion": "0.1.0",
- "CFBundleShortVersionString": "0.1.0",
+ "CFBundleVersion": "1.0.4",
+ "CFBundleShortVersionString": "1.0.4",
"CFBundlePackageType": "APPL",
"CFBundleExecutable": EXECUTABLE_NAME,
+ "CFBundleIconFile": "codex-plus-plus.png",
+ "LSUIElement": True,
"LSMinimumSystemVersion": "12.0",
}
(contents / "Info.plist").write_bytes(plistlib.dumps(plist))
@@ -50,8 +56,22 @@ def install_macos_app(options: "InstallOptions") -> None:
executable.write_text(f"#!/bin/sh\nexec {_launcher_command(options)}\n", encoding="utf-8")
executable.chmod(executable.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+ _copy_codex_icon(resources)
+
def uninstall_macos_app(options: "InstallOptions") -> None:
app = _app_root(options)
if app.exists():
shutil.rmtree(app)
+
+
+def _copy_codex_icon(resources: Path) -> None:
+ if ICON_ASSET.is_file():
+ shutil.copy2(ICON_ASSET, resources / "codex-plus-plus.png")
+ return
+ codex_app = find_macos_codex_app()
+ if codex_app is None:
+ return
+ icon_src = codex_app / "Contents" / "Resources" / "electron.icns"
+ if icon_src.is_file():
+ shutil.copy2(icon_src, resources / "electron.icns")
diff --git a/codex_session_delete/storage_adapter.py b/codex_session_delete/storage_adapter.py
index 05b65c94..42bc55fc 100644
--- a/codex_session_delete/storage_adapter.py
+++ b/codex_session_delete/storage_adapter.py
@@ -47,6 +47,23 @@ def undo(self, token: str) -> DeleteResult:
path.write_bytes(base64.b64decode(file_backup["content_b64"]))
return DeleteResult(DeleteStatus.UNDONE, session_id, "Local session restored from backup", undo_token=token)
+ def find_archived_thread_by_title(self, title: str) -> SessionRef | None:
+ if not self.db_path.exists():
+ return None
+ with sqlite3.connect(self.db_path) as db:
+ db.row_factory = sqlite3.Row
+ if self._schema_kind(db) != "codex_threads" or not self._has_columns(db, "threads", {"archived"}):
+ return None
+ row = db.execute(
+ """
+ SELECT id, title FROM threads
+ WHERE archived = 1 AND (title = ? OR title LIKE ? OR ? LIKE '%' || title || '%')
+ ORDER BY archived_at DESC LIMIT 1
+ """,
+ (title, f"%{title}%", title),
+ ).fetchone()
+ return SessionRef(session_id=str(row["id"]), title=str(row["title"] or title)) if row else None
+
def _delete_generic_session(self, db: sqlite3.Connection, session: SessionRef) -> DeleteResult:
session_rows = self._select_dicts(db, "SELECT * FROM sessions WHERE id = ?", (session.session_id,))
if not session_rows:
diff --git a/codex_session_delete/watcher.py b/codex_session_delete/watcher.py
new file mode 100644
index 00000000..54b148bb
--- /dev/null
+++ b/codex_session_delete/watcher.py
@@ -0,0 +1,243 @@
+from __future__ import annotations
+
+import os
+import socket
+import subprocess
+import sys
+import time
+import traceback
+from datetime import datetime
+from pathlib import Path
+
+
+WATCHER_INTERVAL_SECONDS = 3.0
+CDP_PROBE_TIMEOUT_SECONDS = 0.5
+CDP_WAIT_TIMEOUT_SECONDS = 25.0
+KILL_WAIT_TIMEOUT_SECONDS = 8.0
+CODEX_PROCESS_NAMES = {"codex.exe"}
+
+
+def data_root() -> Path:
+ return Path.home() / ".codex-session-delete"
+
+
+def watcher_log_path() -> Path:
+ return data_root() / "watcher.log"
+
+
+def watcher_disabled_flag() -> Path:
+ return data_root() / "watcher.disabled"
+
+
+def log(line: str) -> None:
+ path = watcher_log_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(f"[{datetime.now().isoformat(timespec='seconds')}] {line}\n")
+
+
+def cdp_listening(port: int) -> bool:
+ try:
+ with socket.create_connection(("127.0.0.1", port), timeout=CDP_PROBE_TIMEOUT_SECONDS):
+ return True
+ except OSError:
+ return False
+
+
+def _run_powershell(script: str, timeout: float = 8.0) -> str:
+ try:
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=timeout,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+ return result.stdout or ""
+ except (OSError, subprocess.SubprocessError) as exc:
+ log(f"powershell failed: {exc}")
+ return ""
+
+
+def find_codex_processes() -> list[int]:
+ script = (
+ "Get-CimInstance Win32_Process -Filter \"Name='Codex.exe' OR Name='codex.exe'\" "
+ "| Select-Object -ExpandProperty ProcessId"
+ )
+ output = _run_powershell(script)
+ return [int(line) for line in output.splitlines() if line.strip().isdigit()]
+
+
+def kill_processes(pids: list[int]) -> None:
+ if not pids:
+ return
+ script = "; ".join(
+ f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue" for pid in pids
+ )
+ _run_powershell(script, timeout=6.0)
+
+
+def wait_until_no_codex(timeout: float = KILL_WAIT_TIMEOUT_SECONDS) -> bool:
+ """Poll until no Codex process is left, or until timeout. Returns True if clean, False if still alive."""
+ deadline = time.time() + timeout
+ while time.time() < deadline: + remaining = find_codex_processes() + if not remaining: + return True + # Be aggressive: re-issue kill for anything still alive. + kill_processes(remaining) + time.sleep(0.5) + return not find_codex_processes() + + +def wait_for_cdp(port: int, timeout: float = CDP_WAIT_TIMEOUT_SECONDS) -> bool:
+ deadline = time.time() + timeout
+ while time.time() < deadline: + if cdp_listening(port): + return True + time.sleep(0.5) + return False + + +def spawn_launcher() -> subprocess.Popen | None:
+ python = sys.executable
+ pythonw = Path(python).with_name("pythonw.exe")
+ exe = str(pythonw if pythonw.exists() else python)
+ args = [exe, "-m", "codex_session_delete", "launch"]
+ creationflags = 0
+ if sys.platform == "win32":
+ creationflags = (
+ subprocess.CREATE_NEW_PROCESS_GROUP
+ | getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0)
+ )
+ try:
+ return subprocess.Popen(
+ args,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ close_fds=True,
+ creationflags=creationflags,
+ )
+ except Exception as exc:
+ log(f"failed to spawn launcher: {exc}")
+ return None
+
+
+def stop_launcher_processes() -> None:
+ script = (
+ "Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe' OR Name='python.exe'\" | "
+ "Where-Object { $_.CommandLine -match 'codex_session_delete\\s+launch' } | "
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
+ )
+ _run_powershell(script, timeout=6.0)
+
+
+def takeover(debug_port: int) -> bool:
+ """Perform one atomic takeover attempt: kill codex cleanly, spawn launcher, wait for CDP.
+
+ Returns True on success (CDP up), False otherwise. On failure, caller should back off briefly.
+ """
+ # Step 1: Kill existing launcher processes (stale / failed) so we start from a known state.
+ stop_launcher_processes()
+
+ # Step 2: Kill all Codex.exe and wait for them to disappear.
+ pids = find_codex_processes()
+ log(f"takeover: killing {len(pids)} codex pid(s): {pids}")
+ kill_processes(pids)
+ if not wait_until_no_codex():
+ log("takeover: codex processes did not exit in time, aborting this attempt")
+ return False
+
+ # Step 3: Give AppX activation machinery a moment to reset the "app is running" state.
+ time.sleep(1.5)
+
+ # Step 4: Spawn a fresh launcher that will activate the packaged app with CDP args.
+ proc = spawn_launcher()
+ if proc is None:
+ return False
+
+ # Step 5: Wait for CDP to come up. Launcher does injection in the background.
+ if wait_for_cdp(debug_port):
+ log(f"takeover: CDP is up on {debug_port} (launcher pid={proc.pid})")
+ return True
+
+ # Step 6: CDP did not come up. Clean up the launcher we spawned and any codex it started,
+ # so the next pass can retry cleanly instead of staring at a broken window.
+ log("takeover: CDP did not come up in time; cleaning up failed attempt")
+ stop_launcher_processes()
+ stragglers = find_codex_processes()
+ if stragglers:
+ kill_processes(stragglers)
+ wait_until_no_codex(timeout=4.0)
+ return False
+
+
+def watch_loop(debug_port: int = 9229) -> int:
+ if sys.platform != "win32":
+ log("watcher only supported on Windows")
+ return 1
+
+ log(f"watcher started (interval={WATCHER_INTERVAL_SECONDS}s)")
+ last_state = None
+ backoff_until = 0.0
+
+ while True:
+ try:
+ if watcher_disabled_flag().exists():
+ if last_state != "disabled":
+ log("disabled flag present; idling")
+ last_state = "disabled"
+ time.sleep(WATCHER_INTERVAL_SECONDS)
+ continue
+
+ if cdp_listening(debug_port):
+ if last_state != "cdp_ok":
+ log("CDP is up")
+ last_state = "cdp_ok"
+ time.sleep(WATCHER_INTERVAL_SECONDS)
+ continue
+
+ codex_pids = find_codex_processes()
+ if not codex_pids:
+ if last_state != "idle":
+ log("no Codex running; idling")
+ last_state = "idle"
+ time.sleep(WATCHER_INTERVAL_SECONDS)
+ continue
+
+ now = time.time()
+ if now < backoff_until: + if last_state != "backoff": + log(f"in backoff after failed takeover; {backoff_until - now:.1f}s remaining") + last_state = "backoff" + time.sleep(WATCHER_INTERVAL_SECONDS) + continue + + log(f"Codex running without CDP (pids={codex_pids}); attempting takeover") + last_state = "takeover" + success = takeover(debug_port) + if success: + last_state = "cdp_ok" + else: + backoff_until = time.time() + 10.0 + last_state = "failed" + except Exception as exc: + log("watch loop error: " + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + + time.sleep(WATCHER_INTERVAL_SECONDS) + + +def enable_watcher() -> None:
+ flag = watcher_disabled_flag()
+ if flag.exists():
+ flag.unlink()
+
+
+def disable_watcher() -> None:
+ flag = watcher_disabled_flag()
+ flag.parent.mkdir(parents=True, exist_ok=True)
+ flag.touch()
diff --git a/codex_session_delete/windows_installer.py b/codex_session_delete/windows_installer.py
index 5bf7720c..bf618a47 100644
--- a/codex_session_delete/windows_installer.py
+++ b/codex_session_delete/windows_installer.py
@@ -26,6 +26,10 @@ def _project_root_expr() -> str:
return _ps_quote(str(Path(__file__).resolve().parent.parent))
+def _icon_path_expr() -> str:
+ return _ps_quote(str(Path(__file__).resolve().parent / "assets" / "codex-plus-plus.ico"))
+
+
def _split_launcher_command(command: str) -> tuple[str, str]:
prefix = "python "
if command.startswith(prefix):
@@ -36,26 +40,26 @@ def _split_launcher_command(command: str) -> tuple[str, str]:
def build_install_shortcut_script(options: "InstallOptions") -> str:
root = _install_root_expr(options)
project_root = _project_root_expr()
+ icon_path = _icon_path_expr()
target, arguments = _split_launcher_command(_launcher_command(options))
target_expr = "$Pythonw" if target == "python" else _ps_quote(target)
arguments_expr = _ps_quote(arguments)
return f"""
$InstallRoot = {root}
$ProjectRoot = {project_root}
+$CodexPlusIcon = {icon_path}
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
$ShortcutPath = Join-Path $InstallRoot 'Codex++.lnk'
$Python = (Get-Command python).Source
$PythonwCandidate = Join-Path (Split-Path $Python -Parent) 'pythonw.exe'
$Pythonw = if (Test-Path $PythonwCandidate) {{ $PythonwCandidate }} else {{ $Python }}
-$CodexPackage = Get-ChildItem 'C:\\Program Files\\WindowsApps' -Directory -Filter 'OpenAI.Codex_*_x64__*' -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1
-$CodexIcon = if ($CodexPackage) {{ Join-Path $CodexPackage.FullName 'app\\Codex.exe' }} else {{ $Python }}
$Shell = New-Object -ComObject WScript.Shell
$Shortcut = $Shell.CreateShortcut($ShortcutPath)
$Shortcut.TargetPath = {target_expr}
$Shortcut.Arguments = {arguments_expr}
$Shortcut.WorkingDirectory = $ProjectRoot
$Shortcut.Description = 'Launch Codex with Codex++ injection'
-$Shortcut.IconLocation = "$CodexIcon,0"
+$Shortcut.IconLocation = $CodexPlusIcon
$Shortcut.Save()
$LegacyUninstallKey = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Codex++'
if (Test-Path $LegacyUninstallKey) {{ Remove-Item $LegacyUninstallKey -Force }}
@@ -63,9 +67,9 @@ def build_install_shortcut_script(options: "InstallOptions") -> str:
$UninstallCommand = 'cmd.exe /c cd /d "' + $ProjectRoot + '" && "' + $Python + '" -m codex_session_delete uninstall --install-root "' + $InstallRoot + '"'
New-Item -Path $UninstallKey -Force | Out-Null
Set-ItemProperty -Path $UninstallKey -Name DisplayName -Value 'Codex++'
-Set-ItemProperty -Path $UninstallKey -Name DisplayVersion -Value '0.1.0'
+Set-ItemProperty -Path $UninstallKey -Name DisplayVersion -Value '1.0.4'
Set-ItemProperty -Path $UninstallKey -Name Publisher -Value 'BigPizzaV3'
-Set-ItemProperty -Path $UninstallKey -Name DisplayIcon -Value $CodexIcon
+Set-ItemProperty -Path $UninstallKey -Name DisplayIcon -Value $CodexPlusIcon
Set-ItemProperty -Path $UninstallKey -Name InstallLocation -Value $ProjectRoot
Set-ItemProperty -Path $UninstallKey -Name UninstallString -Value $UninstallCommand
Set-ItemProperty -Path $UninstallKey -Name QuietUninstallString -Value $UninstallCommand
diff --git a/docs/images/codex-plus-plus.ico b/docs/images/codex-plus-plus.ico
new file mode 100644
index 00000000..52f40f61
Binary files /dev/null and b/docs/images/codex-plus-plus.ico differ
diff --git a/docs/images/codex-plus-plus.png b/docs/images/codex-plus-plus.png
new file mode 100644
index 00000000..600fe358
Binary files /dev/null and b/docs/images/codex-plus-plus.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 2dabc12f..dc4388c1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "codex-session-delete"
-version = "0.1.0"
+version = "1.0.4"
description = "External Codex App session delete injector"
requires-python = ">=3.11"
dependencies = [
@@ -18,6 +18,9 @@ test = ["pytest>=8.0"]
[project.scripts]
codex-session-delete = "codex_session_delete.cli:main"
+[tool.setuptools.package-data]
+codex_session_delete = ["assets/*", "inject/*.js"]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
diff --git a/tests/test_backup_store.py b/tests/test_backup_store.py
index 82a83cfb..bc6dbb10 100644
--- a/tests/test_backup_store.py
+++ b/tests/test_backup_store.py
@@ -25,5 +25,13 @@ def test_backup_store_rejects_unknown_token(tmp_path):
store.read_backup("missing")
except FileNotFoundError as exc:
assert "missing" in str(exc)
- else:
- raise AssertionError("missing backup token was accepted")
+
+
+def test_write_backup_recreates_missing_backup_directory(tmp_path):
+ backup_dir = tmp_path / "backups"
+ store = BackupStore(backup_dir)
+ backup_dir.rmdir()
+
+ token = store.write_backup("s1", "db.sqlite", {"sessions": []})
+
+ assert store.path_for(token).exists()
diff --git a/tests/test_cdp.py b/tests/test_cdp.py
index 730831e4..959e5b67 100644
--- a/tests/test_cdp.py
+++ b/tests/test_cdp.py
@@ -1,7 +1,7 @@
import json
import websocket
-from codex_session_delete.cdp import _bridge_loop, build_bridge_script, pick_page_target
+from codex_session_delete.cdp import BRIDGE_BINDING_NAME, _bridge_loop, build_bridge_script, pick_page_target
class TimeoutThenMessageSocket:
@@ -50,6 +50,10 @@ def test_build_bridge_script_installs_binding_callbacks():
assert "window.__codexSessionDeleteReject" in script
+def test_bridge_binding_name_is_versioned_for_reinjection():
+ assert BRIDGE_BINDING_NAME == "codexSessionDeleteV2"
+
+
def test_bridge_loop_continues_after_idle_timeout():
ws = TimeoutThenMessageSocket()
diff --git a/tests/test_helper_server.py b/tests/test_helper_server.py
index 4bf1ec34..171c4cd5 100644
--- a/tests/test_helper_server.py
+++ b/tests/test_helper_server.py
@@ -10,6 +10,7 @@ class FakeDeleteService:
def __init__(self):
self.deleted = []
self.undone = []
+ self.archived_title_queries = []
def delete(self, session: SessionRef):
self.deleted.append(session)
@@ -19,6 +20,10 @@ def undo(self, token: str):
self.undone.append(token)
return DeleteResult(DeleteStatus.UNDONE, "s1", "Restored", undo_token=token)
+ def find_archived_thread_by_title(self, title: str):
+ self.archived_title_queries.append(title)
+ return SessionRef(session_id="archived-t1", title=title)
+
def post_json(url, payload):
data = json.dumps(payload).encode("utf-8")
@@ -47,6 +52,22 @@ def test_helper_server_delete_and_undo():
assert service.undone == ["u1"]
+def test_helper_server_resolves_archived_thread_by_title():
+ service = FakeDeleteService()
+ server = HelperServer("127.0.0.1", 0, service)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ base = f"http://127.0.0.1:{server.port}"
+ resolved = post_json(base + "/archived-thread", {"title": "Codex Thread"})
+ finally:
+ server.shutdown()
+ thread.join(timeout=3)
+
+ assert resolved == {"session_id": "archived-t1", "title": "Codex Thread"}
+ assert service.archived_title_queries == ["Codex Thread"]
+
+
def test_helper_server_allows_private_network_preflight():
service = FakeDeleteService()
server = HelperServer("127.0.0.1", 0, service)
diff --git a/tests/test_launcher_cli.py b/tests/test_launcher_cli.py
index ec45c945..98766891 100644
--- a/tests/test_launcher_cli.py
+++ b/tests/test_launcher_cli.py
@@ -3,49 +3,120 @@
import pytest
from codex_session_delete import cli, launcher
-from codex_session_delete.launcher import build_codex_command
+from codex_session_delete.launcher import build_codex_command, launch_codex_app, packaged_app_user_model_id
class FakeServer:
port = 57321
+ def __init__(self):
+ self.shutdown_called = False
+ self.server_close_called = False
-def test_build_codex_command_adds_remote_debugging_port():
- app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_1.0.0.0_x64__abc/app")
+ def shutdown(self):
+ self.shutdown_called = True
- command = build_codex_command(app_dir, 9229)
+ def server_close(self):
+ self.server_close_called = True
- assert str(app_dir / "Codex.exe") in command[0] or str(app_dir / "codex.exe") in command[0]
- assert "--remote-debugging-port=9229" in command
+class FakeProcess:
+ def __init__(self):
+ self.waited = False
+ def wait(self):
+ self.waited = True
-def test_build_codex_command_allows_devtools_websocket_origin():
- app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_1.0.0.0_x64__abc/app")
- command = build_codex_command(app_dir, 9229)
+def test_launch_codex_windows_adds_remote_debugging_port(monkeypatch):
+ app_dir = Path("C:/Codex/app")
+ popen_calls = []
+ monkeypatch.setattr(launcher.subprocess, "Popen", lambda args, **kw: popen_calls.append(args))
- assert "--remote-allow-origins=http://127.0.0.1:9229" in command
+ launch_codex_app(app_dir, 9229)
+ assert popen_calls
+ assert str(app_dir / "Codex.exe") in popen_calls[0][0] or str(app_dir / "codex.exe") in popen_calls[0][0]
+ assert "--remote-debugging-port=9229" in popen_calls[0]
-def test_build_codex_command_supports_macos_app_bundle(tmp_path):
+def test_launch_codex_windows_allows_devtools_websocket_origin(monkeypatch):
+ app_dir = Path("C:/Codex/app")
+ popen_calls = []
+ monkeypatch.setattr(launcher.subprocess, "Popen", lambda args, **kw: popen_calls.append(args))
+
+ launch_codex_app(app_dir, 9229)
+
+ assert "--remote-allow-origins=http://127.0.0.1:9229" in popen_calls[0]
+
+
+def test_launch_codex_macos_uses_open_command(monkeypatch, tmp_path):
app = tmp_path / "Codex.app"
- executable = app / "Contents" / "MacOS" / "Codex"
- executable.parent.mkdir(parents=True)
- executable.write_text("#!/bin/sh\n", encoding="utf-8")
+ (app / "Contents" / "MacOS").mkdir(parents=True)
+ run_calls = []
+ monkeypatch.setattr(launcher.subprocess, "run", lambda args, **kw: run_calls.append(args))
+
+ proc = launch_codex_app(app, 9229)
+
+ assert proc is None
+ assert len(run_calls) == 1
+ assert run_calls[0][0] == "open"
+ assert "-a" in run_calls[0]
+ assert str(app) in run_calls[0]
+
+
+def test_packaged_app_user_model_id_from_windowsapps_path():
+ app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_26.506.2212.0_x64__2p2nqsd0c76g0/app")
+
+ assert packaged_app_user_model_id(app_dir) == "OpenAI.Codex_2p2nqsd0c76g0!App"
+
+
+def test_packaged_app_user_model_id_ignores_non_packaged_path():
+ app_dir = Path("C:/Codex/app")
+
+ assert packaged_app_user_model_id(app_dir) is None
+
+
+def test_launch_uses_packaged_activation_for_windowsapps(monkeypatch):
+ app_dir = Path("C:/Program Files/WindowsApps/OpenAI.Codex_26.506.2212.0_x64__2p2nqsd0c76g0/app")
+ activated = []
+ launched = []
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(
+ launcher,
+ "activate_packaged_app",
+ lambda aumid, arguments: activated.append((aumid, arguments)) or 1234,
+ )
+ monkeypatch.setattr(launcher.subprocess, "Popen", lambda command: launched.append(command))
+
+ assert launcher.launch_codex_app(app_dir, 9229) == 1234
+
+ assert activated == [(
+ "OpenAI.Codex_2p2nqsd0c76g0!App",
+ "--remote-debugging-port=9229 --remote-allow-origins=http://127.0.0.1:9229",
+ )]
+ assert launched == []
+
+
+def test_windows_port_selector_uses_ephemeral_port_when_default_is_busy(monkeypatch):
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(launcher, "_can_bind_loopback_port", lambda port: port != 9229)
+ monkeypatch.setattr(launcher, "_find_available_loopback_port", lambda: 43001)
+
+ assert launcher.select_windows_loopback_port(9229) == 43001
+
- command = build_codex_command(app, 9229)
+def test_non_windows_port_selector_keeps_requested_port(monkeypatch):
+ monkeypatch.setattr(launcher.sys, "platform", "darwin")
+ monkeypatch.setattr(launcher, "_can_bind_loopback_port", lambda port: False)
- assert command[0] == str(executable)
- assert "--remote-debugging-port=9229" in command
- assert "--remote-allow-origins=http://127.0.0.1:9229" in command
+ assert launcher.select_windows_loopback_port(9229) == 9229
def test_cli_keeps_helper_server_alive_after_injection(monkeypatch):
waited = []
- monkeypatch.setattr(cli, "launch_and_inject", lambda *args: FakeServer())
- monkeypatch.setattr(cli, "wait_for_shutdown", lambda server: waited.append(server.port))
+ monkeypatch.setattr(cli, "launch_and_inject", lambda *args: (FakeServer(), None))
+ monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: waited.append(server.port))
exit_code = cli.main([])
@@ -56,8 +127,8 @@ def test_cli_keeps_helper_server_alive_after_injection(monkeypatch):
def test_cli_launch_subcommand_keeps_helper_server_alive_after_injection(monkeypatch):
waited = []
calls = []
- monkeypatch.setattr(cli, "launch_and_inject", lambda *args: calls.append(args) or FakeServer())
- monkeypatch.setattr(cli, "wait_for_shutdown", lambda server: waited.append(server.port))
+ monkeypatch.setattr(cli, "launch_and_inject", lambda *args: calls.append(args) or (FakeServer(), None))
+ monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: waited.append(server.port))
exit_code = cli.main(["launch"])
@@ -78,8 +149,6 @@ def test_cli_install_dispatches_to_platform_installer(monkeypatch, tmp_path):
assert calls[0].launcher_command == "python -m codex_session_delete"
-
-
def test_cli_uninstall_dispatches_to_platform_installer(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(cli, "uninstall_codex_plus_plus", lambda options: calls.append(options))
@@ -96,7 +165,7 @@ def test_launch_retries_injection_until_codex_page_is_ready(monkeypatch, tmp_pat
attempts = []
monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path)
monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer())
- monkeypatch.setattr(launcher.subprocess, "Popen", lambda *args, **kwargs: None)
+ monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: None)
def inject_after_retry(*args):
attempts.append(args)
@@ -107,12 +176,38 @@ def inject_after_retry(*args):
monkeypatch.setattr(launcher, "inject_file", inject_after_retry)
monkeypatch.setattr(launcher.time, "sleep", lambda seconds: None)
- server = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321)
+ server, proc = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321)
assert server.port == 57321
assert len(attempts) == 2
+def test_launch_and_inject_returns_windows_packaged_process_id(monkeypatch, tmp_path):
+ monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path)
+ monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer())
+ monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: 1234)
+ monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: {"result": {}})
+
+ server, proc = launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321)
+
+ assert server.port == 57321
+ assert proc == 1234
+
+
+def test_launch_and_inject_closes_helper_when_injection_fails(monkeypatch, tmp_path):
+ server = FakeServer()
+ monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: tmp_path)
+ monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: server)
+ monkeypatch.setattr(launcher, "launch_codex_app", lambda *args: 1234)
+ monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("inject failed")))
+
+ with pytest.raises(RuntimeError, match="inject failed"):
+ launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321)
+
+ assert server.shutdown_called is True
+ assert server.server_close_called is True
+
+
def test_launch_uses_resolved_app_dir(monkeypatch, tmp_path):
launched = []
mac_app = tmp_path / "Applications" / "OpenAI Codex.app"
@@ -121,12 +216,53 @@ def test_launch_uses_resolved_app_dir(monkeypatch, tmp_path):
executable.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr(launcher, "resolve_codex_app_dir", lambda app_dir=None: mac_app)
monkeypatch.setattr(launcher, "start_helper", lambda *args, **kwargs: FakeServer())
- monkeypatch.setattr(launcher.subprocess, "Popen", lambda command: launched.append(command))
+ monkeypatch.setattr(launcher.subprocess, "run", lambda args, **kw: launched.append(args))
monkeypatch.setattr(launcher, "inject_with_retry", lambda *args, **kwargs: {"result": {}})
launcher.launch_and_inject(None, None, tmp_path / "backups", 9229, 57321)
- assert launched[0][0] == str(executable)
+ assert str(executable) not in launched[0]
+ assert "open" in launched[0]
+
+
+def test_cli_stops_existing_windows_launchers_before_launch(monkeypatch):
+ commands = []
+ monkeypatch.setattr(cli.sys, "platform", "win32")
+ monkeypatch.setattr(cli.os, "getpid", lambda: 9876)
+ monkeypatch.setattr(cli.subprocess, "run", lambda command, **kwargs: commands.append((command, kwargs)))
+
+ cli.stop_existing_windows_launchers()
+
+ assert len(commands) == 1
+ command, kwargs = commands[0]
+ assert command[:3] == ["powershell", "-NoProfile", "-Command"]
+ assert "codex_session_delete" in command[3]
+ assert "pythonw?" in command[3]
+ assert "Stop-Process" in command[3]
+ assert kwargs["env"]["CODEX_PLUS_PLUS_PID"] == "9876"
+ assert kwargs["check"] is False
+
+
+def test_cli_skips_launcher_cleanup_on_non_windows(monkeypatch):
+ commands = []
+ monkeypatch.setattr(cli.sys, "platform", "linux")
+ monkeypatch.setattr(cli.subprocess, "run", lambda command, **kwargs: commands.append((command, kwargs)))
+
+ cli.stop_existing_windows_launchers()
+
+ assert commands == []
+
+
+def test_cli_launch_runs_launcher_cleanup_before_injection(monkeypatch):
+ events = []
+ monkeypatch.setattr(cli, "stop_existing_windows_launchers", lambda: events.append("cleanup"))
+ monkeypatch.setattr(cli, "launch_and_inject", lambda *args: events.append("launch") or (FakeServer(), None))
+ monkeypatch.setattr(cli, "wait_for_shutdown", lambda server, proc: events.append("wait"))
+
+ exit_code = cli.main(["launch"])
+
+ assert exit_code == 0
+ assert events == ["cleanup", "launch", "wait"]
def test_cli_setup_alias_installs_with_default_launcher(monkeypatch):
@@ -162,3 +298,27 @@ def test_cli_logs_launch_failure_for_hidden_pythonw(monkeypatch, tmp_path):
cli.main(["launch"])
assert "inject failed" in log_path.read_text(encoding="utf-8")
+
+
+def test_wait_for_shutdown_waits_for_windows_process_id(monkeypatch):
+ server = FakeServer()
+ waited = []
+ monkeypatch.setattr(cli.sys, "platform", "win32")
+ monkeypatch.setattr(cli, "wait_for_windows_process_id", lambda process_id: waited.append(process_id))
+
+ cli.wait_for_shutdown(server, 1234)
+
+ assert waited == [1234]
+ assert server.shutdown_called is True
+ assert server.server_close_called is True
+
+
+def test_wait_for_shutdown_waits_for_popen_like_process():
+ server = FakeServer()
+ proc = FakeProcess()
+
+ cli.wait_for_shutdown(server, proc)
+
+ assert proc.waited is True
+ assert server.shutdown_called is True
+ assert server.server_close_called is True
diff --git a/tests/test_macos_installer.py b/tests/test_macos_installer.py
index c1480979..dd243bb0 100644
--- a/tests/test_macos_installer.py
+++ b/tests/test_macos_installer.py
@@ -23,6 +23,8 @@ def test_install_macos_app_creates_app_bundle(tmp_path):
assert plist["CFBundleName"] == "Codex++"
assert plist["CFBundleExecutable"] == "CodexPlusPlus"
assert plist["CFBundleIdentifier"] == "com.bigpizzav3.codexplusplus"
+ assert plist["CFBundleIconFile"] == "codex-plus-plus.png"
+ assert (app / "Contents" / "Resources" / "codex-plus-plus.png").exists()
script = executable.read_text(encoding="utf-8")
assert "python -m codex_session_delete launch" in script
diff --git a/tests/test_readme.py b/tests/test_readme.py
index 34528f7b..0941de63 100644
--- a/tests/test_readme.py
+++ b/tests/test_readme.py
@@ -9,7 +9,16 @@ def test_readme_limits_discussion_group_qr_size():
assert '' not in text
-def test_readme_includes_linux_do_friend_link():
+def test_readme_includes_codex_plus_icon_and_toc():
+ text = Path("README.md").read_text(encoding="utf-8")
+
+ assert ' runScanStep(scanDeferred))" in text
+ assert "if (window.__codexSessionDeleteScanPending) return" in text
+ assert "setTimeout(runScheduledScan, 200)" in text
+ assert "setTimeout(() => runScanStep(scanDeferred), 50)" not in text
+ assert "codexSessionDeleteAttachButtonFailures" in text
+ assert "tryAttachButton" in text
+ assert "sessionRows().forEach(tryAttachButton)" in text
+ assert "sessionRows().forEach(attachButton)" not in text
assert "new MutationObserver(scheduleScan)" in text
assert "new MutationObserver(scan)" not in text
- assert "scheduleScan();" in text
- assert " scan();\n window.__codexSessionDeleteObserver" not in text
+ assert "scan();" in text
+ assert " scan();\n window.__codexSessionDeleteObserver" in text
+
+
+def test_renderer_script_clears_focus_and_removes_deleted_rows():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "removeDeletedRow(row, button, ref)" in text
+ assert "function releaseDeleteFocus" in text
+ assert "releaseDeleteFocus(row, button)" in text
+ assert "button.blur()" in text
+ assert "document.activeElement.blur()" in text
+ assert "row.remove()" in text
+ assert "row.style.display = \"none\"" not in text
+
+
+def test_renderer_script_uses_in_page_confirm_and_stops_early_pointer_events():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "confirm(" not in text
+ assert "codex-delete-confirm-overlay" in text
+ assert "escapeHtml(title)" in text
+ assert "stopImmediatePropagation" in text
+ assert "\"pointerdown\", \"mousedown\", \"mouseup\", \"touchstart\"" in text
+
+
+def test_renderer_script_reloads_after_deleting_current_session():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "isCurrentSessionRow" in text
+ assert "window.location.href.includes(ref.session_id)" in text
+ assert "window.location.reload()" in text
+
+
+def test_renderer_script_toast_does_not_capture_page_interactions():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "z-index: 2147483000" in text
+ assert "pointer-events: none" in text
+ assert "pointer-events: auto" in text
+def test_renderer_script_sidebar_delete_opens_on_pointerup_when_click_is_unreliable():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "openDeleteConfirm" in text
+ assert "codexDeleteVersion = \"5\"" in text
+ assert "existingDeleteButtons.length === 1" in text
+ assert "existingDeleteButtons[0].dataset.codexDeleteVersion === codexDeleteVersion" in text
+ assert "existingDeleteButtons.forEach((button) => button.remove())" in text
+ assert "row.dataset.codexDeleteRow = \"false\"" in text
+ assert "installDeleteButtonEventDelegation" in text
+ assert "codexSessionDeleteDocumentDeleteHandler" in text
+ assert "document.addEventListener(\"pointerup\", handler, true)" in text
+ assert "document.addEventListener(\"click\", handler, true)" in text
+ assert "button.addEventListener(\"pointerup\", openDeleteConfirm, true)" in text
+
+
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "updateDeleteButtonOffsets" in text
+ assert "codexDeleteStyleVersion = \"4\"" in text
+ assert "right: 66px" in text
+ assert "确认" in text
+ assert "归档对话" in text
+ assert "button.getAttribute(\"aria-label\")" in text
+ assert "label === \"归档对话\"" in text
+
+
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "archivedSessionRows" in text
+ assert "archivedPageRows" in text
+ assert "installArchivedDeleteAllButton" in text
+ assert "删除全部归档" in text
+ assert "deleteArchivedSessions" in text
+ assert "attachArchivedPageDeleteButton" in text
+ assert "resolveArchivedThread" in text
+ assert "stopArchivedButtonEvent" in text
+ assert "[\"pointerdown\", \"mousedown\", \"mouseup\", \"touchstart\"].forEach((eventName) => {\n button.addEventListener(eventName, stopArchivedButtonEvent, true);" in text
+ assert "pointerup" in text
+ assert "button.addEventListener(\"pointerup\", openArchivedDeleteAllConfirm, true)" in text
+ assert "archivedRefFromRow(row)" in text
+ assert "reactArchivedThreadFromNode" in text
+ assert "archivedThreadFromRow" in text
+ assert "props.archivedThread?.id" in text
+ assert "archivedThread.id || archivedThread.sessionId" in text
+ assert "replace(/\\d{4}年\\d{1,2}月\\d{1,2}日.*$/, \"\")" in text
+ assert "const titleMatches = sessionRows().map(sessionRefFromRow)" not in text
+ assert "document.querySelectorAll(\"[data-codex-archive-delete-all]\").forEach((node) => node.remove())" not in text
+ assert "const existingButton = document.querySelector(\"[data-codex-archive-delete-all]\")" in text
+ assert "if (existingButton?.dataset.codexArchiveDeleteAllVersion === codexArchiveDeleteAllVersion) return" in text
+ assert "existingButton?.remove()" in text
+ assert "button.dataset.codexArchiveDeleteAllVersion = codexArchiveDeleteAllVersion" in text
+ assert "data-codex-archive-delete-all" in text
+ assert "codex-archive-action-bar" in text
+ assert "codexDeleteStyleVersion" in text
+ assert "style.dataset.codexDeleteStyleVersion" in text
+ assert "position: fixed" in text
+ assert "archiveTitleContainer" in text
+ assert "element.getBoundingClientRect().x> 350" in text
+ assert "已归档对话" in text
+ assert "insertAdjacentElement(\"afterend\", button)" in text
+ assert "maxWidth: \"fit-content\"" in text
+ assert "alignSelf: \"flex-start\"" in text
+ assert "Object.assign(button.style" in text
+ assert "cursor: \"pointer\"" in text
+ assert "position: \"static\"" in text
+ assert "data-codex-archive-page-row" in text
+ assert "data-app-action-sidebar-thread-id" in text
+ assert "取消归档" in text
+ assert "已归档对话" in text
+
+
+def test_renderer_script_does_not_include_fast_mode_patch():
+ text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
+ assert "codexFastModeUnlockVersion" not in text
+ assert "enableFastModeFeatureFlags" not in text
+ assert "patchFastModeGates" not in text
+ assert "patchGeneralSettingsSpeedGate" not in text
+ assert "patchCodexPostForFastMode" not in text
+ assert "recordFastModeDiagnostic" not in text
+ assert "additionalSpeedTiers" not in text
+ assert "bodyJsonString" not in text
+ assert "forceChatGPTAuthForFastMode" not in text
+ assert "codex-fast-mode-row" not in text
+ assert "setAuthMethod(\"chatgpt\")" in text
+ assert "patchFastModeGateOnObject" not in text
-def test_renderer_script_adds_codex_plus_menu_with_feature_toggles():
text = Path("codex_session_delete/inject/renderer-inject.js").read_text(encoding="utf-8")
assert "installCodexPlusMenu" in text
assert "Codex++" in text
+ assert "codexPlusVersion = \"1.0.4\"" in text
+ assert "Codex++ ${codexPlusVersion}" in text
+ assert "提出问题" in text
+ assert "https://github.com/BigPizzaV3/CodexPlusPlus/issues" in text
+ assert "window.open(issueUrl, \"_blank\")" in text
assert "插件选项解锁" in text
assert "特殊插件强制安装" in text
assert "会话删除" in text
+ assert "原生菜单栏位置" in text
+ assert "nativeMenuPlacement: true" in text
assert "关于 Codex++" in text
assert "https://github.com/BigPizzaV3/CodexPlusPlus" in text
assert "codexPlusSettings" in text
@@ -87,13 +220,19 @@ def test_renderer_script_adds_codex_plus_menu_with_feature_toggles():
assert "backdrop-blur-xl" not in text
assert "codex-plus-menu-floating" in text
assert "findNativeMenuInsertionPoint" in text
+ assert "if (!codexPlusSettings().nativeMenuPlacement) return null" in text
+ assert "right: 140px" in text
+ assert "left: auto" in text
+ assert "pointer-events: auto" in text
+ assert "-webkit-app-region: no-drag" in text
+ assert ".codex-plus-trigger" in text
assert "app-header-tint" in text
assert "flex items-center gap-0.5" in text
assert "codex-plus-menu-floating" in text
assert "nativeButtonClass" in text
assert "removeDuplicateCodexPlusMenus" in text
assert "data-codex-plus-menu" in text
- assert "textContent || \"\").trim() === \"Codex++\"" in text
+ assert "textContent || \"\").trim() === `Codex++ ${codexPlusVersion}`" in text
assert "codexPlusMenuVersion = \"5\"" in text
assert "codexPlusTriggerInstalled = \"5\"" in text
assert ".codex-plus-trigger:hover" not in text
diff --git a/tests/test_storage_adapter.py b/tests/test_storage_adapter.py
index 7a81cef9..eaec34c0 100644
--- a/tests/test_storage_adapter.py
+++ b/tests/test_storage_adapter.py
@@ -116,6 +116,44 @@ def test_delete_codex_thread_schema_accepts_local_prefixed_thread_id(tmp_path):
assert db.execute("SELECT COUNT(*) FROM threads WHERE id = 't1'").fetchone()[0] == 0
+def test_find_archived_codex_thread_by_title(tmp_path):
+ db_path = tmp_path / "state_5.sqlite"
+ rollout_path = tmp_path / "archived.jsonl"
+ create_codex_thread_db(db_path, rollout_path)
+ with sqlite3.connect(db_path) as db:
+ db.execute("UPDATE threads SET archived = 1, archived_at = 123 WHERE id = 't1'")
+ adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups"))
+
+ session = adapter.find_archived_thread_by_title("Codex Thread")
+
+ assert session == SessionRef(session_id="t1", title="Codex Thread")
+
+
+def test_find_archived_codex_thread_by_title_matches_rendered_archive_card_text(tmp_path):
+ db_path = tmp_path / "state_5.sqlite"
+ rollout_path = tmp_path / "archived.jsonl"
+ create_codex_thread_db(db_path, rollout_path)
+ with sqlite3.connect(db_path) as db:
+ db.execute("UPDATE threads SET archived = 1, archived_at = 123 WHERE id = 't1'")
+ adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups"))
+
+ session = adapter.find_archived_thread_by_title("Codex Thread 2026年5月9日,1:19 · RustGUI")
+
+ assert session == SessionRef(session_id="t1", title="Codex Thread")
+
+
+def test_find_archived_codex_thread_by_title_ignores_active_threads(tmp_path):
+ db_path = tmp_path / "state_5.sqlite"
+ rollout_path = tmp_path / "active.jsonl"
+ create_codex_thread_db(db_path, rollout_path)
+ adapter = SQLiteStorageAdapter(db_path, BackupStore(tmp_path / "backups"))
+
+ session = adapter.find_archived_thread_by_title("Codex Thread")
+
+ assert session is None
+
+
+def test_delete_unsupported_schema_fails(tmp_path):
db_path = tmp_path / "unknown.sqlite"
with sqlite3.connect(db_path) as db:
db.execute("CREATE TABLE unrelated (id TEXT PRIMARY KEY)")
diff --git a/tests/test_watcher.py b/tests/test_watcher.py
new file mode 100644
index 00000000..5b017836
--- /dev/null
+++ b/tests/test_watcher.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+import socket
+import sys
+import types
+from pathlib import Path
+
+import pytest
+
+from codex_session_delete import watcher
+
+
+def test_cdp_listening_returns_true_when_bound():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.bind(("127.0.0.1", 0))
+ server.listen(1)
+ port = server.getsockname()[1]
+ assert watcher.cdp_listening(port) is True
+
+
+def test_cdp_listening_returns_false_when_closed():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ probe.bind(("127.0.0.1", 0))
+ port = probe.getsockname()[1]
+ # After the probe socket closes, nothing should be listening on that port
+ # (the port may get reused but the probe finishes with connection refused in normal conditions)
+ assert watcher.cdp_listening(port) is False
+
+
+def test_enable_watcher_removes_flag(tmp_path, monkeypatch):
+ monkeypatch.setattr(watcher, "data_root", lambda: tmp_path)
+ flag = tmp_path / "watcher.disabled"
+ flag.parent.mkdir(parents=True, exist_ok=True)
+ flag.touch()
+ assert flag.exists()
+ watcher.enable_watcher()
+ assert not flag.exists()
+
+
+def test_disable_watcher_creates_flag(tmp_path, monkeypatch):
+ monkeypatch.setattr(watcher, "data_root", lambda: tmp_path)
+ flag = tmp_path / "watcher.disabled"
+ assert not flag.exists()
+ watcher.disable_watcher()
+ assert flag.exists()
+
+
+def test_enable_watcher_is_idempotent(tmp_path, monkeypatch):
+ monkeypatch.setattr(watcher, "data_root", lambda: tmp_path)
+ # Should not raise when flag does not exist
+ watcher.enable_watcher()
+ assert not (tmp_path / "watcher.disabled").exists()
+
+
+def test_watch_loop_exits_on_non_windows(monkeypatch, tmp_path):
+ monkeypatch.setattr(watcher, "data_root", lambda: tmp_path)
+ monkeypatch.setattr(watcher.sys, "platform", "linux")
+ assert watcher.watch_loop() == 1
+
+
+def test_wait_until_no_codex_success(monkeypatch):
+ calls = {"n": 0}
+
+ def find() -> list[int]:
+ calls["n"] += 1
+ # First poll: one process, subsequent polls: empty
+ return [1234] if calls["n"] == 1 else []
+
+ monkeypatch.setattr(watcher, "find_codex_processes", find)
+ killed: list[list[int]] = []
+ monkeypatch.setattr(watcher, "kill_processes", lambda pids: killed.append(list(pids)))
+ assert watcher.wait_until_no_codex(timeout=2.0) is True
+
+
+def test_wait_until_no_codex_times_out(monkeypatch):
+ monkeypatch.setattr(watcher, "find_codex_processes", lambda: [1])
+ monkeypatch.setattr(watcher, "kill_processes", lambda pids: None)
+ assert watcher.wait_until_no_codex(timeout=0.5) is False
+
+
+def test_wait_for_cdp_returns_true_when_listening(monkeypatch):
+ seq = iter([False, False, True])
+ monkeypatch.setattr(watcher, "cdp_listening", lambda port: next(seq))
+ assert watcher.wait_for_cdp(port=9229, timeout=2.0) is True
+
+
+def test_wait_for_cdp_returns_false_on_timeout(monkeypatch):
+ monkeypatch.setattr(watcher, "cdp_listening", lambda port: False)
+ assert watcher.wait_for_cdp(port=9229, timeout=0.3) is False
diff --git a/tests/test_windows_installer.py b/tests/test_windows_installer.py
index 76260d3b..2b6836df 100644
--- a/tests/test_windows_installer.py
+++ b/tests/test_windows_installer.py
@@ -10,6 +10,7 @@ def test_build_install_shortcut_script_contains_codex_plus_shortcuts(tmp_path):
script = build_install_shortcut_script(options)
assert "Codex++.lnk" in script
+ assert "codex-plus-plus.ico" in script
assert "-m codex_session_delete launch" in script
assert "CreateShortcut" in script
assert "TargetPath = $Pythonw" in script
@@ -19,9 +20,9 @@ def test_build_install_shortcut_script_contains_codex_plus_shortcuts(tmp_path):
assert "-EncodedCommand" not in script
assert "powershell.exe" not in script
assert "WorkingDirectory = $ProjectRoot" in script
- assert "OpenAI.Codex_*_x64__*" in script
- assert "Codex.exe" in script
- assert "IconLocation = \"$CodexIcon,0\"" in script
+ assert "codex-plus-plus.ico" in script
+ assert "Codex.exe" not in script
+ assert "IconLocation = $CodexPlusIcon" in script
assert "$Python,0" not in script
assert str(Path.cwd()) in script
assert "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\CodexPlusPlus" in script