From 98fd5f57dac25e5bc77bff7d4d3e93afc0fa660a Mon Sep 17 00:00:00 2001 From: xinzhang <3199569817@qq.com> Date: Wed, 25 Mar 2026 20:19:30 +0800 Subject: [PATCH 1/2] Add Windows restart support and restore SPACE column --- codex_switcher.py | 234 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 219 insertions(+), 15 deletions(-) diff --git a/codex_switcher.py b/codex_switcher.py index c574ab2..fb44566 100755 --- a/codex_switcher.py +++ b/codex_switcher.py @@ -239,6 +239,101 @@ def extract_claims_from_id_token(id_token: str) -> Optional[dict]: 'record_key': record_key, } +def normalize_organizations(auth_info: dict) -> List[dict]: + """Normalize workspace/organization metadata from auth claims.""" + raw_orgs = auth_info.get('organizations', []) + if not isinstance(raw_orgs, list): + return [] + + organizations = [] + for raw_org in raw_orgs: + if not isinstance(raw_org, dict): + continue + + organizations.append({ + 'id': str(raw_org.get('id', '') or '').strip(), + 'title': str(raw_org.get('title', '') or '').strip(), + 'role': str(raw_org.get('role', '') or '').strip(), + 'is_default': bool(raw_org.get('is_default')), + }) + return organizations + +def get_primary_workspace(organizations: List[dict]) -> dict: + """Pick the default workspace when available, otherwise the first one.""" + for org in organizations: + if org.get('is_default'): + return org + return organizations[0] if organizations else {} + +def format_workspace_display(organizations: List[dict], primary: dict) -> str: + """Build a short label for CLI and table output.""" + if not primary: + return 'Unknown' + + label = primary.get('title') or primary.get('id') or 'Unknown' + extra_count = max(0, len(organizations) - 1) + if extra_count: + return f"{label} (+{extra_count})" + return label + +def list_windows_processes() -> List[dict]: + """List Windows processes with executable path and command line details.""" + if platform.system() != 'Windows': + return [] + + script = ( + "Get-CimInstance Win32_Process | " + "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | " + "ConvertTo-Json -Compress" + ) + try: + result = subprocess.run( + ['powershell', '-NoProfile', '-Command', script], + capture_output=True, + text=True, + check=False, + ) + except Exception: + return [] + + if result.returncode != 0: + return [] + + raw = result.stdout.strip() + if not raw: + return [] + + try: + data = json.loads(raw) + except Exception: + return [] + + if isinstance(data, dict): + data = [data] + if not isinstance(data, list): + return [] + + processes = [] + for item in data: + if not isinstance(item, dict): + continue + try: + pid = int(item.get('ProcessId') or 0) + ppid = int(item.get('ParentProcessId') or 0) + except Exception: + continue + if pid <= 0: + continue + + processes.append({ + 'pid': pid, + 'ppid': ppid, + 'name': str(item.get('Name') or ''), + 'exe_path': str(item.get('ExecutablePath') or ''), + 'command_line': str(item.get('CommandLine') or ''), + }) + return processes + def get_usage_cache_key(email: str, account_id: str = '', record_key: str = '') -> str: """生成 usage 缓存 key""" if record_key: @@ -317,6 +412,22 @@ def get_process_cwd(pid: int) -> str: return str(get_home_dir()) def detect_codex_desktop_instances() -> List[dict]: + if platform.system() == 'Windows': + instances = [] + for proc in list_windows_processes(): + exe_path = (proc.get('exe_path') or '').replace('/', '\\') + command = proc.get('command_line', '') + if not exe_path.lower().endswith('\\app\\codex.exe'): + continue + if '\\resources\\codex.exe' in exe_path.lower(): + continue + if '--type=' in command: + continue + instances.append({ + 'pid': proc['pid'], + 'app_path': exe_path, + }) + return instances """检测运行中的 Codex Desktop 主进程""" instances = [] for pid, command in list_processes(): @@ -336,6 +447,8 @@ def detect_codex_desktop_instances() -> List[dict]: return instances def detect_codex_cli_instances() -> List[dict]: + if platform.system() == 'Windows': + return [] """检测运行中的 codex CLI 进程""" tree = list_process_tree() instances = [] @@ -381,6 +494,27 @@ def escape_applescript_string(value: str) -> str: """转义 AppleScript 字符串""" return value.replace('\\', '\\\\').replace('"', '\\"') +def escape_powershell_string(value: str) -> str: + """Escape a string for single-quoted PowerShell literals.""" + return value.replace("'", "''") + +def collect_windows_restart_targets(desktop_instances: List[dict]) -> List[str]: + """Collect executable paths that should be closed during Windows restart.""" + targets = [] + seen = set() + for item in desktop_instances: + app_path = str(item.get('app_path') or '') + if not app_path: + continue + + for candidate in [app_path, str(Path(app_path).parent / 'resources' / 'codex.exe')]: + normalized = candidate.lower() + if normalized in seen: + continue + seen.add(normalized) + targets.append(candidate) + return targets + def build_restart_script( script_path: Path, desktop_instances: List[dict], @@ -407,9 +541,50 @@ def build_restart_script( lines.append(f"rm -f {shlex.quote(str(script_path))}") return '\n'.join(lines) + '\n' +def build_windows_restart_script(script_path: Path, desktop_instances: List[dict]) -> str: + """Build a PowerShell script that restarts Codex Desktop on Windows.""" + desktop_pids = [str(int(item['pid'])) for item in desktop_instances if item.get('pid')] + restart_paths = [] + seen_paths = set() + for item in desktop_instances: + app_path = str(item.get('app_path') or '') + if not app_path: + continue + normalized = app_path.lower() + if normalized in seen_paths: + continue + seen_paths.add(normalized) + restart_paths.append(app_path) + + target_paths = collect_windows_restart_targets(desktop_instances) + escaped_target_paths = ', '.join( + f"'{escape_powershell_string(path)}'" for path in target_paths + ) or "''" + escaped_restart_paths = ', '.join( + f"'{escape_powershell_string(path)}'" for path in restart_paths + ) or "''" + escaped_script_path = escape_powershell_string(str(script_path)) + + lines = [ + "$ErrorActionPreference = 'SilentlyContinue'", + "Start-Sleep -Seconds 1", + f"$desktopPids = @({', '.join(desktop_pids)})" if desktop_pids else "$desktopPids = @()", + f"$targetPaths = @({escaped_target_paths})", + f"$restartPaths = @({escaped_restart_paths})", + "foreach ($pid in $desktopPids) { Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue }", + "$lookup = @{}", + "foreach ($path in $targetPaths) { if ($path) { $lookup[$path.ToLowerInvariant()] = $true } }", + "Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $lookup.ContainsKey($_.ExecutablePath.ToLowerInvariant()) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", + "Start-Sleep -Seconds 1", + "foreach ($path in $restartPaths) { if ($path -and (Test-Path -LiteralPath $path)) { Start-Process -FilePath $path | Out-Null } }", + f"Remove-Item -LiteralPath '{escaped_script_path}' -Force -ErrorAction SilentlyContinue", + ] + return '\r\n'.join(lines) + '\r\n' + def schedule_codex_restart() -> Tuple[bool, bool, str]: """安排后台重启运行中的 Codex Desktop 和 CLI""" - if platform.system() != 'Darwin': + current_platform = platform.system() + if current_platform not in {'Darwin', 'Windows'}: return False, False, '自动重启目前仅支持 macOS' desktop_instances = detect_codex_desktop_instances() @@ -419,12 +594,19 @@ def schedule_codex_restart() -> Tuple[bool, bool, str]: runtime_dir = get_switcher_dir() / 'runtime' runtime_dir.mkdir(parents=True, exist_ok=True) - script_path = runtime_dir / f"restart_codex_{int(time.time())}.sh" - script_path.write_text( - build_restart_script(script_path, desktop_instances, cli_instances), - encoding='utf-8', - ) - script_path.chmod(0o700) + if current_platform == 'Windows': + script_path = runtime_dir / f"restart_codex_{int(time.time())}.ps1" + script_path.write_text( + build_windows_restart_script(script_path, desktop_instances), + encoding='utf-8', + ) + else: + script_path = runtime_dir / f"restart_codex_{int(time.time())}.sh" + script_path.write_text( + build_restart_script(script_path, desktop_instances, cli_instances), + encoding='utf-8', + ) + script_path.chmod(0o700) dry_run = os.environ.get(RESTART_DRY_RUN_ENV) == '1' if dry_run: @@ -434,12 +616,20 @@ def schedule_codex_restart() -> Tuple[bool, bool, str]: ) return True, True, message - subprocess.Popen( - ['/bin/zsh', str(script_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) + if current_platform == 'Windows': + subprocess.Popen( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(script_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), + ) + else: + subprocess.Popen( + ['/bin/zsh', str(script_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) message = ( f"已安排关闭 Codex 客户端及相关 CLI 进程,并重启 {len(desktop_instances)} 个 Codex 客户端" ) @@ -853,6 +1043,8 @@ def get_account_info(auth_data: dict, file_path: str = '') -> Optional[dict]: account_id = tokens.get('account_id', '') or claims.get('chatgpt_account_id', '') chatgpt_user_id = claims.get('chatgpt_user_id', '') record_key = claims.get('record_key', '') + organizations = normalize_organizations(auth_info) + primary_workspace = get_primary_workspace(organizations) token_payload = decode_jwt_payload(access_token) or {} token_exp = token_payload.get('exp', 0) or payload.get('exp', 0) @@ -863,6 +1055,12 @@ def get_account_info(auth_data: dict, file_path: str = '') -> Optional[dict]: 'account_id': account_id, 'chatgpt_user_id': chatgpt_user_id, 'record_key': record_key or (f"{email}::{account_id}" if email and account_id else file_path), + 'organizations': organizations, + 'workspace_title': primary_workspace.get('title', ''), + 'workspace_id': primary_workspace.get('id', ''), + 'workspace_role': primary_workspace.get('role', ''), + 'workspace_is_default': bool(primary_workspace.get('is_default')), + 'workspace_display': format_workspace_display(organizations, primary_workspace), 'sub_active_start': auth_info.get('chatgpt_subscription_active_start', ''), 'sub_active_until': auth_info.get('chatgpt_subscription_active_until', ''), 'last_refresh': auth_data.get('last_refresh', ''), @@ -1087,10 +1285,11 @@ def print_accounts_table( return email_width = 32 + space_width = 22 hourly_width = 20 weekly_width = 20 plan_width = 6 - table_width = 2 + 2 + email_width + 1 + hourly_width + 1 + weekly_width + 1 + plan_width + 2 + table_width = 2 + 2 + email_width + 1 + space_width + 1 + hourly_width + 1 + weekly_width + 1 + plan_width + 2 print() print(f"{Colors.BOLD} {title}{Colors.ENDC}") @@ -1100,6 +1299,7 @@ def print_accounts_table( header = ( f" {pad_display('#', 2)} " f"{pad_display('邮箱', email_width)} " + f"{pad_display('SPACE', space_width)} " f"{pad_display('PLAN', plan_width)} " f"{pad_display('5小时', hourly_width)} " f"{pad_display('每周', weekly_width)}" @@ -1117,6 +1317,10 @@ def render_row(i: int, acc: dict): email = truncate_display_text(email, email_width) email_cell = pad_display(email, email_width) + space_value = acc.get('workspace_title', '') or acc.get('workspace_display', '') or '-' + space_value = truncate_display_text(space_value, space_width) + space_cell = pad_display(space_value, space_width) + plan = str(acc.get('plan_type', 'Unknown') or 'Unknown').upper() plan = truncate_display_text(plan, plan_width) plan_cell = pad_display(plan, plan_width) @@ -1152,7 +1356,7 @@ def get_color(pct): weekly_display = f"{get_color(weekly_remaining)}{pad_display(weekly_str, weekly_width)}{Colors.ENDC}" index_cell = pad_display(str(i), 2) - print(f" {index_cell} {email_cell} {plan_display} {hourly_display} {weekly_display}") + print(f" {index_cell} {email_cell} {space_cell} {plan_display} {hourly_display} {weekly_display}") if highlight_current_first and current_rows: for i, acc in enumerate(current_rows, 1): From e78198326b0765a6059756232cb7182932aa7024 Mon Sep 17 00:00:00 2001 From: xinzhang <3199569817@qq.com> Date: Sat, 28 Mar 2026 15:20:57 +0800 Subject: [PATCH 2/2] Hide unavailable snapshots and add cleanup actions --- codex_switcher.py | 121 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/codex_switcher.py b/codex_switcher.py index fb44566..d17fb16 100755 --- a/codex_switcher.py +++ b/codex_switcher.py @@ -1682,6 +1682,61 @@ def sort_accounts_for_live_view(rows: List[dict]) -> List[dict]: other_rows = [row for row in rows if not row.get('is_current')] return current_rows + sort_accounts_for_agent(other_rows) +def is_account_unavailable(row: dict) -> bool: + """Treat reauth/cached-without-usage rows as unavailable snapshots.""" + if row.get('is_current'): + return False + + refresh_status = str(row.get('refresh_status', '') or '').lower() + if refresh_status == 'reauth': + return True + + has_usage_window = bool(row.get('reset_at_hourly') or row.get('reset_at_weekly')) + if refresh_status in {'cached', 'unknown'} and not has_usage_window: + return True + return False + +def filter_rows_for_view(rows: List[dict], show_unavailable: bool) -> List[dict]: + """Hide unavailable saved snapshots unless explicitly requested.""" + if show_unavailable: + return rows + return [row for row in rows if not is_account_unavailable(row)] + +def delete_saved_account_file(file_path: str) -> bool: + """Delete a saved auth snapshot only when it lives in the accounts directory.""" + if not file_path: + return False + try: + target = Path(file_path).resolve() + accounts_dir = get_accounts_dir().resolve() + target.relative_to(accounts_dir) + except Exception: + return False + + if not target.exists() or not target.is_file(): + return False + + try: + target.unlink() + return True + except Exception: + return False + +def cleanup_unavailable_rows(rows: List[dict]) -> int: + """Remove unavailable saved snapshots from disk.""" + deleted = 0 + visited = set() + for row in rows: + switch_path = str(row.get('switch_path') or '') + if not switch_path or switch_path in visited: + continue + if not is_account_unavailable(row): + continue + if delete_saved_account_file(switch_path): + deleted += 1 + visited.add(switch_path) + return deleted + def serialize_account(acc: dict, rank: Optional[int] = None) -> dict: """将账号信息序列化为 CLI/JSON 输出""" data = { @@ -1736,22 +1791,31 @@ def resolve_account_selector(rows: List[dict], selector: str) -> Optional[dict]: return row return None -def print_view_all_actions(rows: List[dict]): +def print_view_all_actions(rows: List[dict], show_unavailable: bool, hidden_count: int): """打印查看余量页面底部操作""" print(f"{Colors.BOLD} 操作面板{Colors.ENDC}") print(f"{Colors.DIM} {'─' * 40}{Colors.ENDC}") print(f" {Colors.CYAN}[编号]{Colors.ENDC} 切换账号") + print(f" {Colors.CYAN}[d 编号]{Colors.ENDC} 删除指定快照") + print(f" {Colors.CYAN}[c]{Colors.ENDC} 清理失效/掉线快照") + toggle_label = '隐藏失效账号' if show_unavailable else '显示失效账号' + print(f" {Colors.CYAN}[a]{Colors.ENDC} {toggle_label}") + if hidden_count > 0 and not show_unavailable: + print(f" {Colors.DIM}当前已隐藏 {hidden_count} 个失效快照{Colors.ENDC}") print(f" {Colors.CYAN}[0]{Colors.ENDC} 退出工具") print(f" {Colors.DIM}[Enter]{Colors.ENDC} 刷新当前页面") print() def view_all_accounts(): """查看所有账号(自动刷新使用量)""" + show_unavailable = False while True: clear_screen() print_header() print(f"\n{Colors.CYAN}>>> 查看所有账号余量{Colors.ENDC}") - rows = sort_accounts_for_live_view(load_live_account_rows(show_progress=True)) + all_rows = sort_accounts_for_live_view(load_live_account_rows(show_progress=True)) + rows = filter_rows_for_view(all_rows, show_unavailable) + hidden_count = max(0, len(all_rows) - len(rows)) clear_screen() print_header() print(f"\n{Colors.CYAN}>>> 查看所有账号余量{Colors.ENDC}") @@ -1760,9 +1824,12 @@ def view_all_accounts(): else: print(f"\n{Colors.YELLOW} 当前未登录任何账号,且没有已存档账号{Colors.ENDC}") - print(f"{Colors.DIM} 共 {len(rows)} 个账号{Colors.ENDC}") + summary = f" 共 {len(rows)} 个账号" + if hidden_count > 0 and not show_unavailable: + summary += f"(已隐藏 {hidden_count} 个失效快照)" + print(f"{Colors.DIM}{summary}{Colors.ENDC}") print() - print_view_all_actions(rows) + print_view_all_actions(rows, show_unavailable, hidden_count) try: choice = input(" 请选择: ").strip() @@ -1775,10 +1842,54 @@ def view_all_accounts(): if choice == '0': return + lowered = choice.lower() + if lowered == 'a': + show_unavailable = not show_unavailable + continue + + if lowered == 'c': + deleted = cleanup_unavailable_rows(all_rows) + if deleted: + print(f"\n{Colors.GREEN} ✓ 已清理 {deleted} 个失效快照{Colors.ENDC}") + else: + print(f"\n{Colors.YELLOW} 没有可清理的失效快照{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + + if lowered.startswith('d'): + parts = choice.split(maxsplit=1) + selector = parts[1].strip() if len(parts) > 1 else '' + if not selector.isdigit(): + print(f"\n{Colors.RED} 删除请使用 d 编号,例如 d 3{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + + idx = int(selector) - 1 + if idx < 0 or idx >= len(rows): + print(f"\n{Colors.RED} 无效的编号{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + + row = rows[idx] + if row.get('is_current'): + print(f"\n{Colors.YELLOW} 当前登录账号不能直接删除{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + + switch_path = row.get('switch_path') + if not switch_path or not delete_saved_account_file(switch_path): + print(f"\n{Colors.RED} 删除失败:未找到可删除的快照文件{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + + print(f"\n{Colors.GREEN} ✓ 已删除快照: {row.get('email', 'Unknown')}{Colors.ENDC}") + input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") + continue + try: idx = int(choice) - 1 except ValueError: - print(f"\n{Colors.RED} 请输入编号、0 或直接回车{Colors.ENDC}") + print(f"\n{Colors.RED} 请输入编号、d 编号、a、c、0 或直接回车{Colors.ENDC}") input(f"{Colors.DIM}按回车键继续...{Colors.ENDC}") continue