codex-flow 2.1.13__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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. codex_flow-2.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1718 @@
1
+ #!/usr/bin/env python3
2
+ """Release-driven self updater for codex-flow.
3
+
4
+ The updater is deliberately stdlib-only so the installed CLI and the native
5
+ FlowPilot app can share one durable update state without adding runtime deps.
6
+ It supports cached/non-blocking update checks, checksum verified release
7
+ artifacts, transactional managed-file replacement, version history and rollback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import platform
18
+ import re
19
+ import shutil
20
+ import ssl
21
+ import subprocess
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ import zipfile
29
+ from dataclasses import asdict, dataclass, field
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Any, Iterable
33
+
34
+ REPO = "ParsifalC/codex-flow"
35
+ STATE_SCHEMA = 2
36
+ MANIFEST_SCHEMA = 1
37
+ UPDATER_VERSION = "1.0.0"
38
+ DEFAULT_CHECK_INTERVAL_HOURS = 24
39
+ DEFAULT_RELEASES_API = f"https://api.github.com/repos/{REPO}/releases?per_page=20"
40
+ MANIFEST_ASSET = "codex-flow-update.json"
41
+ USER_AGENT = "codex-flow-updater/1"
42
+
43
+
44
+ def _configure_console() -> None:
45
+ if os.name != "nt":
46
+ return
47
+ for stream in (sys.stdout, sys.stderr):
48
+ reconfigure = getattr(stream, "reconfigure", None)
49
+ if callable(reconfigure):
50
+ with contextlib.suppress(OSError, ValueError):
51
+ reconfigure(errors="replace")
52
+
53
+
54
+ _configure_console()
55
+
56
+
57
+ def _codex_home() -> Path:
58
+ return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser()
59
+
60
+
61
+ def _state_dir() -> Path:
62
+ return _codex_home() / "codex-flow"
63
+
64
+
65
+ def _policy_path() -> Path:
66
+ return _codex_home() / "codex-flow.toml"
67
+
68
+
69
+ def _update_dir() -> Path:
70
+ return _state_dir() / "state"
71
+
72
+
73
+ def _update_state_path() -> Path:
74
+ return _update_dir() / "update.json"
75
+
76
+
77
+ def _history_path() -> Path:
78
+ return _update_dir() / "update-history.json"
79
+
80
+
81
+ def _migration_state_path() -> Path:
82
+ return _update_dir() / "migrations.json"
83
+
84
+
85
+ def _bin_dir() -> Path:
86
+ override = os.environ.get("CODEX_FLOW_BIN_DIR")
87
+ if override:
88
+ return Path(override).expanduser()
89
+ persisted = _state_dir() / "bin_dir"
90
+ if persisted.exists():
91
+ try:
92
+ value = persisted.read_text(encoding="utf-8-sig").strip()
93
+ if value:
94
+ return Path(value).expanduser()
95
+ except OSError:
96
+ pass
97
+ return Path.home() / ".local" / "bin"
98
+
99
+
100
+ def utc_now() -> str:
101
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
102
+
103
+
104
+ def _parse_iso(value: str | None) -> float | None:
105
+ if not value:
106
+ return None
107
+ try:
108
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
109
+ except (TypeError, ValueError):
110
+ return None
111
+
112
+
113
+ def atomic_write_text(path: Path, text: str) -> None:
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
116
+ try:
117
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
118
+ handle.write(text)
119
+ handle.flush()
120
+ os.fsync(handle.fileno())
121
+ os.replace(tmp_name, path)
122
+ finally:
123
+ with contextlib.suppress(FileNotFoundError):
124
+ os.unlink(tmp_name)
125
+
126
+
127
+ def atomic_write_json(path: Path, payload: Any) -> None:
128
+ atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
129
+
130
+
131
+ def _read_json(path: Path, default: Any) -> Any:
132
+ try:
133
+ return json.loads(path.read_text(encoding="utf-8-sig"))
134
+ except (OSError, json.JSONDecodeError, TypeError):
135
+ return default
136
+
137
+
138
+ def _strip_v(version: str) -> str:
139
+ version = (version or "").strip()
140
+ return version[1:] if version.lower().startswith("v") else version
141
+
142
+
143
+ def version_key(version: str) -> tuple[tuple[int, ...], int, tuple[Any, ...]]:
144
+ """Return a deterministic SemVer-ish comparison key.
145
+
146
+ Stable releases sort after prereleases with the same numeric core. The
147
+ project currently uses simple x.y.z tags; the tolerant parser also keeps
148
+ beta/rc channels predictable without depending on packaging.version.
149
+ """
150
+
151
+ raw = _strip_v(version).split("+", 1)[0]
152
+ core, sep, prerelease = raw.partition("-")
153
+ nums: list[int] = []
154
+ for piece in core.split("."):
155
+ match = re.match(r"^(\d+)", piece)
156
+ nums.append(int(match.group(1)) if match else 0)
157
+ while len(nums) < 3:
158
+ nums.append(0)
159
+ pre_key: list[Any] = []
160
+ if sep:
161
+ for token in re.split(r"[.-]", prerelease):
162
+ pre_key.append((0, int(token)) if token.isdigit() else (1, token.lower()))
163
+ return tuple(nums), 0 if sep else 1, tuple(pre_key)
164
+
165
+
166
+ def is_newer(candidate: str, current: str) -> bool:
167
+ return version_key(candidate) > version_key(current)
168
+
169
+
170
+ def current_version() -> str:
171
+ for path in (_state_dir() / "version", Path(__file__).resolve().parents[1] / "VERSION"):
172
+ try:
173
+ value = path.read_text(encoding="utf-8-sig").strip()
174
+ if value:
175
+ return _strip_v(value)
176
+ except OSError:
177
+ pass
178
+ return "0.0.0"
179
+
180
+
181
+ def platform_key() -> str:
182
+ system = platform.system().lower()
183
+ machine = platform.machine().lower()
184
+ if machine in {"amd64", "x86_64"}:
185
+ machine = "x86_64"
186
+ elif machine in {"aarch64", "arm64"}:
187
+ machine = "arm64"
188
+ if system == "darwin":
189
+ return f"darwin-{machine}"
190
+ if system == "windows":
191
+ return f"windows-{machine}"
192
+ return f"linux-{machine}"
193
+
194
+
195
+ def _simple_toml_value(text: str, section: str, key: str) -> str | None:
196
+ section_match = re.search(
197
+ rf"(?ms)^\[{re.escape(section)}\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)", text
198
+ )
199
+ if not section_match:
200
+ return None
201
+ key_match = re.search(rf"(?m)^\s*{re.escape(key)}\s*=\s*(.*?)\s*$", section_match.group(1))
202
+ if not key_match:
203
+ return None
204
+ value = re.sub(r"\s+#.*$", "", key_match.group(1)).strip()
205
+ if len(value) >= 2 and value[0] == value[-1] == '"':
206
+ value = value[1:-1]
207
+ return value
208
+
209
+
210
+ def _as_bool(value: str | None, fallback: bool) -> bool:
211
+ if value is None:
212
+ return fallback
213
+ return value.strip().lower() in {"1", "true", "yes", "on"}
214
+
215
+
216
+ def set_policy_value(path: Path, section: str, key: str, value: str, *, quote: bool = True) -> None:
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ if not path.exists():
219
+ formatted_val = f'"{value}"' if quote else value
220
+ atomic_write_text(path, f"[{section}]\n{key} = {formatted_val}\n")
221
+ return
222
+ text = path.read_text(encoding="utf-8-sig")
223
+ section_match = re.search(
224
+ rf"(?ms)^\[{re.escape(section)}\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)", text
225
+ )
226
+ formatted_val = f'"{value}"' if quote else value
227
+ line = f"{key} = {formatted_val}"
228
+ if section_match:
229
+ body = section_match.group(1)
230
+ key_re = re.compile(rf"(?m)^\s*{re.escape(key)}\s*=.*$")
231
+ if key_re.search(body):
232
+ body = key_re.sub(line, body)
233
+ else:
234
+ if body and not body.endswith("\n"):
235
+ body += "\n"
236
+ body += line + "\n"
237
+ text = text[: section_match.start(1)] + body + text[section_match.end(1):]
238
+ else:
239
+ if text and not text.endswith("\n"):
240
+ text += "\n"
241
+ if text and not text.endswith("\n\n"):
242
+ text += "\n"
243
+ text += f"[{section}]\n{line}\n"
244
+ atomic_write_text(path, text)
245
+
246
+
247
+ @dataclass
248
+ class UpdateConfig:
249
+ channel: str = "stable"
250
+ check: bool = True
251
+ check_interval_hours: int = DEFAULT_CHECK_INTERVAL_HOURS
252
+ notify_cli: bool = True
253
+ notify_app: bool = True
254
+ auto_install: bool = True
255
+ releases_api: str = DEFAULT_RELEASES_API
256
+ manifest_url: str | None = None
257
+
258
+
259
+ def load_config(policy: Path | None = None) -> UpdateConfig:
260
+ path = policy or _policy_path()
261
+ config = UpdateConfig()
262
+ try:
263
+ text = path.read_text(encoding="utf-8-sig")
264
+ except OSError:
265
+ text = ""
266
+ config.channel = (_simple_toml_value(text, "update", "channel") or config.channel).lower()
267
+ config.check = _as_bool(_simple_toml_value(text, "update", "check"), config.check)
268
+ config.notify_cli = _as_bool(_simple_toml_value(text, "update", "notify_cli"), config.notify_cli)
269
+ config.notify_app = _as_bool(_simple_toml_value(text, "update", "notify_app"), config.notify_app)
270
+ config.auto_install = _as_bool(_simple_toml_value(text, "update", "auto_install"), config.auto_install)
271
+ interval = _simple_toml_value(text, "update", "check_interval_hours")
272
+ if interval and interval.isdigit() and int(interval) > 0:
273
+ config.check_interval_hours = int(interval)
274
+ config.releases_api = os.environ.get("CODEX_FLOW_UPDATE_RELEASES_API", config.releases_api)
275
+ config.manifest_url = os.environ.get("CODEX_FLOW_UPDATE_MANIFEST_URL") or None
276
+ return config
277
+
278
+
279
+ @dataclass
280
+ class UpdateState:
281
+ schema: int = STATE_SCHEMA
282
+ status: str = "unknown"
283
+ current_version: str = "0.0.0"
284
+ latest_version: str | None = None
285
+ channel: str = "stable"
286
+ notify_cli: bool = True
287
+ notify_app: bool = True
288
+ auto_install: bool = True
289
+ update_available: bool = False
290
+ checked_at: str | None = None
291
+ last_attempt_at: str | None = None
292
+ restart_required: bool = False
293
+ flowpilot_restart_required: bool = False
294
+ mandatory: bool = False
295
+ release_url: str | None = None
296
+ release_notes: str | None = None
297
+ artifact_available: bool = False
298
+ artifact_platform: str | None = None
299
+ last_error: str | None = None
300
+ previous_version: str | None = None
301
+ installed_at: str | None = None
302
+ progress: float | None = None
303
+ pending_codex_pids: list[int] = field(default_factory=list)
304
+ restart_reason: str | None = None
305
+ extra: dict[str, Any] = field(default_factory=dict)
306
+
307
+ @classmethod
308
+ def from_mapping(cls, payload: dict[str, Any] | None) -> "UpdateState":
309
+ payload = dict(payload or {})
310
+ known = {field_name for field_name in cls.__dataclass_fields__}
311
+ kwargs = {key: value for key, value in payload.items() if key in known and key != "extra"}
312
+ extra = {key: value for key, value in payload.items() if key not in known}
313
+ if "pending_codex_pids" in kwargs and isinstance(kwargs["pending_codex_pids"], list):
314
+ kwargs["pending_codex_pids"] = [
315
+ int(p) for p in kwargs["pending_codex_pids"]
316
+ if isinstance(p, (int, str)) and str(p).isdigit()
317
+ ]
318
+ state = cls(**kwargs)
319
+ if isinstance(payload.get("extra"), dict):
320
+ extra.update(payload["extra"])
321
+ state.extra = extra
322
+ return state
323
+
324
+ def to_mapping(self) -> dict[str, Any]:
325
+ payload = asdict(self)
326
+ extra = payload.pop("extra", {})
327
+ payload.update(extra)
328
+ return payload
329
+
330
+
331
+ def _evaluate_auto_ack_restart(state: UpdateState) -> UpdateState:
332
+ if not state.restart_required:
333
+ return state
334
+ if state.pending_codex_pids:
335
+ alive_pids = [pid for pid in state.pending_codex_pids if _pid_is_alive(pid)]
336
+ if not alive_pids:
337
+ state.restart_required = False
338
+ state.pending_codex_pids = []
339
+ state.restart_reason = None
340
+ with contextlib.suppress(OSError):
341
+ save_state(state)
342
+ elif len(alive_pids) != len(state.pending_codex_pids):
343
+ state.pending_codex_pids = alive_pids
344
+ with contextlib.suppress(OSError):
345
+ save_state(state)
346
+ return state
347
+
348
+
349
+ def load_state() -> UpdateState:
350
+ config = load_config()
351
+ state = UpdateState.from_mapping(_read_json(_update_state_path(), {}))
352
+ state.current_version = current_version()
353
+ state.auto_install = config.auto_install
354
+ # Cached availability is advisory. Recompute it from the version currently
355
+ # installed on disk so a second process cannot reinstall a version another
356
+ # updater completed after this cache was written.
357
+ if state.latest_version:
358
+ state.update_available = is_newer(state.latest_version, state.current_version)
359
+ if not state.update_available and state.status in {"available", "downloading", "installing"}:
360
+ state.status = "latest"
361
+ state = _evaluate_auto_ack_restart(state)
362
+ return state
363
+
364
+
365
+ def save_state(state: UpdateState) -> None:
366
+ state.schema = STATE_SCHEMA
367
+ atomic_write_json(_update_state_path(), state.to_mapping())
368
+
369
+
370
+ def cache_is_fresh(state: UpdateState, config: UpdateConfig) -> bool:
371
+ checked = _parse_iso(state.checked_at)
372
+ if checked is None:
373
+ return False
374
+ return time.time() - checked < config.check_interval_hours * 3600
375
+
376
+
377
+ def _ssl_context() -> ssl.SSLContext:
378
+ try:
379
+ context = ssl.create_default_context()
380
+ except Exception:
381
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
382
+ context.check_hostname = True
383
+ context.verify_mode = ssl.CERT_REQUIRED
384
+
385
+ paths = ssl.get_default_verify_paths()
386
+ ca_missing = bool(paths.openssl_cafile and not os.path.exists(paths.openssl_cafile))
387
+ no_ca_configured = not bool(paths.cafile or paths.capath)
388
+
389
+ if ca_missing or no_ca_configured:
390
+ loaded = False
391
+ try:
392
+ import certifi
393
+
394
+ ca = certifi.where()
395
+ if os.path.exists(ca):
396
+ context.load_verify_locations(cafile=ca)
397
+ loaded = True
398
+ except Exception:
399
+ pass
400
+
401
+ if not loaded:
402
+ common_bundle_paths = (
403
+ "/etc/ssl/cert.pem",
404
+ "/etc/pki/tls/certs/ca-bundle.crt",
405
+ "/etc/ssl/certs/ca-certificates.crt",
406
+ "/etc/ssl/ca-bundle.pem",
407
+ "/usr/local/share/certs/ca-root-nss.crt",
408
+ )
409
+ for path in common_bundle_paths:
410
+ if os.path.exists(path):
411
+ try:
412
+ context.load_verify_locations(cafile=path)
413
+ break
414
+ except Exception:
415
+ pass
416
+ return context
417
+
418
+
419
+ def _request_bytes(url: str, timeout: float = 8.0) -> bytes:
420
+ if url.startswith("file://"):
421
+ return Path(url[7:]).read_bytes()
422
+ local = Path(url)
423
+ if "://" not in url and local.exists():
424
+ return local.read_bytes()
425
+ request = urllib.request.Request(
426
+ url,
427
+ headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"},
428
+ )
429
+ with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
430
+ return response.read()
431
+
432
+
433
+ def _request_json(url: str, timeout: float = 8.0) -> Any:
434
+ return json.loads(_request_bytes(url, timeout=timeout).decode("utf-8-sig"))
435
+
436
+
437
+ def _release_matches_channel(release: dict[str, Any], channel: str) -> bool:
438
+ if release.get("draft"):
439
+ return False
440
+ tag = str(release.get("tag_name") or "").lower()
441
+ prerelease = bool(release.get("prerelease"))
442
+ normalized = _strip_v(tag)
443
+ if channel == "stable":
444
+ return not prerelease and "nightly" not in tag and "-" not in normalized
445
+ if channel == "beta":
446
+ return "nightly" not in tag
447
+ if channel == "nightly":
448
+ return "nightly" in tag
449
+ return not prerelease
450
+
451
+
452
+ def _manifest_from_release_api(config: UpdateConfig) -> tuple[dict[str, Any], dict[str, Any]]:
453
+ releases = _request_json(config.releases_api)
454
+ if not isinstance(releases, list):
455
+ raise RuntimeError("release API returned an unexpected payload")
456
+ candidates = [r for r in releases if isinstance(r, dict) and _release_matches_channel(r, config.channel)]
457
+ candidates.sort(key=lambda r: version_key(str(r.get("tag_name") or "0.0.0")), reverse=True)
458
+ if not candidates:
459
+ raise RuntimeError(f"no {config.channel} release found")
460
+ # Prefer the newest release that actually participates in the OTA protocol.
461
+ # During rollout there may be older, pre-OTA GitHub releases without a
462
+ # manifest. If the newest public release is not newer than the installed
463
+ # version, treat the installed version as current instead of surfacing a
464
+ # spurious update-check error in the CLI/App.
465
+ for release in candidates:
466
+ assets = release.get("assets") or []
467
+ manifest_asset = next(
468
+ (item for item in assets if isinstance(item, dict) and item.get("name") == MANIFEST_ASSET), None
469
+ )
470
+ if not manifest_asset or not manifest_asset.get("browser_download_url"):
471
+ continue
472
+ manifest = _request_json(str(manifest_asset["browser_download_url"]))
473
+ if not isinstance(manifest, dict):
474
+ raise RuntimeError("update manifest is not an object")
475
+ return manifest, release
476
+
477
+ newest_release = candidates[0]
478
+ newest_tag = _strip_v(str(newest_release.get("tag_name") or "0.0.0"))
479
+ installed = current_version()
480
+ if not is_newer(newest_tag, installed):
481
+ return (
482
+ {
483
+ "schema": MANIFEST_SCHEMA,
484
+ "version": installed,
485
+ "channel": config.channel,
486
+ "restart_required": False,
487
+ "mandatory": False,
488
+ "release_url": newest_release.get("html_url"),
489
+ "release_notes": "",
490
+ "artifacts": {},
491
+ },
492
+ newest_release,
493
+ )
494
+ raise RuntimeError(f"release {newest_release.get('tag_name', '')} does not provide {MANIFEST_ASSET}")
495
+
496
+
497
+ def fetch_manifest(config: UpdateConfig) -> tuple[dict[str, Any], dict[str, Any]]:
498
+ if config.manifest_url:
499
+ manifest = _request_json(config.manifest_url)
500
+ if not isinstance(manifest, dict):
501
+ raise RuntimeError("update manifest is not an object")
502
+ return manifest, {}
503
+ return _manifest_from_release_api(config)
504
+
505
+
506
+ def _validate_manifest(manifest: dict[str, Any]) -> None:
507
+ if int(manifest.get("schema", 0)) != MANIFEST_SCHEMA:
508
+ raise RuntimeError(f"unsupported update manifest schema: {manifest.get('schema')}")
509
+ if not str(manifest.get("version") or "").strip():
510
+ raise RuntimeError("update manifest is missing version")
511
+ minimum_updater = str(manifest.get("minimum_updater_version") or "").strip()
512
+ if minimum_updater and is_newer(minimum_updater, UPDATER_VERSION):
513
+ raise RuntimeError(
514
+ f"update requires updater v{_strip_v(minimum_updater)} or newer; "
515
+ "reinstall codex-flow once to refresh the updater"
516
+ )
517
+ artifacts = manifest.get("artifacts")
518
+ if not isinstance(artifacts, dict):
519
+ raise RuntimeError("update manifest is missing artifacts")
520
+
521
+
522
+ def _artifact_for_platform(manifest: dict[str, Any], key: str | None = None) -> dict[str, Any] | None:
523
+ artifacts = manifest.get("artifacts") or {}
524
+ value = artifacts.get(key or platform_key()) if isinstance(artifacts, dict) else None
525
+ return value if isinstance(value, dict) else None
526
+
527
+
528
+ def check_for_updates(*, force: bool = False, quiet: bool = False) -> UpdateState:
529
+ config = load_config()
530
+ state = load_state()
531
+ state.channel = config.channel
532
+ state.notify_cli = config.notify_cli
533
+ state.notify_app = config.notify_app
534
+ state.current_version = current_version()
535
+ if not config.check and not force:
536
+ return state
537
+ if not force and cache_is_fresh(state, config):
538
+ return state
539
+
540
+ state.last_attempt_at = utc_now()
541
+ try:
542
+ manifest, release = fetch_manifest(config)
543
+ _validate_manifest(manifest)
544
+ latest = _strip_v(str(manifest["version"]))
545
+ artifact = _artifact_for_platform(manifest)
546
+ state.latest_version = latest
547
+ state.update_available = is_newer(latest, state.current_version)
548
+ state.status = "available" if state.update_available else "latest"
549
+ state.checked_at = utc_now()
550
+ state.last_error = None
551
+ state.mandatory = bool(manifest.get("mandatory", False))
552
+ state.release_url = str(
553
+ manifest.get("release_url") or release.get("html_url") or state.release_url or ""
554
+ ) or None
555
+ notes = manifest.get("release_notes") or release.get("body")
556
+ state.release_notes = str(notes) if notes else None
557
+ state.artifact_platform = platform_key()
558
+ state.artifact_available = bool(
559
+ artifact and artifact.get("url") and artifact.get("sha256")
560
+ )
561
+ state.extra["manifest"] = manifest
562
+ save_state(state)
563
+ except Exception as exc: # keep a known cached result usable on transient failures
564
+ state.last_error = str(exc)
565
+ if state.status == "checking":
566
+ state.status = "unknown"
567
+ save_state(state)
568
+ if not quiet:
569
+ pass
570
+ return state
571
+
572
+
573
+ def cached_status(*, trigger_background: bool = False) -> UpdateState:
574
+ config = load_config()
575
+ state = load_state()
576
+ state.channel = config.channel
577
+ state.notify_cli = config.notify_cli
578
+ state.notify_app = config.notify_app
579
+ if trigger_background and config.check and not cache_is_fresh(state, config):
580
+ start_background_check()
581
+ return state
582
+
583
+
584
+ def _lock_path() -> Path:
585
+ return _update_dir() / "check.lock"
586
+
587
+
588
+ def _pid_is_alive(pid: int) -> bool:
589
+ """Best-effort process liveness check; uncertainty is treated as alive."""
590
+ if pid <= 0:
591
+ return False
592
+ if pid == os.getpid():
593
+ return True
594
+ if os.name == "nt":
595
+ try:
596
+ import ctypes
597
+
598
+ process_query_limited_information = 0x1000
599
+ kernel32 = ctypes.windll.kernel32
600
+ handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
601
+ if handle:
602
+ kernel32.CloseHandle(handle)
603
+ return True
604
+ return False
605
+ except Exception:
606
+ return True
607
+ try:
608
+ os.kill(pid, 0)
609
+ return True
610
+ except ProcessLookupError:
611
+ return False
612
+ except PermissionError:
613
+ return True
614
+ except OSError:
615
+ return True
616
+
617
+
618
+ def _find_running_codex_pids() -> list[int]:
619
+ """Find running Codex or ChatGPT host process PIDs across platforms."""
620
+ pids: set[int] = set()
621
+ my_pid = os.getpid()
622
+ target_names = ("ChatGPT", "Codex", "codex", "chatgpt")
623
+
624
+ if os.name == "nt":
625
+ for name in target_names:
626
+ try:
627
+ proc = subprocess.run(
628
+ ["tasklist", "/FI", f"IMAGENAME eq {name}.exe", "/FO", "CSV", "/NH"],
629
+ capture_output=True,
630
+ text=True,
631
+ timeout=3,
632
+ check=False,
633
+ )
634
+ for line in proc.stdout.splitlines():
635
+ parts = line.strip().split('","')
636
+ if len(parts) >= 2:
637
+ pid_str = parts[1].replace('"', "").strip()
638
+ if pid_str.isdigit():
639
+ pid = int(pid_str)
640
+ if pid != my_pid:
641
+ pids.add(pid)
642
+ except Exception:
643
+ pass
644
+ return sorted(pids)
645
+
646
+ for name in target_names:
647
+ try:
648
+ proc = subprocess.run(
649
+ ["pgrep", "-x", name],
650
+ capture_output=True,
651
+ text=True,
652
+ timeout=3,
653
+ check=False,
654
+ )
655
+ for line in proc.stdout.splitlines():
656
+ line = line.strip()
657
+ if line.isdigit():
658
+ pid = int(line)
659
+ if pid != my_pid:
660
+ pids.add(pid)
661
+ except Exception:
662
+ pass
663
+
664
+ return sorted(pids)
665
+
666
+
667
+ def _file_content_hash(path: Path | None) -> str | None:
668
+ if path is None or not path.is_file():
669
+ return None
670
+ try:
671
+ return hashlib.sha256(path.read_bytes()).hexdigest()
672
+ except OSError:
673
+ return None
674
+
675
+
676
+ def _codex_managed_files_diff(backup: Path) -> tuple[bool, list[str]]:
677
+ """Compare managed assets that affect Codex before and after the update."""
678
+ pairs: list[tuple[str, Path, Path]] = [
679
+ ("config.toml", backup / "config", _codex_home() / "config.toml"),
680
+ ("hooks.json", backup / "user-managed" / "hooks.json", _codex_home() / "hooks.json"),
681
+ ("worker-explorer.toml", backup / "user-managed" / "worker-explorer.toml", _codex_home() / "agents" / "worker-explorer.toml"),
682
+ ("worker-implementer.toml", backup / "user-managed" / "worker-implementer.toml", _codex_home() / "agents" / "worker-implementer.toml"),
683
+ ("worker-reviewer.toml", backup / "user-managed" / "worker-reviewer.toml", _codex_home() / "agents" / "worker-reviewer.toml"),
684
+ ("flow-pilot/SKILL.md", backup / "user-managed" / "SKILL.md", _codex_home() / "skills" / "flow-pilot" / "SKILL.md"),
685
+ ("AGENTS.md", backup / "user-managed" / "AGENTS.md", _codex_home() / "AGENTS.md"),
686
+ ("AGENTS.override.md", backup / "user-managed" / "AGENTS.override.md", _codex_home() / "AGENTS.override.md"),
687
+ ("flow-pilot-instructions.md", backup / "managed" / "flow-pilot-instructions.md", _state_dir() / "flow-pilot-instructions.md"),
688
+ ("codex-flow.toml", backup / "policy", _policy_path()),
689
+ ]
690
+ changed: list[str] = []
691
+ for label, pre_path, post_path in pairs:
692
+ pre_hash = _file_content_hash(pre_path)
693
+ post_hash = _file_content_hash(post_path)
694
+ if pre_hash != post_hash:
695
+ changed.append(label)
696
+ return bool(changed), changed
697
+
698
+
699
+ def _resolve_codex_restart_requirement(
700
+ backup: Path,
701
+ manifest: dict[str, Any] | None = None,
702
+ ) -> tuple[bool, list[int], str | None]:
703
+ """Intelligently determine whether Codex needs a restart after update/rollback."""
704
+ if manifest is not None and manifest.get("restart_required") is False:
705
+ return False, [], None
706
+
707
+ files_changed, changed_names = _codex_managed_files_diff(backup)
708
+ if not files_changed:
709
+ return False, [], None
710
+
711
+ running_pids = _find_running_codex_pids()
712
+ if not running_pids:
713
+ return False, [], None
714
+
715
+ reason = f"Changed: {', '.join(changed_names)}"
716
+ return True, running_pids, reason
717
+
718
+
719
+ @contextlib.contextmanager
720
+ def update_lock(stale_seconds: int = 600):
721
+ path = _lock_path()
722
+ path.parent.mkdir(parents=True, exist_ok=True)
723
+ if path.exists():
724
+ owner_pid: int | None = None
725
+ with contextlib.suppress(OSError, ValueError):
726
+ owner_pid = int(path.read_text(errors="ignore").strip())
727
+ age = 0.0
728
+ with contextlib.suppress(OSError):
729
+ age = max(0.0, time.time() - path.stat().st_mtime)
730
+ should_reclaim = (
731
+ owner_pid is not None and not _pid_is_alive(owner_pid)
732
+ ) or (
733
+ owner_pid is None and age > stale_seconds
734
+ )
735
+ if should_reclaim:
736
+ with contextlib.suppress(OSError):
737
+ path.unlink()
738
+ fd: int | None = None
739
+ owned = False
740
+ try:
741
+ fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
742
+ os.write(fd, str(os.getpid()).encode("ascii"))
743
+ os.close(fd)
744
+ fd = None
745
+ owned = True
746
+ yield True
747
+ except FileExistsError:
748
+ yield False
749
+ finally:
750
+ if fd is not None:
751
+ os.close(fd)
752
+ if owned:
753
+ with contextlib.suppress(FileNotFoundError):
754
+ if path.exists() and path.read_text(errors="ignore").strip() == str(os.getpid()):
755
+ path.unlink()
756
+
757
+
758
+ @contextlib.contextmanager
759
+ def install_lock(timeout_seconds: float = 20.0, poll_interval: float = 0.25):
760
+ """Wait briefly for a background check, then require exclusive writer ownership.
761
+
762
+ Checks and writers intentionally share the PID lock so an update can never
763
+ mutate managed files while a checker is updating shared state. A user action
764
+ should not fail merely because a short detached check was already in flight,
765
+ so writers retry for a bounded interval before reporting real contention.
766
+ """
767
+
768
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
769
+ while True:
770
+ with update_lock() as acquired:
771
+ if acquired:
772
+ yield
773
+ return
774
+ if time.monotonic() >= deadline:
775
+ raise RuntimeError("another codex-flow update, rollback, or update check is still running")
776
+ time.sleep(max(0.01, poll_interval))
777
+
778
+
779
+ def start_background_check() -> bool:
780
+ """Spawn a silent checker and return immediately.
781
+
782
+ UI surfaces call this only after rendering cached state, so a slow network can
783
+ never delay the CLI menu or the native app launch.
784
+ """
785
+
786
+ config = load_config()
787
+ state = load_state()
788
+ if not config.check or cache_is_fresh(state, config):
789
+ return False
790
+ action_flag = "--auto" if config.auto_install else "--check"
791
+ cmd = [sys.executable, str(Path(__file__).resolve()), action_flag, "--quiet"]
792
+ kwargs: dict[str, Any] = {
793
+ "stdin": subprocess.DEVNULL,
794
+ "stdout": subprocess.DEVNULL,
795
+ "stderr": subprocess.DEVNULL,
796
+ "close_fds": os.name != "nt",
797
+ }
798
+ if os.name == "nt":
799
+ kwargs["creationflags"] = (
800
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
801
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
802
+ )
803
+ else:
804
+ kwargs["start_new_session"] = True
805
+ try:
806
+ subprocess.Popen(cmd, **kwargs)
807
+ return True
808
+ except OSError:
809
+ return False
810
+
811
+
812
+ def update_menu_label(lang: str = "en", state: UpdateState | None = None) -> str:
813
+ state = state or load_state()
814
+ zh = lang.lower().startswith("zh")
815
+ if state.notify_cli is False:
816
+ return "🔄 检查更新" if zh else "🔄 Check for updates"
817
+ if state.flowpilot_restart_required or state.restart_required:
818
+ version = state.current_version or state.latest_version or ""
819
+ if state.flowpilot_restart_required and state.restart_required:
820
+ return f"⚠️ v{version} 已安装 · 请重启 FlowPilot / Codex" if zh else f"⚠️ v{version} installed · restart FlowPilot / Codex"
821
+ if state.flowpilot_restart_required:
822
+ return f"⚠️ v{version} 已安装 · 请重启 FlowPilot" if zh else f"⚠️ v{version} installed · restart FlowPilot"
823
+ return f"⚠️ v{version} 已安装 · 请重启 Codex" if zh else f"⚠️ v{version} installed · restart Codex"
824
+ if state.update_available and state.latest_version:
825
+ return (
826
+ f"🆕 更新 codex-flow · v{state.latest_version} 可用"
827
+ if zh
828
+ else f"🆕 Update codex-flow · v{state.latest_version} available"
829
+ )
830
+ if state.status == "latest" and state.current_version:
831
+ return (
832
+ f"✅ 已是最新版本 · v{state.current_version}"
833
+ if zh
834
+ else f"✅ Up to date · v{state.current_version}"
835
+ )
836
+ return "🔄 检查更新" if zh else "🔄 Check for updates"
837
+
838
+
839
+ def _safe_target(root: Path, member_name: str) -> Path:
840
+ target = (root / member_name).resolve()
841
+ root_resolved = root.resolve()
842
+ if target != root_resolved and root_resolved not in target.parents:
843
+ raise RuntimeError(f"unsafe archive member: {member_name}")
844
+ return target
845
+
846
+
847
+ def safe_extract(archive: Path, destination: Path) -> None:
848
+ destination.mkdir(parents=True, exist_ok=True)
849
+ if archive.suffix.lower() == ".zip":
850
+ with zipfile.ZipFile(archive) as zf:
851
+ for info in zf.infolist():
852
+ _safe_target(destination, info.filename)
853
+ zf.extractall(destination)
854
+ return
855
+ with tarfile.open(archive, "r:*") as tf:
856
+ for member in tf.getmembers():
857
+ _safe_target(destination, member.name)
858
+ if member.issym() or member.islnk():
859
+ raise RuntimeError(f"release archive may not contain links: {member.name}")
860
+ if not (member.isfile() or member.isdir()):
861
+ raise RuntimeError(f"release archive contains unsupported special member: {member.name}")
862
+ tf.extractall(destination)
863
+
864
+
865
+ def _sha256(path: Path) -> str:
866
+ digest = hashlib.sha256()
867
+ with path.open("rb") as handle:
868
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
869
+ digest.update(chunk)
870
+ return digest.hexdigest()
871
+
872
+
873
+ def _download(url: str, destination: Path) -> None:
874
+ destination.parent.mkdir(parents=True, exist_ok=True)
875
+ if url.startswith("file://"):
876
+ shutil.copy2(Path(url[7:]), destination)
877
+ return
878
+ local = Path(url)
879
+ if "://" not in url and local.exists():
880
+ shutil.copy2(local, destination)
881
+ return
882
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
883
+ with urllib.request.urlopen(request, timeout=30, context=_ssl_context()) as response, destination.open("wb") as out:
884
+ shutil.copyfileobj(response, out, length=1024 * 1024)
885
+
886
+
887
+ def _package_root(extract_dir: Path) -> Path:
888
+ if (extract_dir / "VERSION").exists():
889
+ return extract_dir
890
+ children = [p for p in extract_dir.iterdir() if p.is_dir()]
891
+ candidates = [p for p in children if (p / "VERSION").exists()]
892
+ if len(candidates) == 1:
893
+ return candidates[0]
894
+ raise RuntimeError("release artifact does not contain a codex-flow package root")
895
+
896
+
897
+ def _atomic_copy(src: Path, dst: Path, executable: bool | None = None) -> None:
898
+ dst.parent.mkdir(parents=True, exist_ok=True)
899
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{dst.name}.", dir=str(dst.parent))
900
+ os.close(fd)
901
+ tmp = Path(tmp_name)
902
+ try:
903
+ shutil.copy2(src, tmp)
904
+ if executable is True and os.name != "nt":
905
+ tmp.chmod(tmp.stat().st_mode | 0o111)
906
+ os.replace(tmp, dst)
907
+ finally:
908
+ with contextlib.suppress(FileNotFoundError):
909
+ tmp.unlink()
910
+
911
+
912
+ def _replace_dir(src: Path, dst: Path) -> None:
913
+ dst.parent.mkdir(parents=True, exist_ok=True)
914
+ staging = dst.parent / f".{dst.name}.new-{os.getpid()}"
915
+ shutil.rmtree(staging, ignore_errors=True)
916
+ shutil.copytree(src, staging)
917
+ old = dst.parent / f".{dst.name}.old-{os.getpid()}"
918
+ shutil.rmtree(old, ignore_errors=True)
919
+ if dst.exists():
920
+ os.replace(dst, old)
921
+ os.replace(staging, dst)
922
+ shutil.rmtree(old, ignore_errors=True)
923
+
924
+
925
+ def _snapshot(current: str) -> Path:
926
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
927
+ backup = _state_dir() / "backups" / f"{stamp}-{current}"
928
+ backup.mkdir(parents=True, exist_ok=True)
929
+ targets = {
930
+ "policy": _policy_path(),
931
+ "config": _codex_home() / "config.toml",
932
+ "version": _state_dir() / "version",
933
+ "source": _state_dir() / "source",
934
+ "defaults": _state_dir() / "defaults.toml",
935
+ }
936
+ for name, path in targets.items():
937
+ if path.exists():
938
+ shutil.copy2(path, backup / name)
939
+ managed = backup / "managed"
940
+ managed.mkdir()
941
+ instruction_managed_names = {
942
+ "manage-instructions.py",
943
+ "flow-pilot-instructions.md",
944
+ "instructions-state.json",
945
+ }
946
+ instruction_presence: dict[str, bool] = {}
947
+ for name in (
948
+ "updater.py",
949
+ "update_runtime_config.py",
950
+ "telemetry.py",
951
+ "manage-hooks.py",
952
+ "manage-instructions.py",
953
+ "menu.py",
954
+ "localization.py",
955
+ "ui.py",
956
+ "doctor.py",
957
+ "strategy_runtime.py",
958
+ "flow-pilot-instructions.md",
959
+ "instructions-state.json",
960
+ ):
961
+ path = _state_dir() / name
962
+ if name in instruction_managed_names:
963
+ instruction_presence[name] = path.exists()
964
+ if path.exists():
965
+ shutil.copy2(path, managed / name)
966
+ atomic_write_json(managed / "instructions-presence.json", instruction_presence)
967
+ for name in ("strategies", "telemetry_core"):
968
+ path = _state_dir() / name
969
+ if path.exists():
970
+ shutil.copytree(path, managed / name)
971
+ bin_backup = backup / "bin"
972
+ bin_backup.mkdir()
973
+ for name in ("codex-flow", "codex-flow.ps1", "codex-flow.cmd"):
974
+ path = _bin_dir() / name
975
+ if path.exists():
976
+ shutil.copy2(path, bin_backup / name)
977
+ overlay_backup = backup / "overlay"
978
+ for name in ("FlowPilot", "codex-flow-overlay"):
979
+ path = _state_dir() / "bin" / name
980
+ if path.exists():
981
+ overlay_backup.mkdir(exist_ok=True)
982
+ shutil.copy2(path, overlay_backup / name)
983
+
984
+ user_backup = backup / "user-managed"
985
+ user_backup.mkdir()
986
+ live_targets = {
987
+ "hooks.json": _codex_home() / "hooks.json",
988
+ "worker-explorer.toml": _codex_home() / "agents" / "worker-explorer.toml",
989
+ "worker-implementer.toml": _codex_home() / "agents" / "worker-implementer.toml",
990
+ "worker-reviewer.toml": _codex_home() / "agents" / "worker-reviewer.toml",
991
+ "SKILL.md": _codex_home() / "skills" / "flow-pilot" / "SKILL.md",
992
+ "AGENTS.md": _codex_home() / "AGENTS.md",
993
+ "AGENTS.override.md": _codex_home() / "AGENTS.override.md",
994
+ "migrations.json": _migration_state_path(),
995
+ }
996
+ presence: dict[str, bool] = {}
997
+ symlinks: dict[str, str] = {}
998
+ for name, path in live_targets.items():
999
+ presence[name] = os.path.lexists(str(path))
1000
+ if path.is_symlink():
1001
+ symlinks[name] = os.readlink(str(path))
1002
+ elif path.exists():
1003
+ shutil.copy2(path, user_backup / name)
1004
+ atomic_write_json(user_backup / "presence.json", presence)
1005
+ atomic_write_json(user_backup / "symlinks.json", symlinks)
1006
+ return backup
1007
+
1008
+
1009
+ def _restore_snapshot(backup: Path) -> None:
1010
+ policy = backup / "policy"
1011
+ if policy.exists():
1012
+ _atomic_copy(policy, _policy_path())
1013
+ config = backup / "config"
1014
+ if config.exists():
1015
+ _atomic_copy(config, _codex_home() / "config.toml")
1016
+ for name in ("version", "source", "defaults"):
1017
+ src = backup / name
1018
+ dst_name = "defaults.toml" if name == "defaults" else name
1019
+ if src.exists():
1020
+ _atomic_copy(src, _state_dir() / dst_name)
1021
+ managed = backup / "managed"
1022
+ if managed.exists():
1023
+ instruction_presence = _read_json(managed / "instructions-presence.json", {})
1024
+ for path in managed.iterdir():
1025
+ if path.name == "instructions-presence.json":
1026
+ continue
1027
+ dst = _state_dir() / path.name
1028
+ if path.is_dir():
1029
+ _replace_dir(path, dst)
1030
+ else:
1031
+ _atomic_copy(path, dst, executable=path.suffix == ".py")
1032
+ if isinstance(instruction_presence, dict):
1033
+ for name, present in instruction_presence.items():
1034
+ if not present:
1035
+ with contextlib.suppress(FileNotFoundError):
1036
+ (_state_dir() / name).unlink()
1037
+ bin_backup = backup / "bin"
1038
+ if bin_backup.exists():
1039
+ for path in bin_backup.iterdir():
1040
+ _atomic_copy(path, _bin_dir() / path.name, executable=path.name == "codex-flow")
1041
+ overlay = backup / "overlay"
1042
+ if overlay.exists():
1043
+ for path in overlay.iterdir():
1044
+ _atomic_copy(path, _state_dir() / "bin" / path.name, executable=True)
1045
+
1046
+ user_backup = backup / "user-managed"
1047
+ if user_backup.exists():
1048
+ presence = _read_json(user_backup / "presence.json", {})
1049
+ live_targets = {
1050
+ "hooks.json": _codex_home() / "hooks.json",
1051
+ "worker-explorer.toml": _codex_home() / "agents" / "worker-explorer.toml",
1052
+ "worker-implementer.toml": _codex_home() / "agents" / "worker-implementer.toml",
1053
+ "worker-reviewer.toml": _codex_home() / "agents" / "worker-reviewer.toml",
1054
+ "SKILL.md": _codex_home() / "skills" / "flow-pilot" / "SKILL.md",
1055
+ "AGENTS.md": _codex_home() / "AGENTS.md",
1056
+ "AGENTS.override.md": _codex_home() / "AGENTS.override.md",
1057
+ "migrations.json": _migration_state_path(),
1058
+ }
1059
+ symlinks = _read_json(user_backup / "symlinks.json", {})
1060
+ for name, dst in live_targets.items():
1061
+ src = user_backup / name
1062
+ if isinstance(symlinks, dict) and name in symlinks:
1063
+ with contextlib.suppress(FileNotFoundError):
1064
+ dst.unlink()
1065
+ dst.parent.mkdir(parents=True, exist_ok=True)
1066
+ os.symlink(str(symlinks[name]), str(dst))
1067
+ elif src.exists():
1068
+ _atomic_copy(src, dst)
1069
+ elif isinstance(presence, dict) and presence.get(name) is False:
1070
+ with contextlib.suppress(FileNotFoundError):
1071
+ dst.unlink()
1072
+
1073
+
1074
+ def _read_policy_bool(section: str, key: str, fallback: bool) -> bool:
1075
+ try:
1076
+ text = _policy_path().read_text(encoding="utf-8-sig")
1077
+ except OSError:
1078
+ return fallback
1079
+ return _as_bool(_simple_toml_value(text, section, key), fallback)
1080
+
1081
+
1082
+ def _run_migrations(package_root: Path) -> list[str]:
1083
+ migrations_dir = package_root / "scripts" / "migrations"
1084
+ state = _read_json(_migration_state_path(), {"applied": []})
1085
+ applied = set(state.get("applied") or []) if isinstance(state, dict) else set()
1086
+ if not migrations_dir.exists():
1087
+ return sorted(applied)
1088
+ for script in sorted(migrations_dir.glob("*.py")):
1089
+ migration_id = script.stem
1090
+ if migration_id.startswith("_") or migration_id in applied:
1091
+ continue
1092
+ subprocess.run(
1093
+ [sys.executable, str(script), "--policy", str(_policy_path())],
1094
+ check=True,
1095
+ env=os.environ.copy(),
1096
+ )
1097
+ applied.add(migration_id)
1098
+ return sorted(applied)
1099
+
1100
+
1101
+ def _reconcile_runtime_config(package_root: Path) -> None:
1102
+ reconciler = package_root / "scripts" / "update_runtime_config.py"
1103
+ defaults = package_root / "policy" / "defaults.toml"
1104
+ if not reconciler.exists():
1105
+ raise RuntimeError("release package is missing runtime config reconciler")
1106
+ if not defaults.exists():
1107
+ raise RuntimeError("release package is missing policy defaults")
1108
+ subprocess.run(
1109
+ [
1110
+ sys.executable,
1111
+ str(reconciler),
1112
+ "--config",
1113
+ str(_codex_home() / "config.toml"),
1114
+ "--policy",
1115
+ str(_policy_path()),
1116
+ "--defaults",
1117
+ str(defaults),
1118
+ ],
1119
+ check=True,
1120
+ env=os.environ.copy(),
1121
+ )
1122
+
1123
+
1124
+ def _sync_managed_runtime(package_root: Path) -> None:
1125
+ state = _state_dir()
1126
+ state.mkdir(parents=True, exist_ok=True)
1127
+ scripts = package_root / "scripts"
1128
+ for name in (
1129
+ "updater.py",
1130
+ "update_runtime_config.py",
1131
+ "telemetry.py",
1132
+ "manage-hooks.py",
1133
+ "manage-instructions.py",
1134
+ "menu.py",
1135
+ "localization.py",
1136
+ "ui.py",
1137
+ "doctor.py",
1138
+ "strategy_runtime.py",
1139
+ ):
1140
+ src = scripts / name
1141
+ if src.exists():
1142
+ _atomic_copy(src, state / name, executable=True)
1143
+ for name in ("strategies", "telemetry_core"):
1144
+ src = scripts / name
1145
+ if src.exists():
1146
+ _replace_dir(src, state / name)
1147
+ defaults = package_root / "policy" / "defaults.toml"
1148
+ if defaults.exists():
1149
+ _atomic_copy(defaults, state / "defaults.toml")
1150
+
1151
+ agents_dir = _codex_home() / "agents"
1152
+ agents_dir.mkdir(parents=True, exist_ok=True)
1153
+ for name in ("worker-explorer.toml", "worker-implementer.toml", "worker-reviewer.toml"):
1154
+ src = package_root / "templates" / "agents" / name
1155
+ if src.exists():
1156
+ _atomic_copy(src, agents_dir / name)
1157
+ skill_src = package_root / "templates" / "skills" / "flow-pilot" / "SKILL.md"
1158
+ if skill_src.exists():
1159
+ _atomic_copy(skill_src, _codex_home() / "skills" / "flow-pilot" / "SKILL.md")
1160
+ instruction_helper = state / "manage-instructions.py"
1161
+ instruction_template = state / "flow-pilot-instructions.md"
1162
+ template_src = package_root / "templates" / "flow-pilot-instructions.md"
1163
+ if template_src.exists():
1164
+ _atomic_copy(template_src, instruction_template)
1165
+ if instruction_helper.exists() and instruction_template.exists():
1166
+ subprocess.run(
1167
+ [
1168
+ sys.executable,
1169
+ str(instruction_helper),
1170
+ "--codex-home",
1171
+ str(_codex_home()),
1172
+ "--template",
1173
+ str(instruction_template),
1174
+ "install",
1175
+ ],
1176
+ check=True,
1177
+ env=os.environ.copy(),
1178
+ )
1179
+
1180
+ bin_dir = _bin_dir()
1181
+ bin_dir.mkdir(parents=True, exist_ok=True)
1182
+ if os.name == "nt":
1183
+ for name in ("codex-flow.ps1", "codex-flow.cmd"):
1184
+ src = package_root / "bin" / name
1185
+ if src.exists():
1186
+ _atomic_copy(src, bin_dir / name)
1187
+ else:
1188
+ src = package_root / "bin" / "codex-flow"
1189
+ if src.exists():
1190
+ _atomic_copy(src, bin_dir / "codex-flow", executable=True)
1191
+
1192
+ if platform.system().lower() == "darwin":
1193
+ for name in ("FlowPilot", "codex-flow-overlay"):
1194
+ src = package_root / "apps" / "macos-overlay" / "bin" / name
1195
+ if src.exists():
1196
+ _atomic_copy(src, state / "bin" / name, executable=True)
1197
+
1198
+ # Hooks keep their stable state-dir target, so replacing telemetry.py does not
1199
+ # require re-authorization. Reconcile both enabled and disabled states so a
1200
+ # previously-installed managed hook cannot survive after telemetry is disabled.
1201
+ manage_hooks = state / "manage-hooks.py"
1202
+ if manage_hooks.exists():
1203
+ hooks = _codex_home() / "hooks.json"
1204
+ if _read_policy_bool("telemetry", "enabled", True):
1205
+ command = [
1206
+ sys.executable,
1207
+ str(manage_hooks),
1208
+ "install",
1209
+ "--hooks",
1210
+ str(hooks),
1211
+ "--script",
1212
+ str(state / "telemetry.py"),
1213
+ ]
1214
+ else:
1215
+ command = [sys.executable, str(manage_hooks), "uninstall", "--hooks", str(hooks)]
1216
+ subprocess.run(command, check=True)
1217
+
1218
+
1219
+ def _run_health_check() -> None:
1220
+ doctor = _state_dir() / "doctor.py"
1221
+ if doctor.exists() and os.environ.get("CODEX_FLOW_UPDATE_SKIP_DOCTOR") != "1":
1222
+ subprocess.run([sys.executable, str(doctor)], check=True)
1223
+
1224
+
1225
+ def _history() -> list[dict[str, Any]]:
1226
+ payload = _read_json(_history_path(), [])
1227
+ return payload if isinstance(payload, list) else []
1228
+
1229
+
1230
+ def _append_history(entry: dict[str, Any]) -> None:
1231
+ history = _history()
1232
+ history.append(entry)
1233
+ retained = history[-20:]
1234
+ atomic_write_json(_history_path(), retained)
1235
+ referenced = {str(item.get("backup")) for item in retained if item.get("backup")}
1236
+ backup_root = _state_dir() / "backups"
1237
+ if backup_root.exists():
1238
+ for candidate in backup_root.iterdir():
1239
+ if candidate.is_dir() and str(candidate) not in referenced:
1240
+ shutil.rmtree(candidate, ignore_errors=True)
1241
+
1242
+
1243
+ def _ensure_current_version_package(version: str) -> Path | None:
1244
+ """Capture the pre-OTA installation so the first OTA update can roll back."""
1245
+
1246
+ versions = _state_dir() / "versions"
1247
+ versions.mkdir(parents=True, exist_ok=True)
1248
+ target = versions / version
1249
+ if target.exists():
1250
+ return target
1251
+ staging = versions / f".{version}.bootstrap-{os.getpid()}"
1252
+ shutil.rmtree(staging, ignore_errors=True)
1253
+ staging.mkdir(parents=True)
1254
+ source = None
1255
+ source_file = _state_dir() / "source"
1256
+ with contextlib.suppress(OSError):
1257
+ candidate = Path(source_file.read_text(encoding="utf-8-sig").strip())
1258
+ if candidate.exists() and (candidate / "VERSION").exists():
1259
+ source = candidate
1260
+ try:
1261
+ if source is not None:
1262
+ package_items = [
1263
+ "VERSION",
1264
+ "LICENSE",
1265
+ "README.md",
1266
+ "README.en.md",
1267
+ "install.sh",
1268
+ "install.ps1",
1269
+ "bin",
1270
+ "scripts",
1271
+ "templates",
1272
+ "completions",
1273
+ "policy",
1274
+ "benchmark",
1275
+ "apps/chatgpt-mcp",
1276
+ ]
1277
+ if platform.system().lower() == "darwin":
1278
+ package_items.append("apps/macos-overlay")
1279
+ for name in package_items:
1280
+ src = source / name
1281
+ dst = staging / name
1282
+ if src.is_dir():
1283
+ shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
1284
+ elif src.is_file():
1285
+ dst.parent.mkdir(parents=True, exist_ok=True)
1286
+ shutil.copy2(src, dst)
1287
+ else:
1288
+ (staging / "VERSION").write_text(version + "\n", encoding="utf-8")
1289
+ scripts = staging / "scripts"
1290
+ scripts.mkdir(parents=True)
1291
+ for name in ("updater.py", "update_runtime_config.py", "telemetry.py", "manage-hooks.py", "manage-instructions.py", "menu.py", "localization.py", "ui.py", "doctor.py", "strategy_runtime.py"):
1292
+ src = _state_dir() / name
1293
+ if src.exists():
1294
+ shutil.copy2(src, scripts / name)
1295
+ for name in ("strategies", "telemetry_core"):
1296
+ src = _state_dir() / name
1297
+ if src.exists():
1298
+ shutil.copytree(src, scripts / name)
1299
+ (staging / "bin").mkdir()
1300
+ for name in ("codex-flow", "codex-flow.ps1", "codex-flow.cmd"):
1301
+ src = _bin_dir() / name
1302
+ if src.exists():
1303
+ shutil.copy2(src, staging / "bin" / name)
1304
+ defaults = _state_dir() / "defaults.toml"
1305
+ if defaults.exists():
1306
+ (staging / "policy").mkdir()
1307
+ shutil.copy2(defaults, staging / "policy" / "defaults.toml")
1308
+ instruction_template = _state_dir() / "flow-pilot-instructions.md"
1309
+ if instruction_template.exists():
1310
+ (staging / "templates").mkdir()
1311
+ shutil.copy2(instruction_template, staging / "templates" / "flow-pilot-instructions.md")
1312
+ for name in ("FlowPilot", "codex-flow-overlay"):
1313
+ src = _state_dir() / "bin" / name
1314
+ if src.exists():
1315
+ dst = staging / "apps" / "macos-overlay" / "bin" / name
1316
+ dst.parent.mkdir(parents=True, exist_ok=True)
1317
+ shutil.copy2(src, dst)
1318
+ os.replace(staging, target)
1319
+ return target
1320
+ except Exception:
1321
+ shutil.rmtree(staging, ignore_errors=True)
1322
+ return None
1323
+
1324
+
1325
+ def _install_package(package_root: Path, version: str, manifest: dict[str, Any]) -> UpdateState:
1326
+ state_dir = _state_dir()
1327
+ versions = state_dir / "versions"
1328
+ versions.mkdir(parents=True, exist_ok=True)
1329
+ target_version = versions / version
1330
+ current = current_version()
1331
+ _ensure_current_version_package(current)
1332
+ backup = _snapshot(current)
1333
+ previous_source = None
1334
+ source_path = state_dir / "source"
1335
+ with contextlib.suppress(OSError):
1336
+ previous_source = source_path.read_text(encoding="utf-8-sig").strip()
1337
+
1338
+ staged_version = versions / f".{version}.staging-{os.getpid()}"
1339
+ shutil.rmtree(staged_version, ignore_errors=True)
1340
+ shutil.copytree(package_root, staged_version)
1341
+ try:
1342
+ applied_migrations = _run_migrations(staged_version)
1343
+ _reconcile_runtime_config(staged_version)
1344
+ _sync_managed_runtime(staged_version)
1345
+ _run_health_check()
1346
+ atomic_write_json(_migration_state_path(), {"applied": applied_migrations})
1347
+ if target_version.exists():
1348
+ shutil.rmtree(target_version)
1349
+ os.replace(staged_version, target_version)
1350
+ atomic_write_text(state_dir / "previous-version", current + "\n")
1351
+ atomic_write_text(state_dir / "version", version + "\n")
1352
+ atomic_write_text(state_dir / "current-version", version + "\n")
1353
+ atomic_write_text(state_dir / "source", str(target_version) + "\n")
1354
+ state = load_state()
1355
+ state.current_version = version
1356
+ state.latest_version = _strip_v(str(manifest.get("version") or version))
1357
+ state.previous_version = current
1358
+ state.update_available = is_newer(state.latest_version, version)
1359
+ state.status = "available" if state.update_available else "latest"
1360
+ restart_req, pending_pids, restart_reason = _resolve_codex_restart_requirement(backup, manifest)
1361
+ state.restart_required = restart_req
1362
+ state.pending_codex_pids = pending_pids
1363
+ state.restart_reason = restart_reason
1364
+ state.flowpilot_restart_required = (
1365
+ platform.system().lower() == "darwin"
1366
+ and any((target_version / "apps" / "macos-overlay" / "bin" / name).exists() for name in ("FlowPilot", "codex-flow-overlay"))
1367
+ )
1368
+ state.installed_at = utc_now()
1369
+ state.last_error = None
1370
+ state.progress = 1.0
1371
+ save_state(state)
1372
+ _append_history(
1373
+ {
1374
+ "from": current,
1375
+ "to": version,
1376
+ "installed_at": state.installed_at,
1377
+ "backup": str(backup),
1378
+ "previous_source": previous_source,
1379
+ }
1380
+ )
1381
+ return state
1382
+ except Exception:
1383
+ _restore_snapshot(backup)
1384
+ shutil.rmtree(backup, ignore_errors=True)
1385
+ raise
1386
+ finally:
1387
+ shutil.rmtree(staged_version, ignore_errors=True)
1388
+
1389
+
1390
+ def _perform_update_unlocked(*, force_check: bool = True) -> UpdateState:
1391
+ state = check_for_updates(force=force_check)
1392
+ manifest = state.extra.get("manifest") if isinstance(state.extra, dict) else None
1393
+ if not state.update_available:
1394
+ return state
1395
+ if not isinstance(manifest, dict):
1396
+ raise RuntimeError("no installable update manifest is cached")
1397
+ artifact = _artifact_for_platform(manifest)
1398
+ if not artifact:
1399
+ raise RuntimeError(f"release does not provide an artifact for {platform_key()}")
1400
+ url = str(artifact.get("url") or "")
1401
+ expected = str(artifact.get("sha256") or "").lower()
1402
+ if not url or not re.fullmatch(r"[0-9a-f]{64}", expected):
1403
+ raise RuntimeError("release artifact metadata is incomplete")
1404
+
1405
+ version = _strip_v(str(manifest["version"]))
1406
+ state.status = "downloading"
1407
+ state.progress = 0.0
1408
+ save_state(state)
1409
+ with tempfile.TemporaryDirectory(prefix="codex-flow-update-") as tmp_value:
1410
+ tmp = Path(tmp_value)
1411
+ suffix = ".zip" if url.lower().endswith(".zip") else ".tar.gz"
1412
+ archive = tmp / f"release{suffix}"
1413
+ _download(url, archive)
1414
+ state.progress = 0.55
1415
+ save_state(state)
1416
+ actual = _sha256(archive)
1417
+ if actual.lower() != expected:
1418
+ raise RuntimeError(f"checksum mismatch for update artifact: expected {expected}, got {actual}")
1419
+ extract = tmp / "extract"
1420
+ safe_extract(archive, extract)
1421
+ root = _package_root(extract)
1422
+ package_version = _strip_v((root / "VERSION").read_text(encoding="utf-8-sig").strip())
1423
+ if package_version != version:
1424
+ raise RuntimeError(f"artifact version {package_version} does not match manifest {version}")
1425
+ state.status = "installing"
1426
+ state.progress = 0.8
1427
+ save_state(state)
1428
+ return _install_package(root, version, manifest)
1429
+
1430
+
1431
+ def perform_update(*, force_check: bool = True) -> UpdateState:
1432
+ with install_lock():
1433
+ return _perform_update_unlocked(force_check=force_check)
1434
+
1435
+
1436
+ def _rollback_unlocked() -> UpdateState:
1437
+ state_dir = _state_dir()
1438
+ current = current_version()
1439
+ previous_path = state_dir / "previous-version"
1440
+ if not previous_path.exists():
1441
+ raise RuntimeError("no previous codex-flow version is available for rollback")
1442
+ previous = _strip_v(previous_path.read_text(encoding="utf-8-sig").strip())
1443
+ history_entry = next(
1444
+ (entry for entry in reversed(_history()) if entry.get("from") == previous and entry.get("to") == current and entry.get("backup")),
1445
+ None,
1446
+ )
1447
+ historical_backup = Path(str(history_entry["backup"])) if history_entry else None
1448
+ has_exact_snapshot = bool(historical_backup and historical_backup.exists())
1449
+ package_root = state_dir / "versions" / previous
1450
+ if not has_exact_snapshot and not package_root.exists():
1451
+ raise RuntimeError(f"rollback package is missing: {package_root}")
1452
+ backup = _snapshot(current)
1453
+ try:
1454
+ if has_exact_snapshot:
1455
+ assert historical_backup is not None
1456
+ _restore_snapshot(historical_backup)
1457
+ else:
1458
+ _reconcile_runtime_config(package_root)
1459
+ _sync_managed_runtime(package_root)
1460
+ _run_health_check()
1461
+ atomic_write_text(state_dir / "version", previous + "\n")
1462
+ atomic_write_text(state_dir / "current-version", previous + "\n")
1463
+ if not has_exact_snapshot:
1464
+ atomic_write_text(state_dir / "source", str(package_root) + "\n")
1465
+ atomic_write_text(state_dir / "previous-version", current + "\n")
1466
+ state = load_state()
1467
+ state.current_version = previous
1468
+ state.previous_version = current
1469
+ state.update_available = bool(state.latest_version and is_newer(state.latest_version, previous))
1470
+ state.status = "available" if state.update_available else "latest"
1471
+ restart_req, pending_pids, restart_reason = _resolve_codex_restart_requirement(backup)
1472
+ state.restart_required = restart_req
1473
+ state.pending_codex_pids = pending_pids
1474
+ state.restart_reason = restart_reason
1475
+ state.flowpilot_restart_required = (
1476
+ platform.system().lower() == "darwin"
1477
+ and any((_state_dir() / "bin" / name).exists() for name in ("FlowPilot", "codex-flow-overlay"))
1478
+ )
1479
+ state.last_error = None
1480
+ save_state(state)
1481
+ _append_history({"from": current, "to": previous, "rollback": True, "installed_at": utc_now(), "backup": str(backup)})
1482
+ return state
1483
+ except Exception:
1484
+ _restore_snapshot(backup)
1485
+ shutil.rmtree(backup, ignore_errors=True)
1486
+ raise
1487
+
1488
+
1489
+ def rollback() -> UpdateState:
1490
+ with install_lock():
1491
+ return _rollback_unlocked()
1492
+
1493
+
1494
+ def _legacy_git_update() -> int:
1495
+ """Compatibility bridge for pre-OTA releases and source installs.
1496
+
1497
+ Once a release ships a manifest/artifact this path is not used. Keeping it
1498
+ temporarily means existing users and old smoke tests still have a recovery
1499
+ path while the release channel is being bootstrapped.
1500
+ """
1501
+
1502
+ source_file = _state_dir() / "source"
1503
+ try:
1504
+ src = Path(source_file.read_text(encoding="utf-8-sig").strip())
1505
+ except OSError:
1506
+ return 2
1507
+ if not (src / ".git").exists():
1508
+ return 2
1509
+ try:
1510
+ branch = subprocess.check_output(
1511
+ ["git", "-C", str(src), "rev-parse", "--abbrev-ref", "HEAD"],
1512
+ text=True,
1513
+ stderr=subprocess.DEVNULL,
1514
+ ).strip()
1515
+ if branch and branch != "HEAD":
1516
+ remote = subprocess.run(
1517
+ ["git", "-C", str(src), "config", "--get", f"branch.{branch}.remote"],
1518
+ check=False,
1519
+ stdout=subprocess.PIPE,
1520
+ stderr=subprocess.DEVNULL,
1521
+ text=True,
1522
+ ).stdout.strip() or "origin"
1523
+ subprocess.run(["git", "-C", str(src), "pull", "--ff-only", remote, branch], check=True)
1524
+ if os.name == "nt":
1525
+ subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(src / "install.ps1")], check=True)
1526
+ else:
1527
+ subprocess.run(["bash", str(src / "install.sh")], check=True)
1528
+ return 0
1529
+ except (OSError, subprocess.CalledProcessError):
1530
+ return 2
1531
+
1532
+
1533
+ def _legacy_fallback_allowed(exc: Exception) -> bool:
1534
+ # Legacy git update exists only to bootstrap users while the first OTA-aware
1535
+ # release is rolling out. Never bypass checksum, transaction, concurrency,
1536
+ # migration, or health-check failures with an unverified git pull.
1537
+ message = str(exc)
1538
+ return MANIFEST_ASSET in message and "does not provide" in message
1539
+
1540
+
1541
+ def _print_status(state: UpdateState, as_json: bool = False) -> None:
1542
+ if as_json:
1543
+ print(json.dumps(state.to_mapping(), ensure_ascii=False, indent=2, sort_keys=True))
1544
+ return
1545
+ if state.flowpilot_restart_required and state.restart_required:
1546
+ print(f"✓ codex-flow v{state.current_version} installed; restart FlowPilot and Codex to activate all updated components.")
1547
+ elif state.flowpilot_restart_required:
1548
+ print(f"✓ codex-flow v{state.current_version} installed; restart FlowPilot to load the updated app binary.")
1549
+ elif state.restart_required:
1550
+ suffix = f" ({state.restart_reason})" if state.restart_reason else ""
1551
+ print(f"✓ codex-flow v{state.current_version} installed; restart Codex to activate updated policy/hooks snapshots.{suffix}")
1552
+ elif state.update_available and state.latest_version:
1553
+ suffix = "" if state.artifact_available else " (release package not available yet)"
1554
+ print(f"↑ codex-flow v{state.latest_version} is available; current v{state.current_version}{suffix}")
1555
+ elif state.status == "latest":
1556
+ print(f"✓ codex-flow v{state.current_version} is up to date.")
1557
+ elif state.last_error:
1558
+ print(f"! Update check unavailable: {state.last_error}")
1559
+ else:
1560
+ print(f"codex-flow v{state.current_version}: update status unknown")
1561
+
1562
+
1563
+ def build_parser() -> argparse.ArgumentParser:
1564
+ parser = argparse.ArgumentParser(description="codex-flow OTA updater")
1565
+ parser.add_argument("command", nargs="?", choices=("rollback", "auto-install", "auto-update"))
1566
+ parser.add_argument(
1567
+ "action",
1568
+ nargs="?",
1569
+ choices=("status", "enable", "disable", "on", "off"),
1570
+ default=None,
1571
+ help="action for auto-install command: status|enable|disable|on|off",
1572
+ )
1573
+ parser.add_argument("--check", action="store_true", help="check only; never install")
1574
+ parser.add_argument("--auto", action="store_true", help="perform auto-update when enabled in policy")
1575
+ parser.add_argument("--force", action="store_true", help="ignore cached check interval")
1576
+ parser.add_argument("--quiet", action="store_true")
1577
+ parser.add_argument("--json", action="store_true")
1578
+ parser.add_argument(
1579
+ "--ack-restart",
1580
+ action="store_true",
1581
+ help="clear Codex restart reminder after Codex has been fully restarted",
1582
+ )
1583
+ parser.add_argument(
1584
+ "--ack-flowpilot-restart",
1585
+ action="store_true",
1586
+ help="clear FlowPilot restart reminder after the updated FlowPilot binary has launched",
1587
+ )
1588
+ parser.add_argument("--no-legacy-fallback", action="store_true")
1589
+ return parser
1590
+
1591
+
1592
+ def main(argv: Iterable[str] | None = None) -> int:
1593
+ args = build_parser().parse_args(list(argv) if argv is not None else None)
1594
+ _update_dir().mkdir(parents=True, exist_ok=True)
1595
+ if args.ack_restart:
1596
+ state = load_state()
1597
+ state.restart_required = False
1598
+ state.pending_codex_pids = []
1599
+ state.restart_reason = None
1600
+ save_state(state)
1601
+ if not args.quiet:
1602
+ print("✓ Codex restart reminder cleared.")
1603
+ return 0
1604
+ if args.ack_flowpilot_restart:
1605
+ state = load_state()
1606
+ state.flowpilot_restart_required = False
1607
+ save_state(state)
1608
+ if not args.quiet:
1609
+ print("✓ FlowPilot restart reminder cleared.")
1610
+ return 0
1611
+ if args.command in {"auto-install", "auto-update"}:
1612
+ action = (args.action or "status").lower()
1613
+ policy = _policy_path()
1614
+ if action in {"enable", "on"}:
1615
+ set_policy_value(policy, "update", "auto_install", "true", quote=False)
1616
+ state = load_state()
1617
+ state.auto_install = True
1618
+ save_state(state)
1619
+ if not args.quiet:
1620
+ if args.json:
1621
+ print(json.dumps({"auto_install": True}, ensure_ascii=False))
1622
+ else:
1623
+ print("✓ Auto-update enabled.")
1624
+ return 0
1625
+ if action in {"disable", "off"}:
1626
+ set_policy_value(policy, "update", "auto_install", "false", quote=False)
1627
+ state = load_state()
1628
+ state.auto_install = False
1629
+ save_state(state)
1630
+ if not args.quiet:
1631
+ if args.json:
1632
+ print(json.dumps({"auto_install": False}, ensure_ascii=False))
1633
+ else:
1634
+ print("○ Auto-update disabled.")
1635
+ return 0
1636
+ config = load_config()
1637
+ if args.json:
1638
+ print(json.dumps({"auto_install": config.auto_install}, ensure_ascii=False))
1639
+ else:
1640
+ print(f"auto_install = {'true' if config.auto_install else 'false'}")
1641
+ return 0
1642
+
1643
+ if args.command == "rollback":
1644
+ try:
1645
+ state = rollback()
1646
+ if not args.quiet:
1647
+ print(f"↩ Rolled back codex-flow to v{state.current_version}.")
1648
+ if state.flowpilot_restart_required:
1649
+ print("⚠ Restart FlowPilot to load the rolled-back app binary.")
1650
+ if state.restart_required:
1651
+ print("⚠ Restart Codex to activate rolled-back policy/hooks snapshots.")
1652
+ return 0
1653
+ except Exception as exc:
1654
+ print(f"codex-flow rollback failed: {exc}", file=sys.stderr)
1655
+ return 1
1656
+
1657
+ if args.auto:
1658
+ config = load_config()
1659
+ if not config.auto_install:
1660
+ return 0
1661
+ with update_lock() as acquired:
1662
+ if not acquired:
1663
+ return 0
1664
+ state = check_for_updates(force=args.force, quiet=args.quiet)
1665
+ if state.update_available and state.artifact_available:
1666
+ state = perform_update(force_check=False)
1667
+ if not args.quiet:
1668
+ print(f"✨ Updated codex-flow to v{state.current_version}.")
1669
+ if state.flowpilot_restart_required:
1670
+ print("⚠ Restart FlowPilot to load the updated app binary.")
1671
+ if state.restart_required:
1672
+ print("⚠ Restart Codex to activate updated FlowPilot policy/hooks snapshots.")
1673
+ return 0
1674
+
1675
+ if args.check:
1676
+ with update_lock() as acquired:
1677
+ state = check_for_updates(force=args.force, quiet=args.quiet) if acquired else load_state()
1678
+ if not args.quiet:
1679
+ _print_status(state, as_json=args.json)
1680
+ return 0 if not state.last_error or state.latest_version else 1
1681
+
1682
+ try:
1683
+ state = check_for_updates(force=True)
1684
+ if state.update_available and state.artifact_available:
1685
+ state = perform_update(force_check=False)
1686
+ if not args.quiet:
1687
+ print(f"✨ Updated codex-flow to v{state.current_version}.")
1688
+ if state.flowpilot_restart_required:
1689
+ print("⚠ Restart FlowPilot to load the updated app binary.")
1690
+ if state.restart_required:
1691
+ print("⚠ Restart Codex to activate updated FlowPilot policy/hooks snapshots.")
1692
+ return 0
1693
+ if state.update_available and not state.artifact_available:
1694
+ raise RuntimeError("new release detected but no installable artifact is available")
1695
+ if state.status == "latest":
1696
+ if not args.quiet:
1697
+ _print_status(state, as_json=args.json)
1698
+ return 0
1699
+ if state.last_error:
1700
+ raise RuntimeError(state.last_error)
1701
+ if not args.quiet:
1702
+ _print_status(state, as_json=args.json)
1703
+ return 0
1704
+ except Exception as exc:
1705
+ if (
1706
+ not args.no_legacy_fallback
1707
+ and _legacy_fallback_allowed(exc)
1708
+ and _legacy_git_update() == 0
1709
+ ):
1710
+ if not args.quiet:
1711
+ print("✓ Updated using the legacy source checkout fallback. Future release packages will use OTA automatically.")
1712
+ return 0
1713
+ print(f"codex-flow update failed: {exc}", file=sys.stderr)
1714
+ return 1
1715
+
1716
+
1717
+ if __name__ == "__main__":
1718
+ raise SystemExit(main())