codex-safe-switch 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1770 @@
1
+ """codex-safe-switch — manage Codex provider profiles.
2
+
3
+ Storage layout:
4
+
5
+ ~/.codex/profiles/
6
+ ├── .active # name of the currently-loaded profile
7
+ ├── .official/ # reserved snapshot for official OpenAI login
8
+ └── <name>/
9
+ ├── auth.json # optional; copied only when the profile owns auth
10
+ ├── provider.toml # merged into ~/.codex/config.toml
11
+ └── session.toml # optional session-state scope metadata
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ import json
20
+ import os
21
+ import signal
22
+ import shutil
23
+ import stat
24
+ import sqlite3
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ import tomlkit
33
+
34
+ from . import _swap
35
+ from .picker import pick
36
+
37
+
38
+ SESSION_STATE_FILES = (
39
+ "history.jsonl",
40
+ "session_index.jsonl",
41
+ "state_5.sqlite",
42
+ "state_5.sqlite-shm",
43
+ "state_5.sqlite-wal",
44
+ )
45
+
46
+ OFFICIAL_PROFILE_NAME = "official"
47
+ OFFICIAL_PROFILE_DIRNAME = ".official"
48
+ OFFICIAL_ALIASES = {OFFICIAL_PROFILE_NAME, "openai"}
49
+ ALFRED_INIT_ARG = "__init__"
50
+ REMOTE_CONTROL_INSTALL_COMMAND = "curl -fsSL https://chatgpt.com/codex/install.sh | sh"
51
+ REMOTE_CONTROL_REPAIR_ENV = "CODEX_SWITCH_REMOTE_REPAIR"
52
+ CLI_SURFACE_CHECK_ENV = "CODEX_SWITCH_CLI_SURFACE_CHECK"
53
+ DESKTOP_CODEX_PATH_ENV = "CODEX_DESKTOP_CODEX_PATH"
54
+ REMOTE_PROXY_RESPAWN_GRACE_SECONDS = 1.5
55
+ GLOBAL_STATE_FILES = (".codex-global-state.json", ".codex-global-state.json.bak")
56
+ REMOTE_HOST_PREFIX = "remote-ssh-"
57
+ ELECTRON_REMOTE_STATE_KEYS = (
58
+ "electron-local-remote-control-environment-id",
59
+ "electron-local-remote-control-installation-id",
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SessionConfig:
65
+ mode: str = "shared"
66
+ scope: Optional[str] = None
67
+
68
+ def describe(self) -> str:
69
+ if self.mode == "scoped":
70
+ return f"scoped ({self.scope})"
71
+ return "shared"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class IdentityConfig:
76
+ provider: Optional[str]
77
+ model: Optional[str]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class MergeHistoryResult:
82
+ changed_files: int
83
+ changed_lines: int
84
+ state_rows: int
85
+ backup_dir: Optional[Path]
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class CodexCliSurface:
90
+ label: str
91
+ version: str
92
+ path: Optional[Path]
93
+
94
+
95
+ def profile_root() -> Path:
96
+ return Path(os.environ.get("CODEX_PROFILE_ROOT") or Path.home() / ".codex" / "profiles")
97
+
98
+
99
+ def codex_dir() -> Path:
100
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
101
+
102
+
103
+ def active_file() -> Path:
104
+ return profile_root() / ".active"
105
+
106
+
107
+ def official_profile_dir() -> Path:
108
+ return profile_root() / OFFICIAL_PROFILE_DIRNAME
109
+
110
+
111
+ def session_state_root() -> Path:
112
+ return profile_root() / ".session-state"
113
+
114
+
115
+ def active_name() -> Optional[str]:
116
+ p = active_file()
117
+ if not p.exists():
118
+ return None
119
+ name = p.read_text().strip()
120
+ return name or None
121
+
122
+
123
+ def normalize_profile_name(name: Optional[str]) -> Optional[str]:
124
+ if name is None:
125
+ return None
126
+ stripped = name.strip()
127
+ if stripped in OFFICIAL_ALIASES or stripped == OFFICIAL_PROFILE_DIRNAME:
128
+ return OFFICIAL_PROFILE_NAME
129
+ return stripped or None
130
+
131
+
132
+ def profile_dir_for_name(name: str) -> Path:
133
+ normalized = normalize_profile_name(name)
134
+ if normalized == OFFICIAL_PROFILE_NAME:
135
+ return official_profile_dir()
136
+ return profile_root() / normalized
137
+
138
+
139
+ def current_identity() -> IdentityConfig:
140
+ cfg = _swap.load(codex_dir() / "config.toml")
141
+ provider = cfg.get("model_provider")
142
+ model = cfg.get("model")
143
+ provider = str(provider).strip() if provider is not None else None
144
+ model = str(model).strip() if model is not None else None
145
+ return IdentityConfig(provider=provider or None, model=model or None)
146
+
147
+
148
+ def profile_requires_auth_file(profile_dir: Path, provider_doc=None) -> bool:
149
+ """Return true when switching this profile should replace ~/.codex/auth.json."""
150
+ if normalize_profile_name(profile_dir.name) == OFFICIAL_PROFILE_NAME:
151
+ return True
152
+ doc = provider_doc if provider_doc is not None else _swap.load(profile_dir / "provider.toml")
153
+ preferred_auth_method = str(doc.get("preferred_auth_method") or "").strip()
154
+ if preferred_auth_method == "chatgpt":
155
+ return True
156
+ provider = str(doc.get("model_provider") or "").strip()
157
+ providers = doc.get("model_providers")
158
+ if provider and providers is not None and provider in providers:
159
+ provider_cfg = providers[provider]
160
+ requires = provider_cfg.get("requires_openai_auth")
161
+ if requires is not None:
162
+ return bool(requires)
163
+ if preferred_auth_method == "apikey":
164
+ return True
165
+ return not provider
166
+
167
+
168
+ def list_profiles() -> list[str]:
169
+ root = profile_root()
170
+ if not root.exists():
171
+ return [OFFICIAL_PROFILE_NAME] if official_profile_dir().is_dir() else []
172
+ names = sorted(
173
+ d.name
174
+ for d in root.iterdir()
175
+ if d.is_dir() and not d.name.startswith(".") and d.name != "bin"
176
+ )
177
+ if official_profile_dir().is_dir():
178
+ return [OFFICIAL_PROFILE_NAME, *names]
179
+ return names
180
+
181
+
182
+ def _safe_profile_name(raw: Optional[str]) -> str:
183
+ name = (raw or "current").strip().lower()
184
+ chars = [ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in name]
185
+ safe = "".join(chars).strip(".-_") or "current"
186
+ if normalize_profile_name(safe) == OFFICIAL_PROFILE_NAME:
187
+ return "openai-api"
188
+ return safe
189
+
190
+
191
+ def bootstrap_current_profile(*, verbose: bool = True) -> Optional[str]:
192
+ """Import the current Codex config on first run, if no profiles exist yet."""
193
+ if list_profiles():
194
+ return None
195
+
196
+ codex = codex_dir()
197
+ auth = codex / "auth.json"
198
+ cfg = codex / "config.toml"
199
+ if not auth.is_file():
200
+ return None
201
+
202
+ if current_looks_official(codex):
203
+ snapshot_official_state(codex)
204
+ name = OFFICIAL_PROFILE_NAME
205
+ else:
206
+ identity = current_identity()
207
+ name = _safe_profile_name(identity.provider)
208
+ dir_ = profile_root() / name
209
+ dir_.mkdir(parents=True, exist_ok=True)
210
+ if cfg.is_file():
211
+ _swap.extract(cfg, dir_ / "provider.toml")
212
+ else:
213
+ (dir_ / "provider.toml").write_text("")
214
+ if profile_requires_auth_file(dir_):
215
+ shutil.copy2(auth, dir_ / "auth.json")
216
+ write_session_config(dir_, SessionConfig())
217
+
218
+ active_file().parent.mkdir(parents=True, exist_ok=True)
219
+ active_file().write_text(name + "\n")
220
+ if verbose:
221
+ print(f"initialized profile from existing Codex config → {name}")
222
+ return name
223
+
224
+
225
+ def no_profiles_message() -> str:
226
+ return (
227
+ "no profiles yet — configure Codex once, then run `codex-safe-switch` to import it, "
228
+ "or create one with `codex-safe-switch save <name>`"
229
+ )
230
+
231
+
232
+ def _die(msg: str) -> "NoReturn": # type: ignore[name-defined]
233
+ print(f"codex-safe-switch: {msg}", file=sys.stderr)
234
+ raise SystemExit(1)
235
+
236
+
237
+ def _ps_output() -> str:
238
+ result = subprocess.run(
239
+ ["ps", "-axo", "pid=,args="],
240
+ check=False,
241
+ stdout=subprocess.PIPE,
242
+ stderr=subprocess.DEVNULL,
243
+ text=True,
244
+ )
245
+ return result.stdout
246
+
247
+
248
+ def _pid_exists(pid: int) -> bool:
249
+ try:
250
+ os.kill(pid, 0)
251
+ except ProcessLookupError:
252
+ return False
253
+ except PermissionError:
254
+ return True
255
+ return True
256
+
257
+
258
+ def _looks_like_codex_process(pid: int, args: str) -> bool:
259
+ if pid == os.getpid():
260
+ return False
261
+ lower = args.lower()
262
+ if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
263
+ return False
264
+ if _looks_like_remote_proxy_process(pid, args):
265
+ return True
266
+ executable = Path(args.split(maxsplit=1)[0]).name.lower() if args.strip() else ""
267
+ if executable in {"codex", "codex-app-server", "codex-server"}:
268
+ return True
269
+ return (
270
+ "/codex.app/contents/" in lower
271
+ or " codex app-server" in lower
272
+ or " codex-app-server" in lower
273
+ or " codex server" in lower
274
+ )
275
+
276
+
277
+ def find_codex_processes() -> list[int]:
278
+ pids: list[int] = []
279
+ for line in _ps_output().splitlines():
280
+ parts = line.strip().split(maxsplit=1)
281
+ if len(parts) != 2:
282
+ continue
283
+ try:
284
+ pid = int(parts[0])
285
+ except ValueError:
286
+ continue
287
+ if _looks_like_codex_process(pid, parts[1]):
288
+ pids.append(pid)
289
+ return pids
290
+
291
+
292
+ def restart_codex_processes() -> int:
293
+ pids = find_codex_processes()
294
+ for pid in pids:
295
+ try:
296
+ os.kill(pid, signal.SIGTERM)
297
+ except ProcessLookupError:
298
+ pass
299
+
300
+ deadline = time.monotonic() + 1.5
301
+ while time.monotonic() < deadline and any(_pid_exists(pid) for pid in pids):
302
+ time.sleep(0.05)
303
+
304
+ for pid in pids:
305
+ if not _pid_exists(pid):
306
+ continue
307
+ try:
308
+ os.kill(pid, signal.SIGKILL)
309
+ except ProcessLookupError:
310
+ pass
311
+ return len(pids)
312
+
313
+
314
+ def _looks_like_unmanaged_app_server(pid: int, args: str) -> bool:
315
+ if pid == os.getpid():
316
+ return False
317
+ lower = args.lower()
318
+ if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
319
+ return False
320
+ if "--listen unix://" not in lower:
321
+ return False
322
+ parts = args.split(maxsplit=1)
323
+ executable = Path(parts[0]).name.lower() if parts else ""
324
+ rest = parts[1].lower() if len(parts) == 2 else ""
325
+ return executable == "codex" and rest.startswith("app-server")
326
+
327
+
328
+ def find_unmanaged_app_server_processes() -> list[int]:
329
+ pids: list[int] = []
330
+ for line in _ps_output().splitlines():
331
+ parts = line.strip().split(maxsplit=1)
332
+ if len(parts) != 2:
333
+ continue
334
+ try:
335
+ pid = int(parts[0])
336
+ except ValueError:
337
+ continue
338
+ if _looks_like_unmanaged_app_server(pid, parts[1]):
339
+ pids.append(pid)
340
+ return pids
341
+
342
+
343
+ def _terminate_processes(pids: list[int]) -> set[int]:
344
+ for pid in pids:
345
+ try:
346
+ os.kill(pid, signal.SIGTERM)
347
+ except ProcessLookupError:
348
+ pass
349
+
350
+ deadline = time.monotonic() + 1.5
351
+ while time.monotonic() < deadline and any(_pid_exists(pid) for pid in pids):
352
+ time.sleep(0.05)
353
+
354
+ for pid in pids:
355
+ if not _pid_exists(pid):
356
+ continue
357
+ try:
358
+ os.kill(pid, signal.SIGKILL)
359
+ except ProcessLookupError:
360
+ pass
361
+ return set(pids)
362
+
363
+
364
+ def stop_unmanaged_app_server_processes() -> set[int]:
365
+ return _terminate_processes(find_unmanaged_app_server_processes())
366
+
367
+
368
+ def _looks_like_remote_proxy_process(pid: int, args: str) -> bool:
369
+ if pid == os.getpid():
370
+ return False
371
+ lower = args.lower()
372
+ if "codex-safe-switch" in lower or "codex_safe_switch" in lower:
373
+ return False
374
+ if "codex app-server proxy" in lower:
375
+ return True
376
+ parts = args.split(maxsplit=1)
377
+ executable = Path(parts[0]).name.lower() if parts else ""
378
+ rest = parts[1].lower() if len(parts) == 2 else ""
379
+ return executable == "codex" and rest.startswith("app-server proxy")
380
+
381
+
382
+ def find_remote_proxy_processes() -> list[int]:
383
+ pids: list[int] = []
384
+ for line in _ps_output().splitlines():
385
+ parts = line.strip().split(maxsplit=1)
386
+ if len(parts) != 2:
387
+ continue
388
+ try:
389
+ pid = int(parts[0])
390
+ except ValueError:
391
+ continue
392
+ if _looks_like_remote_proxy_process(pid, parts[1]):
393
+ pids.append(pid)
394
+ return pids
395
+
396
+
397
+ def stop_remote_proxy_processes(*, exclude: Optional[set[int]] = None) -> set[int]:
398
+ ignored = exclude or set()
399
+ pids = [pid for pid in find_remote_proxy_processes() if pid not in ignored]
400
+ return _terminate_processes(pids)
401
+
402
+
403
+ def _custom_codex_home_is_active(codex: Path) -> bool:
404
+ configured = os.environ.get("CODEX_HOME")
405
+ if not configured:
406
+ return False
407
+ try:
408
+ return Path(configured).expanduser().resolve() != (Path.home() / ".codex").resolve()
409
+ except OSError:
410
+ return Path(configured).expanduser() != Path.home() / ".codex"
411
+
412
+
413
+ def _env_bool(name: str) -> Optional[bool]:
414
+ value = os.environ.get(name)
415
+ if value is None:
416
+ return None
417
+ return value.strip().lower() not in {"0", "false", "no", "off"}
418
+
419
+
420
+ def remote_control_repair_allowed(codex: Path) -> bool:
421
+ override = _env_bool(REMOTE_CONTROL_REPAIR_ENV)
422
+ if override is not None:
423
+ return override
424
+ return not _custom_codex_home_is_active(codex) and has_remote_control_enrollment(codex)
425
+
426
+
427
+ def cli_surface_check_allowed(codex: Path) -> bool:
428
+ override = _env_bool(CLI_SURFACE_CHECK_ENV)
429
+ if override is not None:
430
+ return override
431
+ return not _custom_codex_home_is_active(codex) and remote_control_repair_allowed(codex)
432
+
433
+
434
+ def has_remote_control_enrollment(codex: Path) -> bool:
435
+ state_db = codex / "state_5.sqlite"
436
+ if not state_db.exists():
437
+ return False
438
+ try:
439
+ conn = connect_sqlite_readonly(state_db)
440
+ except sqlite3.Error:
441
+ return False
442
+ try:
443
+ exists = conn.execute(
444
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'remote_control_enrollments'"
445
+ ).fetchone()
446
+ if not exists:
447
+ return False
448
+ row = conn.execute("SELECT COUNT(*) FROM remote_control_enrollments").fetchone()
449
+ return bool(row and int(row[0]) > 0)
450
+ except sqlite3.Error:
451
+ return False
452
+ finally:
453
+ conn.close()
454
+
455
+
456
+ def _run_codex_command(args: list[str]) -> Optional[subprocess.CompletedProcess[str]]:
457
+ if shutil.which("codex") is None:
458
+ return None
459
+ try:
460
+ return subprocess.run(
461
+ ["codex", *args],
462
+ check=False,
463
+ stdout=subprocess.PIPE,
464
+ stderr=subprocess.PIPE,
465
+ text=True,
466
+ )
467
+ except OSError:
468
+ return None
469
+
470
+
471
+ def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
472
+ return "\n".join(part for part in (result.stdout, result.stderr) if part)
473
+
474
+
475
+ def _load_daemon_version(codex: Path) -> dict:
476
+ result = _run_codex_command(["app-server", "daemon", "version"])
477
+ if result is None:
478
+ return {}
479
+ if not result.stdout.strip():
480
+ return {}
481
+ try:
482
+ data = json.loads(result.stdout)
483
+ except json.JSONDecodeError:
484
+ return {}
485
+ return data if isinstance(data, dict) else {}
486
+
487
+
488
+ def _managed_standalone_path(codex: Path, daemon_version: dict) -> Path:
489
+ raw = daemon_version.get("managedCodexPath")
490
+ if raw:
491
+ return Path(str(raw)).expanduser()
492
+ return codex / "packages" / "standalone" / "current" / "codex"
493
+
494
+
495
+ def _desktop_bundled_codex_path() -> Path:
496
+ raw = os.environ.get(DESKTOP_CODEX_PATH_ENV)
497
+ if raw:
498
+ return Path(raw).expanduser()
499
+ return Path("/Applications/Codex.app/Contents/Resources/codex")
500
+
501
+
502
+ def _normalize_codex_version(value: object) -> Optional[str]:
503
+ text = str(value or "").strip()
504
+ if not text:
505
+ return None
506
+ for token in text.replace("\n", " ").split():
507
+ cleaned = token.strip(" \t,;()[]")
508
+ if cleaned and cleaned[0].isdigit():
509
+ return cleaned
510
+ return text
511
+
512
+
513
+ def _run_codex_version(executable: Path) -> Optional[str]:
514
+ try:
515
+ result = subprocess.run(
516
+ [str(executable), "--version"],
517
+ check=False,
518
+ stdout=subprocess.PIPE,
519
+ stderr=subprocess.PIPE,
520
+ text=True,
521
+ )
522
+ except (OSError, subprocess.SubprocessError):
523
+ return None
524
+ output = result.stdout.strip() or result.stderr.strip()
525
+ if result.returncode != 0 and not output:
526
+ return None
527
+ return _normalize_codex_version(output)
528
+
529
+
530
+ def maybe_warn_cli_surface_mismatch(codex: Path, daemon_version: dict) -> None:
531
+ if not cli_surface_check_allowed(codex):
532
+ return
533
+
534
+ surfaces: list[CodexCliSurface] = []
535
+ shell_path = shutil.which("codex")
536
+ shell_version = _normalize_codex_version(daemon_version.get("cliVersion"))
537
+ if shell_path and shell_version:
538
+ surfaces.append(CodexCliSurface("shell codex", shell_version, Path(shell_path)))
539
+
540
+ managed_path = _managed_standalone_path(codex, daemon_version)
541
+ managed_version = _normalize_codex_version(daemon_version.get("managedCodexVersion"))
542
+ if managed_path.is_file() and managed_version:
543
+ surfaces.append(CodexCliSurface("managed standalone", managed_version, managed_path))
544
+
545
+ desktop_path = _desktop_bundled_codex_path()
546
+ if desktop_path.is_file():
547
+ desktop_version = _run_codex_version(desktop_path)
548
+ if desktop_version:
549
+ surfaces.append(CodexCliSurface("Desktop bundled", desktop_version, desktop_path))
550
+
551
+ versions = {surface.version for surface in surfaces}
552
+ if len(versions) <= 1:
553
+ return
554
+
555
+ print("codex cli warning → multiple Codex CLI versions are active")
556
+ for surface in surfaces:
557
+ path = f" ({surface.path})" if surface.path is not None else ""
558
+ print(f" {surface.label}: {surface.version}{path}")
559
+ print("codex cli note → Desktop app owns its bundled CLI; update/restart Codex.app instead of overwriting the app bundle")
560
+
561
+
562
+ def _remote_control_needs_standalone_install(text: str) -> bool:
563
+ lower = text.lower()
564
+ return "managed standalone codex install not found" in lower
565
+
566
+
567
+ def _remote_control_is_unmanaged(text: str) -> bool:
568
+ lower = text.lower()
569
+ return (
570
+ "not managed by codex app-server daemon" in lower
571
+ or "unmanaged" in lower and "app-server" in lower
572
+ )
573
+
574
+
575
+ def _remote_control_pending_description(result: subprocess.CompletedProcess[str]) -> Optional[str]:
576
+ if result.returncode != 0:
577
+ return None
578
+ try:
579
+ data = json.loads(result.stdout)
580
+ except json.JSONDecodeError:
581
+ return None
582
+ if not isinstance(data, dict):
583
+ return None
584
+ status = str(data.get("status") or "").strip()
585
+ timed_out = data.get("timedOut") is True
586
+ if status != "connecting" and not timed_out:
587
+ return None
588
+ bits = []
589
+ if status:
590
+ bits.append(f"status={status}")
591
+ if timed_out:
592
+ bits.append("timedOut=true")
593
+ environment = data.get("environmentId")
594
+ if environment:
595
+ bits.append(f"environment={environment}")
596
+ return " ".join(bits) if bits else "status=connecting"
597
+
598
+
599
+ def _print_missing_standalone_warning(path: Path) -> None:
600
+ print(f"remote-control warning → managed standalone Codex missing at {path}")
601
+ print(f"remote-control install → {REMOTE_CONTROL_INSTALL_COMMAND}")
602
+
603
+
604
+ def _atomic_write_json(path: Path, data: dict) -> None:
605
+ path.parent.mkdir(parents=True, exist_ok=True)
606
+ fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
607
+ os.close(fd)
608
+ tmp = Path(tmp_str)
609
+ try:
610
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
611
+ _copy_mode(path if path.exists() else None, tmp)
612
+ os.replace(tmp, path)
613
+ finally:
614
+ if tmp.exists():
615
+ tmp.unlink(missing_ok=True)
616
+
617
+
618
+ def _repair_remote_selection_payload(data: dict) -> bool:
619
+ changed = False
620
+ selected = data.get("selected-remote-host-id")
621
+ if isinstance(selected, str) and selected.startswith(REMOTE_HOST_PREFIX):
622
+ data.pop("selected-remote-host-id", None)
623
+ changed = True
624
+
625
+ for key in ELECTRON_REMOTE_STATE_KEYS:
626
+ if key in data:
627
+ data.pop(key, None)
628
+ changed = True
629
+
630
+ auto_connect = data.get("remote-connection-auto-connect-by-host-id")
631
+ if isinstance(auto_connect, dict):
632
+ for host_id in list(auto_connect):
633
+ if isinstance(host_id, str) and host_id.startswith(REMOTE_HOST_PREFIX):
634
+ auto_connect.pop(host_id, None)
635
+ changed = True
636
+
637
+ analytics = data.get("remote-connection-analytics-id-by-host-id")
638
+ if isinstance(analytics, dict):
639
+ for host_id in list(analytics):
640
+ if isinstance(host_id, str) and host_id.startswith(REMOTE_HOST_PREFIX):
641
+ analytics.pop(host_id, None)
642
+ changed = True
643
+
644
+ for key in ("codex-managed-remote-connections", "remote-projects"):
645
+ value = data.get(key)
646
+ if not isinstance(value, list):
647
+ continue
648
+ filtered = [
649
+ item
650
+ for item in value
651
+ if not (
652
+ isinstance(item, dict)
653
+ and isinstance(item.get("hostId"), str)
654
+ and item["hostId"].startswith(REMOTE_HOST_PREFIX)
655
+ )
656
+ ]
657
+ if len(filtered) != len(value):
658
+ data[key] = filtered
659
+ changed = True
660
+ return changed
661
+
662
+
663
+ def maybe_repair_remote_selection_state(codex: Path) -> None:
664
+ if not remote_control_repair_allowed(codex):
665
+ return
666
+
667
+ changed_files = 0
668
+ for rel in GLOBAL_STATE_FILES:
669
+ path = codex / rel
670
+ if not path.exists():
671
+ continue
672
+ try:
673
+ data = json.loads(path.read_text())
674
+ except json.JSONDecodeError:
675
+ continue
676
+ if not isinstance(data, dict):
677
+ continue
678
+ if _repair_remote_selection_payload(data):
679
+ _atomic_write_json(path, data)
680
+ changed_files += 1
681
+
682
+ if changed_files:
683
+ print(f"remote selection repaired → {changed_files} files")
684
+
685
+
686
+ def maybe_repair_remote_control(codex: Path) -> set[int]:
687
+ if not remote_control_repair_allowed(codex):
688
+ return set()
689
+
690
+ daemon_version = _load_daemon_version(codex)
691
+ maybe_warn_cli_surface_mismatch(codex, daemon_version)
692
+ managed_path = _managed_standalone_path(codex, daemon_version)
693
+ if not managed_path.is_file():
694
+ _print_missing_standalone_warning(managed_path)
695
+ return set()
696
+
697
+ first = _run_codex_command(["remote-control", "start", "--json"])
698
+ if first is None:
699
+ return set()
700
+ pending = _remote_control_pending_description(first)
701
+ if pending:
702
+ print(f"remote-control warning → daemon still connecting ({pending})")
703
+ return set()
704
+ if first.returncode == 0:
705
+ return set()
706
+
707
+ first_output = _combined_output(first)
708
+ if _remote_control_needs_standalone_install(first_output):
709
+ _print_missing_standalone_warning(managed_path)
710
+ return set()
711
+ if not _remote_control_is_unmanaged(first_output):
712
+ return set()
713
+
714
+ stopped = stop_unmanaged_app_server_processes()
715
+ retry = _run_codex_command(["remote-control", "start", "--json"])
716
+ if retry is not None and retry.returncode == 0:
717
+ print(f"remote-control repaired → restarted managed app-server (stopped {len(stopped)})")
718
+ return stopped
719
+
720
+ retry_output = _combined_output(retry) if retry is not None else first_output
721
+ if _remote_control_needs_standalone_install(retry_output):
722
+ _print_missing_standalone_warning(managed_path)
723
+ else:
724
+ print("remote-control warning → could not repair managed app-server automatically")
725
+ return stopped
726
+
727
+
728
+ def maybe_stop_remote_proxy_processes(codex: Path, *, exclude: Optional[set[int]] = None) -> None:
729
+ if not remote_control_repair_allowed(codex):
730
+ return
731
+ stopped = stop_remote_proxy_processes(exclude=exclude)
732
+ if not stopped:
733
+ return
734
+ print(f"remote-control repaired → stopped stale remote proxy processes (stopped {len(stopped)})")
735
+ print("remote-control hint → restart Codex Desktop if old remote proxy processes reappear")
736
+ time.sleep(REMOTE_PROXY_RESPAWN_GRACE_SECONDS)
737
+ remaining = [pid for pid in find_remote_proxy_processes() if pid not in stopped]
738
+ if remaining:
739
+ joined = ", ".join(str(pid) for pid in remaining)
740
+ print(f"remote-control warning → Desktop respawned remote proxy processes (pids: {joined})")
741
+ print("remote-control hint → restart Codex Desktop to drop stale in-memory remote state")
742
+
743
+
744
+ def _atomic_write_copy(src: Path, dst: Path) -> None:
745
+ """Replace dst with src's contents, preserving dst's mode when present."""
746
+ dst.parent.mkdir(parents=True, exist_ok=True)
747
+ fd, tmp_str = tempfile.mkstemp(prefix=dst.name + ".", dir=dst.parent)
748
+ os.close(fd)
749
+ tmp = Path(tmp_str)
750
+ try:
751
+ shutil.copyfile(src, tmp)
752
+ _copy_mode(dst if dst.exists() else None, tmp)
753
+ os.replace(tmp, dst)
754
+ finally:
755
+ if tmp.exists():
756
+ tmp.unlink(missing_ok=True)
757
+
758
+
759
+ def _copy_mode(ref: Optional[Path], target: Path) -> None:
760
+ """Match target's mode to ref; fall back to 0600 for first-run."""
761
+ if ref is not None and ref.exists():
762
+ os.chmod(target, stat.S_IMODE(ref.stat().st_mode))
763
+ else:
764
+ os.chmod(target, 0o600)
765
+
766
+
767
+ def session_config_path(profile_dir: Path) -> Path:
768
+ return profile_dir / "session.toml"
769
+
770
+
771
+ def load_session_config(profile_dir: Path) -> SessionConfig:
772
+ path = session_config_path(profile_dir)
773
+ if not path.exists() or path.stat().st_size == 0:
774
+ return SessionConfig()
775
+ try:
776
+ doc = tomlkit.parse(path.read_text())
777
+ except Exception as exc: # pragma: no cover - defensive config parsing
778
+ _die(f"invalid {path}: {exc}")
779
+ mode = str(doc.get("mode", "shared")).strip() or "shared"
780
+ if mode not in {"shared", "scoped"}:
781
+ _die(f"invalid {path}: mode must be 'shared' or 'scoped'")
782
+ scope = doc.get("scope")
783
+ scope = str(scope).strip() if scope is not None else None
784
+ if mode == "scoped" and not scope:
785
+ _die(f"invalid {path}: scoped mode requires a non-empty scope")
786
+ return SessionConfig(mode=mode, scope=scope)
787
+
788
+
789
+ def write_session_config(profile_dir: Path, config: SessionConfig) -> None:
790
+ path = session_config_path(profile_dir)
791
+ if config.mode == "shared":
792
+ path.unlink(missing_ok=True)
793
+ return
794
+ doc = tomlkit.document()
795
+ doc["mode"] = config.mode
796
+ doc["scope"] = config.scope
797
+ path.write_text(tomlkit.dumps(doc))
798
+
799
+
800
+ def session_scope_dir(scope: str) -> Path:
801
+ return session_state_root() / scope
802
+
803
+
804
+ def _copy_optional(src: Path, dst: Path) -> None:
805
+ if src.exists():
806
+ _atomic_write_copy(src, dst)
807
+ else:
808
+ dst.unlink(missing_ok=True)
809
+
810
+
811
+ def save_session_state(config: SessionConfig, codex: Path) -> None:
812
+ if config.mode != "scoped" or not config.scope:
813
+ return
814
+ scope_dir = session_scope_dir(config.scope)
815
+ scope_dir.mkdir(parents=True, exist_ok=True)
816
+ for rel in SESSION_STATE_FILES:
817
+ src = codex / rel
818
+ dst = scope_dir / rel
819
+ _copy_optional(src, dst)
820
+
821
+
822
+ def clear_session_state(codex: Path) -> None:
823
+ for rel in SESSION_STATE_FILES:
824
+ (codex / rel).unlink(missing_ok=True)
825
+
826
+
827
+ def restore_session_state(config: SessionConfig, codex: Path) -> None:
828
+ if config.mode != "scoped" or not config.scope:
829
+ return
830
+ scope_dir = session_scope_dir(config.scope)
831
+ if not scope_dir.exists():
832
+ clear_session_state(codex)
833
+ return
834
+ for rel in SESSION_STATE_FILES:
835
+ src = scope_dir / rel
836
+ dst = codex / rel
837
+ _copy_optional(src, dst)
838
+
839
+
840
+ def maybe_switch_session_state(current_name: Optional[str], next_name: str, codex: Path) -> None:
841
+ current_cfg = SessionConfig()
842
+ next_cfg = load_session_config(profile_dir_for_name(next_name))
843
+ current_name = normalize_profile_name(current_name)
844
+ if current_name:
845
+ current_dir = profile_dir_for_name(current_name)
846
+ if current_dir.is_dir():
847
+ current_cfg = load_session_config(current_dir)
848
+ if current_cfg == next_cfg:
849
+ return
850
+ save_session_state(current_cfg, codex)
851
+ restore_session_state(next_cfg, codex)
852
+
853
+
854
+ def iter_rollout_files(codex: Path) -> list[Path]:
855
+ files: list[Path] = []
856
+ sessions = codex / "sessions"
857
+ archived = codex / "archived_sessions"
858
+ if sessions.exists():
859
+ files.extend(sorted(p for p in sessions.rglob("*.jsonl") if p.is_file()))
860
+ if archived.exists():
861
+ files.extend(sorted(p for p in archived.glob("*.jsonl") if p.is_file()))
862
+ return files
863
+
864
+
865
+ def backup_path(codex: Path, label: str) -> Path:
866
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
867
+ return codex / f"{label}-{stamp}"
868
+
869
+
870
+ def backup_root(codex: Path, label: str) -> Path:
871
+ root = backup_path(codex, label)
872
+ root.mkdir(parents=True, exist_ok=True)
873
+ return root
874
+
875
+
876
+ def backup_copy(src: Path, backup_dir: Path, codex: Path) -> None:
877
+ if not src.exists():
878
+ return
879
+ rel = src.relative_to(codex)
880
+ dst = backup_dir / rel
881
+ dst.parent.mkdir(parents=True, exist_ok=True)
882
+ shutil.copy2(src, dst)
883
+
884
+
885
+ def normalize_rollout_file(
886
+ path: Path,
887
+ target: IdentityConfig,
888
+ keep_models: bool,
889
+ *,
890
+ apply: bool = True,
891
+ ) -> tuple[int, bool]:
892
+ lines_out: list[str] = []
893
+ changed_lines = 0
894
+ changed = False
895
+ original_stat = path.stat()
896
+ with path.open() as fh:
897
+ for raw in fh:
898
+ try:
899
+ item = json.loads(raw)
900
+ except json.JSONDecodeError:
901
+ lines_out.append(raw)
902
+ continue
903
+
904
+ item_changed = False
905
+ payload = item.get("payload")
906
+ if isinstance(payload, dict):
907
+ if item.get("type") == "session_meta" and target.provider:
908
+ if payload.get("model_provider") != target.provider:
909
+ payload["model_provider"] = target.provider
910
+ item_changed = True
911
+ elif item.get("type") == "turn_context":
912
+ if target.provider and payload.get("model_provider") != target.provider:
913
+ payload["model_provider"] = target.provider
914
+ item_changed = True
915
+ if not keep_models and target.model and payload.get("model") != target.model:
916
+ payload["model"] = target.model
917
+ item_changed = True
918
+
919
+ if item_changed:
920
+ changed = True
921
+ changed_lines += 1
922
+ lines_out.append(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
923
+ else:
924
+ lines_out.append(raw)
925
+
926
+ if changed and apply:
927
+ fd, tmp_str = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
928
+ os.close(fd)
929
+ tmp = Path(tmp_str)
930
+ try:
931
+ tmp.write_text("".join(lines_out))
932
+ _copy_mode(path, tmp)
933
+ os.replace(tmp, path)
934
+ finally:
935
+ if tmp.exists():
936
+ tmp.unlink(missing_ok=True)
937
+ os.utime(path, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
938
+ return changed_lines, changed
939
+
940
+
941
+ def normalize_state_db(path: Path, target: IdentityConfig, keep_models: bool) -> int:
942
+ if not path.exists():
943
+ return 0
944
+ conn = sqlite3.connect(path)
945
+ try:
946
+ cur = conn.cursor()
947
+ if keep_models:
948
+ if not target.provider:
949
+ return 0
950
+ count = cur.execute(
951
+ "SELECT COUNT(*) FROM threads WHERE model_provider != ?",
952
+ (target.provider,),
953
+ ).fetchone()[0]
954
+ cur.execute("UPDATE threads SET model_provider = ? WHERE model_provider != ?", (target.provider, target.provider))
955
+ else:
956
+ if not target.provider or not target.model:
957
+ return 0
958
+ count = cur.execute(
959
+ "SELECT COUNT(*) FROM threads WHERE model_provider != ? OR ifnull(model, '') != ?",
960
+ (target.provider, target.model),
961
+ ).fetchone()[0]
962
+ cur.execute(
963
+ "UPDATE threads SET model_provider = ?, model = ? WHERE model_provider != ? OR ifnull(model, '') != ?",
964
+ (target.provider, target.model, target.provider, target.model),
965
+ )
966
+ conn.commit()
967
+ return int(count)
968
+ finally:
969
+ conn.close()
970
+
971
+
972
+ def connect_sqlite_readonly(path: Path) -> sqlite3.Connection:
973
+ return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
974
+
975
+
976
+ def count_state_rows_to_normalize(path: Path, target: IdentityConfig, keep_models: bool) -> int:
977
+ if not path.exists():
978
+ return 0
979
+ conn = connect_sqlite_readonly(path)
980
+ try:
981
+ cur = conn.cursor()
982
+ if keep_models:
983
+ if not target.provider:
984
+ return 0
985
+ return int(
986
+ cur.execute(
987
+ "SELECT COUNT(*) FROM threads WHERE model_provider != ?",
988
+ (target.provider,),
989
+ ).fetchone()[0]
990
+ )
991
+ if not target.provider or not target.model:
992
+ return 0
993
+ return int(
994
+ cur.execute(
995
+ "SELECT COUNT(*) FROM threads WHERE model_provider != ? OR ifnull(model, '') != ?",
996
+ (target.provider, target.model),
997
+ ).fetchone()[0]
998
+ )
999
+ finally:
1000
+ conn.close()
1001
+
1002
+
1003
+ def sqlite_threads_columns(conn: sqlite3.Connection) -> list[str]:
1004
+ found = conn.execute(
1005
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads'"
1006
+ ).fetchone()
1007
+ if not found:
1008
+ return []
1009
+ return [str(row[1]) for row in conn.execute("PRAGMA table_info(threads)").fetchall()]
1010
+
1011
+
1012
+ def _fmt_value(value) -> str:
1013
+ if value is None or value == "":
1014
+ return "(empty)"
1015
+ return str(value)
1016
+
1017
+
1018
+ def read_thread_distribution(path: Path) -> tuple[list[tuple[str, str, int]], Optional[str]]:
1019
+ if not path.exists():
1020
+ return [], "missing state_5.sqlite"
1021
+ conn = connect_sqlite_readonly(path)
1022
+ try:
1023
+ columns = sqlite_threads_columns(conn)
1024
+ if not columns:
1025
+ return [], "missing threads table"
1026
+ if "model_provider" not in columns or "model" not in columns:
1027
+ return [], "threads table missing model_provider/model columns"
1028
+ rows = conn.execute(
1029
+ """
1030
+ SELECT ifnull(model_provider, ''), ifnull(model, ''), COUNT(*)
1031
+ FROM threads
1032
+ GROUP BY ifnull(model_provider, ''), ifnull(model, '')
1033
+ ORDER BY COUNT(*) DESC, ifnull(model_provider, ''), ifnull(model, '')
1034
+ """
1035
+ ).fetchall()
1036
+ return [(str(provider), str(model), int(count)) for provider, model, count in rows], None
1037
+ except sqlite3.Error as exc:
1038
+ return [], str(exc)
1039
+ finally:
1040
+ conn.close()
1041
+
1042
+
1043
+ def read_recent_threads(path: Path, limit: int = 5) -> tuple[list[dict[str, object]], Optional[str]]:
1044
+ if not path.exists():
1045
+ return [], "missing state_5.sqlite"
1046
+ conn = connect_sqlite_readonly(path)
1047
+ try:
1048
+ columns = sqlite_threads_columns(conn)
1049
+ if not columns:
1050
+ return [], "missing threads table"
1051
+ selected = [c for c in ("id", "thread_id", "title", "model_provider", "model", "updated_at", "created_at") if c in columns]
1052
+ if not selected:
1053
+ return [], "threads table has no displayable columns"
1054
+ order_col = next((c for c in ("updated_at", "created_at") if c in columns), None)
1055
+ select_sql = ", ".join(f'"{c}"' for c in selected)
1056
+ order_sql = f' ORDER BY "{order_col}" DESC' if order_col else " ORDER BY rowid DESC"
1057
+ rows = conn.execute(f"SELECT rowid, {select_sql} FROM threads{order_sql} LIMIT ?", (limit,)).fetchall()
1058
+ recent = []
1059
+ for row in rows:
1060
+ item = {"rowid": row[0]}
1061
+ item.update({name: value for name, value in zip(selected, row[1:])})
1062
+ recent.append(item)
1063
+ return recent, None
1064
+ except sqlite3.Error as exc:
1065
+ return [], str(exc)
1066
+ finally:
1067
+ conn.close()
1068
+
1069
+
1070
+ def has_provider_model_drift(
1071
+ distribution: list[tuple[str, str, int]],
1072
+ identity: IdentityConfig,
1073
+ ) -> Optional[bool]:
1074
+ if not distribution:
1075
+ return None
1076
+ if not identity.provider:
1077
+ return None
1078
+ for provider, model, _count in distribution:
1079
+ if provider != identity.provider:
1080
+ return True
1081
+ if identity.model and model != identity.model:
1082
+ return True
1083
+ return False
1084
+
1085
+
1086
+ def plan_merge_history_to_target(codex: Path, target: IdentityConfig, keep_models: bool) -> MergeHistoryResult:
1087
+ rollout_files = iter_rollout_files(codex)
1088
+ state_db = codex / "state_5.sqlite"
1089
+ changed_files = 0
1090
+ changed_lines = 0
1091
+
1092
+ for path in rollout_files:
1093
+ line_count, changed = normalize_rollout_file(path, target, keep_models, apply=False)
1094
+ if changed:
1095
+ changed_files += 1
1096
+ changed_lines += line_count
1097
+
1098
+ state_rows = 0
1099
+ if state_db.exists():
1100
+ state_rows = count_state_rows_to_normalize(state_db, target, keep_models)
1101
+
1102
+ backup_dir = backup_path(codex, "history-merge-backup") if changed_files or state_rows else None
1103
+ return MergeHistoryResult(changed_files, changed_lines, state_rows, backup_dir)
1104
+
1105
+
1106
+ def merge_history_to_target(codex: Path, target: IdentityConfig, keep_models: bool) -> MergeHistoryResult:
1107
+ rollout_files = iter_rollout_files(codex)
1108
+ state_db = codex / "state_5.sqlite"
1109
+ backup_dir: Optional[Path] = None
1110
+ changed_files = 0
1111
+ changed_lines = 0
1112
+
1113
+ for path in rollout_files:
1114
+ line_count, changed = normalize_rollout_file(path, target, keep_models, apply=False)
1115
+ if changed:
1116
+ if backup_dir is None:
1117
+ backup_dir = backup_root(codex, "history-merge-backup")
1118
+ backup_copy(path, backup_dir, codex)
1119
+ line_count, _ = normalize_rollout_file(path, target, keep_models)
1120
+ changed_files += 1
1121
+ changed_lines += line_count
1122
+
1123
+ state_rows = 0
1124
+ if state_db.exists():
1125
+ preview_state_rows = count_state_rows_to_normalize(state_db, target, keep_models)
1126
+ if preview_state_rows:
1127
+ if backup_dir is None:
1128
+ backup_dir = backup_root(codex, "history-merge-backup")
1129
+ backup_copy(state_db, backup_dir, codex)
1130
+ for suffix in ("-shm", "-wal"):
1131
+ sidecar = codex / f"state_5.sqlite{suffix}"
1132
+ backup_copy(sidecar, backup_dir, codex)
1133
+ state_rows = normalize_state_db(state_db, target, keep_models)
1134
+
1135
+ return MergeHistoryResult(changed_files, changed_lines, state_rows, backup_dir)
1136
+
1137
+
1138
+ def maybe_auto_merge_history(codex: Path, *, keep_models: bool = False) -> None:
1139
+ target = current_identity()
1140
+ if not target.provider:
1141
+ return
1142
+ effective_keep_models = keep_models or not target.model
1143
+ result = merge_history_to_target(
1144
+ codex,
1145
+ target,
1146
+ effective_keep_models,
1147
+ )
1148
+ if result.changed_files or result.state_rows:
1149
+ mode = "provider-only" if effective_keep_models else "provider+model"
1150
+ model_desc = "(preserved)" if effective_keep_models else target.model
1151
+ print(
1152
+ f"history aligned → provider={target.provider} "
1153
+ f"model={model_desc} mode={mode} "
1154
+ f"files={result.changed_files} lines={result.changed_lines} state_rows={result.state_rows}"
1155
+ )
1156
+ if result.backup_dir is not None:
1157
+ print(f"history backup → {result.backup_dir}")
1158
+
1159
+
1160
+ def _parse_index_timestamp_ms(raw: object) -> int:
1161
+ if not isinstance(raw, str) or not raw.strip():
1162
+ return 0
1163
+ text = raw.strip()
1164
+ if text.endswith("Z"):
1165
+ text = text[:-1] + "+00:00"
1166
+ try:
1167
+ parsed = datetime.fromisoformat(text)
1168
+ except ValueError:
1169
+ return 0
1170
+ if parsed.tzinfo is None:
1171
+ parsed = parsed.replace(tzinfo=timezone.utc)
1172
+ return int(parsed.timestamp() * 1000)
1173
+
1174
+
1175
+ def _format_index_timestamp(updated_at: object, updated_at_ms: object) -> str:
1176
+ millis: Optional[int] = None
1177
+ if isinstance(updated_at_ms, int):
1178
+ millis = updated_at_ms
1179
+ elif isinstance(updated_at_ms, float):
1180
+ millis = int(updated_at_ms)
1181
+ elif isinstance(updated_at, int):
1182
+ millis = updated_at * 1000
1183
+ elif isinstance(updated_at, float):
1184
+ millis = int(updated_at * 1000)
1185
+ if millis is None:
1186
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1187
+ return datetime.fromtimestamp(millis / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
1188
+
1189
+
1190
+ def _read_session_index_latest(path: Path) -> dict[str, int]:
1191
+ latest: dict[str, int] = {}
1192
+ if not path.exists():
1193
+ return latest
1194
+ with path.open() as fh:
1195
+ for raw in fh:
1196
+ try:
1197
+ item = json.loads(raw)
1198
+ except json.JSONDecodeError:
1199
+ continue
1200
+ if not isinstance(item, dict):
1201
+ continue
1202
+ thread_id = item.get("id")
1203
+ if not isinstance(thread_id, str) or not thread_id:
1204
+ continue
1205
+ latest[thread_id] = max(latest.get(thread_id, 0), _parse_index_timestamp_ms(item.get("updated_at")))
1206
+ return latest
1207
+
1208
+
1209
+ def _session_index_thread_columns(conn: sqlite3.Connection) -> list[str]:
1210
+ columns = sqlite_threads_columns(conn)
1211
+ needed = {"id", "updated_at"}
1212
+ if not needed.issubset(set(columns)):
1213
+ return []
1214
+ return columns
1215
+
1216
+
1217
+ def maybe_repair_session_index(codex: Path) -> None:
1218
+ index = codex / "session_index.jsonl"
1219
+ state_db = codex / "state_5.sqlite"
1220
+ if not state_db.exists():
1221
+ return
1222
+ try:
1223
+ conn = connect_sqlite_readonly(state_db)
1224
+ except sqlite3.Error:
1225
+ return
1226
+ try:
1227
+ columns = _session_index_thread_columns(conn)
1228
+ if not columns:
1229
+ return
1230
+ selected = [c for c in ("id", "title", "preview", "updated_at", "updated_at_ms", "archived") if c in columns]
1231
+ select_sql = ", ".join(f'"{c}"' for c in selected)
1232
+ rows = conn.execute(
1233
+ f"""
1234
+ SELECT {select_sql}
1235
+ FROM threads
1236
+ WHERE ifnull(archived, 0) = 0
1237
+ ORDER BY ifnull(updated_at_ms, updated_at * 1000) ASC
1238
+ """
1239
+ ).fetchall()
1240
+ except sqlite3.Error:
1241
+ return
1242
+ finally:
1243
+ conn.close()
1244
+
1245
+ latest = _read_session_index_latest(index)
1246
+ additions: list[dict[str, str]] = []
1247
+ for row in rows:
1248
+ item = {name: value for name, value in zip(selected, row)}
1249
+ thread_id = item.get("id")
1250
+ if not isinstance(thread_id, str) or not thread_id:
1251
+ continue
1252
+ updated_at_ms = item.get("updated_at_ms")
1253
+ updated_at = item.get("updated_at")
1254
+ if isinstance(updated_at_ms, (int, float)):
1255
+ current_ms = int(updated_at_ms)
1256
+ elif isinstance(updated_at, (int, float)):
1257
+ current_ms = int(updated_at * 1000)
1258
+ else:
1259
+ continue
1260
+ if current_ms <= latest.get(thread_id, 0):
1261
+ continue
1262
+ title = item.get("title") or item.get("preview") or "Untitled session"
1263
+ additions.append(
1264
+ {
1265
+ "id": thread_id,
1266
+ "thread_name": str(title),
1267
+ "updated_at": _format_index_timestamp(updated_at, updated_at_ms),
1268
+ }
1269
+ )
1270
+
1271
+ if not additions:
1272
+ return
1273
+
1274
+ index.parent.mkdir(parents=True, exist_ok=True)
1275
+ with index.open("a") as fh:
1276
+ for item in additions:
1277
+ fh.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n")
1278
+ print(f"session index repaired → {len(additions)} entries")
1279
+
1280
+
1281
+ def current_looks_official(codex: Path) -> bool:
1282
+ cfg = _swap.load(codex / "config.toml")
1283
+ auth_path = codex / "auth.json"
1284
+ if not auth_path.exists():
1285
+ return False
1286
+ try:
1287
+ auth = json.loads(auth_path.read_text())
1288
+ except json.JSONDecodeError:
1289
+ return False
1290
+ provider = str(cfg.get("model_provider") or "").strip()
1291
+ auth_method = str(cfg.get("preferred_auth_method") or "").strip()
1292
+ auth_mode = str(auth.get("auth_mode") or "").strip()
1293
+ return provider == "openai" and auth_mode == "chatgpt" and auth_method in {"", "chatgpt"}
1294
+
1295
+
1296
+ def snapshot_official_state(codex: Path) -> None:
1297
+ auth = codex / "auth.json"
1298
+ cfg = codex / "config.toml"
1299
+ if not auth.is_file():
1300
+ _die(f"no {auth} to snapshot")
1301
+ dir_ = official_profile_dir()
1302
+ dir_.mkdir(parents=True, exist_ok=True)
1303
+ shutil.copy2(auth, dir_ / "auth.json")
1304
+ if cfg.is_file():
1305
+ _swap.extract(cfg, dir_ / "provider.toml")
1306
+ else:
1307
+ (dir_ / "provider.toml").write_text("")
1308
+ write_session_config(dir_, SessionConfig())
1309
+
1310
+
1311
+ def ensure_official_snapshot_available(codex: Path) -> None:
1312
+ if official_profile_dir().is_dir():
1313
+ auth = official_profile_dir() / "auth.json"
1314
+ prov = official_profile_dir() / "provider.toml"
1315
+ if auth.is_file() and prov.is_file():
1316
+ return
1317
+ if current_looks_official(codex):
1318
+ snapshot_official_state(codex)
1319
+ return
1320
+ _die("official OpenAI snapshot not found; switch to official once, then run `codex-safe-switch official`")
1321
+
1322
+
1323
+ def switch_to_profile(name: str, *, restart_codex: bool = False) -> int:
1324
+ normalized_name = normalize_profile_name(name)
1325
+ dir_ = profile_dir_for_name(normalized_name)
1326
+ if not dir_.is_dir():
1327
+ bootstrap_current_profile()
1328
+ dir_ = profile_dir_for_name(normalized_name)
1329
+ if not dir_.is_dir():
1330
+ _die(f"profile not found: {name}")
1331
+ auth_src = dir_ / "auth.json"
1332
+ prov_src = dir_ / "provider.toml"
1333
+ if not prov_src.is_file():
1334
+ _die(f"missing {prov_src}")
1335
+ provider_doc = _swap.load(prov_src)
1336
+ needs_auth_file = profile_requires_auth_file(dir_, provider_doc)
1337
+ if needs_auth_file and not auth_src.is_file():
1338
+ _die(f"missing {auth_src}")
1339
+
1340
+ codex = codex_dir()
1341
+ codex.mkdir(parents=True, exist_ok=True)
1342
+ cfg = codex / "config.toml"
1343
+ auth = codex / "auth.json"
1344
+ current = normalize_profile_name(active_name())
1345
+
1346
+ if current_looks_official(codex) and normalized_name != OFFICIAL_PROFILE_NAME:
1347
+ snapshot_official_state(codex)
1348
+
1349
+ if not cfg.exists():
1350
+ cfg.touch()
1351
+
1352
+ fd, tmp_cfg_str = tempfile.mkstemp(prefix="config.toml.", dir=codex)
1353
+ os.close(fd)
1354
+ tmp_cfg = Path(tmp_cfg_str)
1355
+ try:
1356
+ _swap.merge(cfg, prov_src, tmp_cfg)
1357
+ _copy_mode(cfg, tmp_cfg)
1358
+ os.replace(tmp_cfg, cfg)
1359
+ finally:
1360
+ if tmp_cfg.exists():
1361
+ tmp_cfg.unlink(missing_ok=True)
1362
+
1363
+ if needs_auth_file:
1364
+ _atomic_write_copy(auth_src, auth)
1365
+ maybe_switch_session_state(current, normalized_name, codex)
1366
+
1367
+ active_file().write_text(normalized_name + "\n")
1368
+ print(f"switched → {normalized_name}")
1369
+ maybe_auto_merge_history(codex)
1370
+ maybe_repair_session_index(codex)
1371
+ maybe_repair_remote_selection_state(codex)
1372
+ stopped_runtime = maybe_repair_remote_control(codex)
1373
+ if restart_codex:
1374
+ count = restart_codex_processes()
1375
+ print(f"restarted Codex processes → {count}")
1376
+ else:
1377
+ maybe_stop_remote_proxy_processes(codex, exclude=stopped_runtime)
1378
+ return 0
1379
+
1380
+
1381
+ def cmd_merge_history(args) -> int:
1382
+ codex = codex_dir()
1383
+ identity = current_identity()
1384
+ provider = args.provider or identity.provider
1385
+ model = identity.model if args.model is None else args.model
1386
+
1387
+ if not provider:
1388
+ _die("target provider is empty; pass --provider or set model_provider in ~/.codex/config.toml")
1389
+ if not args.keep_models and not model:
1390
+ _die("target model is empty; pass --model or use --keep-models")
1391
+
1392
+ target = IdentityConfig(provider=provider, model=model)
1393
+ if args.dry_run:
1394
+ result = plan_merge_history_to_target(codex, target, args.keep_models)
1395
+ else:
1396
+ result = merge_history_to_target(codex, target, args.keep_models)
1397
+
1398
+ mode = "provider-only" if args.keep_models else "provider+model"
1399
+ model_desc = "(preserved)" if args.keep_models else target.model
1400
+ verb = "would merge history" if args.dry_run else "merged history"
1401
+ action = "would update" if args.dry_run else "updated"
1402
+ print(f"{verb} → provider={target.provider} model={model_desc} mode={mode}")
1403
+ if result.backup_dir is not None:
1404
+ suffix = " (would create)" if args.dry_run else ""
1405
+ print(f"backup → {result.backup_dir}{suffix}")
1406
+ else:
1407
+ print("backup → (not needed)")
1408
+ print(f"rollout files {action} → {result.changed_files}")
1409
+ print(f"rollout lines {action} → {result.changed_lines}")
1410
+ print(f"state rows {action} → {result.state_rows}")
1411
+ return 0
1412
+
1413
+
1414
+ def cmd_doctor_history(_args) -> int:
1415
+ codex = codex_dir()
1416
+ identity = current_identity()
1417
+ profile = normalize_profile_name(active_name())
1418
+ if profile:
1419
+ profile_dir = profile_dir_for_name(profile)
1420
+ session_desc = load_session_config(profile_dir).describe() if profile_dir.is_dir() else "(profile missing)"
1421
+ else:
1422
+ session_desc = "(none)"
1423
+
1424
+ print("history doctor")
1425
+ print(f"current profile → {profile or '(none)'}")
1426
+ print(f"current config → provider={identity.provider or '(empty)'} model={identity.model or '(empty)'}")
1427
+ print(f"session state → {session_desc}")
1428
+
1429
+ state_db = codex / "state_5.sqlite"
1430
+ distribution, dist_error = read_thread_distribution(state_db)
1431
+ print("threads provider/model distribution:")
1432
+ if dist_error:
1433
+ print(f" ({dist_error})")
1434
+ elif not distribution:
1435
+ print(" (empty)")
1436
+ else:
1437
+ for provider, model, count in distribution:
1438
+ print(f" {count} provider={_fmt_value(provider)} model={_fmt_value(model)}")
1439
+
1440
+ recent, recent_error = read_recent_threads(state_db)
1441
+ print("recent threads:")
1442
+ if recent_error:
1443
+ print(f" ({recent_error})")
1444
+ elif not recent:
1445
+ print(" (empty)")
1446
+ else:
1447
+ for item in recent:
1448
+ bits = [f"rowid={item.pop('rowid')}"]
1449
+ for key, value in item.items():
1450
+ bits.append(f"{key}={_fmt_value(value)}")
1451
+ print(" " + " ".join(bits))
1452
+
1453
+ sqlite_drift = has_provider_model_drift(distribution, identity)
1454
+ if sqlite_drift is None:
1455
+ print("sqlite provider/model drift → unknown")
1456
+ else:
1457
+ print(f"sqlite provider/model drift → {'yes' if sqlite_drift else 'no'}")
1458
+
1459
+ if identity.provider:
1460
+ effective_keep_models = not identity.model
1461
+ plan = plan_merge_history_to_target(codex, identity, effective_keep_models)
1462
+ rollout_drift = plan.changed_files > 0
1463
+ overall_drift = bool(sqlite_drift) or rollout_drift or plan.state_rows > 0
1464
+ print(
1465
+ "planned history alignment → "
1466
+ f"files={plan.changed_files} lines={plan.changed_lines} state_rows={plan.state_rows}"
1467
+ )
1468
+ print(f"rollout metadata drift → {'yes' if rollout_drift else 'no'}")
1469
+ print(f"provider/model drift → {'yes' if overall_drift else 'no'}")
1470
+ else:
1471
+ print("planned history alignment → unknown (empty provider)")
1472
+ print("rollout metadata drift → unknown")
1473
+ print("provider/model drift → unknown")
1474
+ return 0
1475
+
1476
+
1477
+ def cmd_ls(_args) -> int:
1478
+ bootstrap_current_profile()
1479
+ active = normalize_profile_name(active_name())
1480
+ profiles = list_profiles()
1481
+ if not profiles:
1482
+ _die(no_profiles_message())
1483
+ for name in profiles:
1484
+ prefix = "★" if name == active else " "
1485
+ print(f"{prefix} {name}")
1486
+ return 0
1487
+
1488
+
1489
+ def cmd_current(_args) -> int:
1490
+ bootstrap_current_profile()
1491
+ name = normalize_profile_name(active_name())
1492
+ if name:
1493
+ print(name)
1494
+ return 0
1495
+ print("(none)")
1496
+ return 1
1497
+
1498
+
1499
+ def cmd_use(args) -> int:
1500
+ name = normalize_profile_name(args.name)
1501
+ if args.name == ALFRED_INIT_ARG:
1502
+ initialized = bootstrap_current_profile()
1503
+ if initialized is None:
1504
+ _die(no_profiles_message())
1505
+ return 0
1506
+ if not name:
1507
+ bootstrap_current_profile()
1508
+ profiles = list_profiles()
1509
+ if not profiles:
1510
+ _die(no_profiles_message())
1511
+ chosen = pick(profiles, active=normalize_profile_name(active_name()), prompt="Switch to which profile?")
1512
+ if chosen is None:
1513
+ print("cancelled")
1514
+ return 1
1515
+ name = normalize_profile_name(chosen)
1516
+ if name == OFFICIAL_PROFILE_NAME:
1517
+ return cmd_official(args)
1518
+ return switch_to_profile(name, restart_codex=getattr(args, "restart_codex", False))
1519
+
1520
+
1521
+ def cmd_official(args) -> int:
1522
+ codex = codex_dir()
1523
+ ensure_official_snapshot_available(codex)
1524
+ return switch_to_profile(OFFICIAL_PROFILE_NAME, restart_codex=getattr(args, "restart_codex", False))
1525
+
1526
+
1527
+ def cmd_save(args) -> int:
1528
+ name = normalize_profile_name(args.name)
1529
+ if name in {OFFICIAL_PROFILE_NAME, "bin", ".active"} or args.name.startswith("."):
1530
+ _die(f"reserved name: {args.name}")
1531
+ if args.shared and args.scope:
1532
+ _die("choose either --shared or --scope, not both")
1533
+ dir_ = profile_root() / name
1534
+ dir_.mkdir(parents=True, exist_ok=True)
1535
+
1536
+ codex = codex_dir()
1537
+ auth = codex / "auth.json"
1538
+ cfg = codex / "config.toml"
1539
+ if cfg.is_file():
1540
+ _swap.extract(cfg, dir_ / "provider.toml")
1541
+ else:
1542
+ (dir_ / "provider.toml").write_text("")
1543
+ if profile_requires_auth_file(dir_):
1544
+ if not auth.is_file():
1545
+ _die(f"no {auth} to snapshot")
1546
+ shutil.copy2(auth, dir_ / "auth.json")
1547
+ else:
1548
+ (dir_ / "auth.json").unlink(missing_ok=True)
1549
+
1550
+ if args.shared:
1551
+ session_cfg = SessionConfig()
1552
+ write_session_config(dir_, session_cfg)
1553
+ elif args.scope:
1554
+ scope = args.scope.strip()
1555
+ if not scope:
1556
+ _die("scope must not be empty")
1557
+ session_cfg = SessionConfig(mode="scoped", scope=scope)
1558
+ write_session_config(dir_, session_cfg)
1559
+ save_session_state(session_cfg, codex)
1560
+
1561
+ print(f"saved → {name}")
1562
+ return 0
1563
+
1564
+
1565
+ def cmd_show(args) -> int:
1566
+ name = normalize_profile_name(args.name)
1567
+ dir_ = profile_dir_for_name(name)
1568
+ if not dir_.is_dir():
1569
+ _die(f"profile not found: {args.name}")
1570
+ prov = dir_ / "provider.toml"
1571
+ print(f"# {prov}")
1572
+ if prov.exists():
1573
+ text = prov.read_text()
1574
+ print(text if text else "(empty)")
1575
+ else:
1576
+ print("(missing)")
1577
+ print()
1578
+ auth = dir_ / "auth.json"
1579
+ print(f"# {auth} (keys only)")
1580
+ if auth.exists():
1581
+ try:
1582
+ data = json.loads(auth.read_text())
1583
+ for k in sorted(data.keys()):
1584
+ print(k)
1585
+ except json.JSONDecodeError as e:
1586
+ print(f"(invalid json: {e})")
1587
+ else:
1588
+ print("(missing)")
1589
+ print()
1590
+ print(f"# {session_config_path(dir_)}")
1591
+ session_cfg = load_session_config(dir_)
1592
+ print(session_cfg.describe())
1593
+ return 0
1594
+
1595
+
1596
+ def cmd_rm(args) -> int:
1597
+ name = normalize_profile_name(args.name)
1598
+ dir_ = profile_dir_for_name(name)
1599
+ if not dir_.is_dir():
1600
+ _die(f"profile not found: {args.name}")
1601
+ if name == normalize_profile_name(active_name()):
1602
+ _die(f"cannot remove the active profile: {args.name}")
1603
+ shutil.rmtree(dir_)
1604
+ print(f"removed → {name}")
1605
+ return 0
1606
+
1607
+
1608
+ def cmd_state(args) -> int:
1609
+ name = normalize_profile_name(args.name)
1610
+ dir_ = profile_dir_for_name(name)
1611
+ if not dir_.is_dir():
1612
+ _die(f"profile not found: {args.name}")
1613
+
1614
+ if args.shared and args.scope:
1615
+ _die("choose either --shared or --scope, not both")
1616
+
1617
+ if not args.shared and not args.scope:
1618
+ print(load_session_config(dir_).describe())
1619
+ return 0
1620
+
1621
+ if args.shared:
1622
+ cfg = SessionConfig()
1623
+ else:
1624
+ scope = args.scope.strip()
1625
+ if not scope:
1626
+ _die("scope must not be empty")
1627
+ cfg = SessionConfig(mode="scoped", scope=scope)
1628
+
1629
+ write_session_config(dir_, cfg)
1630
+
1631
+ current = normalize_profile_name(active_name())
1632
+ if name == current or current is None:
1633
+ save_session_state(cfg, codex_dir())
1634
+
1635
+ print(f"{name}: session state → {cfg.describe()}")
1636
+ return 0
1637
+
1638
+
1639
+ def cmd_alfred_list(_args) -> int:
1640
+ """Emit Alfred Script Filter JSON."""
1641
+ bootstrap_current_profile(verbose=False)
1642
+ active = normalize_profile_name(active_name())
1643
+ items = []
1644
+ for name in list_profiles():
1645
+ if name == active:
1646
+ items.append({
1647
+ "uid": name,
1648
+ "title": f"★ {name}",
1649
+ "arg": name,
1650
+ "subtitle": "current profile — pressing return reloads it",
1651
+ "autocomplete": name,
1652
+ })
1653
+ else:
1654
+ items.append({
1655
+ "uid": name,
1656
+ "title": name,
1657
+ "arg": name,
1658
+ "subtitle": f"switch to {name}",
1659
+ "autocomplete": name,
1660
+ })
1661
+ if not items:
1662
+ items.append({
1663
+ "uid": "initialize",
1664
+ "title": "Initialize Codex profiles",
1665
+ "arg": ALFRED_INIT_ARG,
1666
+ "subtitle": "Run codex-safe-switch save <name> after configuring Codex once",
1667
+ "autocomplete": "initialize",
1668
+ })
1669
+ print(json.dumps({"items": items}, ensure_ascii=False))
1670
+ return 0
1671
+
1672
+
1673
+ def cmd_pick(_args) -> int:
1674
+ """Default action when no subcommand is given: interactive switch."""
1675
+ bootstrap_current_profile()
1676
+ profiles = list_profiles()
1677
+ if not profiles:
1678
+ _die(no_profiles_message())
1679
+ chosen = pick(profiles, active=normalize_profile_name(active_name()), prompt="Switch to which profile?")
1680
+ if chosen is None:
1681
+ print("cancelled")
1682
+ return 1
1683
+ args_ns = argparse.Namespace(name=chosen, restart_codex=False)
1684
+ return cmd_use(args_ns)
1685
+
1686
+
1687
+ def cmd_restart_codex(_args) -> int:
1688
+ count = restart_codex_processes()
1689
+ print(f"restarted Codex processes → {count}")
1690
+ return 0
1691
+
1692
+
1693
+ def build_parser() -> argparse.ArgumentParser:
1694
+ p = argparse.ArgumentParser(
1695
+ prog="codex-safe-switch",
1696
+ description="Switch between Codex provider profiles. Run with no subcommand for an interactive picker.",
1697
+ )
1698
+ subs = p.add_subparsers(dest="cmd", metavar="<command>")
1699
+
1700
+ s = subs.add_parser("ls", aliases=["list"], help="list profiles (★ = active)")
1701
+ s.set_defaults(func=cmd_ls)
1702
+
1703
+ s = subs.add_parser("current", help="print the active profile name")
1704
+ s.set_defaults(func=cmd_current)
1705
+
1706
+ s = subs.add_parser("official", aliases=["openai"], help="switch back to official OpenAI ChatGPT login")
1707
+ s.add_argument("--restart-codex", action="store_true", help="terminate Codex app/server processes after switching")
1708
+ s.set_defaults(func=cmd_official)
1709
+
1710
+ s = subs.add_parser("use", aliases=["switch"], help="load <name> (interactive if omitted)")
1711
+ s.add_argument("name", nargs="?")
1712
+ s.add_argument("--restart-codex", action="store_true", help="terminate Codex app/server processes after switching")
1713
+ s.set_defaults(func=cmd_use)
1714
+
1715
+ s = subs.add_parser("save", help="snapshot the current ~/.codex state as <name>")
1716
+ s.add_argument("name")
1717
+ s.add_argument("--scope", help="also bind this profile to a session-state scope and seed it now")
1718
+ s.add_argument("--shared", action="store_true", help="clear any session-state scope while saving")
1719
+ s.set_defaults(func=cmd_save)
1720
+
1721
+ s = subs.add_parser("show", help="print <name>'s provider.toml + auth.json keys")
1722
+ s.add_argument("name")
1723
+ s.set_defaults(func=cmd_show)
1724
+
1725
+ s = subs.add_parser("state", help="show or set <name>'s session-state scope")
1726
+ s.add_argument("name")
1727
+ s.add_argument("--scope", help="store/restore Codex history state under this shared scope")
1728
+ s.add_argument("--shared", action="store_true", help="disable session-state swapping for this profile")
1729
+ s.set_defaults(func=cmd_state)
1730
+
1731
+ s = subs.add_parser("merge-history", help="rewrite local Codex history metadata into one provider/model identity")
1732
+ s.add_argument("--provider", help="target provider; defaults to current ~/.codex/config.toml model_provider")
1733
+ s.add_argument("--model", help="target model; defaults to current ~/.codex/config.toml model")
1734
+ s.add_argument(
1735
+ "--keep-models",
1736
+ action="store_true",
1737
+ help="only normalize provider identity and keep existing per-thread model values",
1738
+ )
1739
+ s.add_argument("--dry-run", action="store_true", help="report planned history changes without writing files")
1740
+ s.set_defaults(func=cmd_merge_history)
1741
+
1742
+ s = subs.add_parser("doctor-history", help="read-only diagnostics for Codex history provider/model state")
1743
+ s.set_defaults(func=cmd_doctor_history)
1744
+
1745
+ s = subs.add_parser("restart-codex", help="terminate Codex app/server processes so config changes take effect")
1746
+ s.set_defaults(func=cmd_restart_codex)
1747
+
1748
+ s = subs.add_parser("rm", aliases=["remove"], help="delete profile (active is protected)")
1749
+ s.add_argument("name")
1750
+ s.set_defaults(func=cmd_rm)
1751
+
1752
+ s = subs.add_parser("alfred-list", help="JSON for Alfred Script Filter")
1753
+ s.set_defaults(func=cmd_alfred_list)
1754
+
1755
+ return p
1756
+
1757
+
1758
+ def main(argv: Optional[list[str]] = None) -> int:
1759
+ try:
1760
+ args = build_parser().parse_args(argv)
1761
+ if args.cmd is None:
1762
+ return cmd_pick(args)
1763
+ return args.func(args)
1764
+ except KeyboardInterrupt:
1765
+ print("codex-safe-switch: interrupted", file=sys.stderr)
1766
+ return 130
1767
+
1768
+
1769
+ if __name__ == "__main__":
1770
+ raise SystemExit(main())