From 53cdded57ce8e18d344a89ef03883b78152184ed Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:09:48 +0800 Subject: [PATCH 01/10] redesign README for one-click installation --- README.md | 182 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 96 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 1c649ef..9daad62 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,128 @@ -# Codex 一键安装生态 +# Uni-codex -本仓库提供 macOS 与 Windows 的 Codex/Codex++ 部署工具,并连接 Uni-Scholar 科研生态。 +
-## 生态优势:连续科研工作流 +**给普通用户准备的 Codex + Codex++ 一键安装器** -- [Uni-Scholar 云端工作站](https://uni-scholar.asia):云端检索、协作与长流程科研任务。 -- [Research Kit 本地中枢](https://uni-scholar.asia/research-kit):连接 Zotero、Obsidian 与本地知识库。 -- Codex/Codex++ 执行层:完成代码、文档、数据处理与自动化。 +不用 Git,不用命令行,不用配置开发环境。下载、双击,跟着提示完成安装。 -三层共同构成从知识沉淀、研究推理到执行交付的连续科研工作流。Research Kit 当前为 macOS 生态产品;Windows 用户可把它作为生态链接和可选配套,但它不随 Windows 安装包预装。 +[![Windows](https://img.shields.io/badge/Windows-下载安装包-0078D4?style=for-the-badge&logo=windows11&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-Windows-x64-Online-Setup.exe) +[![macOS](https://img.shields.io/badge/macOS-下载安装包-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-macOS-Online.dmg) -## 在线精简版 Release +[查看最新版本](https://github.com/fancr-code/Uni-codex/releases/latest) · [问题反馈](https://github.com/fancr-code/Uni-codex/issues) -Release 提供 Windows x64 与 macOS 在线精简安装包。安装包不镜像、不再分发官方 Codex 桌面应用:Windows 安装时通过 Microsoft Store 产品 `9PLM9XGG6VKS` 获取,macOS 安装时从 OpenAI 官方 `persistent.oaistatic.com` 获取。Codex++ 从其 GitHub Release 获取或随 Windows 引导器集成。 +
-- `Uni-codex-Windows-x64-Online-Setup.exe` -- `Uni-codex-macOS-Online.dmg` +## 为什么用 Uni-codex? -构建入口为 `.github/workflows/online-release.yml`。推送形如 `v1.0.0-online` 的标签或手动运行工作流即可创建 Release。安装过程需要网络。 +官方 Codex 很强,但第一次安装、寻找正确版本、安装 Codex++,对新手并不直观。Uni-codex 把这些步骤放进一个图形化安装包里。 -本仓库不公开镜像分发官方 Codex 桌面应用、Microsoft Store 包或未声明许可证的脚本市场源码。最低 Windows 系统仍为 Windows 10 版本 1809(Build 17763),仅支持 x64。 +| 自己配置 | 使用 Uni-codex | +| --- | --- | +| 查找适合系统的 Codex 下载来源 | 自动获取对应平台的官方 Codex | +| 手动下载并安装 Codex++ | 安装器统一处理 | +| 分辨 Windows、Apple Silicon、Intel 版本 | 自动识别或提供正确入口 | +| 阅读多份安装说明 | 跟着安装向导操作即可 | +| 容易重复安装或覆盖已有版本 | 优先复用已安装且可用的 Codex | -完整说明见 [Windows 文档](windows/README.md)。 +> Uni-codex 是轻量的**在线安装器**:安装时从官方来源获取最新组件,因此安装包体积小,使用时需要联网。 -## Windows 能力 +## 一键安装 -默认纯 API,支持 DeepSeek、Kimi 开放平台、Kimi Code、智谱 GLM、阿里千问、Xiaomi MiMo。安装器内提供各服务商官方 API Key 申请入口,并优先刷新上游模型;断网或刷新失败时回退到随包校验的离线快照,Kimi 开放平台快照包含 Kimi K3。 +### Windows -推荐“OpenAI 账号 + 所选国产 API”:可在安装器内点击“获取 OpenAI 授权”,但实际模型调用仍由所选服务商完成。OpenAI 授权不等于 GPT API Key,不赠送 GPT API 额度,也不会获得账号未拥有的权限。 +支持 **Windows 10 1809 及以上版本、Windows 11(x64)**。 -安装器会复用健康 Codex,不重复安装或降级。Codex++ 带有 `cross-provider-content-v1`;同时配置 3 个插件市场和 9 个插件。用户可从作者的上游地址直接获取 `Context Used Meter` 与 `Codex Token Usage`;本公开仓库不镜像这些未声明许可证的脚本源码。API Key、OAuth Token、设备码、对话正文与敏感报告字段都会被排除或脱敏。 +1. 下载 [Windows 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-Windows-x64-Online-Setup.exe)。 +2. 双击 `Uni-codex-Windows-x64-Online-Setup.exe`。 +3. 按安装向导提示完成安装。 -## macOS +安装器会通过微软官方渠道获取 Codex,并完成 Codex++ 的安装或集成。 -macOS 安装器支持 Apple Silicon 与 Intel。构建入口: +### macOS -```bash -bash build-codex-one-click-installer.sh -``` +支持 **Apple Silicon(M 系列)和 Intel Mac**。 -macOS 同样默认使用所选 API 服务商,可选 OpenAI 账号授权,并复用健康应用。具体构建、签名、硬件烟测和分发边界以仓库内 macOS 脚本及安装包引导为准。 +1. 下载 [macOS 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-macOS-Online.dmg)。 +2. 打开 `Uni-codex-macOS-Online.dmg`。 +3. 按窗口中的说明完成安装。 -### 开发构建与测试 +安装器会识别 Mac 的芯片类型,从 OpenAI 官方地址获取适合的 Codex,并安装 Codex++。 -```bash -# macOS 构建与测试 -bash build-codex-one-click-installer.sh -bash tests/run-all-tests.sh -``` +## 你会得到什么? -```powershell -# Windows 开发构建与测试(从仓库根目录,在 Windows x64) -# 前置:.NET 8 SDK、Node.js 22、Rust stable、NSIS、Inno Setup 7.0.2, -# 并确保 dotnet、node、cargo、makensis、ISCC.exe 均在 PATH。 -# StorePackageResolver 会按 ProductId 从微软 DisplayCatalog/FE3 解析当前官方 MSIX; -# 构建不会信任会变化为联网引导 EXE 的 get.microsoft.com 下载响应。 -dotnet restore windows/CodexOneClickInstaller.sln --locked-mode -$compat = 'windows/build/codex-plus-compat' -pwsh windows/scripts/build-codex-plus-compatibility-payload.ps1 ` - -Tag v1.2.43 ` - -Patch patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch ` - -OutputRoot $compat - -pwsh windows/scripts/refresh-offline-payloads.ps1 ` - -OutputRoot windows/vendor/offline-payloads ` - -CodexPlusPlusSetup "$compat/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe" ` - -CodexPlusPlusSource "$compat/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz" - -. windows/scripts/offline-payload-supply.ps1 -$active = Resolve-ActivePayloadRoot ( - [IO.Path]::GetFullPath('windows/vendor/offline-payloads')) -$iscc = (Get-Command ISCC.exe -ErrorAction Stop).Source - -pwsh windows/tests/run-all-tests.ps1 ` - -PayloadRoot $active ` - -BuiltRoot $compat ` - -TestResultsRoot windows/test-results/full -pwsh windows/scripts/build-installer.ps1 ` - -PayloadRoot $active -OutputDir dist -IsccPath $iscc -``` +- **Codex 桌面版**:由官方来源下载,不在本仓库重复打包。 +- **Codex++**:随引导流程安装,减少手动配置步骤。 +- **跨平台支持**:一个项目同时覆盖 Windows 和 macOS。 +- **已有安装保护**:检测到健康的 Codex 时优先复用,避免无意义地重复安装或降级。 +- **更适合中文用户**:下载入口、安装说明和常见问题集中在同一页面。 + +## 可选模型与科研生态 + +Windows 配置工具支持 DeepSeek、Kimi 开放平台、Kimi Code、智谱 GLM、阿里千问和 Xiaomi MiMo 等 API 服务商,并提供相应的官方 API Key 申请入口。 + +Uni-codex 也可以配合以下工具形成连续科研工作流: + +- [Uni-Scholar 云端工作站](https://uni-scholar.asia):检索、协作与长流程科研任务。 +- [Research Kit](https://uni-scholar.asia/research-kit):连接 Zotero、Obsidian 与本地知识库,目前主要面向 macOS。 +- Codex / Codex++:执行代码、文档、数据处理与自动化任务。 + +这些功能均为可选项。只想安装 Codex 的用户,直接使用上方安装包即可。 + +## 常见问题 + +### 为什么安装包这么小? + +因为它是在线安装器,不把体积较大的官方 Codex 应用重复塞进仓库。运行安装器后,才会从 OpenAI、Microsoft 和相关项目的官方地址下载所需组件。 + +### 安装时必须联网吗? + +是。下载 Codex、Codex++,以及刷新上游模型信息都需要网络。 -### 项目结构 +### Release 里的 Source code 是安装包吗? + +不是。`Source code (zip)` 和 `Source code (tar.gz)` 是 GitHub 为每个版本标签自动生成的源码快照。普通用户只需下载 `.exe` 或 `.dmg`。 + +### OpenAI 授权等于 API Key 吗? + +不等于。OpenAI 账号授权不会赠送 GPT API 额度,也不会获得账号原本没有的权限。选择第三方 API 服务商时,实际模型调用及费用由相应服务商负责。 + +### 这是 OpenAI 官方项目吗? + +不是。Uni-codex 是社区项目,与 OpenAI 没有隶属关系。Codex 的名称、商标和官方应用归其各自权利人所有。 + +## 安全与隐私 + +- Codex 桌面应用只从 OpenAI 或 Microsoft 官方渠道获取。 +- Codex++ 从其 GitHub Release 获取,或由安装引导器集成。 +- 本仓库不镜像分发官方 Codex 桌面应用、Microsoft Store 包或许可证不明确的脚本源码。 +- API Key、OAuth Token、设备码、对话正文等敏感内容不会作为公开构建产物上传。 +- 请不要在 Issue、日志、截图或公开配置中提交任何密钥和令牌。 + +## 给开发者 + +普通用户不需要阅读本节。项目的在线发布入口是 [`.github/workflows/online-release.yml`](.github/workflows/online-release.yml),版本标签格式为 `v*-online`。 ```text . -├── README.md -├── Resources/ # macOS 资源与安装包指南 -├── scripts/ # macOS 构建、验证与烟测 -├── tests/ # macOS 测试 -├── windows/ -│ ├── README.md -│ ├── installer/ # Inno Setup 外层安装器 -│ ├── src/ # WPF GUI 与 InstallerCore -│ ├── scripts/ # Windows 构建与载荷工具 -│ ├── tests/ # Windows 契约与运行时测试 -│ └── docs/guides/ # Windows 随包指南 -└── .github/workflows/ # Windows CI 与正式发布工作流 +├── macos/ # macOS 在线安装器 +├── windows/ # Windows 安装器与配置工具 +├── scripts/ # 构建、验证与烟雾测试 +├── tests/ # 自动化测试 +├── Resources/ # macOS 资源 +└── .github/workflows/ # CI 与 Release 工作流 ``` -## 安全边界 - -- 只从明确的官方服务商页面或已取得再分发授权的项目 Release 获取文件与凭据。 -- API Key、OAuth Token、设备码不应进入日志、报告、截图或公开配置。 -- 模型调用、OpenAI 授权和上游模型刷新需要网络,并受所选服务商与账号权限约束。 -- 本项目不是 OpenAI 产品;公开分发前应核对适用的许可、商标与再分发条款。 +Windows 的完整构建说明见 [windows/README.md](windows/README.md)。第三方组件和再分发边界见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 与 [LICENSES](LICENSES)。 ## 开源许可证 -Uni-codex 原创安装器代码采用 [MIT License](LICENSE)。MIT 仅适用于本项目拥有 -版权的原创部分,不会重新授权 Codex、Codex++、插件、脚本、图标或商标。 +Uni-codex 原创安装器代码采用 [MIT License](LICENSE)。该许可证只适用于本项目拥有版权的原创部分,不会重新授权 Codex、Codex++、插件、脚本、图标或商标;第三方组件继续遵循各自的上游许可证。 + +--- + +
-- OpenAI Codex CLI:Apache-2.0。 -- Codex++ 及其兼容补丁:AGPL-3.0-only。 -- 其他组件:保持各自上游许可证;未声明许可证的内容不由本仓库镜像分发。 +如果 Uni-codex 帮你省下了配置时间,欢迎点一个 ⭐,也欢迎通过 [Issue](https://github.com/fancr-code/Uni-codex/issues) 提交建议。 -完整边界见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 和 -[`LICENSES/`](LICENSES/)。 +
From 3d8652144fe55979bbc7bd282d6320f477142b86 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:57:40 +0800 Subject: [PATCH 02/10] preinstall pinned research skill collections --- README.md | 12 ++++ Resources/installer-core.sh | 11 +++ build-codex-one-click-installer.sh | 8 ++- macos/online/build-online-dmg.sh | 9 ++- macos/online/install-unicodex.sh | 5 ++ scripts/Install-SkillCollections.ps1 | 86 +++++++++++++++++++++++ scripts/install-skill-collections.sh | 62 ++++++++++++++++ skills/collections.json | 29 ++++++++ tests/skill_collections_contract_tests.py | 52 ++++++++++++++ windows/installer/CodexOneClick.iss | 15 +++- windows/online/Install-UniCodex.ps1 | 11 +++ windows/online/UniCodexOnline.iss | 2 + windows/scripts/build-installer.ps1 | 8 ++- 13 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 scripts/Install-SkillCollections.ps1 create mode 100644 scripts/install-skill-collections.sh create mode 100644 skills/collections.json create mode 100644 tests/skill_collections_contract_tests.py diff --git a/README.md b/README.md index 9daad62..ede8abe 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ - **Codex 桌面版**:由官方来源下载,不在本仓库重复打包。 - **Codex++**:随引导流程安装,减少手动配置步骤。 +- **211 个科研 Skills**:在线版与离线版都预装 Nature Skills、Scientific Agent Skills 和 Research Skills。 - **跨平台支持**:一个项目同时覆盖 Windows 和 macOS。 - **已有安装保护**:检测到健康的 Codex 时优先复用,避免无意义地重复安装或降级。 - **更适合中文用户**:下载入口、安装说明和常见问题集中在同一页面。 @@ -69,6 +70,17 @@ Uni-codex 也可以配合以下工具形成连续科研工作流: 这些功能均为可选项。只想安装 Codex 的用户,直接使用上方安装包即可。 +### 开箱即用的科研 Skills + +安装完成后,Codex 会自动获得三套经过固定版本管理的开源技能合集: + +- [Nature Skills](https://github.com/Yuan1z0825/nature-skills):Nature 风格论文写作、润色、审稿、作图和投稿工作流,共 19 个技能。 +- [Scientific Agent Skills](https://github.com/K-Dense-AI/scientific-agent-skills):覆盖生物、化学、医学、数据分析和科研数据库等场景,共 158 个技能。 +- [Research Skills](https://github.com/neuromechanist/research-skills):研究规划、实验设计、文献与图表处理、工程化研究流程,共 34 个技能。 + +三套合集合计 **211 个技能**。在线安装包在安装时获取锁定版本;离线安装包在构建时已将同一版本完整打包。安装器会保留用户自己维护的同名技能,不会静默覆盖。 +三套合集的 MIT 许可证会随技能一并保留在 `.codex/skills/.uni-codex-licenses/`。 + ## 常见问题 ### 为什么安装包这么小? diff --git a/Resources/installer-core.sh b/Resources/installer-core.sh index fe3c44a..109e25c 100755 --- a/Resources/installer-core.sh +++ b/Resources/installer-core.sh @@ -1836,6 +1836,15 @@ install_script_market() { fi } +install_skill_collections() { + [[ -x "$SCRIPT_DIR/install-skill-collections.sh" \ + && -f "$SCRIPT_DIR/skill-collections.json" \ + && -d "$SCRIPT_DIR/skill-collections" ]] || return 66 + UNICODEX_SKILL_MANIFEST="$SCRIPT_DIR/skill-collections.json" \ + UNICODEX_SKILL_BUNDLE="$SCRIPT_DIR/skill-collections" \ + "$SCRIPT_DIR/install-skill-collections.sh" +} + script_market_version() { local filename="$1" local config="$HOME/.config/Codex++/user_scripts.json" @@ -2389,6 +2398,8 @@ install_command() { emit_event installing_scripts 0.78 'installing the Codex++ script market' null install_script_market || return $? + emit_event installing_skills 0.81 'installing 211 research skills' null + install_skill_collections || return $? if [[ "$TEST_MODE" == "1" && "${FAIL_AFTER_SCRIPTS:-0}" == "1" ]]; then emit_event install_failed null 'injected failure after script deployment' '"injected_script_failure"' return 75 diff --git a/build-codex-one-click-installer.sh b/build-codex-one-click-installer.sh index 550a293..4e18bcf 100755 --- a/build-codex-one-click-installer.sh +++ b/build-codex-one-click-installer.sh @@ -183,6 +183,12 @@ compile_gui x86_64 x86_64-apple-macos14.0 /bin/cp -cR "$PAYLOAD_ROOT" "$RESOURCES_DIR/offline-payloads" \ || die "unable to clone offline payloads into the app bundle" /bin/cp "$ROOT/Resources/installer-core.sh" "$RESOURCES_DIR/installer-core.sh" +/bin/cp "$ROOT/scripts/install-skill-collections.sh" "$RESOURCES_DIR/install-skill-collections.sh" +/bin/cp "$ROOT/skills/collections.json" "$RESOURCES_DIR/skill-collections.json" +UNICODEX_SKILL_MANIFEST="$ROOT/skills/collections.json" \ + UNICODEX_SKILL_DESTINATION="$RESOURCES_DIR/skill-collections" \ + UNICODEX_SKILL_PREPARE_BUNDLE=1 \ + "$ROOT/scripts/install-skill-collections.sh" /bin/cp "$PAYLOAD_ROOT/model-catalog.json" "$RESOURCES_DIR/model-catalog.json" /bin/cp "$ROOT/Resources/plugin-catalog.json" "$RESOURCES_DIR/plugin-catalog.json" /bin/mkdir -p "$RESOURCES_DIR/CodexPlusPlus-Compatibility" @@ -192,7 +198,7 @@ compile_gui x86_64 x86_64-apple-macos14.0 > "$RESOURCES_DIR/CodexPlusPlus-Compatibility/CODEXKIT-PATCH.md" /usr/bin/ditto "$ROOT/Resources/guides" "$RESOURCES_DIR/guides" /usr/bin/ditto "$ROOT/Resources/licenses" "$RESOURCES_DIR/licenses" -/bin/chmod 755 "$MACOS_DIR/CodexOneClickInstaller" "$RESOURCES_DIR/installer-support" "$RESOURCES_DIR/installer-core.sh" +/bin/chmod 755 "$MACOS_DIR/CodexOneClickInstaller" "$RESOURCES_DIR/installer-support" "$RESOURCES_DIR/installer-core.sh" "$RESOURCES_DIR/install-skill-collections.sh" /usr/bin/plutil -lint "$CONTENTS/Info.plist" >/dev/null architectures="$(/usr/bin/lipo -archs "$MACOS_DIR/CodexOneClickInstaller")" diff --git a/macos/online/build-online-dmg.sh b/macos/online/build-online-dmg.sh index 4a4c1ab..7a364fa 100644 --- a/macos/online/build-online-dmg.sh +++ b/macos/online/build-online-dmg.sh @@ -8,12 +8,17 @@ trap '/bin/rm -rf "$STAGE"' EXIT /bin/mkdir -p "$OUTPUT" /bin/cp "$ROOT/install-unicodex.sh" "$STAGE/安装 Uni-codex.command" +/bin/cp "$ROOT/../../scripts/install-skill-collections.sh" "$STAGE/install-skill-collections.sh" +/bin/mkdir -p "$STAGE/skills" +/bin/cp "$ROOT/../../skills/collections.json" "$STAGE/skills/collections.json" /bin/chmod 755 "$STAGE/安装 Uni-codex.command" -/usr/bin/ditto "$ROOT/../../LICENSE" "$STAGE/LICENSE.txt" +/bin/chmod 755 "$STAGE/install-skill-collections.sh" +if [[ -f "$ROOT/../../LICENSE" ]]; then + /usr/bin/ditto "$ROOT/../../LICENSE" "$STAGE/LICENSE.txt" +fi /usr/bin/hdiutil create -quiet -volname 'Uni-codex Online Installer' \ -srcfolder "$STAGE" -format UDZO "$OUTPUT/Uni-codex-macOS-Online.dmg" ( cd "$OUTPUT" /usr/bin/shasum -a 256 'Uni-codex-macOS-Online.dmg' > 'SHA256SUMS-macOS.txt' ) - diff --git a/macos/online/install-unicodex.sh b/macos/online/install-unicodex.sh index 5c650fc..3f1e9ff 100644 --- a/macos/online/install-unicodex.sh +++ b/macos/online/install-unicodex.sh @@ -90,4 +90,9 @@ for app_name in 'Codex++.app' 'Codex++ 管理工具.app'; do done /usr/bin/hdiutil detach "$codex_plus_mount" >/dev/null +printf '正在安装科研技能合集…\n' +script_dir="$(cd "$(dirname "$0")" && pwd)" +UNICODEX_SKILL_MANIFEST="$script_dir/skills/collections.json" \ + "$script_dir/install-skill-collections.sh" + printf 'Uni-codex 安装完成。\n' diff --git a/scripts/Install-SkillCollections.ps1 b/scripts/Install-SkillCollections.ps1 new file mode 100644 index 0000000..baad755 --- /dev/null +++ b/scripts/Install-SkillCollections.ps1 @@ -0,0 +1,86 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [string] $ManifestPath = (Join-Path $PSScriptRoot '../skills/collections.json'), + [string] $BundleRoot = '', + [string] $DestinationRoot = (Join-Path $HOME '.codex/skills'), + [switch] $PrepareBundle +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$work = Join-Path ([IO.Path]::GetTempPath()) "uni-codex-skills-$([Guid]::NewGuid().ToString('N'))" + +function Copy-CollectionSkills([string] $Source, [string] $RepositoryRoot, [object] $Collection) { + $collectionDestination = if ($PrepareBundle) { + Join-Path $DestinationRoot "$($Collection.id)/skills" + } else { $DestinationRoot } + New-Item -ItemType Directory -Path $collectionDestination -Force | Out-Null + $skills = @(Get-ChildItem -LiteralPath $Source -Recurse -Filter SKILL.md -File | + ForEach-Object { $_.Directory } | Sort-Object FullName -Unique) + if ($skills.Count -eq 0) { throw "No skills found for $($Collection.id)" } + foreach ($skill in $skills) { + $name = $skill.Name + if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { throw "Unsafe skill name: $name" } + $target = Join-Path $collectionDestination $name + if (Test-Path -LiteralPath $target) { + $marker = Join-Path $target '.uni-codex-collection.json' + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + Write-Warning "Keeping user-managed skill: $name" + continue + } + Remove-Item -LiteralPath $target -Recurse -Force + } + New-Item -ItemType Directory -Path $target -Force | Out-Null + & robocopy.exe $skill.FullName $target /E /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { throw "Unable to copy skill: $name" } + $global:LASTEXITCODE = 0 + [ordered]@{ collection = $Collection.id; repository = $Collection.repository; commit = $Collection.commit } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $target '.uni-codex-collection.json') -Encoding UTF8 + } + $license = Get-ChildItem -LiteralPath $RepositoryRoot -File | + Where-Object { $_.Name -match '^(LICENSE|COPYING)(\..+)?$' } | + Select-Object -First 1 + if ($null -eq $license) { throw "License missing for $($Collection.id)" } + $licenseTarget = if ($PrepareBundle) { + Join-Path $DestinationRoot "$($Collection.id)/LICENSE" + } else { + Join-Path $DestinationRoot ".uni-codex-licenses/$($Collection.id)/LICENSE" + } + New-Item -ItemType Directory -Path (Split-Path -Parent $licenseTarget) -Force | Out-Null + Copy-Item -LiteralPath $license.FullName -Destination $licenseTarget -Force + return $skills.Count +} + +try { + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json + New-Item -ItemType Directory -Path $DestinationRoot, $work -Force | Out-Null + $total = 0 + foreach ($collection in @($manifest.collections)) { + if ([string] $collection.commit -notmatch '^[0-9a-f]{40}$') { throw "Unpinned collection: $($collection.id)" } + if ($BundleRoot) { + $repositoryRoot = Join-Path $BundleRoot ([string] $collection.id) + $source = Join-Path $repositoryRoot 'skills' + } else { + $zip = Join-Path $work "$($collection.id).zip" + $expanded = Join-Path $work $collection.id + $uri = "https://codeload.github.com/$($collection.repository)/zip/$($collection.commit)" + $curl = Get-Command curl.exe -ErrorAction Stop + & $curl.Source --fail --location --retry 3 --connect-timeout 20 ` + --output $zip $uri + if ($LASTEXITCODE -ne 0) { throw "Unable to download $($collection.id)" } + New-Item -ItemType Directory -Path $expanded -Force | Out-Null + $tar = Get-Command tar.exe -ErrorAction Stop + & $tar.Source -xf $zip -C $expanded + if ($LASTEXITCODE -ne 0) { throw "Unable to extract $($collection.id)" } + $repoRoot = Get-ChildItem -LiteralPath $expanded -Directory | Select-Object -First 1 + $repositoryRoot = $repoRoot.FullName + $source = Join-Path $repoRoot.FullName ([string] $collection.skillsRoot) + } + if (-not (Test-Path -LiteralPath $source -PathType Container)) { throw "Missing collection root: $source" } + $total += Copy-CollectionSkills $source $repositoryRoot $collection + } + Write-Host "Installed $total Codex skills into $DestinationRoot" +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install-skill-collections.sh b/scripts/install-skill-collections.sh new file mode 100644 index 0000000..f09a00d --- /dev/null +++ b/scripts/install-skill-collections.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +SCRIPT_ROOT="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="${UNICODEX_SKILL_MANIFEST:-$SCRIPT_ROOT/../skills/collections.json}" +BUNDLE_ROOT="${UNICODEX_SKILL_BUNDLE:-}" +DESTINATION="${UNICODEX_SKILL_DESTINATION:-$HOME/.codex/skills}" +PREPARE_BUNDLE="${UNICODEX_SKILL_PREPARE_BUNDLE:-0}" +WORK="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/uni-codex-skills.XXXXXX")" +trap '/bin/rm -rf "$WORK"' EXIT +/bin/mkdir -p "$DESTINATION" + +total=0 +while IFS=$'\t' read -r id repository commit skills_root; do + [[ "$id" =~ ^[A-Za-z0-9._-]+$ && "$commit" =~ ^[0-9a-f]{40}$ ]] || exit 65 + if [[ -n "$BUNDLE_ROOT" ]]; then + repo_root="$BUNDLE_ROOT/$id" + source_root="$repo_root/skills" + else + archive="$WORK/$id.zip" + /usr/bin/curl --fail --location --retry 3 \ + "https://codeload.github.com/$repository/zip/$commit" -o "$archive" + /usr/bin/ditto -x -k "$archive" "$WORK/$id" + repo_root="$(/usr/bin/find "$WORK/$id" -mindepth 1 -maxdepth 1 -type d -print -quit)" + source_root="$repo_root/$skills_root" + fi + [[ -d "$source_root" ]] || { printf '缺少技能合集:%s\n' "$id" >&2; exit 66; } + collection_destination="$DESTINATION" + [[ "$PREPARE_BUNDLE" == 1 ]] && collection_destination="$DESTINATION/$id/skills" + /bin/mkdir -p "$collection_destination" + while IFS= read -r skill_file; do + skill_dir="${skill_file%/SKILL.md}" + name="${skill_dir##*/}" + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || exit 65 + target="$collection_destination/$name" + if [[ -e "$target" && ! -f "$target/.uni-codex-collection.json" ]]; then + printf '保留用户已有技能:%s\n' "$name" + continue + fi + /bin/rm -rf "$target" + /usr/bin/ditto "$skill_dir" "$target" + printf '{"collection":"%s","repository":"%s","commit":"%s"}\n' \ + "$id" "$repository" "$commit" > "$target/.uni-codex-collection.json" + total=$((total + 1)) + done < <(/usr/bin/find "$source_root" -type f -name SKILL.md | /usr/bin/sort) + license="$(/usr/bin/find "$repo_root" -mindepth 1 -maxdepth 1 -type f \( -name 'LICENSE*' -o -name 'COPYING*' \) -print -quit)" + [[ -f "$license" ]] || { printf '缺少许可证:%s\n' "$id" >&2; exit 66; } + if [[ "$PREPARE_BUNDLE" == 1 ]]; then + license_target="$DESTINATION/$id/LICENSE" + else + license_target="$DESTINATION/.uni-codex-licenses/$id/LICENSE" + fi + /bin/mkdir -p "${license_target%/*}" + /bin/cp "$license" "$license_target" +done < <(/usr/bin/python3 - "$MANIFEST" <<'PY' +import json, sys +for item in json.load(open(sys.argv[1]))['collections']: + print(item['id'], item['repository'], item['commit'], item['skillsRoot'], sep='\t') +PY +) +printf '已安装 %s 个 Codex skills。\n' "$total" diff --git a/skills/collections.json b/skills/collections.json new file mode 100644 index 0000000..8adf0c3 --- /dev/null +++ b/skills/collections.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "collections": [ + { + "id": "nature-skills", + "name": "Nature Skills", + "repository": "Yuan1z0825/nature-skills", + "commit": "9410400815df189d3e76d4be19c74afa68d03412", + "skillsRoot": "skills", + "license": "MIT" + }, + { + "id": "scientific-agent-skills", + "name": "Scientific Agent Skills", + "repository": "K-Dense-AI/scientific-agent-skills", + "commit": "d767725c6e93b1d02a220e6be75b261a9833ede5", + "skillsRoot": "skills", + "license": "MIT" + }, + { + "id": "research-skills", + "name": "Research Skills", + "repository": "neuromechanist/research-skills", + "commit": "b597928beb9f1b8b9b4e4f922da1a1ccc691a4da", + "skillsRoot": ".", + "license": "MIT" + } + ] +} diff --git a/tests/skill_collections_contract_tests.py b/tests/skill_collections_contract_tests.py new file mode 100644 index 0000000..9b6b7ea --- /dev/null +++ b/tests/skill_collections_contract_tests.py @@ -0,0 +1,52 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class SkillCollectionsContractTests(unittest.TestCase): + def test_manifest_pins_three_approved_collections(self): + manifest = json.loads((ROOT / "skills" / "collections.json").read_text("utf-8")) + self.assertEqual( + [item["repository"] for item in manifest["collections"]], + [ + "Yuan1z0825/nature-skills", + "K-Dense-AI/scientific-agent-skills", + "neuromechanist/research-skills", + ], + ) + for item in manifest["collections"]: + self.assertRegex(item["commit"], r"^[0-9a-f]{40}$") + self.assertEqual(item["license"], "MIT") + + def test_online_installers_install_skill_collections(self): + windows = (ROOT / "windows" / "online" / "Install-UniCodex.ps1").read_text("utf-8") + macos = (ROOT / "macos" / "online" / "install-unicodex.sh").read_text("utf-8") + self.assertIn("Install-SkillCollections.ps1", windows) + self.assertIn("install-skill-collections.sh", macos) + + def test_windows_installer_clears_successful_robocopy_exit_code(self): + installer = (ROOT / "scripts" / "Install-SkillCollections.ps1").read_text("utf-8") + self.assertIn("$global:LASTEXITCODE = 0", installer) + + def test_offline_builds_bundle_skill_collections(self): + windows = (ROOT / "windows" / "scripts" / "build-installer.ps1").read_text("utf-8") + macos = (ROOT / "build-codex-one-click-installer.sh").read_text("utf-8") + inno = (ROOT / "windows" / "installer" / "CodexOneClick.iss").read_text("utf-8") + core = (ROOT / "Resources" / "installer-core.sh").read_text("utf-8") + self.assertIn("skill-collections", windows) + self.assertIn("skill-collections", macos) + self.assertIn("Install-SkillCollections.ps1", inno) + self.assertIn("install_skill_collections", core) + + def test_readme_names_all_preinstalled_collections(self): + readme = (ROOT / "README.md").read_text("utf-8") + for name in ("Nature Skills", "Scientific Agent Skills", "Research Skills"): + self.assertIn(name, readme) + self.assertIn("211", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/installer/CodexOneClick.iss b/windows/installer/CodexOneClick.iss index 7afbce7..5bd6832 100644 --- a/windows/installer/CodexOneClick.iss +++ b/windows/installer/CodexOneClick.iss @@ -34,6 +34,9 @@ Source: "{#StageRoot}\app\*"; DestDir: "{code:GetContentDestination|app}"; Flags Source: "{#StageRoot}\offline-payloads\*"; DestDir: "{code:GetContentDestination|offline-payloads}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#StageRoot}\guides\*"; DestDir: "{code:GetContentDestination|guides}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#StageRoot}\licenses\*"; DestDir: "{code:GetContentDestination|licenses}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#StageRoot}\skill-collections\*"; DestDir: "{code:GetContentDestination|skill-collections}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#StageRoot}\Install-SkillCollections.ps1"; DestDir: "{code:GetContentDestination|skills-installer}"; Flags: ignoreversion +Source: "{#StageRoot}\skill-collections.json"; DestDir: "{code:GetContentDestination|skills-installer}"; Flags: ignoreversion [Code] var @@ -108,7 +111,17 @@ begin if not Exec(Executable, Parameters, '', SW_SHOWNORMAL, ewWaitUntilTerminated, ResultCode) then WpfExitCode := 3 else if ResultCode = 0 then - WpfExitCode := 0 + begin + Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + + ExpandConstant('{code:GetContentDestination|skills-installer}\Install-SkillCollections.ps1') + + '" -ManifestPath "' + ExpandConstant('{code:GetContentDestination|skills-installer}\skill-collections.json') + + '" -BundleRoot "' + ExpandConstant('{code:GetContentDestination|skill-collections}') + '"'; + if Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', + SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then + WpfExitCode := 0 + else + WpfExitCode := 3; + end else if ResultCode = 2 then WpfExitCode := 2 else diff --git a/windows/online/Install-UniCodex.ps1 b/windows/online/Install-UniCodex.ps1 index 8642fd6..f69617d 100644 --- a/windows/online/Install-UniCodex.ps1 +++ b/windows/online/Install-UniCodex.ps1 @@ -118,10 +118,21 @@ function Install-CodexPlusPlus { if ($process.ExitCode -ne 0) { throw "Codex++ installer failed: $($process.ExitCode)" } } +function Install-SkillCollections { + $installer = Join-Path $PSScriptRoot 'Install-SkillCollections.ps1' + $manifest = Join-Path $PSScriptRoot 'skills/collections.json' + if (-not (Test-Path -LiteralPath $installer) -or -not (Test-Path -LiteralPath $manifest)) { + throw 'Uni-codex skill collection resources are missing' + } + Write-Step 'Installing Nature, scientific-agent, and research skills' + & $installer -ManifestPath $manifest +} + try { New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null Install-CodexDesktop Install-CodexPlusPlus + Install-SkillCollections Write-Step 'Uni-codex installation completed' } finally { if (Test-Path -LiteralPath $WorkRoot) { diff --git a/windows/online/UniCodexOnline.iss b/windows/online/UniCodexOnline.iss index 545ac44..53d93d8 100644 --- a/windows/online/UniCodexOnline.iss +++ b/windows/online/UniCodexOnline.iss @@ -20,6 +20,8 @@ WizardStyle=modern [Files] Source: "{#SourcePath}\Install-UniCodex.ps1"; DestDir: "{tmp}\Uni-codex"; Flags: deleteafterinstall +Source: "{#SourcePath}\..\..\scripts\Install-SkillCollections.ps1"; DestDir: "{tmp}\Uni-codex"; Flags: deleteafterinstall +Source: "{#SourcePath}\..\..\skills\collections.json"; DestDir: "{tmp}\Uni-codex\skills"; Flags: deleteafterinstall Source: "{#CodexPlusSetup}"; DestDir: "{tmp}\Uni-codex"; DestName: "CodexPlusPlus-Setup.exe"; Flags: deleteafterinstall [Run] diff --git a/windows/scripts/build-installer.ps1 b/windows/scripts/build-installer.ps1 index cf4acee..996eef7 100644 --- a/windows/scripts/build-installer.ps1 +++ b/windows/scripts/build-installer.ps1 @@ -190,12 +190,18 @@ $appStage = Join-Path $stage 'app' $payloadStage = Join-Path $stage 'offline-payloads' $guideStage = Join-Path $stage 'guides' $licenseStage = Join-Path $stage 'licenses' +$skillsStage = Join-Path $stage 'skill-collections' +$skillInstaller = Join-Path $RepositoryRoot 'scripts/Install-SkillCollections.ps1' +$skillManifest = Join-Path $RepositoryRoot 'skills/collections.json' $temporaryDist = Join-Path $outputParent ( '.' + (Split-Path -Leaf $output) + '-' + [Guid]::NewGuid().ToString('N') + '.tmp') $backupDist = $null try { - New-Item -ItemType Directory -Path $appStage, $guideStage, $licenseStage, $temporaryDist | + New-Item -ItemType Directory -Path $appStage, $guideStage, $licenseStage, $skillsStage, $temporaryDist | Out-Null + & $skillInstaller -ManifestPath $skillManifest -DestinationRoot $skillsStage -PrepareBundle + Copy-Item -LiteralPath $skillInstaller -Destination $stage + Copy-Item -LiteralPath $skillManifest -Destination (Join-Path $stage 'skill-collections.json') if ([string]::IsNullOrWhiteSpace($PublishRoot)) { $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if ($null -eq $dotnet) { Fail '.NET 8 SDK is required' } From 55a1deebce9042a7fe4fe75c8c1903baf6c31fd5 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:53:16 +0800 Subject: [PATCH 03/10] bundle macOS Codex++ and migrate offline payload to v1.2.44 --- .github/workflows/online-release.yml | 43 +- .github/workflows/windows-release.yml | 8 +- Resources/licenses/Third-Party-Notices.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- build-codex-one-click-installer.sh | 8 +- macos/online/build-online-dmg.sh | 5 + macos/online/install-unicodex.sh | 28 +- .../v1.2.44-cross-provider-history.patch | 480 ++++++++++++++++++ ...build-codex-plus-compatibility-payloads.sh | 4 +- scripts/verify-release-artifact.sh | 10 +- tests/codex-plus-compatibility-tests.sh | 4 +- tests/codex-plus-payload-build-tests.sh | 38 +- tests/installer-package-tests.sh | 6 +- tests/online_release_contract_tests.py | 14 + windows/README.md | 8 +- ...build-codex-plus-compatibility-payload.ps1 | 38 +- windows/scripts/offline-payload-supply.ps1 | 10 +- windows/scripts/refresh-offline-payloads.ps1 | 14 +- .../tests/CodexPlusCompatibility.Tests.ps1 | 48 +- windows/tests/DocumentationTests.ps1 | 6 +- windows/tests/ReleaseArtifact.Tests.ps1 | 2 +- .../fixtures/payload-manifest.valid.json | 12 +- .../fixtures/payload-root/payload-lock.json | 6 +- .../payload-root/payload-manifest.json | 4 +- windows/vendor/payload-lock.json | 6 +- windows/vendor/upstream-sources.json | 4 +- 26 files changed, 678 insertions(+), 132 deletions(-) create mode 100644 patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch diff --git a/.github/workflows/online-release.yml b/.github/workflows/online-release.yml index e531d36..d85da57 100644 --- a/.github/workflows/online-release.yml +++ b/.github/workflows/online-release.yml @@ -75,8 +75,49 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v6 + - name: Download Codex++ macOS installers + shell: bash + run: | + release_json="$RUNNER_TEMP/codex-plus.json" + curl --fail --location --retry 3 --connect-timeout 20 --max-time 1800 \ + -H 'User-Agent: Uni-codex' \ + 'https://api.github.com/repos/BigPizzaV3/CodexPlusPlus/releases/latest' \ + -o "$release_json" + python3 - "$release_json" "$RUNNER_TEMP" <<'PY' + import hashlib + import json + import pathlib + import subprocess + import sys + + release = json.loads(pathlib.Path(sys.argv[1]).read_text()) + output = pathlib.Path(sys.argv[2]) + assets = {asset['name']: asset for asset in release.get('assets', [])} + for architecture in ('arm64', 'x64'): + name = f"CodexPlusPlus-{release['tag_name'].lstrip('v')}-macos-{architecture}.dmg" + asset = assets.get(name) + if asset is None: + raise SystemExit(f'missing Codex++ macOS asset: {name}') + destination = output / f'CodexPlusPlus-{architecture}.dmg' + subprocess.run([ + 'curl', '--fail', '--location', '--retry', '3', + '--connect-timeout', '20', '--max-time', '1800', + '-o', str(destination), asset['browser_download_url'], + ], check=True) + digest = asset.get('digest', '') + if not digest.startswith('sha256:'): + raise SystemExit(f'Codex++ asset has no SHA-256 digest: {name}') + actual = hashlib.sha256(destination.read_bytes()).hexdigest() + if actual != digest.removeprefix('sha256:'): + raise SystemExit(f'Codex++ SHA-256 mismatch: {name}') + PY - name: Build macOS online DMG - run: bash macos/online/build-online-dmg.sh dist/macos + run: | + CodexPlusPlusArmDmg="$RUNNER_TEMP/CodexPlusPlus-arm64.dmg" + CodexPlusPlusX64Dmg="$RUNNER_TEMP/CodexPlusPlus-x64.dmg" + bash macos/online/build-online-dmg.sh dist/macos \ + "$CodexPlusPlusArmDmg" \ + "$CodexPlusPlusX64Dmg" - uses: actions/upload-artifact@v7 with: name: online-macos diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 1326622..16037d1 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -127,16 +127,16 @@ jobs: shell: pwsh run: | windows/scripts/build-codex-plus-compatibility-payload.ps1 ` - -Tag v1.2.43 ` - -Patch patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch ` + -Tag v1.2.44 ` + -Patch patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch ` -OutputRoot windows/build/codex-plus-compat - name: Refresh complete official offline payload shell: pwsh run: | windows/scripts/refresh-offline-payloads.ps1 ` -OutputRoot windows/vendor/offline-payloads ` - -CodexPlusPlusSetup windows/build/codex-plus-compat/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe ` - -CodexPlusPlusSource windows/build/codex-plus-compat/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz + -CodexPlusPlusSetup windows/build/codex-plus-compat/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe ` + -CodexPlusPlusSource windows/build/codex-plus-compat/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz - name: Strict payload signatures and licenses id: payload shell: pwsh diff --git a/Resources/licenses/Third-Party-Notices.md b/Resources/licenses/Third-Party-Notices.md index c907d44..971d306 100644 --- a/Resources/licenses/Third-Party-Notices.md +++ b/Resources/licenses/Third-Party-Notices.md @@ -12,7 +12,7 @@ 来源:https://github.com/BigPizzaV3/CodexPlusPlus -上游版本:v1.2.43。安装包使用 `codexkit.1` / `cross-provider-content-v1` 下游兼容修订,修复 DeepSeek/Kimi 与 OpenAI 模型之间切换时旧历史消息 `content` 类型不兼容的问题。 +上游版本:v1.2.44。安装包使用 `codexkit.1` / `cross-provider-content-v1` 下游兼容修订,修复 DeepSeek/Kimi 与 OpenAI 模型之间切换时旧历史消息 `content` 类型不兼容的问题。 许可证:AGPL-3.0-only。DMG 的“第三方许可与源码”目录同时包含与二进制版本一致的完整修订源码归档、`CODEXKIT-PATCH.md`、可单独审计的补丁文件和许可证文本。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5a28dbd..bb489a4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -29,7 +29,7 @@ administrator variable. Project: https://github.com/BigPizzaV3/CodexPlusPlus -Pinned upstream version: v1.2.43. +Pinned upstream version: v1.2.44. License metadata: AGPL-3.0-only. A copy is provided at `LICENSES/AGPL-3.0-only.txt`. diff --git a/build-codex-one-click-installer.sh b/build-codex-one-click-installer.sh index 4e18bcf..63ab7da 100755 --- a/build-codex-one-click-installer.sh +++ b/build-codex-one-click-installer.sh @@ -18,9 +18,9 @@ CHECKSUMS_FILE="$DIST_ROOT/SHA256SUMS.txt" DEVELOPER_ID="${DEVELOPER_ID_APPLICATION:-}" ICON_MASTER="$ROOT/Resources/AppIcon/AppIcon-1024.png" ICON_FILE="$ROOT/Resources/AppIcon/AppIcon.icns" -CODEX_PLUS_VERSION='1.2.43' +CODEX_PLUS_VERSION='1.2.44' CODEX_PLUS_COMPATIBILITY_REVISION='cross-provider-content-v1' -CODEX_PLUS_PATCH="$ROOT/patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch" +CODEX_PLUS_PATCH="$ROOT/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch" die() { printf 'build-codex-one-click-installer: %s\n' "$*" >&2 @@ -193,7 +193,7 @@ UNICODEX_SKILL_MANIFEST="$ROOT/skills/collections.json" \ /bin/cp "$ROOT/Resources/plugin-catalog.json" "$RESOURCES_DIR/plugin-catalog.json" /bin/mkdir -p "$RESOURCES_DIR/CodexPlusPlus-Compatibility" /bin/cp "$CODEX_PLUS_PATCH" \ - "$RESOURCES_DIR/CodexPlusPlus-Compatibility/v1.2.43-cross-provider-history.patch" + "$RESOURCES_DIR/CodexPlusPlus-Compatibility/v1.2.44-cross-provider-history.patch" /usr/bin/tar -xOzf "$source_archive" "$codex_plus_provenance_member" \ > "$RESOURCES_DIR/CodexPlusPlus-Compatibility/CODEXKIT-PATCH.md" /usr/bin/ditto "$ROOT/Resources/guides" "$RESOURCES_DIR/guides" @@ -254,7 +254,7 @@ ln -s /Applications "$DMG_STAGE/Applications" /bin/cp "$source_archive" "$DMG_STAGE/第三方许可与源码/$(/usr/bin/basename "$source_archive")" /bin/cp "$CODEX_PLUS_PATCH" \ - "$DMG_STAGE/第三方许可与源码/v1.2.43-cross-provider-history.patch" + "$DMG_STAGE/第三方许可与源码/v1.2.44-cross-provider-history.patch" /usr/bin/tar -xOzf "$source_archive" "$codex_plus_provenance_member" \ > "$DMG_STAGE/第三方许可与源码/CODEXKIT-PATCH.md" license_member="$(/usr/bin/tar -tzf "$source_archive" | /usr/bin/awk '/\/(LICENSE|COPYING)(\.[A-Za-z0-9_-]+)?$/ {print; exit}')" diff --git a/macos/online/build-online-dmg.sh b/macos/online/build-online-dmg.sh index 7a364fa..609b62d 100644 --- a/macos/online/build-online-dmg.sh +++ b/macos/online/build-online-dmg.sh @@ -3,11 +3,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" OUTPUT="${1:?output directory required}" +CODEX_PLUS_ARM_DMG="${2:?arm64 Codex++ DMG required}" +CODEX_PLUS_X64_DMG="${3:?x64 Codex++ DMG required}" STAGE="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/uni-codex-dmg.XXXXXX")" trap '/bin/rm -rf "$STAGE"' EXIT /bin/mkdir -p "$OUTPUT" /bin/cp "$ROOT/install-unicodex.sh" "$STAGE/安装 Uni-codex.command" +/bin/mkdir -p "$STAGE/codex-plus-plus" +/bin/cp "$CODEX_PLUS_ARM_DMG" "$STAGE/codex-plus-plus/CodexPlusPlus-arm64.dmg" +/bin/cp "$CODEX_PLUS_X64_DMG" "$STAGE/codex-plus-plus/CodexPlusPlus-x64.dmg" /bin/cp "$ROOT/../../scripts/install-skill-collections.sh" "$STAGE/install-skill-collections.sh" /bin/mkdir -p "$STAGE/skills" /bin/cp "$ROOT/../../skills/collections.json" "$STAGE/skills/collections.json" diff --git a/macos/online/install-unicodex.sh b/macos/online/install-unicodex.sh index 3f1e9ff..efe9c5d 100644 --- a/macos/online/install-unicodex.sh +++ b/macos/online/install-unicodex.sh @@ -5,6 +5,7 @@ umask 077 OPENAI_ARM_URL='https://persistent.oaistatic.com/codex-app-prod/Codex.dmg' OPENAI_X64_URL='https://persistent.oaistatic.com/codex-app-prod/Codex-latest-x64.dmg' CODEX_PLUS_REPOSITORY='BigPizzaV3/CodexPlusPlus' +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" WORK_ROOT="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/uni-codex.XXXXXX")" MOUNTS=() @@ -63,14 +64,19 @@ openai_dmg="$WORK_ROOT/Codex.dmg" download "$openai_url" "$openai_dmg" install_app_from_dmg "$openai_dmg" 'ChatGPT.app' -printf '正在查询 Codex++ GitHub Release…\n' -release_json="$WORK_ROOT/codex-plus.json" -download "https://api.github.com/repos/$CODEX_PLUS_REPOSITORY/releases/latest" "$release_json" -version="$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"].lstrip("v"))' "$release_json")" arch="$(/usr/bin/uname -m)" [[ "$arch" == x86_64 ]] && asset_arch=x64 || asset_arch=arm64 -asset_name="CodexPlusPlus-$version-macos-$asset_arch.dmg" -asset_url="$(/usr/bin/python3 - "$release_json" "$asset_name" <<'PY' +BUNDLED_CODEX_PLUS_DMG="$SCRIPT_DIR/codex-plus-plus/CodexPlusPlus-$asset_arch.dmg" +if [[ -s "$BUNDLED_CODEX_PLUS_DMG" ]]; then + codex_plus_dmg="$BUNDLED_CODEX_PLUS_DMG" + printf '使用安装包内置的 Codex++ (%s)…\n' "$asset_arch" +else + printf '安装包未包含 Codex++,正在查询官方 GitHub Release…\n' + release_json="$WORK_ROOT/codex-plus.json" + download "https://api.github.com/repos/$CODEX_PLUS_REPOSITORY/releases/latest" "$release_json" + version="$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"].lstrip("v"))' "$release_json")" + asset_name="CodexPlusPlus-$version-macos-$asset_arch.dmg" + asset_url="$(/usr/bin/python3 - "$release_json" "$asset_name" <<'PY' import json, sys data = json.load(open(sys.argv[1])) matches = [a['browser_download_url'] for a in data['assets'] if a['name'] == sys.argv[2]] @@ -79,8 +85,9 @@ if len(matches) != 1: print(matches[0]) PY )" -codex_plus_dmg="$WORK_ROOT/$asset_name" -download "$asset_url" "$codex_plus_dmg" + codex_plus_dmg="$WORK_ROOT/$asset_name" + download "$asset_url" "$codex_plus_dmg" +fi codex_plus_mount="$(mount_dmg "$codex_plus_dmg")" for app_name in 'Codex++.app' 'Codex++ 管理工具.app'; do app="$codex_plus_mount/$app_name" @@ -91,8 +98,7 @@ done /usr/bin/hdiutil detach "$codex_plus_mount" >/dev/null printf '正在安装科研技能合集…\n' -script_dir="$(cd "$(dirname "$0")" && pwd)" -UNICODEX_SKILL_MANIFEST="$script_dir/skills/collections.json" \ - "$script_dir/install-skill-collections.sh" +UNICODEX_SKILL_MANIFEST="$SCRIPT_DIR/skills/collections.json" \ + "$SCRIPT_DIR/install-skill-collections.sh" printf 'Uni-codex 安装完成。\n' diff --git a/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch b/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch new file mode 100644 index 0000000..519a662 --- /dev/null +++ b/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch @@ -0,0 +1,480 @@ +diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs +index fa76094..22aff5a 100644 +--- a/apps/codex-plus-manager/src-tauri/src/commands.rs ++++ b/apps/codex-plus-manager/src-tauri/src/commands.rs +@@ -454,6 +454,12 @@ pub fn startup_options() -> CommandResult { + }, + ) + } ++#[tauri::command] ++pub fn take_content_compatibility_notice() ++-> Result, String> { ++ codex_plus_core::compatibility_notice::take_content_repair_notice() ++ .map_err(|error| error.to_string()) ++} + + pub fn startup_should_show_update() -> bool { + should_show_update( +diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs +index 65caed1..0854ece 100644 +--- a/apps/codex-plus-manager/src-tauri/src/lib.rs ++++ b/apps/codex-plus-manager/src-tauri/src/lib.rs +@@ -64,6 +64,7 @@ pub fn run() { + .invoke_handler(tauri::generate_handler![ + commands::backend_version, + commands::startup_options, ++ commands::take_content_compatibility_notice, + commands::load_overview, + commands::launch_codex_plus, + commands::restart_codex_plus, +diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx +index b41a66b..81e89ec 100644 +--- a/apps/codex-plus-manager/src/App.tsx ++++ b/apps/codex-plus-manager/src/App.tsx +@@ -753,6 +753,10 @@ function syncMarketInstalledState(current: ScriptMarketResult | null, userScript + type StartupResult = CommandResult<{ + showUpdate: boolean; + }>; ++type ContentCompatibilityNotice = { ++ repairedMessageCount: number; ++ timestampMs: number; ++}; + + type Route = "overview" | "relay" | "relayEnvironment" | "sessions" | "context" | "enhance" | "dreamSkin" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; + type Theme = "dark" | "light"; +@@ -960,6 +964,24 @@ export function App() { + return null; + } + }; ++ const refreshContentCompatibilityNotice = async () => { ++ try { ++ const result = await call( ++ "take_content_compatibility_notice", ++ ); ++ if (result && result.repairedMessageCount > 0) { ++ showNotice( ++ t("跨模型兼容"), ++ tf("已兼容转换 {0} 条旧会话内容,可继续使用当前任务。", [ ++ result.repairedMessageCount, ++ ]), ++ "ok", ++ ); ++ } ++ } catch { ++ // A missing notice must never interrupt the manager. ++ } ++ }; + + const refreshOverview = async (silent = false) => { + const result = await run(() => call("load_overview")); +@@ -2353,6 +2375,7 @@ export function App() { + useEffect(() => { + void (async () => { + const startup = await run(() => call("startup_options")); ++ await refreshContentCompatibilityNotice(); + if (startup?.showUpdate) { + setRoute("about"); + void checkUpdate(false); +@@ -2382,6 +2405,7 @@ export function App() { + + useEffect(() => { + const timer = window.setInterval(() => { ++ void refreshContentCompatibilityNotice(); + void refreshPendingProviderImport(true); + }, 1200); + return () => window.clearInterval(timer); +diff --git a/crates/codex-plus-core/src/compatibility_notice.rs b/crates/codex-plus-core/src/compatibility_notice.rs +new file mode 100644 +index 0000000..2042364 +--- /dev/null ++++ b/crates/codex-plus-core/src/compatibility_notice.rs +@@ -0,0 +1,88 @@ ++use std::io; ++use std::path::PathBuf; ++use std::sync::{Mutex, OnceLock}; ++use std::time::{SystemTime, UNIX_EPOCH}; ++ ++use serde::{Deserialize, Serialize}; ++ ++const NOTICE_FILE: &str = "content-compatibility-notice.json"; ++ ++static NOTICE_IO_LOCK: OnceLock> = OnceLock::new(); ++static TEST_NOTICE_PATH: OnceLock>> = OnceLock::new(); ++ ++#[derive(Debug, Clone, Serialize, Deserialize)] ++#[serde(rename_all = "camelCase")] ++pub struct ContentRepairNotice { ++ pub repaired_message_count: usize, ++ pub timestamp_ms: u64, ++} ++ ++pub fn record_content_repair(repaired_message_count: usize) -> io::Result<()> { ++ if repaired_message_count == 0 { ++ return Ok(()); ++ } ++ ++ let _guard = notice_io_lock()?; ++ let path = notice_path(); ++ if let Some(parent) = path.parent() { ++ std::fs::create_dir_all(parent)?; ++ } ++ let previous_count = match std::fs::read(&path) { ++ Ok(bytes) => serde_json::from_slice::(&bytes) ++ .map(|notice| notice.repaired_message_count) ++ .unwrap_or(0), ++ Err(error) if error.kind() == io::ErrorKind::NotFound => 0, ++ Err(error) => return Err(error), ++ }; ++ let notice = ContentRepairNotice { ++ repaired_message_count: previous_count.saturating_add(repaired_message_count), ++ timestamp_ms: now_ms(), ++ }; ++ let bytes = serde_json::to_vec(¬ice) ++ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; ++ crate::settings::atomic_write(&path, &bytes).map_err(io::Error::other) ++} ++ ++pub fn take_content_repair_notice() -> io::Result> { ++ let _guard = notice_io_lock()?; ++ let path = notice_path(); ++ let bytes = match std::fs::read(&path) { ++ Ok(bytes) => bytes, ++ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), ++ Err(error) => return Err(error), ++ }; ++ let notice = serde_json::from_slice(&bytes) ++ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; ++ std::fs::remove_file(path)?; ++ Ok(Some(notice)) ++} ++ ++fn notice_path() -> PathBuf { ++ if let Ok(guard) = TEST_NOTICE_PATH.get_or_init(|| Mutex::new(None)).lock() { ++ if let Some(path) = &*guard { ++ return path.clone(); ++ } ++ } ++ crate::paths::default_app_state_dir().join(NOTICE_FILE) ++} ++ ++fn notice_io_lock() -> io::Result> { ++ NOTICE_IO_LOCK ++ .get_or_init(|| Mutex::new(())) ++ .lock() ++ .map_err(|_| io::Error::other("compatibility notice lock poisoned")) ++} ++ ++fn now_ms() -> u64 { ++ SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .unwrap_or_default() ++ .as_millis() as u64 ++} ++ ++#[doc(hidden)] ++pub fn set_notice_path_for_tests(path: Option) { ++ if let Ok(mut guard) = TEST_NOTICE_PATH.get_or_init(|| Mutex::new(None)).lock() { ++ *guard = path; ++ } ++} +diff --git a/crates/codex-plus-core/src/dream_skin.rs b/crates/codex-plus-core/src/dream_skin.rs +index 887c536..6e1c6c6 100644 +--- a/crates/codex-plus-core/src/dream_skin.rs ++++ b/crates/codex-plus-core/src/dream_skin.rs +@@ -148,8 +148,11 @@ fn supported_image_extension(path: &Path) -> anyhow::Result { + fn prepare_image( + source: &Path, + managed_dir: &Path, +- _extension: &str, ++ extension: &str, + ) -> anyhow::Result<(PathBuf, String)> { ++ if matches!(extension, "jpg" | "jpeg") { ++ return Ok((source.to_path_buf(), "jpg".to_string())); ++ } + let prepared = managed_dir.join(".dream-skin-import.jpg"); + let output = std::process::Command::new("/usr/bin/sips") + .args(["-s", "format", "jpeg"]) +diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs +index 0bf914b..906850f 100644 +--- a/crates/codex-plus-core/src/lib.rs ++++ b/crates/codex-plus-core/src/lib.rs +@@ -8,6 +8,7 @@ pub mod codex_app_state; + pub mod codex_home; + pub mod codex_local_storage; + pub mod codex_sqlite; ++pub mod compatibility_notice; + mod computer_use_guard; + pub mod diagnostic_log; + pub mod dream_skin; +diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs +index ee01e3e..0d85789 100644 +--- a/crates/codex-plus-core/src/protocol_proxy.rs ++++ b/crates/codex-plus-core/src/protocol_proxy.rs +@@ -149,6 +149,19 @@ pub fn responses_to_chat_completions(body: Value) -> anyhow::Result { + append_responses_input(input, &mut messages); + } + normalize_chat_messages(&mut messages); ++ let compatibility_repair = normalize_chat_message_contents(&mut messages); ++ if compatibility_repair.repaired_message_count > 0 { ++ let _ = crate::compatibility_notice::record_content_repair( ++ compatibility_repair.repaired_message_count, ++ ); ++ let _ = crate::diagnostic_log::append_diagnostic_log( ++ "protocol_proxy.content_compatibility", ++ json!({ ++ "repairedMessageCount": compatibility_repair.repaired_message_count, ++ "originalTypes": compatibility_repair.original_types ++ }), ++ ); ++ } + let messages = collapse_system_messages_to_head(messages); + result["messages"] = json!(messages); + +@@ -2209,6 +2222,125 @@ fn normalize_chat_messages(messages: &mut [Value]) { + } + } + ++#[derive(Default)] ++struct ContentCompatibilityRepair { ++ repaired_message_count: usize, ++ original_types: Vec<&'static str>, ++} ++ ++fn normalize_chat_message_contents(messages: &mut [Value]) -> ContentCompatibilityRepair { ++ let mut report = ContentCompatibilityRepair::default(); ++ for message in messages { ++ let Some(object) = message.as_object_mut() else { ++ continue; ++ }; ++ let original = object.get("content").cloned().unwrap_or(Value::Null); ++ let (normalized, repaired) = normalize_chat_content(&original); ++ if repaired { ++ report.repaired_message_count += 1; ++ let original_type = json_value_type(&original); ++ if !report.original_types.contains(&original_type) { ++ report.original_types.push(original_type); ++ } ++ } ++ object.insert("content".to_string(), normalized); ++ } ++ report ++} ++ ++fn normalize_chat_content(content: &Value) -> (Value, bool) { ++ match content { ++ Value::String(_) => (content.clone(), false), ++ Value::Array(parts) => { ++ let safe_parts = parts ++ .iter() ++ .filter_map(normalize_chat_part) ++ .collect::>(); ++ let repaired = ++ safe_parts.len() != parts.len() || safe_parts.as_slice() != parts.as_slice(); ++ if safe_parts.is_empty() { ++ (Value::String(String::new()), true) ++ } else { ++ (Value::Array(safe_parts), repaired) ++ } ++ } ++ Value::Null => (Value::String(String::new()), true), ++ Value::Bool(value) => (Value::String(value.to_string()), true), ++ Value::Number(value) => (Value::String(value.to_string()), true), ++ Value::Object(_) => (Value::String(extract_compatible_text(content)), true), ++ } ++} ++ ++fn normalize_chat_part(part: &Value) -> Option { ++ let Some(object) = part.as_object() else { ++ let text = extract_compatible_text(part); ++ return (!text.is_empty()).then(|| json!({ "type": "text", "text": text })); ++ }; ++ match object.get("type").and_then(Value::as_str) { ++ Some("text") => object ++ .get("text") ++ .and_then(Value::as_str) ++ .filter(|text| !text.is_empty()) ++ .map(|text| json!({ "type": "text", "text": text })), ++ Some("image_url") => object.get("image_url").and_then(|image_url| { ++ if image_url.is_string() || image_url.is_object() { ++ Some(json!({ "type": "image_url", "image_url": image_url })) ++ } else { ++ None ++ } ++ }), ++ _ => { ++ let text = extract_compatible_text(part); ++ (!text.is_empty()).then(|| json!({ "type": "text", "text": text })) ++ } ++ } ++} ++ ++fn extract_compatible_text(value: &Value) -> String { ++ if let Some(text) = value.as_str() { ++ return text.to_string(); ++ } ++ if let Some(object) = value.as_object() { ++ for key in ["text", "content", "output_text", "refusal"] { ++ if let Some(candidate) = object.get(key) { ++ let text = extract_compatible_text(candidate); ++ if !text.trim().is_empty() { ++ return text; ++ } ++ } ++ } ++ return format!( ++ "[兼容转换的历史内容] {}", ++ serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) ++ ); ++ } ++ if let Some(parts) = value.as_array() { ++ return parts ++ .iter() ++ .map(extract_compatible_text) ++ .filter(|text| !text.trim().is_empty()) ++ .collect::>() ++ .join("\n"); ++ } ++ match value { ++ Value::Null => String::new(), ++ Value::Bool(value) => value.to_string(), ++ Value::Number(value) => value.to_string(), ++ Value::String(_) | Value::Array(_) | Value::Object(_) => unreachable!(), ++ } ++} ++ ++fn json_value_type(value: &Value) -> &'static str { ++ match value { ++ Value::Null => "null", ++ Value::Bool(_) => "boolean", ++ Value::Number(_) => "number", ++ Value::String(_) => "string", ++ Value::Array(_) => "array", ++ Value::Object(_) => "object", ++ } ++} ++ + fn collapse_system_messages_to_head(messages: Vec) -> Vec { + let mut system_chunks = Vec::new(); + let mut rest = Vec::with_capacity(messages.len()); +@@ -2379,7 +2511,12 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value { + has_non_text_part = true; + } + } +- _ => {} ++ _ => { ++ chat_parts.push(json!({ ++ "type": "text", ++ "text": extract_compatible_text(part) ++ })); ++ } + } + } + +diff --git a/crates/codex-plus-core/tests/dream_skin.rs b/crates/codex-plus-core/tests/dream_skin.rs +index 0356d1c..2884caa 100644 +--- a/crates/codex-plus-core/tests/dream_skin.rs ++++ b/crates/codex-plus-core/tests/dream_skin.rs +@@ -108,12 +108,54 @@ fn imports_supported_image_into_managed_theme_directory() { + assert!(imported.starts_with(temp.path().join("dream-skin/theme"))); + assert_eq!( + imported.file_name().and_then(|name| name.to_str()), +- Some("current.png") ++ if cfg!(target_os = "macos") { ++ Some("current.jpg") ++ } else { ++ Some("current.png") ++ } + ); +- assert_eq!(std::fs::read(&imported).unwrap(), image); ++ if cfg!(target_os = "macos") { ++ assert!( ++ std::fs::read(&imported) ++ .unwrap() ++ .starts_with(&[0xff, 0xd8, 0xff]) ++ ); ++ } else { ++ assert_eq!(std::fs::read(&imported).unwrap(), image); ++ } + assert!(is_managed_dream_skin_image(&imported, temp.path())); + } + ++#[cfg(target_os = "macos")] ++#[test] ++fn imports_existing_jpeg_without_lossy_reencoding() { ++ let temp = tempfile::tempdir().unwrap(); ++ let source = temp.path().join("source.jpg"); ++ let image = ++ include_bytes!("../../../assets/inject/upstream/dream-skin/windows/dream-reference.jpg"); ++ std::fs::write(&source, image).unwrap(); ++ ++ let imported = import_dream_skin_image(&source, temp.path()).unwrap(); ++ ++ assert_eq!( ++ imported.file_name().and_then(|name| name.to_str()), ++ Some("current.jpg") ++ ); ++ assert_eq!(std::fs::read(imported).unwrap(), image); ++} ++ ++#[cfg(target_os = "macos")] ++#[test] ++fn rejects_invalid_image_during_macos_preparation() { ++ let temp = tempfile::tempdir().unwrap(); ++ let source = temp.path().join("invalid.png"); ++ std::fs::write(&source, b"not a valid image").unwrap(); ++ ++ let error = import_dream_skin_image(&source, temp.path()).unwrap_err(); ++ ++ assert!(error.to_string().contains("macOS could not convert")); ++} ++ + #[test] + fn rejects_source_larger_than_fifty_mebibytes() { + let temp = tempfile::tempdir().unwrap(); +diff --git a/crates/codex-plus-core/tests/dream_skin_market.rs b/crates/codex-plus-core/tests/dream_skin_market.rs +index 5536ba1..2fa9934 100644 +--- a/crates/codex-plus-core/tests/dream_skin_market.rs ++++ b/crates/codex-plus-core/tests/dream_skin_market.rs +@@ -55,7 +55,7 @@ async fn installs_verified_market_theme_into_local_library() { + config.name = "Market Demo".to_string(); + config.style_preset = "cyber-neon".to_string(); + let config = serde_json::to_vec(&config).unwrap(); +- let image = b"\x89PNG\r\n\x1a\nmarket-image"; ++ let image = include_bytes!("../../../assets/inject/dream-skin-default.png"); + mount_theme(&server, &config, image).await; + + let installed = install_market_theme_from_base( +@@ -82,7 +82,11 @@ async fn installs_verified_market_theme_into_local_library() { + assert!( + state + .path() +- .join("dream-skin/themes/market-demo/image.png") ++ .join(if cfg!(target_os = "macos") { ++ "dream-skin/themes/market-demo/image.jpg" ++ } else { ++ "dream-skin/themes/market-demo/image.png" ++ }) + .is_file() + ); + let records = +diff --git a/scripts/installer/macos/package-dmg.sh b/scripts/installer/macos/package-dmg.sh +index 287778e..b59aa03 100755 +--- a/scripts/installer/macos/package-dmg.sh ++++ b/scripts/installer/macos/package-dmg.sh +@@ -3,6 +3,7 @@ set -euo pipefail + + VERSION="${1:-0.0.0}" + ARCH="${2:-$(uname -m)}" ++CODEXKIT_COMPATIBILITY_REVISION="${CODEXKIT_COMPATIBILITY_REVISION:?compatibility revision is required}" + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + DIST="$ROOT/dist/macos" + STAGE="$DIST/stage" +@@ -68,6 +69,8 @@ create_app() { + $VERSION + CFBundleShortVersionString + $VERSION ++ CodexKitCompatibilityRevision ++ $CODEXKIT_COMPATIBILITY_REVISION + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType diff --git a/scripts/build-codex-plus-compatibility-payloads.sh b/scripts/build-codex-plus-compatibility-payloads.sh index 769180f..3878a15 100755 --- a/scripts/build-codex-plus-compatibility-payloads.sh +++ b/scripts/build-codex-plus-compatibility-payloads.sh @@ -4,7 +4,7 @@ umask 077 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -UPSTREAM_VERSION='1.2.43' +UPSTREAM_VERSION='1.2.44' PATCH_REVISION='codexkit.1' COMPATIBILITY_REVISION='cross-provider-content-v1' ARM_TARGET='aarch64-apple-darwin' @@ -112,7 +112,7 @@ Downstream fixes: \`class\`, \`data-theme\`, \`data-appearance\`, and \`data-color-mode\`. - Align upstream Dream Skin import tests with macOS JPEG conversion behavior. - Avoid lossy re-encoding when a managed macOS Dream Skin image is already JPEG. -- Refresh v1.2.43 upstream theme asset SHA-256 fixtures without altering the assets. +- Refresh v1.2.44 upstream theme asset SHA-256 fixtures without altering the assets. Build sequence: diff --git a/scripts/verify-release-artifact.sh b/scripts/verify-release-artifact.sh index 15e295d..076793f 100755 --- a/scripts/verify-release-artifact.sh +++ b/scripts/verify-release-artifact.sh @@ -208,7 +208,7 @@ if [[ "${TEST_MODE:-0}" != 1 ]]; then .id == "codex-plus-plus-x86_64" or .id == "codex-plus-plus-source" )] | all( - .version == "1.2.43" and + .version == "1.2.44" and .compatibilityRevision == "cross-provider-content-v1" )) ' "$MANIFEST" >/dev/null || die 'required product payload set is invalid' @@ -250,13 +250,13 @@ if [[ "${TEST_MODE:-0}" != 1 ]]; then | /usr/bin/awk '/\/CODEXKIT-PATCH\.md$/ {print; exit}')" [[ -n "$source_provenance_member" \ && -s "$RESOURCES/CodexPlusPlus-Compatibility/CODEXKIT-PATCH.md" \ - && -s "$RESOURCES/CodexPlusPlus-Compatibility/v1.2.43-cross-provider-history.patch" \ + && -s "$RESOURCES/CodexPlusPlus-Compatibility/v1.2.44-cross-provider-history.patch" \ && -s "$MOUNT_POINT/第三方许可与源码/CODEXKIT-PATCH.md" \ - && -s "$MOUNT_POINT/第三方许可与源码/v1.2.43-cross-provider-history.patch" ]] \ + && -s "$MOUNT_POINT/第三方许可与源码/v1.2.44-cross-provider-history.patch" ]] \ || die 'Codex++ compatibility provenance is incomplete' /usr/bin/cmp -s \ - "$ROOT/patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch" \ - "$RESOURCES/CodexPlusPlus-Compatibility/v1.2.43-cross-provider-history.patch" \ + "$ROOT/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch" \ + "$RESOURCES/CodexPlusPlus-Compatibility/v1.2.44-cross-provider-history.patch" \ || die 'bundled Codex++ compatibility patch differs from the approved patch' [[ "$(/usr/bin/wc -c < "$MOUNT_POINT/第三方许可与源码/AGPL-3.0.txt" | /usr/bin/tr -d ' ')" -gt 20000 ]] \ || die 'full AGPL-3.0 license text is missing' diff --git a/tests/codex-plus-compatibility-tests.sh b/tests/codex-plus-compatibility-tests.sh index 72a6850..83bc25e 100755 --- a/tests/codex-plus-compatibility-tests.sh +++ b/tests/codex-plus-compatibility-tests.sh @@ -2,8 +2,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -PATCH_FILE="$ROOT/patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch" -DEFAULT_ARCHIVE="$ROOT/vendor/offline-payloads/sources/CodexPlusPlus-v1.2.43.tar.gz" +PATCH_FILE="$ROOT/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch" +DEFAULT_ARCHIVE="$ROOT/vendor/offline-payloads/sources/CodexPlusPlus-v1.2.44.tar.gz" TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/codex-plus-compatibility-tests.XXXXXX")" trap 'rm -rf "$TEMP_ROOT"' EXIT diff --git a/tests/codex-plus-payload-build-tests.sh b/tests/codex-plus-payload-build-tests.sh index 16b6ac7..cc462e4 100755 --- a/tests/codex-plus-payload-build-tests.sh +++ b/tests/codex-plus-payload-build-tests.sh @@ -17,7 +17,7 @@ last_build_line="$(rg -nF '"$CARGO_BIN" build' \ [[ -n "$archive_line" && -n "$last_build_line" && "$archive_line" -gt "$last_build_line" ]] \ || fail 'source archive staging must follow Rust checks and both target builds' -SOURCE_FIXTURE="$TEST_ROOT/CodexPlusPlus-1.2.43" +SOURCE_FIXTURE="$TEST_ROOT/CodexPlusPlus-1.2.44" OUTPUT_ROOT="$TEST_ROOT/offline-payloads" FAKE_BIN="$TEST_ROOT/fake-bin" CAPTURE_DIR="$TEST_ROOT/capture" @@ -49,7 +49,7 @@ chmod +x "$SOURCE_FIXTURE/scripts/installer/macos/package-dmg.sh" ( cd "$TEST_ROOT" - tar -czf CodexPlusPlus-v1.2.43.tar.gz CodexPlusPlus-1.2.43 + tar -czf CodexPlusPlus-v1.2.44.tar.gz CodexPlusPlus-1.2.44 ) cat > "$OUTPUT_ROOT/payload-manifest.json" <<'JSON' @@ -59,9 +59,9 @@ cat > "$OUTPUT_ROOT/payload-manifest.json" <<'JSON' "files": [ { "id": "codex-plus-plus-arm64", - "version": "1.2.43", + "version": "1.2.44", "architecture": "arm64", - "relativePath": "apps/CodexPlusPlus-1.2.43-macos-arm64.dmg", + "relativePath": "apps/CodexPlusPlus-1.2.44-macos-arm64.dmg", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sourceURL": "https://example.invalid/upstream-arm64.dmg", "format": "dmg", @@ -69,9 +69,9 @@ cat > "$OUTPUT_ROOT/payload-manifest.json" <<'JSON' }, { "id": "codex-plus-plus-x86_64", - "version": "1.2.43", + "version": "1.2.44", "architecture": "x86_64", - "relativePath": "apps/CodexPlusPlus-1.2.43-macos-x64.dmg", + "relativePath": "apps/CodexPlusPlus-1.2.44-macos-x64.dmg", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sourceURL": "https://example.invalid/upstream-x64.dmg", "format": "dmg", @@ -79,9 +79,9 @@ cat > "$OUTPUT_ROOT/payload-manifest.json" <<'JSON' }, { "id": "codex-plus-plus-source", - "version": "1.2.43", + "version": "1.2.44", "architecture": "source", - "relativePath": "sources/CodexPlusPlus-v1.2.43.tar.gz", + "relativePath": "sources/CodexPlusPlus-v1.2.44.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "sourceURL": "https://example.invalid/upstream-source.tar.gz", "format": "archive", @@ -143,18 +143,18 @@ esac jq -cn \ --arg architecture "$architecture" \ '{ - version:"1.2.43", + version:"1.2.44", compatibilityRevision:"cross-provider-content-v1", applications:[ { bundleIdentifier:"com.bigpizzav3.codexplusplus", - version:"1.2.43", + version:"1.2.44", compatibilityRevision:"cross-provider-content-v1", architectures:[$architecture] }, { bundleIdentifier:"com.bigpizzav3.codexplusplus.manager", - version:"1.2.43", + version:"1.2.44", compatibilityRevision:"cross-provider-content-v1", architectures:[$architecture] } @@ -165,7 +165,7 @@ chmod +x "$FAKE_BIN/"* CAPTURE_DIR="$CAPTURE_DIR" \ TEST_MODE=1 \ -SOURCE_ARCHIVE="$TEST_ROOT/CodexPlusPlus-v1.2.43.tar.gz" \ +SOURCE_ARCHIVE="$TEST_ROOT/CodexPlusPlus-v1.2.44.tar.gz" \ OUTPUT_ROOT="$OUTPUT_ROOT" \ PATCH_BIN="$FAKE_BIN/patch" \ NPM_BIN="$FAKE_BIN/npm" \ @@ -187,9 +187,9 @@ rg -F -- '--target x86_64-apple-darwin' "$commands" >/dev/null \ || fail 'x86_64 Rust target was not built' rg -F $'npm\t' "$commands" >/dev/null || fail 'manager frontend commands were not run' -arm_dmg="$OUTPUT_ROOT/apps/CodexPlusPlus-1.2.43-codexkit.1-macos-arm64.dmg" -x64_dmg="$OUTPUT_ROOT/apps/CodexPlusPlus-1.2.43-codexkit.1-macos-x64.dmg" -source_archive="$OUTPUT_ROOT/sources/CodexPlusPlus-v1.2.43-codexkit.1.tar.gz" +arm_dmg="$OUTPUT_ROOT/apps/CodexPlusPlus-1.2.44-codexkit.1-macos-arm64.dmg" +x64_dmg="$OUTPUT_ROOT/apps/CodexPlusPlus-1.2.44-codexkit.1-macos-x64.dmg" +source_archive="$OUTPUT_ROOT/sources/CodexPlusPlus-v1.2.44-codexkit.1.tar.gz" [[ -f "$arm_dmg" && -f "$x64_dmg" && -f "$source_archive" ]] \ || fail 'patched payload set is incomplete' tar -tzf "$source_archive" | rg -q 'CODEXKIT-PATCH.md$' \ @@ -197,8 +197,8 @@ tar -tzf "$source_archive" | rg -q 'CODEXKIT-PATCH.md$' \ source_extract="$TEST_ROOT/source-extract" mkdir -p "$source_extract" tar -xzf "$source_archive" -C "$source_extract" -provenance_file="$source_extract/CodexPlusPlus-1.2.43/CODEXKIT-PATCH.md" -expected_patch_sha="$(shasum -a 256 "$ROOT/patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch" | awk '{print $1}')" +provenance_file="$source_extract/CodexPlusPlus-1.2.44/CODEXKIT-PATCH.md" +expected_patch_sha="$(shasum -a 256 "$ROOT/patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch" | awk '{print $1}')" rg -F "Patch SHA-256: $expected_patch_sha" "$provenance_file" >/dev/null \ || fail 'source provenance patch SHA is stale' rg -F 'Remove `style` from the macOS Dream Skin root MutationObserver filters' "$provenance_file" >/dev/null \ @@ -206,7 +206,7 @@ rg -F 'Remove `style` from the macOS Dream Skin root MutationObserver filters' " jq -e ' [.files[] | select(.id | startswith("codex-plus-plus-"))] | length == 3 - and all(.[]; .version == "1.2.43") + and all(.[]; .version == "1.2.44") and all(.[]; .compatibilityRevision == "cross-provider-content-v1") ' "$OUTPUT_ROOT/payload-manifest.json" >/dev/null \ || fail 'patched manifest metadata is invalid' @@ -232,7 +232,7 @@ bash "$ROOT/scripts/build-codex-plus-compatibility-payloads.sh" \ wrong_status=$? set -e [[ "$wrong_status" -eq 65 ]] || fail 'wrong source version was not rejected' -rg -F 'source archive must contain CodexPlusPlus-1.2.43' "$TEST_ROOT/wrong-version.log" >/dev/null \ +rg -F 'source archive must contain CodexPlusPlus-1.2.44' "$TEST_ROOT/wrong-version.log" >/dev/null \ || fail 'wrong source version explanation is missing' printf '%s\n' 'codex-plus-payload-build-tests: PASS' diff --git a/tests/installer-package-tests.sh b/tests/installer-package-tests.sh index 526387d..35701f1 100755 --- a/tests/installer-package-tests.sh +++ b/tests/installer-package-tests.sh @@ -24,7 +24,7 @@ for contract in \ 'SHA256SUMS.txt' \ 'shasum -a 256' \ 'cross-provider-content-v1' \ - 'v1.2.43-cross-provider-history.patch' \ + 'v1.2.44-cross-provider-history.patch' \ 'CODEXKIT-PATCH.md' \ '/bin/chmod -R u+w "$APP"' \ '/usr/bin/xattr -cr "$APP"' \ @@ -37,8 +37,8 @@ done VERIFY_SCRIPT="$ROOT/scripts/verify-release-artifact.sh" [[ -f "$VERIFY_SCRIPT" ]] || fail 'missing scripts/verify-release-artifact.sh' for contract in \ - '.version == "1.2.43"' \ - 'v1.2.43-cross-provider-history.patch'; do + '.version == "1.2.44"' \ + 'v1.2.44-cross-provider-history.patch'; do rg -Fq "$contract" "$VERIFY_SCRIPT" || fail "release verifier contract missing: $contract" done ! rg -Fq 'v1.2.42-cross-provider-history.patch' "$VERIFY_SCRIPT" \ diff --git a/tests/online_release_contract_tests.py b/tests/online_release_contract_tests.py index 2e0707d..7c456ae 100644 --- a/tests/online_release_contract_tests.py +++ b/tests/online_release_contract_tests.py @@ -24,6 +24,20 @@ def test_macos_bootstrap_uses_openai_and_codex_plus_release(self): self.assertIn("BigPizzaV3/CodexPlusPlus", script) self.assertNotIn("agentsmirror", script) self.assertIn("codesign", script) + self.assertIn("codex-plus-plus", script) + self.assertIn("BUNDLED_CODEX_PLUS_DMG", script) + + def test_macos_online_dmg_stages_both_codex_plus_architectures(self): + builder = (ROOT / "macos" / "online" / "build-online-dmg.sh").read_text( + encoding="utf-8" + ) + workflow = (ROOT / ".github" / "workflows" / "online-release.yml").read_text( + encoding="utf-8" + ) + self.assertIn("CodexPlusPlusArmDmg", workflow) + self.assertIn("CodexPlusPlusX64Dmg", workflow) + self.assertIn("codex-plus-plus/CodexPlusPlus-arm64.dmg", builder) + self.assertIn("codex-plus-plus/CodexPlusPlus-x64.dmg", builder) def test_release_workflow_builds_both_platform_assets(self): workflow = (ROOT / ".github" / "workflows" / "online-release.yml").read_text( diff --git a/windows/README.md b/windows/README.md index 8b2f14f..c9bcaef 100644 --- a/windows/README.md +++ b/windows/README.md @@ -81,14 +81,14 @@ Rust stable、NSIS 与 Inno Setup 7.0.2,并确保 `dotnet`、`node`、`cargo` dotnet restore .\CodexOneClickInstaller.sln --locked-mode $compat = '.\build\codex-plus-compat' .\scripts\build-codex-plus-compatibility-payload.ps1 ` - -Tag v1.2.43 ` - -Patch ..\patches\CodexPlusPlus\v1.2.43-cross-provider-history.patch ` + -Tag v1.2.44 ` + -Patch ..\patches\CodexPlusPlus\v1.2.44-cross-provider-history.patch ` -OutputRoot $compat .\scripts\refresh-offline-payloads.ps1 ` -OutputRoot .\vendor\offline-payloads ` - -CodexPlusPlusSetup "$compat\CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe" ` - -CodexPlusPlusSource "$compat\CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz" + -CodexPlusPlusSetup "$compat\CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe" ` + -CodexPlusPlusSource "$compat\CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz" . .\scripts\offline-payload-supply.ps1 $active = Resolve-ActivePayloadRoot ( diff --git a/windows/scripts/build-codex-plus-compatibility-payload.ps1 b/windows/scripts/build-codex-plus-compatibility-payload.ps1 index 11da89a..6184b0d 100644 --- a/windows/scripts/build-codex-plus-compatibility-payload.ps1 +++ b/windows/scripts/build-codex-plus-compatibility-payload.ps1 @@ -1,7 +1,7 @@ #Requires -Version 5.1 <# .SYNOPSIS -Builds the fixed Codex++ v1.2.43 Windows compatibility payload. +Builds the fixed Codex++ v1.2.44 Windows compatibility payload. .DESCRIPTION Applies the shared CodexKit patch to the pinned upstream source, runs the @@ -14,7 +14,7 @@ explicit fixtureOnly marker and are not accepted by BuiltRoot release inspection [CmdletBinding()] param( [ValidateNotNullOrEmpty()] - [string] $Tag = 'v1.2.43', + [string] $Tag = 'v1.2.44', [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -35,28 +35,28 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$ExpectedTag = 'v1.2.43' +$ExpectedTag = 'v1.2.44' $ExpectedSourceArchiveSha256 = ` - '5612fbdc60244b9080823b596745c6e88f8cc5fa1996015b143b44ba6e51dd7f' + '2c9a1900b24e838ed7b9405534be15efc81a670636cd97d4de8a16cab17a73cb' $ExpectedPatchSha256 = ` - '5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739' -$UpstreamVersion = '1.2.43' + '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15' +$UpstreamVersion = '1.2.44' $PatchRevision = 'codexkit.1' -$PayloadVersion = '1.2.43+codexkit.1' +$PayloadVersion = '1.2.44+codexkit.1' $CompatibilityRevision = 'cross-provider-content-v1' $ExecutableMetadataMagic = 'CODEXKIT-EXECUTABLE-METADATA-V1:' $SetupProvenanceSchema = 'CODEXKIT-SETUP-PROVENANCE-V1' $SetupProvenanceMagic = "$SetupProvenanceSchema`:" $PerUserInstallDir = '$LOCALAPPDATA\Programs\Codex++' -$SetupName = 'CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe' -$SourceName = 'CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz' -$UpstreamDirectory = 'CodexPlusPlus-1.2.43' +$SetupName = 'CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe' +$SourceName = 'CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz' +$UpstreamDirectory = 'CodexPlusPlus-1.2.44' $ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $WindowsRoot = Split-Path -Parent $ScriptRoot $RepositoryRoot = Split-Path -Parent $WindowsRoot $SourcesFile = Join-Path (Join-Path $WindowsRoot 'vendor') 'upstream-sources.json' $CanonicalPatch = Join-Path (Join-Path (Join-Path $RepositoryRoot 'patches') ` - 'CodexPlusPlus') 'v1.2.43-cross-provider-history.patch' + 'CodexPlusPlus') 'v1.2.44-cross-provider-history.patch' $SharedFixture = Join-Path (Join-Path (Join-Path $RepositoryRoot 'tests') 'fixtures') ` 'codex-plus/cross_provider_content.rs' $WorkRoot = '' @@ -722,8 +722,8 @@ function Convert-NsisToCodexKitPerUserInstaller([string] $NsiPath) { if ($anchorIndex -lt 0) { Fail 'CodexPlusPlus.nsi version anchor is missing' } $metadata = @" -VIProductVersion "1.2.43.1" -VIFileVersion "1.2.43.1" +VIProductVersion "1.2.44.1" +VIFileVersion "1.2.44.1" VIAddVersionKey "ProductName" "Codex++" VIAddVersionKey "ProductVersion" "`${VERSION}" VIAddVersionKey "FileVersion" "`${VERSION}" @@ -784,7 +784,7 @@ if ($canonicalPatchSha -cne $ExpectedPatchSha256) { Fail "repository shared patch SHA-256 is not the reviewed digest: $canonicalPatchSha" } if ($inputPatchSha -cne $ExpectedPatchSha256) { - Fail 'Patch must be byte-identical to the reviewed repository v1.2.43 shared patch' + Fail 'Patch must be byte-identical to the reviewed repository v1.2.44 shared patch' } Assert-RegularFile $SourcesFile 'upstream source policy' Assert-RegularFile $SharedFixture 'shared cross-provider Rust fixture' @@ -792,7 +792,7 @@ $sources = Get-Content -LiteralPath $SourcesFile -Raw | ConvertFrom-Json $expectedSourceUrl = "https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/$ExpectedTag.tar.gz" if ($sources.codexPlusPlusTag -cne $ExpectedTag -or $sources.codexPlusPlusSource -cne $expectedSourceUrl) { - Fail 'upstream source policy is not pinned to the exact Codex++ v1.2.43 archive' + Fail 'upstream source policy is not pinned to the exact Codex++ v1.2.44 archive' } $OutputFull = [IO.Path]::GetFullPath($OutputRoot) @@ -838,7 +838,7 @@ try { } $sourceSha = Get-Sha256 $archiveFull if (-not $TestMode -and $sourceSha -cne $ExpectedSourceArchiveSha256) { - Fail 'pinned upstream source archive SHA-256 does not match the reviewed v1.2.43 archive' + Fail 'pinned upstream source archive SHA-256 does not match the reviewed v1.2.44 archive' } $patchSha = $ExpectedPatchSha256 $extractRoot = Join-Path $WorkRoot 'extracted' @@ -886,7 +886,7 @@ try { 'codex-plus-core') 'tests/codexkit_cross_provider_content.rs' Copy-Item -LiteralPath $SharedFixture -Destination $sourceFixture -Force $patchInSource = Join-Path (Join-Path (Join-Path $sourceRoot 'patches') ` - 'CodexPlusPlus') 'v1.2.43-cross-provider-history.patch' + 'CodexPlusPlus') 'v1.2.44-cross-provider-history.patch' New-Item -ItemType Directory -Path (Split-Path -Parent $patchInSource) ` -Force | Out-Null Copy-Item -LiteralPath $PatchFull -Destination $patchInSource -Force @@ -954,7 +954,7 @@ try { upstreamTag = $ExpectedTag upstreamSource = $expectedSourceUrl sourceArchiveSha256 = $sourceSha - patch = 'patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch' + patch = 'patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch' patchSha256 = $patchSha patchRevision = $PatchRevision payloadVersion = $PayloadVersion @@ -1002,7 +1002,7 @@ try { - Upstream tag: $ExpectedTag - Upstream source: $expectedSourceUrl - Source archive SHA-256: $sourceSha -- Patch: patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch +- Patch: patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch - Patch SHA-256: $patchSha - Patch revision: $PatchRevision - Payload version: $PayloadVersion diff --git a/windows/scripts/offline-payload-supply.ps1 b/windows/scripts/offline-payload-supply.ps1 index 5d1d5c8..aa8da01 100644 --- a/windows/scripts/offline-payload-supply.ps1 +++ b/windows/scripts/offline-payload-supply.ps1 @@ -5,8 +5,8 @@ function Get-WindowsPayloadPolicy([switch] $FixtureMode) { $components = if ($FixtureMode) { [ordered]@{ 'codex-windows-x64' = [ordered]@{ relativePath = 'apps/Codex.msix'; format = 'msix' } - 'codex-plus-plus-windows-x64' = [ordered]@{ relativePath = 'apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe'; format = 'exe' } - 'codex-plus-plus-source' = [ordered]@{ relativePath = 'sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz'; format = 'archive' } + 'codex-plus-plus-windows-x64' = [ordered]@{ relativePath = 'apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe'; format = 'exe' } + 'codex-plus-plus-source' = [ordered]@{ relativePath = 'sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz'; format = 'archive' } 'model-catalog' = [ordered]@{ relativePath = 'model-catalog.json'; format = 'json' } 'plugin-marketplaces' = [ordered]@{ relativePath = 'plugins'; format = 'directory' } 'script-market' = [ordered]@{ relativePath = 'script-market'; format = 'directory' } @@ -14,8 +14,8 @@ function Get-WindowsPayloadPolicy([switch] $FixtureMode) { } else { [ordered]@{ 'codex-windows-x64' = [ordered]@{ relativePath = 'apps/Codex.msix'; format = 'msix' } - 'codex-plus-plus-windows-x64' = [ordered]@{ relativePath = 'apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe'; format = 'exe' } - 'codex-plus-plus-source' = [ordered]@{ relativePath = 'sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz'; format = 'archive' } + 'codex-plus-plus-windows-x64' = [ordered]@{ relativePath = 'apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe'; format = 'exe' } + 'codex-plus-plus-source' = [ordered]@{ relativePath = 'sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz'; format = 'archive' } 'model-catalog' = [ordered]@{ relativePath = 'model-catalog.json'; format = 'json' } 'plugin-marketplaces' = [ordered]@{ relativePath = 'plugins'; format = 'directory' } 'script-market' = [ordered]@{ relativePath = 'script-market'; format = 'directory' } @@ -43,7 +43,7 @@ function Get-WindowsPayloadPolicy([switch] $FixtureMode) { packageFamilyName = 'OpenAI.Codex_2p2nqsd0c76g0' } CodexPlusPlus = [ordered]@{ - payloadVersion = '1.2.43+codexkit.1' + payloadVersion = '1.2.44+codexkit.1' architecture = 'x64' compatibilityRevision = 'cross-provider-content-v1' licenseID = 'AGPL-3.0-only' diff --git a/windows/scripts/refresh-offline-payloads.ps1 b/windows/scripts/refresh-offline-payloads.ps1 index 77fd14b..bf1c6fd 100644 --- a/windows/scripts/refresh-offline-payloads.ps1 +++ b/windows/scripts/refresh-offline-payloads.ps1 @@ -543,7 +543,7 @@ try { $sources.codexStoreProductId -cne '9PLM9XGG6VKS' -or $sources.codexStoreListingURL -cne 'https://apps.microsoft.com/detail/9PLM9XGG6VKS' -or - $sources.codexPlusPlusTag -cne 'v1.2.43' -or + $sources.codexPlusPlusTag -cne 'v1.2.44' -or $sources.scriptMarketCommit -cne 'b3c1d16d7d75145b9cf5b0e34000316436d905dd') { Fail 'upstream source or payload lock is not the approved schema-2 lock' } @@ -554,8 +554,8 @@ try { if ((Split-Path -Leaf $CodexPlusPlusSetup) -cnotmatch '^CodexPlusPlus-1\.2\.43-codexkit\.1-windows-x64-setup\.exe$' -or (Split-Path -Leaf $CodexPlusPlusSource) -cnotmatch - '^CodexPlusPlus-v1\.2\.43-codexkit\.1-source\.tar\.gz$') { - Fail 'Codex++ inputs are not the exact Task 3 v1.2.43+codexkit.1 artifacts' + '^CodexPlusPlus-v1\.2\.44-codexkit\.1-source\.tar\.gz$') { + Fail 'Codex++ inputs are not the exact Task 3 v1.2.44+codexkit.1 artifacts' } New-Item -ItemType Directory -Path $OutputParent, $StageRoot, $WorkRoot -Force | Out-Null @@ -602,8 +602,8 @@ try { $dependencyIndex++ } - $cppRelative = 'apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe' - $sourceRelative = 'sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz' + $cppRelative = 'apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe' + $sourceRelative = 'sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz' Copy-Item -LiteralPath $CodexPlusPlusSetup -Destination (Join-Path $StageRoot $cppRelative) Copy-Item -LiteralPath $CodexPlusPlusSource -Destination (Join-Path $StageRoot $sourceRelative) @@ -644,12 +644,12 @@ try { packageName = $identity.Name })) foreach ($entry in $dependencyEntries) { $entries.Add($entry) } - $entries.Add((New-PayloadEntry 'codex-plus-plus-windows-x64' '1.2.43+codexkit.1' 'x64' ` + $entries.Add((New-PayloadEntry 'codex-plus-plus-windows-x64' '1.2.44+codexkit.1' 'x64' ` $cppRelative $sources.codexPlusPlusSource 'exe' @{ compatibilityRevision = 'cross-provider-content-v1'; licenseID = 'AGPL-3.0-only' authenticodePolicy = 'unsigned' })) - $entries.Add((New-PayloadEntry 'codex-plus-plus-source' '1.2.43+codexkit.1' 'source' ` + $entries.Add((New-PayloadEntry 'codex-plus-plus-source' '1.2.44+codexkit.1' 'source' ` $sourceRelative $sources.codexPlusPlusSource 'archive' @{ compatibilityRevision = 'cross-provider-content-v1'; licenseID = 'AGPL-3.0-only' })) diff --git a/windows/tests/CodexPlusCompatibility.Tests.ps1 b/windows/tests/CodexPlusCompatibility.Tests.ps1 index 4902687..8dbfed7 100644 --- a/windows/tests/CodexPlusCompatibility.Tests.ps1 +++ b/windows/tests/CodexPlusCompatibility.Tests.ps1 @@ -1,7 +1,7 @@ #Requires -Version 5.1 <# .SYNOPSIS -Runs the Codex++ v1.2.43 cross-provider compatibility contract. +Runs the Codex++ v1.2.44 cross-provider compatibility contract. .DESCRIPTION With no parameters, this script runs a hermetic fixture build. SourceArchive @@ -33,17 +33,17 @@ $Builder = Join-Path (Join-Path $WindowsRoot 'scripts') ` $PayloadLock = Join-Path (Join-Path $WindowsRoot 'vendor') 'payload-lock.json' $SharedFixture = Join-Path (Join-Path (Join-Path $RepositoryRoot 'tests') 'fixtures') ` 'codex-plus/cross_provider_content.rs' -$ExpectedTag = 'v1.2.43' -$ExpectedVersion = '1.2.43+codexkit.1' +$ExpectedTag = 'v1.2.44' +$ExpectedVersion = '1.2.44+codexkit.1' $ExpectedRevision = 'cross-provider-content-v1' $ExpectedPatchSha256 = ` - '5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739' + '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15' $ExecutableMetadataMagic = 'CODEXKIT-EXECUTABLE-METADATA-V1:' $SetupProvenanceSchema = 'CODEXKIT-SETUP-PROVENANCE-V1' $SetupProvenanceMagic = "$SetupProvenanceSchema`:" $ExpectedInstallDir = '$LOCALAPPDATA\Programs\Codex++' -$SetupName = 'CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe' -$SourceName = 'CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz' +$SetupName = 'CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe' +$SourceName = 'CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz' $TestRoot = Join-Path ([IO.Path]::GetTempPath()) ` "codex-plus-compatibility-$([Guid]::NewGuid().ToString('N'))" @@ -317,8 +317,8 @@ function Expand-SourceArchive([string] $Archive, [string] $Destination) { Invoke-Native $tar @('-xzf', $Archive, '-C', $Destination) $RepositoryRoot $roots = @(Get-ChildItem -LiteralPath $Destination -Directory -Force) Assert-True ($roots.Count -eq 1) 'source archive must have exactly one top-level directory' - Assert-True ($roots[0].Name -ceq 'CodexPlusPlus-1.2.43') ` - 'source archive top-level directory is not CodexPlusPlus-1.2.43' + Assert-True ($roots[0].Name -ceq 'CodexPlusPlus-1.2.44') ` + 'source archive top-level directory is not CodexPlusPlus-1.2.44' return $roots[0].FullName } @@ -508,7 +508,7 @@ function New-FixtureSourceArchive( [string] $NsiVariant = 'Valid' ) { $fixtureParent = Join-Path $TestRoot 'fixture-source' - $root = Join-Path $fixtureParent 'CodexPlusPlus-1.2.43' + $root = Join-Path $fixtureParent 'CodexPlusPlus-1.2.44' $manager = Join-Path (Join-Path (Join-Path $root 'apps') 'codex-plus-manager') '' $managerTauri = Join-Path (Join-Path $manager 'src-tauri') 'src' $tests = Join-Path (Join-Path (Join-Path $root 'crates') 'codex-plus-core') 'tests' @@ -528,10 +528,10 @@ pub fn run() { } '@ Write-Utf8NoBom (Join-Path $manager 'package.json') @' -{"name":"codex-plus-manager","version":"1.2.43","private":true,"scripts":{}} +{"name":"codex-plus-manager","version":"1.2.44","private":true,"scripts":{}} '@ Write-Utf8NoBom (Join-Path $manager 'package-lock.json') @' -{"name":"codex-plus-manager","version":"1.2.43","lockfileVersion":3,"packages":{}} +{"name":"codex-plus-manager","version":"1.2.44","lockfileVersion":3,"packages":{}} '@ $nsiText = @' Unicode true @@ -598,7 +598,7 @@ SectionEnd } Write-Utf8NoBom (Join-Path $installer 'CodexPlusPlus.nsi') $nsiText $tar = Get-TarCommand - Invoke-Native $tar @('-czf', $ArchivePath, 'CodexPlusPlus-1.2.43') $fixtureParent + Invoke-Native $tar @('-czf', $ArchivePath, 'CodexPlusPlus-1.2.44') $fixtureParent } function New-ToolShim([string] $Path, [string] $Body) { @@ -616,7 +616,7 @@ if ($sourceIndex -lt 0 -or $sourceIndex + 1 -ge $Rest.Count) { exit 91 } $sourceRoot = $Rest[$sourceIndex + 1] $patchPath = $Rest[$Rest.Count - 1] $patchSha = (Get-FileHash -LiteralPath $patchPath -Algorithm SHA256).Hash.ToLowerInvariant() -if ($patchSha -cne '5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739') { +if ($patchSha -cne '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15') { exit 92 } $patchText = Get-Content -LiteralPath $patchPath -Raw @@ -740,7 +740,7 @@ function Invoke-ContractMode { "builder is missing: $Builder" $builderText = Get-Content -LiteralPath $Builder -Raw Assert-Contains $builderText ` - '5612fbdc60244b9080823b596745c6e88f8cc5fa1996015b143b44ba6e51dd7f' ` + '2c9a1900b24e838ed7b9405534be15efc81a670636cd97d4de8a16cab17a73cb' ` 'builder does not pin the reviewed upstream source archive SHA-256' Assert-Contains $builderText $ExpectedPatchSha256 ` 'builder does not pin the reviewed shared patch SHA-256' @@ -755,7 +755,7 @@ function Invoke-ContractMode { Assert-Contains $builderText $SetupProvenanceSchema ` 'builder does not append the fixed setup provenance schema' $defaultPatch = Join-Path (Join-Path (Join-Path $RepositoryRoot 'patches') ` - 'CodexPlusPlus') 'v1.2.43-cross-provider-history.patch' + 'CodexPlusPlus') 'v1.2.44-cross-provider-history.patch' Assert-PatchContract $defaultPatch $lock = Get-Content -LiteralPath $PayloadLock -Raw | ConvertFrom-Json Assert-True ($lock.codexPlusPlus.payloadVersion -ceq $ExpectedVersion) ` @@ -771,7 +771,7 @@ function Invoke-ContractMode { $lock.components.'codex-plus-plus-source'.relativePath -ceq "sources/$SourceName") 'payload lock source path is stale' - $archive = Join-Path $TestRoot 'CodexPlusPlus-v1.2.43-source.tar.gz' + $archive = Join-Path $TestRoot 'CodexPlusPlus-v1.2.44-source.tar.gz' $toolRoot = Join-Path $TestRoot 'tools' $output = Join-Path $TestRoot 'built' $capture = Join-Path $TestRoot 'commands.log' @@ -866,7 +866,7 @@ function Invoke-ContractMode { 'LICENSE', 'CODEXKIT-PATCH.md', 'CODEXKIT-BUILD-MANIFEST.json', - 'patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch' + 'patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch' )) { Assert-True (Test-Path -LiteralPath (Join-Path $sourceRoot $relative) -PathType Leaf) ` "source archive omitted $relative" @@ -971,7 +971,7 @@ function Invoke-ContractMode { } '*executable record differs from source manifest*' ` 'setup provenance validator accepted a tampered executable hash' $archivedPatch = Join-Path $sourceRoot ` - 'patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch' + 'patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch' Assert-True ((Get-FileHash -LiteralPath $archivedPatch -Algorithm SHA256).Hash -ceq $ExpectedPatchSha256.ToUpperInvariant()) ` 'source archive patch is not byte-identical to the shared patch' @@ -985,8 +985,8 @@ function Invoke-ContractMode { Join-Path $sourceRoot 'scripts/installer/windows/CodexPlusPlus.nsi') -Raw Assert-PerUserNsiContract $archivedNsi 'fixture archived NSIS source' foreach ($needle in @( - 'VIProductVersion "1.2.43.1"', - 'VIFileVersion "1.2.43.1"', + 'VIProductVersion "1.2.44.1"', + 'VIFileVersion "1.2.44.1"', 'VIAddVersionKey "ProductVersion" "${VERSION}"', 'VIAddVersionKey "FileVersion" "${VERSION}"', 'VIAddVersionKey "CodexKitCompatibilityRevision" "cross-provider-content-v1"' @@ -1073,7 +1073,7 @@ function Invoke-ContractMode { Assert-ThrowsLike { & $Builder -Tag 'v1.2.42' -Patch $defaultPatch -OutputRoot $wrongOutput ` -SourceArchive $archive -TestMode -ToolRoot $toolRoot - } '*only fixed tag v1.2.43 is allowed*' 'builder accepted an unpinned tag' + } '*only fixed tag v1.2.44 is allowed*' 'builder accepted an unpinned tag' $tamperedPatch = Join-Path $TestRoot 'tampered.patch' Write-Utf8NoBom $tamperedPatch ((Get-Content -LiteralPath $defaultPatch -Raw) + "`n") Assert-ThrowsLike { @@ -1201,7 +1201,7 @@ function Invoke-BuiltMode { } Assert-ExactPayloadChecksums $root $canonicalPatch = Join-Path (Join-Path (Join-Path $RepositoryRoot 'patches') ` - 'CodexPlusPlus') 'v1.2.43-cross-provider-history.patch' + 'CodexPlusPlus') 'v1.2.44-cross-provider-history.patch' Assert-PatchContract $canonicalPatch $setup = Join-Path $root $SetupName $version = (Get-Item -LiteralPath $setup).VersionInfo @@ -1313,13 +1313,13 @@ function Invoke-BuiltMode { 'LICENSE', 'CODEXKIT-PATCH.md', 'CODEXKIT-BUILD-MANIFEST.json', - 'patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch' + 'patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch' )) { Assert-True (Test-Path -LiteralPath (Join-Path $sourceRoot $relative) -PathType Leaf) ` "source archive omitted $relative" } $archivedPatch = Join-Path $sourceRoot ` - 'patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch' + 'patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch' $archivedPatchSha = (Get-FileHash -LiteralPath $archivedPatch ` -Algorithm SHA256).Hash.ToLowerInvariant() Assert-True ($archivedPatchSha -ceq $ExpectedPatchSha256) ` diff --git a/windows/tests/DocumentationTests.ps1 b/windows/tests/DocumentationTests.ps1 index 5cbff9d..6b62cd1 100644 --- a/windows/tests/DocumentationTests.ps1 +++ b/windows/tests/DocumentationTests.ps1 @@ -192,7 +192,7 @@ foreach ($entry in $developerDocs.GetEnumerator()) { 'NSIS', 'Inno Setup 7.0.2', 'build-codex-plus-compatibility-payload.ps1', - 'v1.2.43-cross-provider-history.patch', + 'v1.2.44-cross-provider-history.patch', '-OutputRoot $compat', 'refresh-offline-payloads.ps1', '-CodexPlusPlusSetup', @@ -215,7 +215,7 @@ foreach ($entry in $developerDocs.GetEnumerator()) { ) "$($entry.Key) README passes the payload container directly" } foreach ($value in @( - '-Patch patches/CodexPlusPlus/v1.2.43-cross-provider-history.patch', + '-Patch patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch', '-OutputRoot windows/vendor/offline-payloads', "GetFullPath('windows/vendor/offline-payloads')", '-BuiltRoot $compat' @@ -223,7 +223,7 @@ foreach ($value in @( Assert-Contains $rootReadme $value 'root checkout-relative Windows chain' } foreach ($value in @( - '-Patch ..\patches\CodexPlusPlus\v1.2.43-cross-provider-history.patch', + '-Patch ..\patches\CodexPlusPlus\v1.2.44-cross-provider-history.patch', '-OutputRoot .\vendor\offline-payloads', "GetFullPath('.\vendor\offline-payloads')", '-TestResultsRoot .\test-results\full' diff --git a/windows/tests/ReleaseArtifact.Tests.ps1 b/windows/tests/ReleaseArtifact.Tests.ps1 index bd1d768..db9de90 100644 --- a/windows/tests/ReleaseArtifact.Tests.ps1 +++ b/windows/tests/ReleaseArtifact.Tests.ps1 @@ -159,7 +159,7 @@ if (-not [string]::IsNullOrWhiteSpace($env:CODEX_FAKE_ISCC_RUNTIME_BANNER)) { Copy-Item -LiteralPath (Join-Path $fakeState 'extracted') ` -Destination $tamperedExtraction -Recurse [IO.File]::AppendAllText( - (Join-Path $tamperedExtraction 'offline-payloads/apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe'), + (Join-Path $tamperedExtraction 'offline-payloads/apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe'), 'tampered') $tamperedExtractionRejected = $false try { diff --git a/windows/tests/fixtures/payload-manifest.valid.json b/windows/tests/fixtures/payload-manifest.valid.json index 4097062..9bfb5c4 100644 --- a/windows/tests/fixtures/payload-manifest.valid.json +++ b/windows/tests/fixtures/payload-manifest.valid.json @@ -16,12 +16,12 @@ }, { "id": "codex-plus-plus-windows-x64", - "version": "1.2.43+codexkit.1", + "version": "1.2.44+codexkit.1", "architecture": "x64", - "relativePath": "apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe", + "relativePath": "apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe", "sha256": "bbd6b21a57051728dee2799e9573d9bac561bd87b19c4bc7d36b15377ab6dacf", "size": 24, - "sourceURL": "https://github.com/BigPizzaV3/CodexPlusPlus/releases/tag/v1.2.43", + "sourceURL": "https://github.com/BigPizzaV3/CodexPlusPlus/releases/tag/v1.2.44", "format": "exe", "compatibilityRevision": "cross-provider-content-v1", "licenseID": "AGPL-3.0-only", @@ -29,12 +29,12 @@ }, { "id": "codex-plus-plus-source", - "version": "1.2.43+codexkit.1", + "version": "1.2.44+codexkit.1", "architecture": "source", - "relativePath": "sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz", + "relativePath": "sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz", "sha256": "0a1a0198cfd41ea374b7d5903f454523e3c1ab8e6d3ac6b4a26e3702c0e76d1d", "size": 31, - "sourceURL": "https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.43.tar.gz", + "sourceURL": "https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.44.tar.gz", "format": "archive", "compatibilityRevision": "cross-provider-content-v1", "licenseID": "AGPL-3.0-only" diff --git a/windows/tests/fixtures/payload-root/payload-lock.json b/windows/tests/fixtures/payload-root/payload-lock.json index 3d09b32..fc77ea4 100644 --- a/windows/tests/fixtures/payload-root/payload-lock.json +++ b/windows/tests/fixtures/payload-root/payload-lock.json @@ -11,8 +11,8 @@ ], "components": { "codex-windows-x64": {"relativePath":"apps/Codex.msix","format":"msix"}, - "codex-plus-plus-windows-x64": {"relativePath":"apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe","format":"exe"}, - "codex-plus-plus-source": {"relativePath":"sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz","format":"archive"}, + "codex-plus-plus-windows-x64": {"relativePath":"apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe","format":"exe"}, + "codex-plus-plus-source": {"relativePath":"sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz","format":"archive"}, "model-catalog": {"relativePath":"model-catalog.json","format":"json"}, "plugin-marketplaces": {"relativePath":"plugins","format":"directory"}, "script-market": {"relativePath":"script-market","format":"directory"} @@ -22,7 +22,7 @@ "packageFamilyName": "OpenAI.Codex_2p2nqsd0c76g0" }, "codexPlusPlus": { - "payloadVersion": "1.2.43+codexkit.1", + "payloadVersion": "1.2.44+codexkit.1", "architecture": "x64", "compatibilityRevision": "cross-provider-content-v1", "licenseID": "AGPL-3.0-only" diff --git a/windows/tests/fixtures/payload-root/payload-manifest.json b/windows/tests/fixtures/payload-root/payload-manifest.json index 9aab78a..04141b7 100644 --- a/windows/tests/fixtures/payload-root/payload-manifest.json +++ b/windows/tests/fixtures/payload-root/payload-manifest.json @@ -3,8 +3,8 @@ "fixture": true, "files": [ {"id":"codex-windows-x64","version":"26.7.27.0","architecture":"x64","relativePath":"apps/Codex.msix","sha256":"d1f00a6bd03f3f9b711bf98cc0d7efc3b8c918eb8e943e4c7d251b67c31645b4","size":420,"sourceURL":"https://get.microsoft.com/installer/download/9PLM9XGG6VKS","format":"msix","packageFamilyName":"OpenAI.Codex_2p2nqsd0c76g0","publisher":"CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B"}, - {"id":"codex-plus-plus-windows-x64","version":"1.2.43+codexkit.1","architecture":"x64","relativePath":"apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe","sha256":"bbd6b21a57051728dee2799e9573d9bac561bd87b19c4bc7d36b15377ab6dacf","size":24,"sourceURL":"https://github.com/BigPizzaV3/CodexPlusPlus/releases/tag/v1.2.43","format":"exe","compatibilityRevision":"cross-provider-content-v1","licenseID":"AGPL-3.0-only","authenticodePolicy":"unsigned"}, - {"id":"codex-plus-plus-source","version":"1.2.43+codexkit.1","architecture":"source","relativePath":"sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz","sha256":"0a1a0198cfd41ea374b7d5903f454523e3c1ab8e6d3ac6b4a26e3702c0e76d1d","size":31,"sourceURL":"https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.43.tar.gz","format":"archive","compatibilityRevision":"cross-provider-content-v1","licenseID":"AGPL-3.0-only"}, + {"id":"codex-plus-plus-windows-x64","version":"1.2.44+codexkit.1","architecture":"x64","relativePath":"apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe","sha256":"bbd6b21a57051728dee2799e9573d9bac561bd87b19c4bc7d36b15377ab6dacf","size":24,"sourceURL":"https://github.com/BigPizzaV3/CodexPlusPlus/releases/tag/v1.2.44","format":"exe","compatibilityRevision":"cross-provider-content-v1","licenseID":"AGPL-3.0-only","authenticodePolicy":"unsigned"}, + {"id":"codex-plus-plus-source","version":"1.2.44+codexkit.1","architecture":"source","relativePath":"sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz","sha256":"0a1a0198cfd41ea374b7d5903f454523e3c1ab8e6d3ac6b4a26e3702c0e76d1d","size":31,"sourceURL":"https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.44.tar.gz","format":"archive","compatibilityRevision":"cross-provider-content-v1","licenseID":"AGPL-3.0-only"}, {"id":"model-catalog","version":"1","architecture":"any","relativePath":"model-catalog.json","sha256":"83d618a8bc4c5260b3d5745573b54b25e6e2c2955109a1736a14c3132de87a21","size":35,"sourceURL":"https://example.invalid/repository/Resources/model-catalog.json","format":"json"}, {"id":"plugin-marketplaces","version":"fixture-1","architecture":"any","relativePath":"plugins","sha256":"50df9475ad644647734e8c2d27cea9c2a06de6a1ee005388211c727a42f2fc26","size":1298,"sourceURL":"https://get.microsoft.com/installer/download/9PLM9XGG6VKS","format":"directory"}, {"id":"script-market","version":"fixture-commit","architecture":"any","relativePath":"script-market","sha256":"03beffe0081eb818981d81c4e34787f9a72d23f180a9e2b2c24d9a264d76ebd7","size":2939,"sourceURL":"https://raw.githubusercontent.com/example/market/fixture-commit/index.json","format":"directory"} diff --git a/windows/vendor/payload-lock.json b/windows/vendor/payload-lock.json index 58adc1c..48cd56d 100644 --- a/windows/vendor/payload-lock.json +++ b/windows/vendor/payload-lock.json @@ -10,8 +10,8 @@ ], "components": { "codex-windows-x64": {"relativePath":"apps/Codex.msix","format":"msix"}, - "codex-plus-plus-windows-x64": {"relativePath":"apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe","format":"exe"}, - "codex-plus-plus-source": {"relativePath":"sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz","format":"archive"}, + "codex-plus-plus-windows-x64": {"relativePath":"apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe","format":"exe"}, + "codex-plus-plus-source": {"relativePath":"sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz","format":"archive"}, "model-catalog": {"relativePath":"model-catalog.json","format":"json"}, "plugin-marketplaces": {"relativePath":"plugins","format":"directory"}, "script-market": {"relativePath":"script-market","format":"directory"} @@ -21,7 +21,7 @@ "packageFamilyName": "OpenAI.Codex_2p2nqsd0c76g0" }, "codexPlusPlus": { - "payloadVersion": "1.2.43+codexkit.1", + "payloadVersion": "1.2.44+codexkit.1", "architecture": "x64", "compatibilityRevision": "cross-provider-content-v1", "licenseID": "AGPL-3.0-only" diff --git a/windows/vendor/upstream-sources.json b/windows/vendor/upstream-sources.json index 1b8f4ab..2522ef1 100644 --- a/windows/vendor/upstream-sources.json +++ b/windows/vendor/upstream-sources.json @@ -2,8 +2,8 @@ "schemaVersion": 2, "codexStoreProductId": "9PLM9XGG6VKS", "codexStoreListingURL": "https://apps.microsoft.com/detail/9PLM9XGG6VKS", - "codexPlusPlusTag": "v1.2.43", - "codexPlusPlusSource": "https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.43.tar.gz", + "codexPlusPlusTag": "v1.2.44", + "codexPlusPlusSource": "https://github.com/BigPizzaV3/CodexPlusPlus/archive/refs/tags/v1.2.44.tar.gz", "scriptMarketCommit": "b3c1d16d7d75145b9cf5b0e34000316436d905dd", "innoSetupVersion": "7.0.2", "innoSetupSha256": "5ad54ca3def786f8f4212552e54cc6d8d61329e2d24a1cfee0571d42c2684ff1" From 31db3a7683f332b64712fbaa9f425d6d7531c167 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:57:19 +0800 Subject: [PATCH 04/10] align v1.2.44 offline fixture filenames --- .../apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe | 1 - ...rce.tar.gz => CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz} | 0 2 files changed, 1 deletion(-) delete mode 100644 windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe rename windows/tests/fixtures/payload-root/sources/{CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz => CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz} (100%) diff --git a/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe b/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe deleted file mode 100644 index d9aa42c..0000000 --- a/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe +++ /dev/null @@ -1 +0,0 @@ -fixture-codex-plus-plus diff --git a/windows/tests/fixtures/payload-root/sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz b/windows/tests/fixtures/payload-root/sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz similarity index 100% rename from windows/tests/fixtures/payload-root/sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz rename to windows/tests/fixtures/payload-root/sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz From 99f4af0cb00bbc09f1f538b4c74ff044e7601e5f Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:58:04 +0800 Subject: [PATCH 05/10] include v1.2.44 offline installer fixture --- .../apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe | 1 + 1 file changed, 1 insertion(+) create mode 100644 windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe diff --git a/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe b/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe new file mode 100644 index 0000000..d9aa42c --- /dev/null +++ b/windows/tests/fixtures/payload-root/apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe @@ -0,0 +1 @@ +fixture-codex-plus-plus From 6231481a79a0d91795c19cff48998c04291ff3d4 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:04:05 +0800 Subject: [PATCH 06/10] restore audited monitor script sources for offline validation --- Resources/script-market-overrides.json | 22 + .../codex-context-used-meter.js | 3801 +++++++++++++++++ .../codex-token-usage.js | 1815 ++++++++ 3 files changed, 5638 insertions(+) create mode 100644 Resources/script-market-sources/codex-context-used-meter.js create mode 100644 Resources/script-market-sources/codex-token-usage.js diff --git a/Resources/script-market-overrides.json b/Resources/script-market-overrides.json index c2fe49b..93fc700 100644 --- a/Resources/script-market-overrides.json +++ b/Resources/script-market-overrides.json @@ -9,6 +9,28 @@ "pinnedURL": "https://raw.githubusercontent.com/hL091015/CodexPlusPlusScriptMarket/482076e76af9c78f18e3998bd99a96dc6033eb5d/scripts/zh_CN%E6%B1%89%E5%8C%96.user.js", "pinnedSHA256": "be19a7930116dfe8fa1c68571d6a3bb3130714f77c7e32a6c1da543a182270f5", "sourceCommit": "482076e76af9c78f18e3998bd99a96dc6033eb5d" + }, + { + "mode": "managed", + "id": "codex-context-used-meter", + "upstreamURL": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-context-used-meter.js", + "upstreamSHA256": "d2a5efe42d88d9b706b8ca8799947d2bc31c1b608b30c2673ac36fa0050911ab", + "managedSource": "script-market-sources/codex-context-used-meter.js", + "managedURL": "https://raw.githubusercontent.com/fancr-code/Uni-codex/8f47a2b20bc7fd16b771ca51d5e409219f0fd7df/Resources/script-market-sources/codex-context-used-meter.js", + "managedSHA256": "7d1f79dd2f379bf25787ed1fc65778266fd286cd33966692708f985fe3adba7d", + "sourceCommit": "8f47a2b20bc7fd16b771ca51d5e409219f0fd7df", + "provenance": "codex-context-meter-v101" + }, + { + "mode": "managed", + "id": "codex-token-usage", + "upstreamURL": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-token-usage.js", + "upstreamSHA256": "03808d22f53da374837227636ebd9f1ba593fe5aba6219e90e636d7ed7c806eb", + "managedSource": "script-market-sources/codex-token-usage.js", + "managedURL": "https://raw.githubusercontent.com/fancr-code/Uni-codex/8f47a2b20bc7fd16b771ca51d5e409219f0fd7df/Resources/script-market-sources/codex-token-usage.js", + "managedSHA256": "bf233607f8e60f56b3c68d29c15bbd5ed5d7582fc488380f56b8d2f553bb4ddd", + "sourceCommit": "8f47a2b20bc7fd16b771ca51d5e409219f0fd7df", + "provenance": "codex-token-usage-dedupe-v1" } ] } diff --git a/Resources/script-market-sources/codex-context-used-meter.js b/Resources/script-market-sources/codex-context-used-meter.js new file mode 100644 index 0000000..64922e1 --- /dev/null +++ b/Resources/script-market-sources/codex-context-used-meter.js @@ -0,0 +1,3801 @@ +(() => { + // Codex++ 会把本文件注入 Codex 渲染页;所有读取都只能依赖页面里已经暴露的运行态信号。 + const INSTALL_KEY = "__codexContextMeterInstalled"; + const API_KEY = "__codexContextMeter"; + const STYLE_ID = "codex-context-meter-style"; + const ROOT_ID = "codex-context-meter"; + const HISTORY_PORTAL_ID = "codex-context-meter-history-portal"; + const CONFIG_KEY = "__codexContextMeterConfig"; + const UI_STATE_STORAGE_KEY = "__codexContextMeterUiState"; + const PROVIDER_SUMMARY_KEY = "__codexContextMeterProviderSummary"; + const PROVIDER_SUMMARY_EVENT = "codex-context-meter-provider-summary"; + const SCRIPT_VERSION = 101; + const UPDATE_INTERVAL_MS = 5000; + const SLOW_SCAN_INTERVAL_MS = UPDATE_INTERVAL_MS; + const CONTEXT_USAGE_BACKGROUND_SAMPLE_INTERVAL_MS = UPDATE_INTERVAL_MS; + const CONTEXT_USAGE_BACKGROUND_SAMPLE_MAX_CONVERSATIONS = 32; + const SWITCH_RETRY_WINDOW_MS = 8000; + const SWITCH_RETRY_INTERVAL_MS = 700; + const NAVIGATION_PENDING_MS = 1500; + const NAVIGATION_UPDATE_DELAY_MS = 30; + const MUTATION_UPDATE_DELAY_MS = 500; + const ACTIVE_CONVERSATION_LOOKUP_CACHE_MS = 250; + const APP_SIGNAL_READING_CACHE_MS = 120; + const APP_SIGNAL_IMPORT_GRACE_MS = 600; + const INLINE_MOUNT_CACHE_MS = 5000; + // 以下限额只约束兜底扫描;主路径读 app signal / 已缓存读数,不受这些值影响。 + const EXPENSIVE_FALLBACK_INTERVAL_MS = 2500; + const REACT_HOST_SCAN_LIMIT = 180; + const WINDOW_KEY_CACHE_MS = 10000; + const SPEND_HISTORY_WINDOW_MS = 60 * 60 * 1000; + const SPEND_HISTORY_MAX_ITEMS = 200; + const SPEND_HISTORY_CHART_WIDTH = 244; + const SPEND_HISTORY_CHART_HEIGHT = 72; + const SPEND_HISTORY_CHART_PADDING = 8; + const SPEND_HISTORY_CHART_AXIS_WIDTH = 52; + const SPEND_EFFECT_DURATION_MS = 3000; + const SPEND_EFFECT_FALLBACK_MS = 3200; + const CONTEXT_SPEND_DEDUPE_WINDOW_MS = UPDATE_INTERVAL_MS * 2 + MUTATION_UPDATE_DELAY_MS; + const CONTEXT_SPEND_DEDUPE_KEY = "__codexContextMeterContextSpendDedupe"; + const PROVIDER_SPEND_DEDUPE_WINDOW_MS = 1500; + const PROVIDER_SPEND_DEDUPE_KEY = "__codexContextMeterProviderSpendDedupe"; + const HISTORY_PANEL_VIEWPORT_PADDING = 8; + const HISTORY_PANEL_GAP = 8; + const HISTORY_PANEL_MIN_WIDTH = 240; + const HISTORY_PANEL_MIN_HEIGHT = 80; + const HISTORY_PORTAL_THEME_VARIABLES = [ + "--ccm-card-value", + "--ccm-panel-border", + "--ccm-panel-bg", + "--ccm-panel-text", + "--ccm-panel-shadow", + "--ccm-muted-strong", + "--ccm-muted", + "--ccm-muted-soft", + "--ccm-axis-line", + "--ccm-gridline", + ]; + const FLOAT_DRAG_HOLD_MS = 260; + const FLOAT_SCALE_MIN = 0.7; + const FLOAT_SCALE_MAX = 1.8; + const FLOAT_SCALE_STEP = 0.08; + const DEFAULT_FLOATING_UI = { + mode: "inline", + floatingLayout: "horizontal", + theme: "dark", + x: 16, + y: 10, + scale: 1, + }; + const DEFAULT_UI_CONFIG = { + context: { + showUsedInsteadOfLeft: false, + compressionWarningLeftPercent: 20, + levelThresholds: { + criticalLeftPercent: 30, + dangerLeftPercent: 40, + warnLeftPercent: 50, + noticeLeftPercent: 60, + }, + }, + provider: { + levelThresholds: { + criticalLeftPercent: 30, + dangerLeftPercent: 40, + warnLeftPercent: 50, + noticeLeftPercent: 60, + }, + }, + }; + const CODEX_COMPOSER_SELECTOR = `[data-codex-composer="true"]`; + const THREAD_COMPOSER_SELECTOR = `[data-thread-find-composer="true"]`; + const CODEX_INTELLIGENCE_TRIGGER_SELECTOR = `[data-codex-intelligence-trigger="true"]`; + const REACT_CONVERSATION_SCAN_DEPTH = 14; + const APP_SIGNAL_SELECTOR_SCAN_INTERVAL_MS = 2000; + const APP_SIGNAL_SELECTOR_SCAN_LIMIT = 360; + const THREAD_CONTENT_SELECTOR = [ + `[data-thread-find-target="conversation"]`, + THREAD_COMPOSER_SELECTOR, + '[data-app-shell-main-content-layout*="thread"]', + ].join(","); + const REACT_STATE_HOST_SELECTOR = [ + "[data-app-action-sidebar-thread-id]", + THREAD_COMPOSER_SELECTOR, + CODEX_COMPOSER_SELECTOR, + `[data-thread-find-target="conversation"]`, + "[data-message-author-role]", + "main", + "article", + ].join(","); + const CONVERSATION_CONTENT_SELECTOR = [ + `[data-thread-find-target]`, + `[data-message-author-role]`, + `article`, + ].join(","); + const INVALID_INLINE_MOUNT_SELECTOR = [ + "button", + "[role='button']", + "[aria-haspopup]", + "[data-codex-intelligence-trigger]", + ].join(","); + const MESSAGE_MUTATION_SELECTOR = [ + `[data-thread-find-target]`, + `[data-message-author-role]`, + `article`, + ].join(","); + const PREFERRED_STATUS_KEYS = [ + "contextUsage", + "context_usage", + "tokenUsage", + "token_usage", + "usage", + "data", + "props", + "memoizedState", + "memoizedProps", + "pendingProps", + "updateQueue", + "dependencies", + "alternate", + "return", + "child", + "sibling", + "stateNode", + "current", + "value", + "store", + "atom", + "atoms", + "map", + "cache", + ]; + const PREFERRED_STATUS_KEY_SET = new Set(PREFERRED_STATUS_KEYS); + const STATUS_TREE_KEY_RE = /context|usage|status|thread|conversation|token|query|data|props|memoized|pending|return|child|sibling|state|value|current|store|atom|map|cache/i; + const APP_SIGNAL_SCOPE_KEY_RE = /memoized|pending|dependencies|firstContext|context|value|current|return|child|sibling|state|store|node|chain|scope|provider|props|query|cache/i; + const CONVERSATION_REACT_KEY_RE = /^(?:props|children|memoizedProps|pendingProps|memoizedState|stateNode|child|sibling|return|alternate|value|current|context|node|chain|conversationId|localConversationId|threadId|id|key|params|thread|conversation)$/; + const REACT_PRIVATE_KEY_RE = /^__react(?:Props|Fiber|Container)\$/; + const CONVERSATION_ID_KEYS = [ + "conversationId", + "localConversationId", + "threadId", + "id", + "key", + ]; + const CONVERSATION_ID_KEY_SET = new Set(CONVERSATION_ID_KEYS); + + for (const key of Object.keys(window)) { + if (!/CodexContextUsageMeter(?:Installed)?$/.test(key)) continue; + + const legacyApi = window[key]; + if (legacyApi && typeof legacyApi.destroy === "function") { + legacyApi.destroy(); + } + delete window[key]; + } + + const legacyRootId = ["codex", "context", "usage", "meter"].join("-"); + document.getElementById(legacyRootId)?.remove(); + document.getElementById(`${legacyRootId}-style`)?.remove(); + + if (window[INSTALL_KEY]) { + const api = window[API_KEY]; + if (api && api.version !== SCRIPT_VERSION && typeof api.destroy === "function") { + api.destroy(); + } else { + if (api && typeof api.refresh === "function") { + api.refresh(); + } + return; + } + } + + window[INSTALL_KEY] = true; + + const state = { + activeConversationId: null, + lastReading: null, + readingsByConversationId: new Map(), + lastAnimatedUsedByConversationId: new Map(), + lastAnimatedProviderUsedById: new Map(), + root: null, + contextCard: null, + providerCard: null, + historyPanel: null, + historyPortal: null, + value: null, + fill: null, + compressionZone: null, + contextRing: null, + providerValue: null, + providerFill: null, + providerRing: null, + providerSummary: null, + inlineHost: null, + inlineBefore: null, + inlineMountCache: null, + inlineMountPending: false, + inlineMountLookupAt: 0, + uiState: DEFAULT_FLOATING_UI, + contextMenu: null, + contextMenuCloseListener: null, + floatingPointerCleanup: null, + floatingDrag: null, + spendHistory: { + context: [], + provider: [], + }, + spendEffectQueue: [], + spendEffectActive: null, + spendEffectTimer: 0, + contextSessionTotalsByConversationId: new Map(), + providerSessionTotalsByConversationId: new Map(), + historyCloseTimer: 0, + historyHoverCleanup: null, + uiConfig: DEFAULT_UI_CONFIG, + providerSummaryListener: null, + lastScanAt: 0, + lastScannedConversationId: null, + contextUsageBackgroundSampleAt: 0, + contextUsageBackgroundSampleConversationIds: [], + navigationPendingUntil: 0, + switchRetryUntil: 0, + retryTimer: 0, + timer: 0, + observer: null, + navigationListener: null, + pendingUpdate: 0, + pendingUpdateDueAt: 0, + cachedActiveConversationId: null, + activeConversationIdLookupAt: 0, + appSignalScope: null, + appSignalModules: null, + appSignalModulesPromise: null, + appSignalLastLookupAt: 0, + appSignalCachedReading: null, + appSignalCachedConversationId: null, + appSignalCachedAt: 0, + appSignalLastSuccessAt: 0, + appSignalModulesRequestedAt: 0, + appSignalTokenUsageSelector: null, + appSignalTokenUsageSelectorExport: null, + appSignalTokenUsageSelectorLookupAt: 0, + waitingForAppSignalModules: false, + expensiveFallbackScannedAt: 0, + expensiveFallbackConversationId: null, + windowUsageKeys: null, + windowUsageKeysAt: 0, + reactPrivateKeyCache: new WeakMap(), + scanGeneration: 0, + filteredReflectKeyCache: new WeakMap(), + appSignalSkipGeneration: new WeakMap(), + threadContentLookupAt: 0, + threadContentLookupResult: false, + }; + + function installStyle() { + const existingStyle = document.getElementById(STYLE_ID); + if (existingStyle && existingStyle.dataset.version === String(SCRIPT_VERSION)) return; + existingStyle?.remove(); + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.dataset.version = String(SCRIPT_VERSION); + style.textContent = ` + #${ROOT_ID} { + --ccm-card-border: rgba(255, 255, 255, 0.16); + --ccm-card-bg: rgba(20, 22, 28, 0.78); + --ccm-card-bg-strong: rgba(20, 22, 28, 0.88); + --ccm-card-text: rgba(255, 255, 255, 0.92); + --ccm-card-value: rgba(255, 255, 255, 0.98); + --ccm-card-shadow: 0 5px 18px rgba(0, 0, 0, 0.18); + --ccm-card-shadow-strong: 0 8px 28px rgba(0, 0, 0, 0.24); + --ccm-ring-rest: rgba(255, 255, 255, 0.18); + --ccm-ring-core: rgba(20, 22, 28, 0.96); + --ccm-track-bg: rgba(255, 255, 255, 0.16); + --ccm-panel-border: rgba(255, 255, 255, 0.14); + --ccm-panel-bg: rgba(16, 18, 24, 0.94); + --ccm-panel-text: rgba(255, 255, 255, 0.9); + --ccm-panel-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); + --ccm-muted-strong: rgba(255, 255, 255, 0.72); + --ccm-muted: rgba(255, 255, 255, 0.48); + --ccm-muted-soft: rgba(255, 255, 255, 0.46); + --ccm-axis-line: rgba(255, 255, 255, 0.16); + --ccm-gridline: rgba(255, 255, 255, 0.1); + --ccm-fill-normal: #22c55e; + --ccm-fill-normal-start: #2563eb; + --ccm-fill-normal-end: #22c55e; + --ccm-fill-notice: #0ea5e9; + --ccm-fill-notice-start: #06b6d4; + --ccm-fill-notice-end: #0ea5e9; + --ccm-fill-warn: #ea580c; + --ccm-fill-warn-start: #d97706; + --ccm-fill-warn-end: #ea580c; + --ccm-fill-danger: #dc2626; + --ccm-fill-danger-start: #e11d48; + --ccm-fill-danger-end: #dc2626; + --ccm-fill-critical: #b91c1c; + --ccm-fill-critical-start: #be123c; + --ccm-fill-critical-end: #b91c1c; + --ccm-ring-size: 22px; + --ccm-ring-width: 3px; + --ccm-inline-max-width: 210px; + position: fixed; + top: var(--ccm-float-y, 10px); + left: var(--ccm-float-x, 16px); + transform: scale(var(--ccm-float-scale, 1)); + transform-origin: top left; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: var(--ccm-ring-size); + max-width: calc(100vw - 32px); + overflow: visible; + pointer-events: auto; + user-select: none; + /* Codex/Electron 顶部可能是窗口拖拽区;不退出拖拽区时,真实鼠标 hover 会被系统层吞掉。 */ + -webkit-app-region: no-drag; + } + + #${ROOT_ID}[data-theme="light"] { + --ccm-card-border: rgba(15, 23, 42, 0.14); + --ccm-card-bg: rgba(248, 250, 252, 0.9); + --ccm-card-bg-strong: rgba(248, 250, 252, 0.96); + --ccm-card-text: rgba(15, 23, 42, 0.86); + --ccm-card-value: rgba(15, 23, 42, 0.96); + --ccm-card-shadow: 0 5px 18px rgba(15, 23, 42, 0.14); + --ccm-card-shadow-strong: 0 8px 28px rgba(15, 23, 42, 0.18); + --ccm-ring-rest: rgba(15, 23, 42, 0.24); + --ccm-ring-core: rgba(248, 250, 252, 0.96); + --ccm-track-bg: rgba(15, 23, 42, 0.2); + --ccm-panel-border: rgba(15, 23, 42, 0.12); + --ccm-panel-bg: rgba(255, 255, 255, 0.96); + --ccm-panel-text: rgba(15, 23, 42, 0.88); + --ccm-panel-shadow: 0 10px 30px rgba(15, 23, 42, 0.16); + --ccm-muted-strong: rgba(51, 65, 85, 0.72); + --ccm-muted: rgba(71, 85, 105, 0.58); + --ccm-muted-soft: rgba(71, 85, 105, 0.54); + --ccm-axis-line: rgba(15, 23, 42, 0.16); + --ccm-gridline: rgba(15, 23, 42, 0.1); + } + + #${ROOT_ID}[data-placement="inline"] { + position: relative; + inset: auto; + z-index: auto; + transform: none; + flex: 0 0 auto; + align-self: center; + max-width: min(42vw, 360px); + margin-right: 8px; + justify-content: flex-start; + } + + #${ROOT_ID}[data-placement="inline"] .ccm-card { + width: var(--ccm-ring-size); + max-width: var(--ccm-ring-size); + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + } + + #${ROOT_ID}[data-placement="inline"] .ccm-row { + gap: 0; + } + + #${ROOT_ID}[data-placement="inline"] .ccm-value, + #${ROOT_ID}[data-placement="inline"] .ccm-provider-value { + display: none !important; + } + + #${ROOT_ID}[data-placement="floating"] { + cursor: default; + } + + #${ROOT_ID}[data-placement="floating"][data-floating-layout="vertical"] { + flex-direction: column; + align-items: stretch; + } + + #${ROOT_ID}[data-dragging="true"] { + cursor: grabbing; + } + + #${ROOT_ID}[hidden] { + display: none !important; + } + + #${HISTORY_PORTAL_ID} { + position: fixed; + inset: 0; + z-index: 2147483647; + pointer-events: none; + } + + #${HISTORY_PORTAL_ID}[hidden] { + display: none !important; + } + + #${ROOT_ID} .ccm-card { + position: relative; + box-sizing: border-box; + flex: 0 1 auto; + width: auto; + min-width: 0; + max-width: var(--ccm-inline-max-width); + padding: 5px 8px; + border: 1px solid var(--ccm-card-border); + border-radius: 999px; + background: var(--ccm-card-bg); + color: var(--ccm-card-text); + box-shadow: var(--ccm-card-shadow); + font: 12px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + overflow: visible; + backdrop-filter: blur(10px); + pointer-events: auto; + -webkit-app-region: no-drag; + } + + #${ROOT_ID}[data-placement="floating"] .ccm-card { + max-width: 240px; + padding: 8px 10px 9px; + border-radius: 8px; + background: var(--ccm-card-bg-strong); + box-shadow: var(--ccm-card-shadow-strong); + } + + #${ROOT_ID}[data-placement="floating"] .ccm-row { + justify-content: center; + margin-bottom: 6px; + } + + #${ROOT_ID}[data-placement="floating"] .ccm-ring { + display: none; + } + + #${ROOT_ID}[data-placement="floating"] .ccm-track { + display: block; + } + + #${ROOT_ID} .ccm-card[hidden] { + display: none !important; + } + + #${ROOT_ID} .ccm-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 7px; + min-width: 0; + margin-bottom: 0; + white-space: nowrap; + } + + #${ROOT_ID} .ccm-ring { + position: relative; + flex: 0 0 var(--ccm-ring-size); + width: var(--ccm-ring-size); + height: var(--ccm-ring-size); + border-radius: 50%; + background: + conic-gradient(var(--ccm-fill-color, var(--ccm-fill-normal)) 0deg, var(--ccm-fill-color, var(--ccm-fill-normal)) var(--ccm-ring-angle, 0deg), var(--ccm-ring-rest) var(--ccm-ring-angle, 0deg) 360deg); + filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.16)); + } + + #${ROOT_ID} .ccm-ring::after { + content: ""; + position: absolute; + inset: var(--ccm-ring-width); + border-radius: 50%; + background: var(--ccm-ring-core); + } + + #${ROOT_ID} .ccm-value { + color: var(--ccm-card-value); + font-weight: 650; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + } + + #${ROOT_ID} .ccm-track { + display: none; + position: relative; + width: 100%; + height: 7px; + overflow: hidden; + border-radius: 999px; + background: var(--ccm-track-bg); + } + + #${ROOT_ID} .ccm-fill { + --ccm-fill-color: var(--ccm-fill-normal); + --ccm-fill-gradient: linear-gradient(90deg, var(--ccm-fill-normal-start), var(--ccm-fill-normal-end)); + width: 0%; + height: 100%; + border-radius: inherit; + background: var(--ccm-fill-gradient); + transition: width 180ms ease, background 180ms ease; + } + + #${ROOT_ID} .ccm-compression-zone { + position: absolute; + inset: 0 auto 0 0; + z-index: 1; + width: 0%; + border-radius: inherit; + background: + linear-gradient(90deg, rgba(255, 196, 0, 0.2), rgba(255, 126, 34, 0.12)), + repeating-linear-gradient( + -45deg, + rgba(253, 224, 71, 0.72) 0, + rgba(253, 224, 71, 0.72) 4px, + rgba(251, 146, 60, 0.64) 4px, + rgba(251, 146, 60, 0.64) 8px + ); + box-shadow: + inset 0 0 0 1px rgba(251, 191, 36, 0.38), + inset 0 0 7px rgba(251, 146, 60, 0.18); + opacity: 0.82; + pointer-events: none; + transition: width 180ms ease, opacity 180ms ease; + } + + #${ROOT_ID} .ccm-provider-value { + color: var(--ccm-card-value); + font-weight: 650; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + } + + #${ROOT_ID} .ccm-context-card[data-level="warn"] .ccm-fill, + #${ROOT_ID} .ccm-context-card[data-level="warn"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="warn"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="warn"] .ccm-fill { + --ccm-fill-color: var(--ccm-fill-warn); + --ccm-fill-gradient: linear-gradient(90deg, var(--ccm-fill-warn-start), var(--ccm-fill-warn-end)); + } + + #${ROOT_ID} .ccm-context-card[data-level="danger"] .ccm-fill, + #${ROOT_ID} .ccm-context-card[data-level="danger"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="danger"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="danger"] .ccm-fill { + --ccm-fill-color: var(--ccm-fill-danger); + --ccm-fill-gradient: linear-gradient(90deg, var(--ccm-fill-danger-start), var(--ccm-fill-danger-end)); + } + + #${ROOT_ID} .ccm-context-card[data-level="notice"] .ccm-fill, + #${ROOT_ID} .ccm-context-card[data-level="notice"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="notice"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="notice"] .ccm-fill { + --ccm-fill-color: var(--ccm-fill-notice); + --ccm-fill-gradient: linear-gradient(90deg, var(--ccm-fill-notice-start), var(--ccm-fill-notice-end)); + } + + #${ROOT_ID} .ccm-context-card[data-level="critical"] .ccm-fill, + #${ROOT_ID} .ccm-context-card[data-level="critical"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="critical"] .ccm-ring, + #${ROOT_ID} .ccm-provider-card[data-level="critical"] .ccm-fill { + --ccm-fill-color: var(--ccm-fill-critical); + --ccm-fill-gradient: linear-gradient(90deg, var(--ccm-fill-critical-start), var(--ccm-fill-critical-end)); + } + + #${ROOT_ID} .ccm-context-card[data-compression-warning="true"] .ccm-compression-zone { + opacity: 1; + } + + #${ROOT_ID} .ccm-context-card[data-show-used-instead-of-left="true"] .ccm-compression-zone { + left: auto; + right: 0; + } + + #${HISTORY_PORTAL_ID} .ccm-history-panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: auto; + z-index: 1; + box-sizing: border-box; + width: max-content; + min-width: 240px; + max-width: min(560px, var(--ccm-history-max-width, calc(100vw - 32px))); + max-height: var(--ccm-history-max-height, calc(100vh - 16px)); + overflow: hidden; + padding: 9px 10px 10px; + border: 1px solid var(--ccm-panel-border); + border-radius: 8px; + background: var(--ccm-panel-bg); + color: var(--ccm-panel-text); + box-shadow: var(--ccm-panel-shadow); + font: 12px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + visibility: hidden; + backdrop-filter: blur(12px); + -webkit-app-region: no-drag; + transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; + } + + #${HISTORY_PORTAL_ID} .ccm-history-panel { + position: fixed; + top: var(--ccm-history-top, 0px); + left: var(--ccm-history-left, 0px); + right: auto; + z-index: 2147483647; + } + + #${HISTORY_PORTAL_ID} .ccm-history-panel::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -8px; + height: 8px; + pointer-events: auto; + } + + #${HISTORY_PORTAL_ID}[data-history-open="true"] .ccm-history-panel { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + visibility: visible; + } + + #${HISTORY_PORTAL_ID} .ccm-history-grid { + display: grid; + grid-template-columns: minmax(0, 260px) minmax(0, 260px); + gap: 12px; + } + + #${HISTORY_PORTAL_ID} .ccm-history-grid[data-provider-visible="false"] { + grid-template-columns: minmax(0, 292px); + } + + #${HISTORY_PORTAL_ID} .ccm-history-section { + min-width: 0; + --ccm-history-accent: #38bdf8; + --ccm-history-fill: rgba(56, 189, 248, 0.12); + } + + #${HISTORY_PORTAL_ID} .ccm-history-section[data-history-kind="provider"] { + --ccm-history-accent: #f97316; + --ccm-history-fill: rgba(249, 115, 22, 0.12); + } + + #${HISTORY_PORTAL_ID} .ccm-history-section[hidden] { + display: none !important; + } + + #${HISTORY_PORTAL_ID} .ccm-history-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + white-space: nowrap; + } + + #${HISTORY_PORTAL_ID} .ccm-history-title { + color: var(--ccm-card-value); + font-weight: 700; + } + + #${HISTORY_PORTAL_ID} .ccm-history-total { + color: var(--ccm-muted-strong); + font-variant-numeric: tabular-nums; + } + + #${HISTORY_PORTAL_ID} .ccm-history-chart { + position: relative; + width: 100%; + min-height: 78px; + } + + #${HISTORY_PORTAL_ID} .ccm-history-svg { + display: block; + width: 100%; + height: 78px; + overflow: visible; + } + + #${HISTORY_PORTAL_ID} .ccm-history-axis-line { + fill: none; + stroke: var(--ccm-axis-line); + stroke-width: 1; + vector-effect: non-scaling-stroke; + } + + #${HISTORY_PORTAL_ID} .ccm-history-axis-label { + fill: var(--ccm-muted-soft); + font-size: 10px; + font-variant-numeric: tabular-nums; + } + + #${HISTORY_PORTAL_ID} .ccm-history-gridline { + fill: none; + stroke: var(--ccm-gridline); + stroke-dasharray: 2 4; + stroke-width: 1; + } + + #${HISTORY_PORTAL_ID} .ccm-history-area { + fill: var(--ccm-history-fill); + } + + #${HISTORY_PORTAL_ID} .ccm-history-line { + fill: none; + stroke: var(--ccm-history-accent); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.2; + vector-effect: non-scaling-stroke; + } + + #${HISTORY_PORTAL_ID} .ccm-history-point { + fill: #fff7ed; + stroke: var(--ccm-history-accent); + stroke-width: 1.5; + } + + #${HISTORY_PORTAL_ID} .ccm-history-hit { + fill: transparent; + pointer-events: all; + } + + #${HISTORY_PORTAL_ID} .ccm-history-caption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 2px; + color: var(--ccm-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + #${HISTORY_PORTAL_ID} .ccm-history-empty { + position: absolute; + inset: 19px 0 auto 0; + color: var(--ccm-muted-soft); + } + + .ccm-context-menu { + --ccm-menu-border: rgba(255, 255, 255, 0.14); + --ccm-menu-bg: rgba(18, 20, 26, 0.96); + --ccm-menu-text: rgba(255, 255, 255, 0.92); + --ccm-menu-shadow: 0 12px 32px rgba(0, 0, 0, 0.36); + --ccm-menu-hover: rgba(255, 255, 255, 0.1); + --ccm-menu-checked: rgba(255, 255, 255, 0.08); + --ccm-menu-separator: rgba(255, 255, 255, 0.12); + --ccm-menu-check: #86efac; + --ccm-menu-hint: rgba(255, 255, 255, 0.48); + position: fixed; + z-index: 2147483647; + min-width: 150px; + padding: 5px; + border: 1px solid var(--ccm-menu-border); + border-radius: 8px; + background: var(--ccm-menu-bg); + color: var(--ccm-menu-text); + box-shadow: var(--ccm-menu-shadow); + font: 12px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + backdrop-filter: blur(12px); + -webkit-app-region: no-drag; + } + + .ccm-context-menu[data-theme="light"] { + --ccm-menu-border: rgba(15, 23, 42, 0.12); + --ccm-menu-bg: rgba(255, 255, 255, 0.98); + --ccm-menu-text: rgba(15, 23, 42, 0.9); + --ccm-menu-shadow: 0 12px 32px rgba(15, 23, 42, 0.18); + --ccm-menu-hover: rgba(15, 23, 42, 0.08); + --ccm-menu-checked: rgba(15, 23, 42, 0.07); + --ccm-menu-separator: rgba(15, 23, 42, 0.12); + --ccm-menu-check: #16a34a; + --ccm-menu-hint: rgba(71, 85, 105, 0.58); + } + + .ccm-context-menu button { + display: flex; + align-items: center; + gap: 7px; + box-sizing: border-box; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + } + + .ccm-context-menu button:hover { + background: var(--ccm-menu-hover); + } + + .ccm-context-menu button[aria-checked="true"] { + background: var(--ccm-menu-checked); + } + + .ccm-context-menu .ccm-menu-separator { + height: 1px; + margin: 5px 4px; + background: var(--ccm-menu-separator); + } + + .ccm-context-menu .ccm-menu-check { + flex: 0 0 14px; + width: 14px; + color: var(--ccm-menu-check); + font-weight: 800; + text-align: center; + } + + .ccm-context-menu .ccm-menu-hint { + padding: 5px 8px 6px 21px; + color: var(--ccm-menu-hint); + font-size: 11px; + line-height: 1.3; + white-space: nowrap; + } + + #${ROOT_ID} .ccm-hit-pop { + position: absolute; + left: 0; + right: auto; + top: 50%; + z-index: 1; + color: #fff7ed; + background: linear-gradient(92deg, #fff7ed 0%, #fecdd3 38%, #fb7185 68%, #f97316 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 14px; + font-weight: 850; + line-height: 1; + opacity: 0; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.78)) + drop-shadow(0 3px 8px rgba(0, 0, 0, 0.58)) + drop-shadow(0 0 14px rgba(251, 113, 133, 0.56)) + drop-shadow(0 0 26px rgba(249, 115, 22, 0.24)); + text-shadow: 0 0 1px rgba(255, 255, 255, 0.45); + transform: translate(-108%, -50%) scale(0.72); + transform-origin: center center; + animation: ccm-hit-pop ${SPEND_EFFECT_DURATION_MS}ms cubic-bezier(0.16, 0.84, 0.24, 1) forwards; + pointer-events: none; + white-space: nowrap; + will-change: opacity, transform, filter; + } + + @keyframes ccm-hit-pop { + 0% { + opacity: 0; + transform: translate(-108%, -50%) scale(0.72); + } + 12% { + opacity: 1; + transform: translate(-114%, -51%) scale(1); + } + 72% { + opacity: 1; + transform: translate(-146%, -54%) scale(1.22); + } + 100% { + opacity: 0; + transform: translate(-160%, -55%) scale(1.34); + } + } + + @media (max-width: 720px) { + #${ROOT_ID}[data-placement="inline"] { + max-width: 72px; + } + + #${ROOT_ID}[data-placement="inline"] .ccm-value, + #${ROOT_ID}[data-placement="inline"] .ccm-provider-value { + display: none; + } + } + `; + document.head.appendChild(style); + } + + function bindRootElements(root) { + if (!root) return; + + state.root = root; + state.contextCard = root.querySelector(".ccm-context-card"); + state.providerCard = root.querySelector(".ccm-provider-card"); + state.historyPortal = document.getElementById(HISTORY_PORTAL_ID); + state.historyPanel = state.historyPortal?.querySelector(".ccm-history-panel") || null; + state.value = root.querySelector(".ccm-value"); + state.fill = root.querySelector(".ccm-fill"); + state.compressionZone = root.querySelector(".ccm-compression-zone"); + state.contextRing = root.querySelector(".ccm-context-card .ccm-ring"); + state.providerValue = root.querySelector(".ccm-provider-value"); + state.providerFill = root.querySelector(".ccm-provider-fill"); + state.providerRing = root.querySelector(".ccm-provider-card .ccm-ring"); + } + + function isRootBound(root) { + return !!( + root && + state.root === root && + state.contextCard && + state.value && + state.fill && + state.compressionZone && + state.contextRing && + state.providerCard && + state.providerValue && + state.providerFill && + state.providerRing && + state.historyPortal && + state.historyPanel + ); + } + + function historyPanelMarkup() { + return ` + + `; + } + + function syncHistoryPortalTheme(root) { + const portal = state.historyPortal || document.getElementById(HISTORY_PORTAL_ID); + if (!root || !portal) return; + + portal.dataset.theme = root.dataset.theme === "light" ? "light" : "dark"; + const computed = getComputedStyle(root); + for (const name of HISTORY_PORTAL_THEME_VARIABLES) { + const value = computed.getPropertyValue(name); + if (value) portal.style.setProperty(name, value.trim()); + } + } + + function ensureHistoryPortal(root) { + let portal = state.historyPortal && state.historyPortal.isConnected + ? state.historyPortal + : document.getElementById(HISTORY_PORTAL_ID); + if (!portal) { + portal = document.createElement("div"); + portal.id = HISTORY_PORTAL_ID; + portal.dataset.historyOpen = "false"; + document.body.appendChild(portal); + } else if (portal.parentNode !== document.body) { + document.body.appendChild(portal); + } + + if (portal.dataset.infrastructureVersion !== String(SCRIPT_VERSION)) { + portal.innerHTML = historyPanelMarkup(); + portal.dataset.infrastructureVersion = String(SCRIPT_VERSION); + } + + state.historyPortal = portal; + state.historyPanel = portal.querySelector(".ccm-history-panel"); + if (root && portal.dataset.historyOpen !== root.dataset.historyOpen) { + portal.dataset.historyOpen = root.dataset.historyOpen === "true" ? "true" : "false"; + } + if (state.historyPanel) { + state.historyPanel.setAttribute( + "aria-hidden", + portal.dataset.historyOpen === "true" ? "false" : "true", + ); + } + syncHistoryPortalTheme(root); + return portal; + } + + function ensureRootInfrastructure(root) { + ensureHistoryPortal(root); + if (root.dataset.infrastructureVersion !== String(SCRIPT_VERSION)) { + root.querySelector(".ccm-hover-zone")?.remove(); + root.querySelector(".ccm-history-panel")?.remove(); + const contextTrack = root.querySelector(".ccm-context-card .ccm-track"); + if (contextTrack && !contextTrack.querySelector(".ccm-compression-zone")) { + contextTrack.insertBefore(document.createElement("div"), contextTrack.firstChild); + contextTrack.firstElementChild.className = "ccm-compression-zone"; + } + root.dataset.infrastructureVersion = String(SCRIPT_VERSION); + } + if (!isRootBound(root)) bindRootElements(root); + installHistoryHover(root); + installContextMenu(root); + installFloatingControls(root); + } + + function isInlineMountCurrent(root) { + const uiState = state.uiState || readUiState(); + if (uiState.mode === "floating") return root.parentNode === document.body && root.dataset.placement === "floating"; + if (root.dataset.placement !== "inline") return false; + if (!state.inlineHost || !state.inlineHost.isConnected || root.parentNode !== state.inlineHost) return false; + if (state.inlineHost.closest(INVALID_INLINE_MOUNT_SELECTOR)) return false; + return state.inlineBefore ? state.inlineBefore.isConnected && root.nextSibling === state.inlineBefore : true; + } + + function ensureRoot() { + let root = state.root && state.root.isConnected ? state.root : document.getElementById(ROOT_ID); + if (root) { + ensureRootInfrastructure(root); + state.uiState = readUiState(); + if (!isInlineMountCurrent(root)) mountRoot(root); + return root; + } + + root = document.createElement("div"); + root.id = ROOT_ID; + root.innerHTML = ` + + + `; + ensureRootInfrastructure(root); + mountRoot(root); + return root; + } + + function findInlineMount() { + const now = Date.now(); + if ( + state.inlineMountCache && + now - state.inlineMountLookupAt < INLINE_MOUNT_CACHE_MS && + state.inlineMountCache.parent && + state.inlineMountCache.parent.isConnected && + (!state.inlineMountCache.before || state.inlineMountCache.before.isConnected) + ) { + return state.inlineMountCache; + } + state.inlineMountLookupAt = now; + + const visibleDirectChildren = (node) => + Array.from(node.children || []).filter((child) => child.id !== ROOT_ID && isVisibleElement(child)); + const firstVisibleChild = (node) => visibleDirectChildren(node)[0] || null; + const classText = (node) => (typeof node?.className === "string" ? node.className : ""); + const hasClassToken = (node, token) => classText(node).split(/\s+/).includes(token); + const sortByLeft = (nodes) => + nodes.slice().sort( + (left, right) => left.getBoundingClientRect().left - right.getBoundingClientRect().left + ); + const hasVisibleInteractiveControl = (node) => + Array.from( + node.querySelectorAll(`button, [role='button'], [aria-haspopup], ${CODEX_INTELLIGENCE_TRIGGER_SELECTOR}`) + ).some((child) => child.id !== ROOT_ID && isVisibleElement(child)); + const directChildOf = (parent, node) => { + let current = node; + while (current && current.parentElement && current.parentElement !== parent) { + current = current.parentElement; + } + return current && current.parentElement === parent ? current : null; + }; + const isComposerArea = (node) => { + const rect = node && node.getBoundingClientRect(); + if (!rect || rect.top < window.innerHeight * 0.45) return false; + if (node.closest(CONVERSATION_CONTENT_SELECTOR)) return false; + if (node.closest("aside, nav, [data-app-action-sidebar-thread-id], [data-app-action-sidebar-thread-active]")) return false; + if (node.closest("article, [data-message-author-role]")) return false; + if (!node.querySelector(`textarea, input, [contenteditable='true'], [role='textbox'], ${CODEX_COMPOSER_SELECTOR}`)) return false; + return true; + }; + const findComposerArea = (node) => { + let current = node; + while (current && current !== document.body) { + if (isComposerArea(current)) return current; + current = current.parentElement; + } + return null; + }; + const findComposerFooterMount = () => { + const footers = Array.from(document.querySelectorAll(".composer-footer")) + .filter((footer) => isVisibleElement(footer) && footer.getBoundingClientRect().top > window.innerHeight * 0.45) + .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top); + + for (const footer of footers) { + const footerChildren = sortByLeft(visibleDirectChildren(footer)); + const toolbarRoot = footerChildren + .filter((child) => hasClassToken(child, "justify-end") && hasVisibleInteractiveControl(child)) + .sort((left, right) => right.getBoundingClientRect().right - left.getBoundingClientRect().right)[0]; + if (!toolbarRoot) continue; + + const toolbarChildren = sortByLeft(visibleDirectChildren(toolbarRoot)); + const providerGroup = toolbarChildren.find( + (child) => + hasVisibleInteractiveControl(child) && + !hasClassToken(child, "shrink-0") && + (hasClassToken(child, "flex-1") || hasClassToken(child, "min-w-0")) + ); + if (!providerGroup) continue; + const before = firstVisibleChild(providerGroup); + if (!before) continue; + const footerRect = footer.getBoundingClientRect(); + const beforeRect = before.getBoundingClientRect(); + if (beforeRect.left < footerRect.left + footerRect.width * 0.5) continue; + + return { + parent: providerGroup, + before, + }; + } + + return null; + }; + const findStructuralMountForControl = (control) => { + const footer = control.closest(".composer-footer"); + if (footer) { + const footerRect = footer.getBoundingClientRect(); + const controlRect = control.getBoundingClientRect(); + if (controlRect.left < footerRect.left + footerRect.width * 0.5) return null; + } + + let current = control.parentElement; + while (current && current !== document.body) { + const rect = current.getBoundingClientRect(); + if (rect.top < window.innerHeight * 0.45) break; + if (current.closest(CONVERSATION_CONTENT_SELECTOR)) break; + if (!current.closest(INVALID_INLINE_MOUNT_SELECTOR)) { + const before = directChildOf(current, control) || control; + if (before && before !== current && current.contains(before)) { + return { + parent: current, + before, + }; + } + } + current = current.parentElement; + } + return null; + }; + const rememberMount = (mount) => { + if ( + mount && + mount.parent && + mount.parent.isConnected && + !mount.parent.closest(INVALID_INLINE_MOUNT_SELECTOR) + ) { + state.inlineMountCache = mount; + return mount; + } + + state.inlineMountCache = null; + return null; + }; + + const footerMount = findComposerFooterMount(); + if (footerMount) return rememberMount(footerMount); + + const codexModelTrigger = document.querySelector(CODEX_INTELLIGENCE_TRIGGER_SELECTOR); + if (codexModelTrigger && isVisibleElement(codexModelTrigger)) { + const bar = findComposerArea(codexModelTrigger); + if (bar && isVisibleElement(bar) && isComposerArea(bar)) { + const triggerMount = findStructuralMountForControl(codexModelTrigger); + if (triggerMount) return rememberMount(triggerMount); + } + } + + state.inlineMountCache = null; + return null; + } + + function mountRoot(root) { + state.uiState = readUiState(); + if (state.uiState.mode === "floating") { + state.inlineMountCache = null; + state.inlineMountPending = false; + if (root.parentNode !== document.body) document.body.appendChild(root); + state.inlineHost = null; + root.dataset.placement = "floating"; + applyFloatingUiState(root); + refreshOpenSpendHistory(root); + return; + } + + const mount = findInlineMount(); + if (!mount || !mount.parent || root.contains(mount.parent)) { + state.inlineHost = null; + state.inlineBefore = null; + state.inlineMountCache = null; + state.inlineMountPending = true; + if (root.parentNode !== document.body) document.body.appendChild(root); + root.dataset.placement = "inline"; + root.hidden = true; + applyFloatingUiState(root); + closeSpendHistory(); + scheduleUpdate(SWITCH_RETRY_INTERVAL_MS); + return; + } + + const before = mount.before || null; + if (before !== root && (root.parentNode !== mount.parent || root.nextSibling !== before)) { + mount.parent.insertBefore(root, before); + } + state.inlineHost = mount.parent; + state.inlineBefore = before; + state.inlineMountCache = mount; + state.inlineMountPending = false; + root.dataset.placement = "inline"; + applyFloatingUiState(root); + refreshOpenSpendHistory(root); + } + + function applyFloatingUiState(root) { + const uiState = state.uiState || DEFAULT_FLOATING_UI; + root.style.setProperty("--ccm-float-x", `${Math.round(uiState.x)}px`); + root.style.setProperty("--ccm-float-y", `${Math.round(uiState.y)}px`); + root.style.setProperty("--ccm-float-scale", String(uiState.scale)); + root.dataset.floatingLayout = uiState.floatingLayout === "vertical" ? "vertical" : "horizontal"; + root.dataset.theme = uiState.theme === "light" ? "light" : "dark"; + syncHistoryPortalTheme(root); + } + + function setUiMode(mode) { + state.uiState = { + ...readUiState(), + mode: mode === "floating" ? "floating" : "inline", + }; + writeUiState(); + closeContextMenu(); + state.inlineMountCache = null; + const root = state.root || document.getElementById(ROOT_ID); + if (root) mountRoot(root); + } + + function setUiTheme(theme) { + state.uiState = { + ...readUiState(), + theme: theme === "light" ? "light" : "dark", + }; + writeUiState(); + closeContextMenu(); + const root = state.root || document.getElementById(ROOT_ID); + if (root) applyFloatingUiState(root); + } + + function setFloatingLayout(layout) { + state.uiState = { + ...readUiState(), + floatingLayout: layout === "vertical" ? "vertical" : "horizontal", + }; + writeUiState(); + closeContextMenu(); + const root = state.root || document.getElementById(ROOT_ID); + if (root) applyFloatingUiState(root); + } + + function closeContextMenu() { + if (state.contextMenu) { + state.contextMenu.remove(); + state.contextMenu = null; + } + if (state.contextMenuCloseListener) { + document.removeEventListener("pointerdown", state.contextMenuCloseListener, true); + document.removeEventListener("keydown", state.contextMenuCloseListener, true); + state.contextMenuCloseListener = null; + } + } + + function openContextMenu(event) { + const root = state.root || document.getElementById(ROOT_ID); + if (!root || !root.contains(event.target)) return; + event.preventDefault(); + closeContextMenu(); + state.uiState = readUiState(); + + const currentMode = (state.uiState && state.uiState.mode) === "floating" ? "floating" : "inline"; + const currentFloatingLayout = + (state.uiState && state.uiState.floatingLayout) === "vertical" ? "vertical" : "horizontal"; + const currentTheme = (state.uiState && state.uiState.theme) === "light" ? "light" : "dark"; + const menu = document.createElement("div"); + menu.className = "ccm-context-menu"; + menu.dataset.theme = currentTheme; + menu.setAttribute("role", "menu"); + const createRadioItem = (group, value, label, checked) => { + const item = document.createElement("button"); + item.type = "button"; + item.dataset[group] = value; + item.setAttribute("role", "menuitemradio"); + item.setAttribute("aria-checked", checked ? "true" : "false"); + + const check = document.createElement("span"); + check.className = "ccm-menu-check"; + check.setAttribute("aria-hidden", "true"); + check.textContent = checked ? "✓" : ""; + + const text = document.createElement("span"); + text.textContent = label; + + item.append(check, text); + return item; + }; + const items = [ + createRadioItem("theme", "dark", "Dark theme", currentTheme === "dark"), + createRadioItem("theme", "light", "Light theme", currentTheme === "light"), + document.createElement("div"), + createRadioItem("mode", "inline", "Inline mode", currentMode === "inline"), + createRadioItem("mode", "floating", "Floating mode", currentMode === "floating"), + ]; + items[2].className = "ccm-menu-separator"; + items[2].setAttribute("role", "separator"); + if (currentMode === "floating") { + const separator = document.createElement("div"); + separator.className = "ccm-menu-separator"; + separator.setAttribute("role", "separator"); + const hint = document.createElement("div"); + hint.className = "ccm-menu-hint"; + hint.textContent = "Use mouse wheel to resize"; + items.push( + separator, + createRadioItem("floatingLayout", "horizontal", "Horizontal layout", currentFloatingLayout === "horizontal"), + createRadioItem("floatingLayout", "vertical", "Vertical layout", currentFloatingLayout === "vertical"), + hint, + ); + } + menu.replaceChildren(...items); + menu.style.left = `${Math.max(6, event.clientX)}px`; + menu.style.top = `${Math.max(6, event.clientY)}px`; + menu.addEventListener("pointerdown", (menuEvent) => { + menuEvent.stopPropagation(); + }); + menu.addEventListener("click", (menuEvent) => { + const button = menuEvent.target && menuEvent.target.closest("button[data-mode]"); + if (button) { + setUiMode(button.dataset.mode); + return; + } + + const layoutButton = menuEvent.target && menuEvent.target.closest("button[data-floating-layout]"); + if (layoutButton) { + setFloatingLayout(layoutButton.dataset.floatingLayout); + return; + } + + const themeButton = menuEvent.target && menuEvent.target.closest("button[data-theme]"); + if (themeButton) setUiTheme(themeButton.dataset.theme); + }); + document.body.appendChild(menu); + const menuRect = menu.getBoundingClientRect(); + const x = clampNumber(event.clientX, 6, Math.max(6, window.innerWidth - menuRect.width - 6)); + const y = clampNumber(event.clientY, 6, Math.max(6, window.innerHeight - menuRect.height - 6)); + menu.style.left = `${Math.round(x)}px`; + menu.style.top = `${Math.round(y)}px`; + state.contextMenu = menu; + state.contextMenuCloseListener = (closeEvent) => { + if (closeEvent.type === "keydown" && closeEvent.key !== "Escape") return; + if (state.contextMenu && closeEvent.target && state.contextMenu.contains(closeEvent.target)) return; + closeContextMenu(); + }; + window.setTimeout(() => { + document.addEventListener("pointerdown", state.contextMenuCloseListener, true); + document.addEventListener("keydown", state.contextMenuCloseListener, true); + }, 0); + } + + function installContextMenu(root) { + if (!root || root.dataset.contextMenuInstalled === "true") return; + root.dataset.contextMenuInstalled = "true"; + root.addEventListener("contextmenu", openContextMenu); + } + + function clampFloatingPosition(root, x, y, scale) { + const rect = root.getBoundingClientRect(); + const safeScale = clampNumber(scale, FLOAT_SCALE_MIN, FLOAT_SCALE_MAX); + const width = Math.max(40, rect.width / (Number(state.uiState.scale) || 1) * safeScale); + const height = Math.max(28, rect.height / (Number(state.uiState.scale) || 1) * safeScale); + return { + x: clampNumber(x, 0, Math.max(0, window.innerWidth - width)), + y: clampNumber(y, 0, Math.max(0, window.innerHeight - height)), + scale: safeScale, + }; + } + + function installFloatingControls(root) { + if (!root || root.dataset.floatingControlsInstalled === "true") return; + root.dataset.floatingControlsInstalled = "true"; + + const onPointerDown = (event) => { + if (root.dataset.placement !== "floating" || event.button !== 0) return; + if (event.target && event.target.closest(".ccm-context-menu")) return; + + const uiState = readUiState(); + const startX = event.clientX; + const startY = event.clientY; + const pointerId = event.pointerId; + let dragging = false; + const holdTimer = window.setTimeout(() => { + dragging = true; + root.dataset.dragging = "true"; + try { + root.setPointerCapture(pointerId); + } catch { + } + }, FLOAT_DRAG_HOLD_MS); + + const onPointerMove = (moveEvent) => { + if (!dragging) return; + const next = clampFloatingPosition( + root, + uiState.x + moveEvent.clientX - startX, + uiState.y + moveEvent.clientY - startY, + uiState.scale, + ); + state.uiState = { ...uiState, ...next, mode: "floating" }; + applyFloatingUiState(root); + }; + + const finish = () => { + window.clearTimeout(holdTimer); + document.removeEventListener("pointermove", onPointerMove, true); + document.removeEventListener("pointerup", finish, true); + document.removeEventListener("pointercancel", finish, true); + if (dragging) { + root.dataset.dragging = "false"; + writeUiState(); + } + }; + + document.addEventListener("pointermove", onPointerMove, true); + document.addEventListener("pointerup", finish, true); + document.addEventListener("pointercancel", finish, true); + }; + + const onWheel = (event) => { + if (root.dataset.placement !== "floating") return; + event.preventDefault(); + const current = readUiState(); + const direction = event.deltaY < 0 ? 1 : -1; + const nextScale = clampNumber(current.scale + direction * FLOAT_SCALE_STEP, FLOAT_SCALE_MIN, FLOAT_SCALE_MAX); + const next = clampFloatingPosition(root, current.x, current.y, nextScale); + state.uiState = { ...current, ...next, mode: "floating" }; + applyFloatingUiState(root); + writeUiState(); + }; + + root.addEventListener("pointerdown", onPointerDown); + root.addEventListener("wheel", onWheel, { passive: false }); + state.floatingPointerCleanup = () => { + root.removeEventListener("pointerdown", onPointerDown); + root.removeEventListener("wheel", onWheel); + }; + } + + function toNumber(value, unit) { + if (value == null) return null; + + const parsed = Number(String(value).replace(/,/g, "")); + if (!Number.isFinite(parsed)) return null; + + const normalizedUnit = String(unit || "").toLowerCase(); + if (normalizedUnit === "k") return parsed * 1000; + if (normalizedUnit === "m") return parsed * 1000000; + return parsed; + } + + function clampPercent(value) { + if (!Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, value)); + } + + function clampNumber(value, min, max) { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); + } + + function numberOrDefault(value, fallback) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; + } + + function readUiState() { + try { + const parsed = JSON.parse(localStorage.getItem(UI_STATE_STORAGE_KEY) || "null"); + const input = parsed && typeof parsed === "object" ? parsed : {}; + return { + mode: input.mode === "floating" ? "floating" : "inline", + floatingLayout: input.floatingLayout === "vertical" ? "vertical" : "horizontal", + theme: input.theme === "light" ? "light" : "dark", + x: clampNumber(Number(input.x), 0, Math.max(0, window.innerWidth - 80)), + y: clampNumber(Number(input.y), 0, Math.max(0, window.innerHeight - 40)), + scale: clampNumber(Number(input.scale) || 1, FLOAT_SCALE_MIN, FLOAT_SCALE_MAX), + }; + } catch { + return { ...DEFAULT_FLOATING_UI }; + } + } + + function writeUiState() { + try { + localStorage.setItem(UI_STATE_STORAGE_KEY, JSON.stringify(state.uiState)); + } catch { + } + } + + function shouldShowUsedInsteadOfLeft(config = state.uiConfig) { + const context = config && config.context; + return !!(context && context.showUsedInsteadOfLeft === true); + } + + function normalizeLevelThresholds(value, defaults) { + const input = value && typeof value === "object" ? value : {}; + + return { + criticalLeftPercent: clampPercent(numberOrDefault(input.criticalLeftPercent, defaults.criticalLeftPercent)), + dangerLeftPercent: clampPercent(numberOrDefault(input.dangerLeftPercent, defaults.dangerLeftPercent)), + warnLeftPercent: clampPercent(numberOrDefault(input.warnLeftPercent, defaults.warnLeftPercent)), + noticeLeftPercent: clampPercent(numberOrDefault(input.noticeLeftPercent, defaults.noticeLeftPercent)), + }; + } + + function normalizeUiConfig(value) { + const input = value && typeof value === "object" ? value : {}; + const context = input.context && typeof input.context === "object" ? input.context : {}; + const provider = input.provider && typeof input.provider === "object" ? input.provider : {}; + + return { + context: { + showUsedInsteadOfLeft: context.showUsedInsteadOfLeft === true, + compressionWarningLeftPercent: clampPercent(numberOrDefault( + context.compressionWarningLeftPercent, + DEFAULT_UI_CONFIG.context.compressionWarningLeftPercent, + )), + levelThresholds: normalizeLevelThresholds( + context.levelThresholds, + DEFAULT_UI_CONFIG.context.levelThresholds, + ), + }, + provider: { + levelThresholds: normalizeLevelThresholds( + provider.levelThresholds, + DEFAULT_UI_CONFIG.provider.levelThresholds, + ), + }, + }; + } + + function readUiConfig() { + const summaryConfig = state.providerSummary && state.providerSummary.ui; + return normalizeUiConfig(summaryConfig || window[CONFIG_KEY] || DEFAULT_UI_CONFIG); + } + + function levelForLeftPercent(leftPercent, scope) { + const config = scope === "provider" ? state.uiConfig.provider : state.uiConfig.context; + const thresholds = config.levelThresholds; + if (leftPercent <= thresholds.criticalLeftPercent) return "critical"; + if (leftPercent <= thresholds.dangerLeftPercent) return "danger"; + if (leftPercent <= thresholds.warnLeftPercent) return "warn"; + if (leftPercent <= thresholds.noticeLeftPercent) return "notice"; + return "normal"; + } + + function shouldShowCompressionWarning(leftPercent) { + const threshold = state.uiConfig.context.compressionWarningLeftPercent; + return Number.isFinite(threshold) && leftPercent <= threshold; + } + + function compactNumber(value) { + if (!Number.isFinite(value)) return ""; + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; + return String(Math.round(value)); + } + + function formatTokenCount(value) { + if (!Number.isFinite(value)) return "--"; + return Math.round(value).toLocaleString("en-US"); + } + + function formatAmount(value) { + if (!Number.isFinite(value)) return "--"; + if (Math.abs(value) >= 1000) return value.toLocaleString("en-US", { maximumFractionDigits: 1 }); + return value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function formatMoney(value) { + const amount = formatAmount(value); + return amount === "--" ? amount : `$${amount}`; + } + + function formatProviderTitle(name, provider, usedAmount, remainingAmount, totalAmount, usedPercent, leftPercent) { + return [ + `${name} Balance`, + `Left: ${formatMoney(remainingAmount)} (${leftPercent.toFixed(1)}%)`, + `Used: ${formatMoney(usedAmount)} (${usedPercent.toFixed(1)}%)`, + `Total: ${formatMoney(totalAmount)}`, + `Status: ${provider.status || "unknown"}`, + ].join(" | "); + } + + function formatContextTitle(reading, usedTokens, remainingTokens, leftPercent, usedPercent) { + const limitTokens = Number.isFinite(reading.limit) ? reading.limit : null; + const parts = [ + "Context", + `Left: ${formatTokenCount(remainingTokens)} Tokens (${leftPercent.toFixed(1)}%)`, + `Used: ${formatTokenCount(usedTokens)} Tokens (${usedPercent.toFixed(1)}%)`, + ]; + if (Number.isFinite(limitTokens)) parts.push(`Total: ${formatTokenCount(limitTokens)} Tokens`); + if (reading.source) parts.push(`Source: ${reading.source}`); + return parts.join(" | "); + } + + function pruneSpendHistory(now = Date.now()) { + const cutoff = now - SPEND_HISTORY_WINDOW_MS; + for (const kind of ["context", "provider"]) { + const items = state.spendHistory[kind]; + while (items.length && items[0].time < cutoff) items.shift(); + if (items.length > SPEND_HISTORY_MAX_ITEMS) { + items.splice(0, items.length - SPEND_HISTORY_MAX_ITEMS); + } + } + } + + function recordSpend(kind, amount, meta) { + if (!Number.isFinite(amount) || amount <= 0 || !state.spendHistory[kind]) return; + + const now = Date.now(); + const conversationId = + kind === "context" + ? normalizeConversationId(meta || state.activeConversationId || "__unknown__") || "__unknown__" + : metaConversationId(); + const itemMeta = kind === "provider" ? String(meta || "") : ""; + if (kind === "context") { + const previousTotal = state.contextSessionTotalsByConversationId.get(conversationId) || 0; + state.contextSessionTotalsByConversationId.set(conversationId, previousTotal + amount); + } else { + const previousTotal = state.providerSessionTotalsByConversationId.get(conversationId) || 0; + state.providerSessionTotalsByConversationId.set(conversationId, previousTotal + amount); + } + + state.spendHistory[kind].push({ + time: now, + amount, + conversationId, + meta: itemMeta, + }); + pruneSpendHistory(now); + if (state.root && state.root.dataset.historyOpen === "true") { + renderSpendHistory(); + } + } + + function formatHistoryTime(time) { + const date = new Date(time); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + function formatHistoryDelta(kind, amount) { + if (kind === "provider") return `-${formatMoney(amount)}`; + return `-${Math.round(amount).toLocaleString("en-US")} Tokens`; + } + + function formatHistoryAxisValue(kind, amount) { + if (kind === "provider") return formatMoney(amount); + if (!Number.isFinite(amount)) return "--"; + if (amount >= 1000000) return `${(amount / 1000000).toFixed(1)}M`; + if (amount >= 1000) return `${(amount / 1000).toFixed(1)}K`; + return String(Math.round(amount)); + } + + function formatHistoryPointTitle(kind, point) { + const lines = [ + `${formatHistoryTime(point.item.time)} ${formatHistoryDelta(kind, point.item.amount)}`, + ]; + if (point.item.meta) lines.push(String(point.item.meta)); + return lines.join("\n"); + } + + function currentContextHistorySnapshot(conversationId) { + const reading = state.lastReading; + if (!reading || !conversationIdsMatch(reading.conversationId, conversationId)) return null; + const used = Number(reading.used); + if (!Number.isFinite(used) || used <= 0) return null; + + return { + time: Date.now(), + amount: used, + conversationId: normalizeConversationId(conversationId || reading.conversationId || "__unknown__") || "__unknown__", + meta: "Current used", + }; + } + + function currentProviderHistorySnapshot(conversationId) { + const provider = pickProviderSummary(readProviderSummary()); + if (!provider) return null; + + const remainingAmount = Number(provider.remainingAmount); + const totalAmount = Number(provider.totalAmount); + const usedAmount = Number.isFinite(Number(provider.usedAmount)) + ? Number(provider.usedAmount) + : totalAmount - remainingAmount; + if (!Number.isFinite(usedAmount) || usedAmount <= 0) return null; + + return { + time: Date.now(), + amount: usedAmount, + conversationId: normalizeConversationId(conversationId || "__unknown__") || "__unknown__", + meta: String(provider.displayName || provider.id || "Current used"), + }; + } + + function currentHistorySnapshot(kind, conversationId) { + return kind === "context" + ? currentContextHistorySnapshot(conversationId) + : currentProviderHistorySnapshot(conversationId); + } + + function svgPoint(value) { + return Number.isFinite(value) ? value.toFixed(1) : "0.0"; + } + + function makeSpendHistoryChart(items, kind) { + const now = Date.now(); + const cutoff = now - SPEND_HISTORY_WINDOW_MS; + const width = SPEND_HISTORY_CHART_WIDTH; + const height = SPEND_HISTORY_CHART_HEIGHT; + const padding = SPEND_HISTORY_CHART_PADDING; + const axisWidth = SPEND_HISTORY_CHART_AXIS_WIDTH; + const plotLeft = axisWidth; + const plotRight = width - padding; + const plotTop = padding; + const plotBottom = height - padding; + const innerWidth = plotRight - plotLeft; + const innerHeight = height - padding * 2; + const chart = document.createElement("div"); + chart.className = "ccm-history-chart"; + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "ccm-history-svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("preserveAspectRatio", "none"); + svg.setAttribute("aria-hidden", "true"); + + const validItems = []; + for (const item of items) { + if (!Number.isFinite(item.amount) || item.amount <= 0) continue; + const time = Number(item.time); + if (!Number.isFinite(time) || time < cutoff) continue; + validItems.push({ + time: clampNumber(time, cutoff, now), + amount: item.amount, + meta: item.meta || "", + }); + } + + if (!validItems.length) { + const empty = document.createElement("div"); + empty.className = "ccm-history-empty"; + empty.textContent = "No spend in the last hour"; + chart.append(svg, empty); + return chart; + } + + const firstTime = validItems[0].time; + const lastTime = validItems[validItems.length - 1].time; + const timeSpan = lastTime - firstTime; + const useIndexAxis = validItems.length === 1 || timeSpan <= 0; + const axisLabel = formatHistoryTime(firstTime) === formatHistoryTime(lastTime) + ? formatHistoryTime(firstTime) + : `${formatHistoryTime(firstTime)}-${formatHistoryTime(lastTime)}`; + const rawPoints = []; + validItems.forEach((item, index) => { + const xRatio = useIndexAxis + ? index / Math.max(validItems.length - 1, 1) + : (item.time - firstTime) / timeSpan; + const x = plotLeft + xRatio * innerWidth; + rawPoints.push({ x, value: item.amount, item }); + }); + + const minValue = Math.min(...rawPoints.map((point) => point.value)); + const maxValue = Math.max(...rawPoints.map((point) => point.value)); + const fallbackRange = Math.max(maxValue, 1) * 0.12; + const axisMin = minValue === maxValue ? Math.max(0, minValue - fallbackRange) : minValue; + const axisMax = minValue === maxValue ? maxValue + fallbackRange : maxValue; + const axisRange = Math.max(axisMax - axisMin, 1); + const yForValue = (value) => plotBottom - ((value - axisMin) / axisRange) * innerHeight; + const points = rawPoints.map((point) => ({ + ...point, + y: yForValue(point.value), + })); + + if (points.length === 1) { + const onlyPoint = points[0]; + points.push({ + x: plotRight, + y: onlyPoint.y, + value: onlyPoint.value, + item: onlyPoint.item, + isSynthetic: true, + }); + } + + const yAxis = document.createElementNS("http://www.w3.org/2000/svg", "path"); + yAxis.setAttribute("class", "ccm-history-axis-line"); + yAxis.setAttribute("d", `M ${svgPoint(plotLeft)} ${svgPoint(plotTop)} V ${svgPoint(plotBottom)} H ${svgPoint(plotRight)}`); + svg.appendChild(yAxis); + + const topGridline = document.createElementNS("http://www.w3.org/2000/svg", "path"); + topGridline.setAttribute("class", "ccm-history-gridline"); + topGridline.setAttribute("d", `M ${svgPoint(plotLeft)} ${svgPoint(plotTop)} H ${svgPoint(plotRight)}`); + svg.appendChild(topGridline); + + const bottomGridline = document.createElementNS("http://www.w3.org/2000/svg", "path"); + bottomGridline.setAttribute("class", "ccm-history-gridline"); + bottomGridline.setAttribute("d", `M ${svgPoint(plotLeft)} ${svgPoint(plotBottom)} H ${svgPoint(plotRight)}`); + svg.appendChild(bottomGridline); + + const topLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); + topLabel.setAttribute("class", "ccm-history-axis-label"); + topLabel.setAttribute("x", "2"); + topLabel.setAttribute("y", svgPoint(plotTop + 4)); + topLabel.textContent = formatHistoryAxisValue(kind, axisMax); + svg.appendChild(topLabel); + + const bottomLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); + bottomLabel.setAttribute("class", "ccm-history-axis-label"); + bottomLabel.setAttribute("x", "2"); + bottomLabel.setAttribute("y", svgPoint(plotBottom)); + bottomLabel.textContent = formatHistoryAxisValue(kind, axisMin); + svg.appendChild(bottomLabel); + + const linePath = points + .map((point, index) => `${index === 0 ? "M" : "L"} ${svgPoint(point.x)} ${svgPoint(point.y)}`) + .join(" "); + const areaPath = `${linePath} L ${svgPoint(points[points.length - 1].x)} ${svgPoint(plotBottom)} L ${svgPoint(points[0].x)} ${svgPoint(plotBottom)} Z`; + + const area = document.createElementNS("http://www.w3.org/2000/svg", "path"); + area.setAttribute("class", "ccm-history-area"); + area.setAttribute("d", areaPath); + svg.appendChild(area); + + const line = document.createElementNS("http://www.w3.org/2000/svg", "path"); + line.setAttribute("class", "ccm-history-line"); + line.setAttribute("d", linePath); + svg.appendChild(line); + + const realPoints = points.filter((historyPoint) => !historyPoint.isSynthetic); + const lastPoint = realPoints[realPoints.length - 1] || points[points.length - 1]; + for (const historyPoint of realPoints) { + const isLatestPoint = historyPoint === lastPoint; + const point = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + point.setAttribute("class", "ccm-history-point"); + point.setAttribute("cx", svgPoint(historyPoint.x)); + point.setAttribute("cy", svgPoint(historyPoint.y)); + point.setAttribute("r", isLatestPoint ? "3" : "2.4"); + const pointTitle = document.createElementNS("http://www.w3.org/2000/svg", "title"); + pointTitle.textContent = formatHistoryPointTitle(kind, historyPoint); + point.appendChild(pointTitle); + svg.appendChild(point); + + const hit = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + hit.setAttribute("class", "ccm-history-hit"); + hit.setAttribute("cx", svgPoint(historyPoint.x)); + hit.setAttribute("cy", svgPoint(historyPoint.y)); + hit.setAttribute("r", "8"); + const title = document.createElementNS("http://www.w3.org/2000/svg", "title"); + title.textContent = formatHistoryPointTitle(kind, historyPoint); + hit.appendChild(title); + svg.appendChild(hit); + } + + const caption = document.createElement("div"); + caption.className = "ccm-history-caption"; + const windowLabel = document.createElement("span"); + windowLabel.textContent = axisLabel; + const lastLabel = document.createElement("span"); + lastLabel.textContent = formatHistoryDelta(kind, lastPoint.item.amount); + if (lastPoint.item.meta) lastLabel.title = String(lastPoint.item.meta); + caption.append(windowLabel, lastLabel); + + chart.append(svg, caption); + return chart; + } + + function renderHistorySection(kind) { + const panel = state.historyPanel; + const section = panel && panel.querySelector(`[data-history-kind="${kind}"]`); + if (!section) return; + + const visibleCard = kind === "provider" ? state.providerCard : state.contextCard; + if (!visibleCard || visibleCard.hidden) { + section.hidden = true; + return; + } + if (section.hidden) section.hidden = false; + + pruneSpendHistory(); + const conversationId = metaConversationId(); + let items = state.spendHistory[kind].filter((item) => { + const itemConversationId = normalizeConversationId(item.conversationId || "__unknown__") || "__unknown__"; + return itemConversationId === conversationId; + }); + let total = + kind === "context" + ? contextSessionTotal(conversationId) + : providerSessionTotal(conversationId); + if (!items.length) { + const snapshot = currentHistorySnapshot(kind, conversationId); + if (snapshot) { + items = [snapshot]; + total = snapshot.amount; + } + } + const totalNode = section.querySelector(".ccm-history-total"); + const chart = section.querySelector(".ccm-history-chart"); + if (totalNode) totalNode.textContent = total > 0 ? formatHistoryDelta(kind, total) : "--"; + if (!chart) return; + + chart.replaceWith(makeSpendHistoryChart(items, kind)); + } + + function metaConversationId() { + return normalizeConversationId(state.activeConversationId || (state.lastReading && state.lastReading.conversationId) || "__unknown__") || "__unknown__"; + } + + function contextSessionTotal(conversationId) { + const normalizedConversationId = normalizeConversationId(conversationId || "__unknown__") || "__unknown__"; + return state.contextSessionTotalsByConversationId.get(normalizedConversationId) || 0; + } + + function providerSessionTotal(conversationId) { + const normalizedConversationId = normalizeConversationId(conversationId || "__unknown__") || "__unknown__"; + return state.providerSessionTotalsByConversationId.get(normalizedConversationId) || 0; + } + + function renderSpendHistory() { + const grid = state.historyPanel && state.historyPanel.querySelector(".ccm-history-grid"); + if (grid) { + grid.dataset.providerVisible = state.providerCard && !state.providerCard.hidden ? "true" : "false"; + } + renderHistorySection("context"); + renderHistorySection("provider"); + } + + function clampHistoryPanelToViewport(root) { + const panel = state.historyPanel; + if (!root || !panel) return; + + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + if (viewportWidth <= 0 || viewportHeight <= 0) return; + + const maxWidth = Math.max( + HISTORY_PANEL_MIN_WIDTH, + viewportWidth - HISTORY_PANEL_VIEWPORT_PADDING * 2, + ); + const maxHeight = Math.max( + HISTORY_PANEL_MIN_HEIGHT, + viewportHeight - HISTORY_PANEL_VIEWPORT_PADDING * 2, + ); + panel.style.setProperty("--ccm-history-max-width", `${maxWidth}px`); + panel.style.setProperty("--ccm-history-max-height", `${maxHeight}px`); + + const anchor = state.contextCard && !state.contextCard.hidden + ? state.contextCard + : state.providerCard && !state.providerCard.hidden + ? state.providerCard + : root; + const anchorRect = anchor.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const panelWidth = Math.min( + Math.max(panelRect.width || HISTORY_PANEL_MIN_WIDTH, HISTORY_PANEL_MIN_WIDTH), + maxWidth, + ); + const panelHeight = Math.min( + Math.max(panelRect.height || HISTORY_PANEL_MIN_HEIGHT, HISTORY_PANEL_MIN_HEIGHT), + maxHeight, + ); + const left = clampNumber( + anchorRect.left, + HISTORY_PANEL_VIEWPORT_PADDING, + Math.max(HISTORY_PANEL_VIEWPORT_PADDING, viewportWidth - panelWidth - HISTORY_PANEL_VIEWPORT_PADDING), + ); + const belowTop = anchorRect.bottom + HISTORY_PANEL_GAP; + const aboveTop = anchorRect.top - panelHeight - HISTORY_PANEL_GAP; + const top = belowTop + panelHeight + HISTORY_PANEL_VIEWPORT_PADDING <= viewportHeight + ? belowTop + : Math.max(HISTORY_PANEL_VIEWPORT_PADDING, aboveTop); + + panel.style.setProperty("--ccm-history-left", `${Math.round(left)}px`); + panel.style.setProperty("--ccm-history-top", `${Math.round(top)}px`); + } + + function openSpendHistory() { + const root = state.root; + if (!root) return; + if (root.hidden) { + closeSpendHistory(); + return; + } + ensureHistoryPortal(root); + if (state.historyCloseTimer) { + window.clearTimeout(state.historyCloseTimer); + state.historyCloseTimer = 0; + } + renderSpendHistory(); + clampHistoryPanelToViewport(root); + if (root.dataset.historyOpen !== "true") root.dataset.historyOpen = "true"; + if (state.historyPortal) state.historyPortal.dataset.historyOpen = "true"; + if (state.historyPanel) state.historyPanel.setAttribute("aria-hidden", "false"); + } + + function refreshOpenSpendHistory(root = state.root) { + if (!root || root.dataset.historyOpen !== "true") return; + ensureHistoryPortal(root); + renderSpendHistory(); + clampHistoryPanelToViewport(root); + } + + function closeSpendHistory() { + const root = state.root; + if (!root) return; + if (state.historyCloseTimer) { + window.clearTimeout(state.historyCloseTimer); + state.historyCloseTimer = 0; + } + if (root.dataset.historyOpen !== "false") root.dataset.historyOpen = "false"; + if (state.historyPortal) state.historyPortal.dataset.historyOpen = "false"; + if (state.historyPanel) { + state.historyPanel.setAttribute("aria-hidden", "true"); + } + } + + function scheduleCloseSpendHistory(event) { + const root = state.root; + if (!root) return; + const relatedTarget = event && event.relatedTarget; + if (relatedTarget && typeof relatedTarget.nodeType === "number" && root.contains(relatedTarget)) return; + if (state.historyCloseTimer) window.clearTimeout(state.historyCloseTimer); + state.historyCloseTimer = 0; + closeSpendHistory(); + } + + function expandedRectContainsPoint(rect, x, y, expand) { + return !!rect && + x >= rect.left - expand && + x <= rect.right + expand && + y >= rect.top - expand && + y <= rect.bottom + expand; + } + + function isPointerInsideHistorySurface(x, y) { + const root = state.root; + if (!root || root.hidden) return false; + + const cards = [state.contextCard, state.providerCard].filter((card) => card && !card.hidden); + if (cards.some((card) => expandedRectContainsPoint(card.getBoundingClientRect(), x, y, 6))) { + return true; + } + + return false; + } + + function installHistoryPointerTracker(root) { + const onPointerMove = (event) => { + if (isPointerInsideHistorySurface(event.clientX, event.clientY)) { + openSpendHistory(); + } else if (root.dataset.historyOpen === "true") { + scheduleCloseSpendHistory(); + } + }; + const onPointerLeave = () => { + if (root.dataset.historyOpen === "true") scheduleCloseSpendHistory(); + }; + + document.addEventListener("pointermove", onPointerMove, { passive: true }); + document.addEventListener("pointerdown", onPointerMove, { passive: true }); + window.addEventListener("blur", closeSpendHistory); + document.addEventListener("mouseleave", onPointerLeave); + + return () => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerdown", onPointerMove); + window.removeEventListener("blur", closeSpendHistory); + document.removeEventListener("mouseleave", onPointerLeave); + }; + } + + function installHistoryHover(root) { + if (!root) return; + if (state.historyHoverCleanup && root.dataset.historyHoverInstalled === "true") return; + if (state.historyHoverCleanup) state.historyHoverCleanup(); + root.dataset.historyHoverInstalled = "true"; + if (root.dataset.historyOpen !== "true") root.dataset.historyOpen = "false"; + ensureHistoryPortal(root); + if (state.historyPortal && state.historyPortal.dataset.historyOpen !== "true") { + state.historyPortal.dataset.historyOpen = "false"; + } + state.historyHoverCleanup = installHistoryPointerTracker(root); + } + + function clearSpendEffects() { + window.clearTimeout(state.spendEffectTimer); + state.spendEffectTimer = 0; + state.spendEffectQueue.length = 0; + if (state.spendEffectActive) { + state.spendEffectActive.remove(); + state.spendEffectActive = null; + } + const root = state.root || document.getElementById(ROOT_ID); + root?.querySelectorAll(".ccm-hit-pop").forEach((node) => node.remove()); + } + + function hasPendingSpendEffects() { + return !!(state.spendEffectActive || state.spendEffectQueue.length); + } + + function finishSpendEffect(pop) { + if (state.spendEffectActive !== pop) return; + window.clearTimeout(state.spendEffectTimer); + state.spendEffectTimer = 0; + state.spendEffectActive = null; + pop.remove(); + playNextSpendEffect(); + if (!hasPendingSpendEffects()) { + const root = state.root || document.getElementById(ROOT_ID); + if (root) updateDockVisibility(root); + } + } + + function playNextSpendEffect() { + if (state.spendEffectActive) return; + const root = state.root || document.getElementById(ROOT_ID); + if (!root || root.hidden) return; + + const text = state.spendEffectQueue.shift(); + if (!text) return; + + const pop = document.createElement("div"); + pop.className = "ccm-hit-pop"; + pop.textContent = text; + state.spendEffectActive = pop; + root.appendChild(pop); + + const onAnimationEnd = (event) => { + if (event.target !== pop || event.animationName !== "ccm-hit-pop") return; + pop.removeEventListener("animationend", onAnimationEnd); + finishSpendEffect(pop); + }; + pop.addEventListener("animationend", onAnimationEnd); + state.spendEffectTimer = window.setTimeout(() => finishSpendEffect(pop), SPEND_EFFECT_FALLBACK_MS); + } + + function enqueueSpendEffect(text) { + if (!text) return; + state.spendEffectQueue.push(text); + playNextSpendEffect(); + } + + function showTokenSpendEffect(deltaTokens) { + if (!Number.isFinite(deltaTokens) || deltaTokens <= 0) return; + enqueueSpendEffect(`-${Math.round(deltaTokens).toLocaleString("en-US")} Tokens`); + } + + function showProviderSpendEffect(deltaAmount) { + if (!Number.isFinite(deltaAmount) || deltaAmount <= 0) return; + enqueueSpendEffect(`-${formatMoney(deltaAmount)}`); + } + + function shouldShowContextSpendEffect(conversationId, currentUsed) { + if (!Number.isFinite(currentUsed)) return false; + const now = Date.now(); + const normalizedConversationId = normalizeConversationId(conversationId || "__unknown__") || "__unknown__"; + const roundedCurrentUsed = Math.round(currentUsed); + const previous = window[CONTEXT_SPEND_DEDUPE_KEY]; + const isSameReading = + previous && + previous.currentUsed === roundedCurrentUsed && + ( + previous.conversationId === normalizedConversationId || + previous.conversationId === "__unknown__" || + normalizedConversationId === "__unknown__" + ); + if (isSameReading && now - previous.at < CONTEXT_SPEND_DEDUPE_WINDOW_MS) { + return false; + } + window[CONTEXT_SPEND_DEDUPE_KEY] = { + conversationId: normalizedConversationId, + currentUsed: roundedCurrentUsed, + at: now, + }; + return true; + } + + function shouldShowProviderSpendEffect(providerId, currentUsed) { + if (!providerId || !Number.isFinite(currentUsed)) return false; + const now = Date.now(); + const key = `${providerId}:${currentUsed}`; + const previous = window[PROVIDER_SPEND_DEDUPE_KEY]; + if (previous && previous.key === key && now - previous.at < PROVIDER_SPEND_DEDUPE_WINDOW_MS) { + return false; + } + window[PROVIDER_SPEND_DEDUPE_KEY] = { key, at: now }; + return true; + } + + function hasDescendant(element, selector) { + return !!(element && element.querySelector(selector)); + } + + // 只把主会话内容区当作可显示区域;Codex App DOM 更新后,优先从这里重找 thread/transcript 锚点。 + function hasThreadContentSurface() { + if (!isConversationWindow()) return false; + + const now = Date.now(); + if (now - state.threadContentLookupAt < ACTIVE_CONVERSATION_LOOKUP_CACHE_MS) { + return state.threadContentLookupResult; + } + + const main = document.querySelector("main"); + const result = hasDescendant(main, THREAD_CONTENT_SELECTOR); + state.threadContentLookupAt = now; + state.threadContentLookupResult = result; + return result; + } + + function invalidateThreadContentCache() { + state.threadContentLookupAt = 0; + state.inlineMountLookupAt = 0; + } + + // Pet/头像层也运行在 app://-/index.html,需要额外按 route 排除非对话窗口。 + function isConversationWindow() { + const url = new URL(location.href); + const route = `${url.pathname} ${url.search} ${url.hash}`.toLowerCase(); + if (route.includes("avatar-overlay") || route.includes("pet")) return false; + + return url.protocol === "app:" && url.pathname.endsWith("/index.html"); + } + + function updateDockVisibility(root) { + const contextVisible = state.contextCard && !state.contextCard.hidden; + const providerVisible = state.providerCard && !state.providerCard.hidden; + if (state.inlineMountPending && (state.uiState || readUiState()).mode !== "floating") { + root.hidden = true; + closeSpendHistory(); + return; + } + + const hidden = !contextVisible && !providerVisible; + const keepVisibleForSpend = hidden && hasPendingSpendEffects(); + root.hidden = hidden && !keepVisibleForSpend; + if (hidden) { + closeSpendHistory(); + if (!keepVisibleForSpend) clearSpendEffects(); + } else if (root.dataset.historyOpen === "true") { + renderSpendHistory(); + } + if (!root.hidden) playNextSpendEffect(); + } + + function hideMeter(root, card, value, fill, title) { + if (!card) return; + if (card.dataset.known !== "false") card.dataset.known = "false"; + if (card.dataset.level !== "normal") card.dataset.level = "normal"; + if (card.dataset.compressionWarning !== "false") card.dataset.compressionWarning = "false"; + if (card.title !== title) card.title = title; + if (value.textContent !== "Context Left --") value.textContent = "Context Left --"; + if (fill.style.width !== "0%") fill.style.width = "0%"; + const ring = state.contextRing || card.querySelector(".ccm-ring"); + if (ring && !state.contextRing) state.contextRing = ring; + if (ring) ring.style.setProperty("--ccm-ring-angle", "0deg"); + card.hidden = true; + updateDockVisibility(root); + } + + function hideProviderMeter(root, reason) { + const card = state.providerCard; + if (!card) return; + if (card.dataset.known !== "false") card.dataset.known = "false"; + if (card.dataset.level !== "normal") card.dataset.level = "normal"; + if (card.title !== reason) card.title = reason; + if (state.providerFill && state.providerFill.style.width !== "0%") state.providerFill.style.width = "0%"; + const ring = state.providerRing || card.querySelector(".ccm-ring"); + if (ring && !state.providerRing) state.providerRing = ring; + if (ring) ring.style.setProperty("--ccm-ring-angle", "0deg"); + card.hidden = true; + updateDockVisibility(root); + } + + function pickProviderSummary(summary) { + const providers = summary && Array.isArray(summary.providers) ? summary.providers : []; + return providers.find((provider) => provider && provider.status === "active") || null; + } + + function renderProviderMeter(root) { + state.providerSummary = readProviderSummary(); + state.uiConfig = readUiConfig(); + const provider = pickProviderSummary(state.providerSummary); + if (!provider) { + hideProviderMeter(root, "No active provider summary is available."); + return; + } + + const card = state.providerCard; + if (!card || !state.providerValue || !state.providerFill) return; + + const usedPercent = clampPercent(Number(provider.usedPercent)); + const remainingAmount = Number(provider.remainingAmount); + const totalAmount = Number(provider.totalAmount); + const usedAmount = Number.isFinite(Number(provider.usedAmount)) + ? Number(provider.usedAmount) + : totalAmount - remainingAmount; + if (usedPercent == null || !Number.isFinite(remainingAmount) || !Number.isFinite(totalAmount) || !Number.isFinite(usedAmount)) { + hideProviderMeter(root, "Provider summary is missing quota values."); + return; + } + + const leftPercent = clampPercent(100 - usedPercent); + const level = levelForLeftPercent(leftPercent, "provider"); + const name = String(provider.displayName || provider.id || "Provider").slice(0, 48); + const text = `${name} Left ${leftPercent.toFixed(1)}% (${formatMoney(remainingAmount)} left)`; + const width = `${leftPercent.toFixed(1)}%`; + const title = formatProviderTitle(name, provider, usedAmount, remainingAmount, totalAmount, usedPercent, leftPercent); + const providerId = String(provider.id || name || "__provider__"); + const currentUsed = Number(provider.used); + + if (Number.isFinite(currentUsed)) { + const previousUsed = state.lastAnimatedProviderUsedById.get(providerId); + if (Number.isFinite(previousUsed) && currentUsed > previousUsed && Number.isFinite(provider.total) && provider.total > 0) { + const deltaAmount = (currentUsed - previousUsed) * (totalAmount / Number(provider.total)); + if (shouldShowProviderSpendEffect(providerId, currentUsed)) { + recordSpend("provider", deltaAmount, providerId); + showProviderSpendEffect(deltaAmount); + } + } + state.lastAnimatedProviderUsedById.set(providerId, currentUsed); + } + + if (card.dataset.known !== "true") card.dataset.known = "true"; + if (card.dataset.level !== level) card.dataset.level = level; + if (card.title !== title) card.title = title; + if (state.providerValue.textContent !== text) state.providerValue.textContent = text; + if (state.providerFill.style.width !== width) state.providerFill.style.width = width; + const providerRing = state.providerRing || card.querySelector(".ccm-ring"); + if (providerRing && !state.providerRing) state.providerRing = providerRing; + if (providerRing) providerRing.style.setProperty("--ccm-ring-angle", `${leftPercent * 3.6}deg`); + if (card.hidden) card.hidden = false; + updateDockVisibility(root); + } + + function readProviderSummary() { + const summary = window[PROVIDER_SUMMARY_KEY]; + return summary && typeof summary === "object" ? summary : null; + } + + // helper 通过 CDP 写入脱敏 summary;真实 token、用户 ID 和服务商地址不进入渲染页。 + function setProviderSummary(summary) { + state.providerSummary = summary && typeof summary === "object" ? summary : null; + if (state.providerSummary) { + window[PROVIDER_SUMMARY_KEY] = state.providerSummary; + if (state.providerSummary.ui && typeof state.providerSummary.ui === "object") { + window[CONFIG_KEY] = normalizeUiConfig(state.providerSummary.ui); + } + } + const root = state.root || document.getElementById(ROOT_ID); + if (root) renderProviderMeter(root); + } + + function installProviderSummaryListener() { + if (state.providerSummaryListener) return; + state.providerSummary = readProviderSummary(); + state.providerSummaryListener = (event) => { + setProviderSummary(event && event.detail); + }; + try { + window.addEventListener(PROVIDER_SUMMARY_EVENT, state.providerSummaryListener); + } catch { + } + } + + function makeReading(percent, source, raw, used, limit) { + const safePercent = clampPercent(percent); + if (safePercent == null) return null; + + return { + percent: safePercent, + source, + raw: String(raw || "").slice(0, 240), + used: Number.isFinite(used) ? used : null, + limit: Number.isFinite(limit) ? limit : null, + conversationId: null, + }; + } + + function normalizeConversationId(value) { + if (value == null) return null; + if (typeof value !== "string" && typeof value !== "number") return null; + + const text = String(value).trim(); + if (!text) return null; + + const uuidMatch = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(text); + if (uuidMatch) return uuidMatch[0].toLowerCase(); + + return text.replace(/^[a-z]+:/i, "").toLowerCase(); + } + + function normalizeConversationUuid(value) { + if (value == null) return null; + if (typeof value !== "string" && typeof value !== "number") return null; + + const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(String(value)); + return match ? match[0].toLowerCase() : null; + } + + function conversationIdsMatch(left, right) { + const normalizedLeft = normalizeConversationId(left); + const normalizedRight = normalizeConversationId(right); + return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight; + } + + function withConversationId(reading, conversationId) { + if (!reading) return null; + + const normalizedConversationId = normalizeConversationId(conversationId); + if (normalizedConversationId) { + reading.conversationId = normalizedConversationId; + } + + return reading; + } + + // React 私有 key 每次构建都会换后缀;按节点缓存 key 列表能避开冷启动时的大量 Reflect.ownKeys。 + function getReactPrivateKeys(node) { + if (!node || (typeof node !== "object" && typeof node !== "function")) return []; + + const cached = state.reactPrivateKeyCache.get(node); + if (cached) return cached; + + let keys = []; + try { + keys = Reflect.ownKeys(node).map(String).filter((key) => REACT_PRIVATE_KEY_RE.test(key)); + } catch { + keys = []; + } + + state.reactPrivateKeyCache.set(node, keys); + return keys; + } + + function getFilteredReflectKeys(value, cacheName, pattern, limit) { + if (!value || (typeof value !== "object" && typeof value !== "function")) return []; + + let cache = state.filteredReflectKeyCache.get(value); + if (!cache) { + cache = {}; + state.filteredReflectKeyCache.set(value, cache); + } + + if (cache[cacheName]) return cache[cacheName]; + + let keys = []; + try { + keys = Reflect.ownKeys(value) + .map(String) + .filter((key) => pattern.test(key)) + .slice(0, limit); + } catch { + keys = []; + } + + cache[cacheName] = keys; + return keys; + } + + // Codex 部分会话 ID 只存在于 React 写到 DOM 节点上的私有 props,普通 attribute 读不到。 + function getReactPropValue(node, propName) { + if (!node) return null; + + for (const key of getReactPrivateKeys(node)) { + if (!key.startsWith("__reactProps$")) continue; + + let props; + try { + props = node[key]; + } catch { + continue; + } + + if (props && props[propName] != null) { + return props[propName]; + } + } + + return null; + } + + function getElementConversationId(element) { + for (let node = element; node && node.nodeType === Node.ELEMENT_NODE; node = node.parentElement) { + const attrValue = + node.getAttribute("data-app-action-sidebar-thread-id") || + node.getAttribute("data-thread-id") || + node.getAttribute("data-conversation-id") || + getReactPropValue(node, "data-app-action-sidebar-thread-id") || + getReactPropValue(node, "data-thread-id") || + getReactPropValue(node, "data-conversation-id"); + const normalized = normalizeConversationId(attrValue); + if (normalized) return normalized; + } + + return null; + } + + function getLikelyObjectConversationId(value, depth = 3, seen = new WeakSet()) { + if (!value || typeof value !== "object") return null; + if (depth < 0 || seen.has(value)) return null; + seen.add(value); + + for (const key of CONVERSATION_ID_KEYS) { + let candidate; + try { + candidate = value[key]; + } catch { + continue; + } + + const normalized = normalizeConversationUuid(candidate); + if (normalized) return normalized; + } + + const nested = [ + value.params, + value.thread, + value.conversation, + value.props, + ]; + for (const child of nested) { + if (!child || typeof child !== "object") continue; + const normalized = getLikelyObjectConversationId(child, depth - 1, seen); + if (normalized) return normalized; + } + + return null; + } + + function readReactConversationIdFromValue(value, depth, seen) { + if (!value || typeof value !== "object" || depth < 0) return null; + if (seen.has(value)) return null; + seen.add(value); + + const direct = getLikelyObjectConversationId(value); + if (direct) return direct; + + if (value.nodeType === Node.ELEMENT_NODE) { + const elementConversationId = getElementConversationId(value); + if (elementConversationId) return elementConversationId; + + for (const key of getReactPrivateKeys(value)) { + let child; + try { + child = value[key]; + } catch { + continue; + } + + const childConversationId = readReactConversationIdFromValue(child, depth - 1, seen); + if (childConversationId) return childConversationId; + } + } + + if (Array.isArray(value)) { + const limit = Math.min(value.length, 40); + for (let index = 0; index < limit; index += 1) { + const childConversationId = readReactConversationIdFromValue(value[index], depth - 1, seen); + if (childConversationId) return childConversationId; + } + return null; + } + + if (value instanceof Map) { + let index = 0; + for (const [mapKey, mapValue] of value) { + if (index >= 40) break; + + const keyConversationId = normalizeConversationUuid(mapKey); + if (keyConversationId) return keyConversationId; + + const mapKeyConversationId = readReactConversationIdFromValue(mapKey, depth - 1, seen); + if (mapKeyConversationId) return mapKeyConversationId; + + const mapValueConversationId = readReactConversationIdFromValue(mapValue, depth - 1, seen); + if (mapValueConversationId) return mapValueConversationId; + + index += 1; + } + return null; + } + + const keys = getFilteredReflectKeys(value, "reactConversationId", CONVERSATION_REACT_KEY_RE, 120); + for (const key of keys) { + let child; + try { + child = value[key]; + } catch { + continue; + } + + if (CONVERSATION_ID_KEY_SET.has(key)) { + const keyConversationId = normalizeConversationUuid(child); + if (keyConversationId) return keyConversationId; + } + + const childConversationId = readReactConversationIdFromValue(child, depth - 1, seen); + if (childConversationId) return childConversationId; + } + + return null; + } + + function readActiveConversationIdFromReact() { + const anchors = [ + document.querySelector("main"), + document.querySelector(`[data-thread-find-target="conversation"]`), + document.querySelector(THREAD_COMPOSER_SELECTOR), + document.querySelector(CODEX_COMPOSER_SELECTOR), + document.getElementById("root"), + ].filter(Boolean); + const seen = new WeakSet(); + + for (const anchor of anchors) { + const direct = getElementConversationId(anchor); + if (direct) return direct; + + for (const key of getReactPrivateKeys(anchor)) { + let value; + try { + value = anchor[key]; + } catch { + continue; + } + + const conversationId = readReactConversationIdFromValue(value, REACT_CONVERSATION_SCAN_DEPTH, seen); + if (conversationId) return conversationId; + } + } + + return null; + } + + function readActiveConversationId() { + const now = Date.now(); + if (now - state.activeConversationIdLookupAt < ACTIVE_CONVERSATION_LOOKUP_CACHE_MS) { + return state.cachedActiveConversationId; + } + + // 当前会话 ID 主要挂在左侧会话列表的 current/selected/active 状态节点上。 + const selectors = [ + `[aria-current="page"][data-app-action-sidebar-thread-id]`, + `[data-app-action-sidebar-thread-active="true"][data-app-action-sidebar-thread-id]`, + `[aria-selected="true"][data-app-action-sidebar-thread-id]`, + `[aria-current="page"]`, + `[data-app-action-sidebar-thread-active="true"]`, + `[aria-selected="true"]`, + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector); + const conversationId = getElementConversationId(element); + if (conversationId) { + state.cachedActiveConversationId = conversationId; + state.activeConversationIdLookupAt = now; + return conversationId; + } + } + + const reactConversationId = readActiveConversationIdFromReact(); + if (reactConversationId) { + state.cachedActiveConversationId = reactConversationId; + state.activeConversationIdLookupAt = now; + return reactConversationId; + } + + state.cachedActiveConversationId = null; + state.activeConversationIdLookupAt = now; + return null; + } + + function getObjectConversationId(value) { + if (!value || typeof value !== "object") return null; + + const candidates = [ + value.conversationId, + value.localConversationId, + value.threadId, + value.id, + value.key, + value.params && value.params.conversationId, + value.params && value.params.localConversationId, + value.params && value.params.threadId, + value.thread && value.thread.id, + value.thread && value.thread.threadId, + value.conversation && value.conversation.id, + ]; + + for (const candidate of candidates) { + const normalized = normalizeConversationId(candidate); + if (normalized) return normalized; + } + + return null; + } + + function activateConversationId(activeConversationId, options = {}) { + const normalizedConversationId = normalizeConversationId(activeConversationId); + if (!normalizedConversationId) return false; + + if (normalizedConversationId !== state.activeConversationId) { + clearRetryUpdate(); + window.clearTimeout(state.pendingUpdate); + state.pendingUpdateDueAt = 0; + state.activeConversationId = normalizedConversationId; + state.cachedActiveConversationId = normalizedConversationId; + state.activeConversationIdLookupAt = Date.now(); + state.lastReading = state.readingsByConversationId.get(normalizedConversationId) || null; + state.lastScanAt = 0; + state.lastScannedConversationId = null; + state.expensiveFallbackScannedAt = 0; + state.expensiveFallbackConversationId = null; + state.inlineMountLookupAt = 0; + state.navigationPendingUntil = options.pendingNavigation ? Date.now() + NAVIGATION_PENDING_MS : 0; + state.switchRetryUntil = Date.now() + SWITCH_RETRY_WINDOW_MS; + scheduleRetryUpdate(); + refreshOpenSpendHistory(); + return true; + } + + return false; + } + + // 只刷新会话指针,不触发“切换会话”副作用;用于侧栏隐藏或短暂缺失 active 节点的场景。 + function retainConversationId(conversationId) { + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedConversationId) return false; + + state.activeConversationId = normalizedConversationId; + state.cachedActiveConversationId = normalizedConversationId; + state.activeConversationIdLookupAt = Date.now(); + return true; + } + + function updateActiveConversationId() { + const activeConversationId = readActiveConversationId(); + if (activeConversationId) { + if ( + state.activeConversationId && + !conversationIdsMatch(activeConversationId, state.activeConversationId) && + Date.now() < state.navigationPendingUntil + ) { + return state.activeConversationId; + } + + activateConversationId(activeConversationId); + } else if (hasThreadContentSurface() && state.activeConversationId) { + // sidebar 隐藏时 active/current 节点会消失;主会话区仍在时沿用最后确认的会话 ID。 + retainConversationId(state.activeConversationId); + } else if (hasThreadContentSurface() && state.lastReading && state.lastReading.conversationId) { + retainConversationId(state.lastReading.conversationId); + } else { + state.activeConversationId = null; + state.lastReading = null; + state.cachedActiveConversationId = null; + state.activeConversationIdLookupAt = Date.now(); + state.navigationPendingUntil = 0; + state.switchRetryUntil = 0; + clearRetryUpdate(); + } + + return state.activeConversationId; + } + + function parseStatusContextUsageObject(value, source, conversationId) { + if (!value || typeof value !== "object") return null; + + const modelContextWindow = firstFiniteNumber(value.modelContextWindow, value.model_context_window); + const lastUsage = value.last || value.lastTokenUsage || value.last_token_usage; + const totalTokens = firstFiniteNumber( + lastUsage && lastUsage.totalTokens, + lastUsage && lastUsage.total_tokens, + ); + if (Number.isFinite(modelContextWindow) && modelContextWindow > 0 && Number.isFinite(totalTokens) && totalTokens >= 0) { + const usedTokens = Math.min(totalTokens, modelContextWindow); + return withConversationId(makeReading( + (usedTokens / modelContextWindow) * 100, + source, + "status tokenUsage", + usedTokens, + modelContextWindow, + ), conversationId); + } + + const percent = Number(value.percent); + const usedTokens = firstFiniteNumber(value.usedTokens, value.used_tokens); + const contextWindow = firstFiniteNumber(value.contextWindow, value.context_window); + + if (Number.isFinite(usedTokens) && Number.isFinite(contextWindow) && contextWindow > 0) { + return withConversationId(makeReading( + (usedTokens / contextWindow) * 100, + source, + "status contextUsage", + usedTokens, + contextWindow, + ), conversationId); + } + + if (Number.isFinite(percent) && percent >= 0 && percent <= 100) { + return withConversationId(makeReading(percent, source, "status contextUsage.percent"), conversationId); + } + + return null; + } + + function looksLikeStatusContextUsageObject(value) { + if (!value || typeof value !== "object") return false; + + const modelContextWindow = firstFiniteNumber(value.modelContextWindow, value.model_context_window); + const lastUsage = value.last || value.lastTokenUsage || value.last_token_usage; + const totalTokens = firstFiniteNumber( + lastUsage && lastUsage.totalTokens, + lastUsage && lastUsage.total_tokens, + ); + if (Number.isFinite(modelContextWindow) && modelContextWindow > 0 && Number.isFinite(totalTokens) && totalTokens >= 0) { + return true; + } + + const usedTokens = firstFiniteNumber(value.usedTokens, value.used_tokens); + const contextWindow = firstFiniteNumber(value.contextWindow, value.context_window); + return Number.isFinite(usedTokens) && usedTokens >= 0 && Number.isFinite(contextWindow) && contextWindow > 0; + } + + function firstFiniteNumber(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number)) return number; + } + + return null; + } + + // 反向遍历 React / window 状态树时,Map key 或父对象常常比叶子值更像会话归属来源。 + function findStatusContextUsageObject(value, depth, seen, activeConversationId, ownerConversationId) { + if (!value || typeof value !== "object" || depth < 0) return null; + if (seen.has(value)) return null; + seen.add(value); + + const valueConversationId = getObjectConversationId(value); + const nextOwnerConversationId = valueConversationId || ownerConversationId; + const ownerMatchesActive = + !activeConversationId || + conversationIdsMatch(activeConversationId, nextOwnerConversationId) || + conversationIdsMatch(activeConversationId, ownerConversationId); + + if (ownerMatchesActive) { + const direct = parseStatusContextUsageObject(value, "status-react", nextOwnerConversationId); + if (direct) return direct; + } + + if (Array.isArray(value)) { + const limit = Math.min(value.length, 120); + for (let index = 0; index < limit; index += 1) { + const reading = findStatusContextUsageObject( + value[index], + depth - 1, + seen, + activeConversationId, + nextOwnerConversationId, + ); + if (reading) return reading; + } + return null; + } + + if (value instanceof Map) { + let index = 0; + for (const [mapKey, mapValue] of value) { + if (index >= 120) break; + + const mapKeyConversationId = normalizeConversationId(mapKey); + const childOwnerConversationId = mapKeyConversationId || nextOwnerConversationId; + + const keyReading = findStatusContextUsageObject( + mapKey, + depth - 1, + seen, + activeConversationId, + childOwnerConversationId, + ); + if (keyReading) return keyReading; + + const valueReading = findStatusContextUsageObject( + mapValue, + depth - 1, + seen, + activeConversationId, + childOwnerConversationId, + ); + if (valueReading) return valueReading; + + index += 1; + } + return null; + } + + if (value instanceof Set) { + let index = 0; + for (const setValue of value) { + if (index >= 120) break; + + const reading = findStatusContextUsageObject( + setValue, + depth - 1, + seen, + activeConversationId, + nextOwnerConversationId, + ); + if (reading) return reading; + + index += 1; + } + return null; + } + + const keys = getFilteredReflectKeys(value, "statusTree", STATUS_TREE_KEY_RE, 120); + const keySet = new Set(keys); + + for (const key of PREFERRED_STATUS_KEYS) { + if (!keySet.has(key)) continue; + + let child; + try { + child = value[key]; + } catch { + continue; + } + + const reading = findStatusContextUsageObject( + child, + depth - 1, + seen, + activeConversationId, + nextOwnerConversationId, + ); + if (reading) return reading; + } + + for (const key of keys) { + if (PREFERRED_STATUS_KEY_SET.has(key)) continue; + + let child; + try { + child = value[key]; + } catch { + continue; + } + + const keyConversationId = normalizeConversationId(key); + const childOwnerConversationId = keyConversationId || nextOwnerConversationId; + const reading = findStatusContextUsageObject( + child, + depth - 1, + seen, + activeConversationId, + childOwnerConversationId, + ); + if (reading) return reading; + } + + return null; + } + + // React 私有字段是从界面节点回溯运行态状态的桥;升级后若读不到值,优先检查 __react* 键。 + function scanStatusReactContextUsage(activeConversationId) { + const nodes = [ + document.getElementById("root"), + document.querySelector(`[aria-current="page"]`), + document.querySelector(`[data-app-action-sidebar-thread-active="true"]`), + document.body, + document.documentElement, + ].filter(Boolean); + const limit = nodes.length; + + for (let index = 0; index < limit; index += 1) { + const node = nodes[index]; + const keys = getReactPrivateKeys(node); + + for (const key of keys) { + let value; + try { + value = node[key]; + } catch { + continue; + } + + const reading = findStatusContextUsageObject(value, 10, new WeakSet(), activeConversationId, null); + if (reading) return reading; + } + } + + return null; + } + + function isAppSignalScope(value) { + return !!( + value && + typeof value === "object" && + typeof value.get === "function" && + typeof value.watch === "function" && + value.node && + value.chain + ); + } + + // app signal scope 没有稳定全局入口,只能从 React fiber 链里按结构特征反查。 + function findAppSignalScopeInValue(value, depth, seen) { + if (!value || typeof value !== "object" || depth < 0) return null; + if (seen.has(value)) return null; + if (state.appSignalSkipGeneration.get(value) === state.scanGeneration) return null; + seen.add(value); + + if (isAppSignalScope(value)) return value; + + if (Array.isArray(value)) { + const limit = Math.min(value.length, 40); + for (let index = 0; index < limit; index += 1) { + const scope = findAppSignalScopeInValue(value[index], depth - 1, seen); + if (scope) return scope; + } + return null; + } + + if (value instanceof Map) { + let index = 0; + for (const [mapKey, mapValue] of value) { + if (index >= 30) break; + + const keyScope = findAppSignalScopeInValue(mapKey, depth - 1, seen); + if (keyScope) return keyScope; + + const valueScope = findAppSignalScopeInValue(mapValue, depth - 1, seen); + if (valueScope) return valueScope; + + index += 1; + } + } + + const keys = getFilteredReflectKeys(value, "appSignalScope", APP_SIGNAL_SCOPE_KEY_RE, 120); + + for (const key of keys) { + let child; + try { + child = value[key]; + } catch { + continue; + } + + const scope = findAppSignalScopeInValue(child, depth - 1, seen); + if (scope) return scope; + } + + state.appSignalSkipGeneration.set(value, state.scanGeneration); + return null; + } + + function findAppSignalScope() { + if (isAppSignalScope(state.appSignalScope)) return state.appSignalScope; + + const now = Date.now(); + if (now - state.appSignalLastLookupAt < 2000) return null; + state.appSignalLastLookupAt = now; + + // app signal scope 挂在 React 树里;只扫稳定锚点,避免初始化时遍历整页 DOM。 + const root = document.getElementById("root"); + const current = document.querySelector(`[aria-current="page"]`); + const activeThread = document.querySelector(`[data-app-action-sidebar-thread-active="true"]`); + const nodes = [ + root, + current, + activeThread, + current && current.closest("[data-app-action-sidebar-thread-id]"), + activeThread && activeThread.closest("[data-app-action-sidebar-thread-id]"), + document.body, + document.documentElement, + ...(root + ? Array.from(root.querySelectorAll(REACT_STATE_HOST_SELECTOR)).slice(0, REACT_HOST_SCAN_LIMIT) + : []), + ].filter(Boolean); + + const seen = new WeakSet(); + for (const node of nodes) { + const keys = getReactPrivateKeys(node); + for (const key of keys) { + let value; + try { + value = node[key]; + } catch { + continue; + } + + const scope = findAppSignalScopeInValue(value, 18, seen); + if (scope) { + state.appSignalScope = scope; + return scope; + } + } + } + + return null; + } + + // hashed asset 文件名会随版本变化;先从已加载资源定位,fallback 只保留当前版本的相对路径。 + function findLoadedAssetUrl(fragment, fallbackPath) { + const selectors = [ + `script[src*="${fragment}"]`, + `link[href*="${fragment}"]`, + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector); + const value = element && (element.src || element.href); + if (value) return value; + } + + const resources = + typeof performance !== "undefined" && typeof performance.getEntriesByType === "function" + ? performance.getEntriesByType("resource") + : []; + for (const resource of resources) { + if (resource && typeof resource.name === "string" && resource.name.includes(fragment)) { + return resource.name; + } + } + + const links = document.querySelectorAll(`link[rel="modulepreload"][href*="${fragment}"]`); + for (const link of links) { + const value = link && link.href; + if (value) return value; + } + + return new URL(fallbackPath, location.href).href; + } + + // 优先读取 Status 使用的 app signal;bundle hash 或导出名变化时,先更新这两个资产入口。 + function ensureAppSignalModules() { + if (state.appSignalModules) return state.appSignalModules; + if (state.appSignalModulesPromise) return null; + state.appSignalModulesRequestedAt = Date.now(); + + const appServerUrl = findLoadedAssetUrl( + "app-server-manager-signals", + "./assets/app-server-manager-signals-7MlBpIlX.js", + ); + const signalUrl = findLoadedAssetUrl( + "setting-storage", + "./assets/setting-storage-kJblH-wH.js", + ); + + state.appSignalModulesPromise = Promise.all([ + import(appServerUrl), + import(signalUrl), + ]) + .then(([appServerSignals, signalStorage]) => { + state.appSignalModules = { appServerSignals, signalStorage }; + scheduleUpdate(); + return state.appSignalModules; + }) + .catch(() => { + state.appSignalModulesPromise = null; + return null; + }); + + return null; + } + + // setting-storage 的 rt helper 会解开嵌套 signal;缺失时退回 scope.get 的两段读取。 + function readSignalValue(scope, selector, argument) { + if (!scope || !selector) return null; + + const modules = state.appSignalModules; + const readHelper = modules && modules.signalStorage && modules.signalStorage.rt; + if (typeof readHelper === "function") { + try { + return readHelper(scope, selector, argument); + } catch { + return null; + } + } + + try { + const nestedSignal = scope.get(selector, argument); + if (nestedSignal && typeof nestedSignal === "object") { + return scope.get(nestedSignal); + } + } catch { + return null; + } + + return null; + } + + function findTokenUsageSelector(scope, conversationId) { + if (!scope || !conversationId) return null; + + if (state.appSignalTokenUsageSelector) return state.appSignalTokenUsageSelector; + + const now = Date.now(); + if (now - state.appSignalTokenUsageSelectorLookupAt < APP_SIGNAL_SELECTOR_SCAN_INTERVAL_MS) { + return null; + } + state.appSignalTokenUsageSelectorLookupAt = now; + + const modules = state.appSignalModules; + const appServerSignals = modules && modules.appServerSignals; + if (!appServerSignals || typeof appServerSignals !== "object") return null; + + let scanned = 0; + for (const [exportName, selector] of Object.entries(appServerSignals)) { + if (!selector || (typeof selector !== "object" && typeof selector !== "function")) continue; + scanned += 1; + if (scanned > APP_SIGNAL_SELECTOR_SCAN_LIMIT) break; + + const value = readSignalValue(scope, selector, conversationId); + if (!looksLikeStatusContextUsageObject(value)) continue; + + state.appSignalTokenUsageSelector = selector; + state.appSignalTokenUsageSelectorExport = exportName; + return selector; + } + + return null; + } + + function scanAppSignalContextUsage(activeConversationId) { + const now = Date.now(); + const requestedConversationId = normalizeConversationId(activeConversationId); + if ( + state.appSignalCachedReading && + requestedConversationId && + conversationIdsMatch(requestedConversationId, state.appSignalCachedConversationId) && + now - state.appSignalCachedAt < APP_SIGNAL_READING_CACHE_MS + ) { + return state.appSignalCachedReading; + } + + const modules = ensureAppSignalModules(); + if (!modules || !modules.appServerSignals) { + state.waitingForAppSignalModules = !!state.appSignalModulesPromise; + return null; + } + state.waitingForAppSignalModules = false; + + const scope = findAppSignalScope(); + if (!scope) return null; + + const conversationId = + normalizeConversationId(activeConversationId) || + normalizeConversationId(scope.value && scope.value.conversationId); + if (!conversationId) return null; + + const latestTokenUsageSelector = findTokenUsageSelector(scope, conversationId); + const tokenUsage = readSignalValue(scope, latestTokenUsageSelector, conversationId); + const reading = parseStatusContextUsageObject(tokenUsage, "app-signal", conversationId); + if (reading) { + state.appSignalCachedReading = reading; + state.appSignalCachedConversationId = conversationId; + state.appSignalCachedAt = now; + state.appSignalLastSuccessAt = now; + return reading; + } + + return null; + } + + function collectContextUsageSampleConversationIds(activeConversationId) { + const ids = []; + const add = (conversationId) => { + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedConversationId || ids.includes(normalizedConversationId)) return; + ids.push(normalizedConversationId); + }; + + add(activeConversationId); + for (const element of document.querySelectorAll("[data-app-action-sidebar-thread-id]")) { + add(getElementConversationId(element)); + if (ids.length >= CONTEXT_USAGE_BACKGROUND_SAMPLE_MAX_CONVERSATIONS) return ids; + } + for (const conversationId of state.readingsByConversationId.keys()) add(conversationId); + for (const conversationId of state.lastAnimatedUsedByConversationId.keys()) add(conversationId); + + return ids.slice(0, CONTEXT_USAGE_BACKGROUND_SAMPLE_MAX_CONVERSATIONS); + } + + function rememberContextUsageReading(reading, fallbackConversationId) { + const conversationId = normalizeConversationId(reading && (reading.conversationId || fallbackConversationId)); + if (!conversationId || !reading) return null; + + reading.conversationId = conversationId; + state.readingsByConversationId.set(conversationId, reading); + return conversationId; + } + + function recordContextUsageDelta(reading, fallbackConversationId, options = {}) { + const conversationId = rememberContextUsageReading(reading, fallbackConversationId); + if (!conversationId || !Number.isFinite(reading.used)) return null; + + const previousUsed = state.lastAnimatedUsedByConversationId.get(conversationId); + if (Number.isFinite(previousUsed) && reading.used > previousUsed) { + const deltaTokens = reading.used - previousUsed; + if (shouldShowContextSpendEffect(conversationId, reading.used)) { + recordSpend("context", deltaTokens, conversationId); + if (options.showEffect !== false) showTokenSpendEffect(deltaTokens); + } + } + state.lastAnimatedUsedByConversationId.set(conversationId, reading.used); + return conversationId; + } + + // 已知/侧栏可见会话的 app-signal 读数可按会话 ID 查询;每 5 秒顺手刷新一次,避免只记录当前可见会话。 + function sampleKnownConversationContextUsage(activeConversationId) { + const now = Date.now(); + if (now - state.contextUsageBackgroundSampleAt < CONTEXT_USAGE_BACKGROUND_SAMPLE_INTERVAL_MS) return; + + const conversationIds = collectContextUsageSampleConversationIds(activeConversationId); + state.contextUsageBackgroundSampleAt = now; + state.contextUsageBackgroundSampleConversationIds = conversationIds; + if (!conversationIds.length) return; + + for (const conversationId of conversationIds) { + if (conversationIdsMatch(conversationId, activeConversationId)) continue; + + const reading = scanAppSignalContextUsage(conversationId); + if (!reading) continue; + recordContextUsageDelta(reading, conversationId, { showEffect: false }); + } + } + + function shouldWaitForAppSignalModules(now) { + return !!( + state.appSignalModulesPromise && + !state.appSignalModules && + now - state.appSignalModulesRequestedAt < APP_SIGNAL_IMPORT_GRACE_MS + ); + } + + function isVisibleElement(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) return false; + + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0; + } + + function isConversationContent(element) { + return !!(element && element.closest(CONVERSATION_CONTENT_SELECTOR)); + } + + function shouldIgnoreMutationTarget(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) return false; + if (element.closest(`#${ROOT_ID}`)) return true; + if (element.closest(`#${HISTORY_PORTAL_ID}`)) return true; + return !!element.closest(MESSAGE_MUTATION_SELECTOR); + } + + // 这里直接找结构化 usage 对象,比把 window 状态拼成文本再正则解析更便宜。 + function scanWindowForContextUsage(activeConversationId) { + const seen = new WeakSet(); + const now = Date.now(); + if (!state.windowUsageKeys || now - state.windowUsageKeysAt > WINDOW_KEY_CACHE_MS) { + state.windowUsageKeys = Object.keys(window).filter((key) => + /codex|thread|token|usage|context|store|query|cache|notification|message/i.test(key), + ); + state.windowUsageKeysAt = now; + } + + for (const key of state.windowUsageKeys) { + let value; + try { + value = window[key]; + } catch { + continue; + } + + const reading = findStatusContextUsageObject(value, 6, seen, activeConversationId, null); + if (reading) return reading; + } + + return null; + } + + // 读取顺序按稳定性排列:app signal > 结构化 React 状态 > window 缓存。 + function detectReading() { + state.scanGeneration += 1; + const activeConversationId = updateActiveConversationId(); + + if (!activeConversationId) { + const appSignalReading = scanAppSignalContextUsage(null); + if (appSignalReading && appSignalReading.conversationId) { + retainConversationId(appSignalReading.conversationId); + state.switchRetryUntil = 0; + clearRetryUpdate(); + return appSignalReading; + } + + return null; + } + + const cachedReading = state.readingsByConversationId.get(activeConversationId) || null; + const fallbackReading = + state.lastReading && state.lastReading.conversationId && conversationIdsMatch(activeConversationId, state.lastReading.conversationId) + ? state.lastReading + : cachedReading; + + const now = Date.now(); + const activeChangedSinceScan = activeConversationId !== state.lastScannedConversationId; + const inSwitchRetryWindow = !!activeConversationId && now < state.switchRetryUntil; + + const shouldRunStatusScan = + activeChangedSinceScan || + inSwitchRetryWindow || + now - state.lastScanAt >= SLOW_SCAN_INTERVAL_MS; + const appSignalReading = scanAppSignalContextUsage(activeConversationId); + if (appSignalReading && !shouldRunStatusScan) { + state.switchRetryUntil = 0; + clearRetryUpdate(); + return appSignalReading; + } + + if (!shouldRunStatusScan) { + if (shouldWaitForAppSignalModules(now)) { + scheduleUpdate(APP_SIGNAL_IMPORT_GRACE_MS); + return state.lastReading; + } + return fallbackReading; + } + + // 会话切换初期允许快速兜底;同一会话内的昂贵扫描按窗口限频。 + const canRunExpensiveFallback = + activeChangedSinceScan || + !conversationIdsMatch(activeConversationId, state.expensiveFallbackConversationId) || + now - state.expensiveFallbackScannedAt >= EXPENSIVE_FALLBACK_INTERVAL_MS; + + state.lastScannedConversationId = activeConversationId; + state.lastScanAt = now; + + const statusReactReading = scanStatusReactContextUsage(activeConversationId); + if (statusReactReading) { + state.switchRetryUntil = 0; + clearRetryUpdate(); + return statusReactReading; + } + + if (canRunExpensiveFallback) { + state.expensiveFallbackScannedAt = now; + state.expensiveFallbackConversationId = activeConversationId; + + // fallback 只保留结构化对象,避免按页面文案猜测。 + const windowReading = scanWindowForContextUsage(activeConversationId); + if (windowReading) { + state.switchRetryUntil = 0; + clearRetryUpdate(); + return windowReading; + } + } + + if (appSignalReading) { + state.switchRetryUntil = 0; + clearRetryUpdate(); + return appSignalReading; + } + + if (shouldWaitForAppSignalModules(now)) { + scheduleUpdate(APP_SIGNAL_IMPORT_GRACE_MS); + return state.lastReading; + } + + if (inSwitchRetryWindow) { + scheduleRetryUpdate(); + } else { + clearRetryUpdate(); + } + + if (fallbackReading) return fallbackReading; + + return null; + } + + function rememberReading(reading, fallbackConversationId) { + if (!reading) return; + + rememberContextUsageReading(reading, fallbackConversationId); + state.lastReading = reading; + } + + function updateMeter() { + installStyle(); + + if (!document.body) return; + + const root = ensureRoot(); + state.uiConfig = readUiConfig(); + const contextCard = state.contextCard; + const value = state.value; + const fill = state.fill; + const compressionZone = state.compressionZone; + const reading = detectReading(); + const activeConversationId = state.activeConversationId || readActiveConversationId(); + + if (!contextCard || !value || !fill) return; + + if (!hasThreadContentSurface()) { + state.lastReading = null; + hideProviderMeter(root, "No thread content is open in the main view."); + hideMeter(root, contextCard, value, fill, "No thread content is open in the main view."); + return; + } + renderProviderMeter(root); + + if (!reading) { + if (state.waitingForAppSignalModules) { + scheduleUpdate(APP_SIGNAL_IMPORT_GRACE_MS); + if (contextCard.dataset.known === "true" && value.textContent !== "Context Left --") return; + hideMeter(root, contextCard, value, fill, "Waiting for Codex context usage signal."); + return; + } + + const title = activeConversationId + ? `No context usage value is exposed for conversation ${activeConversationId} in the current page state yet.` + : "No context usage value is exposed in the current page state yet."; + hideMeter(root, contextCard, value, fill, title); + return; + } + + rememberReading(reading, activeConversationId); + + const leftPercent = clampPercent(100 - reading.percent); + const showUsedInsteadOfLeft = shouldShowUsedInsteadOfLeft(state.uiConfig); + const displayPercent = showUsedInsteadOfLeft ? clampPercent(reading.percent) : leftPercent; + const percentText = displayPercent.toFixed(1); + const details = + reading.used != null && reading.limit != null + ? ` ${compactNumber(reading.used)} / ${compactNumber(reading.limit)}` + : ""; + const readingConversationId = normalizeConversationId( + reading.conversationId || activeConversationId || "__unknown__" + ); + + recordContextUsageDelta(reading, readingConversationId, { showEffect: true }); + sampleKnownConversationContextUsage(readingConversationId); + + const level = levelForLeftPercent(leftPercent, "context"); + const compressionWarning = shouldShowCompressionWarning(leftPercent) ? "true" : "false"; + const remainingTokens = reading.used != null && reading.limit != null ? Math.max(0, reading.limit - reading.used) : null; + const title = formatContextTitle(reading, reading.used, remainingTokens, leftPercent, reading.percent); + const text = showUsedInsteadOfLeft + ? `Context Used ${percentText}%${details}` + : Number.isFinite(remainingTokens) + ? `Context Left ${percentText}% (${compactNumber(remainingTokens)} left)` + : `Context Left ${percentText}%${details}`; + const width = `${displayPercent.toFixed(1)}%`; + const compressionZoneWidth = `${state.uiConfig.context.compressionWarningLeftPercent.toFixed(1)}%`; + + if (contextCard.dataset.known !== "true") contextCard.dataset.known = "true"; + if (contextCard.hidden) contextCard.hidden = false; + if (contextCard.dataset.level !== level) contextCard.dataset.level = level; + const showUsedInsteadOfLeftValue = showUsedInsteadOfLeft ? "true" : "false"; + if (contextCard.dataset.showUsedInsteadOfLeft !== showUsedInsteadOfLeftValue) { + contextCard.dataset.showUsedInsteadOfLeft = showUsedInsteadOfLeftValue; + } + if (contextCard.dataset.compressionWarning !== compressionWarning) { + contextCard.dataset.compressionWarning = compressionWarning; + } + if (contextCard.title !== title) contextCard.title = title; + if (value.textContent !== text) value.textContent = text; + if (fill.style.width !== width) fill.style.width = width; + const contextRing = state.contextRing; + if (contextRing) contextRing.style.setProperty("--ccm-ring-angle", `${displayPercent * 3.6}deg`); + if (compressionZone && compressionZone.style.width !== compressionZoneWidth) { + compressionZone.style.width = compressionZoneWidth; + } + updateDockVisibility(root); + } + + function scheduleUpdate(delayMs = MUTATION_UPDATE_DELAY_MS) { + const delay = Math.max(0, Number(delayMs) || 0); + const dueAt = Date.now() + delay; + if (state.pendingUpdate && state.pendingUpdateDueAt <= dueAt) return; + + window.clearTimeout(state.pendingUpdate); + state.pendingUpdateDueAt = dueAt; + state.pendingUpdate = window.setTimeout(() => { + state.pendingUpdate = 0; + state.pendingUpdateDueAt = 0; + updateMeter(); + }, delay); + } + + // 提前捕获侧栏会话切换,避免 DOM/状态更新滞后时短暂沿用旧会话读数。 + function handlePotentialNavigation(event) { + const target = event.target && event.target.closest + ? event.target.closest("[data-app-action-sidebar-thread-id]") + : null; + const conversationId = getElementConversationId(target); + if (!conversationId) return; + + activateConversationId(conversationId, { pendingNavigation: true }); + scheduleUpdate(NAVIGATION_UPDATE_DELAY_MS); + } + + function clearRetryUpdate() { + window.clearTimeout(state.retryTimer); + state.retryTimer = 0; + } + + function scheduleRetryUpdate() { + if (state.retryTimer || Date.now() >= state.switchRetryUntil) return; + + state.retryTimer = window.setTimeout(() => { + state.retryTimer = 0; + updateMeter(); + }, SWITCH_RETRY_INTERVAL_MS); + } + + function restoreLegacyCaptureHooks() { + const captureState = window.__codexContextMeterCaptureState; + if (!captureState || typeof captureState !== "object") return; + + if (captureState.nativeFetch && window.fetch !== captureState.nativeFetch) { + window.fetch = captureState.nativeFetch; + } + if (captureState.NativeWebSocket && window.WebSocket !== captureState.NativeWebSocket) { + window.WebSocket = captureState.NativeWebSocket; + } + if (captureState.messageListener) { + window.removeEventListener("message", captureState.messageListener, true); + } + + delete window.__codexContextMeterCaptureState; + delete window.__codexContextMeterFetchPatched; + delete window.__codexContextMeterWebSocketPatched; + delete window.__codexContextMeterPostMessagePatched; + } + + function installObserver() { + if (state.observer) return; + + state.navigationListener = handlePotentialNavigation; + document.addEventListener("pointerdown", state.navigationListener, true); + document.addEventListener("click", state.navigationListener, true); + document.addEventListener("keydown", state.navigationListener, true); + + state.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + const target = mutation.target && mutation.target.nodeType === Node.ELEMENT_NODE + ? mutation.target + : mutation.target && mutation.target.parentElement; + if (shouldIgnoreMutationTarget(target)) continue; + + invalidateThreadContentCache(); + scheduleUpdate(MUTATION_UPDATE_DELAY_MS); + return; + } + }); + state.observer.observe(document.documentElement, { + attributes: true, + attributeFilter: [ + // 会话切换只依赖侧栏 active/current/id 相关属性;Codex 改名时同步这组属性。 + "aria-current", + "aria-selected", + "data-app-action-sidebar-thread-active", + "data-app-action-sidebar-thread-id", + ], + childList: true, + subtree: true, + }); + } + + window[API_KEY] = { + version: SCRIPT_VERSION, + refresh: updateMeter, + setProviderSummary, + destroy() { + window.clearInterval(state.timer); + window.clearTimeout(state.pendingUpdate); + window.clearTimeout(state.historyCloseTimer); + state.pendingUpdateDueAt = 0; + state.historyCloseTimer = 0; + if (state.historyHoverCleanup) { + state.historyHoverCleanup(); + state.historyHoverCleanup = null; + } + if (state.floatingPointerCleanup) { + state.floatingPointerCleanup(); + state.floatingPointerCleanup = null; + } + closeContextMenu(); + clearSpendEffects(); + clearRetryUpdate(); + if (state.observer) state.observer.disconnect(); + if (state.navigationListener) { + document.removeEventListener("pointerdown", state.navigationListener, true); + document.removeEventListener("click", state.navigationListener, true); + document.removeEventListener("keydown", state.navigationListener, true); + } + if (state.providerSummaryListener) { + window.removeEventListener(PROVIDER_SUMMARY_EVENT, state.providerSummaryListener); + state.providerSummaryListener = null; + } + + const root = document.getElementById(ROOT_ID); + if (root) root.remove(); + + const portal = document.getElementById(HISTORY_PORTAL_ID); + if (portal) portal.remove(); + + const style = document.getElementById(STYLE_ID); + if (style) style.remove(); + + delete window[INSTALL_KEY]; + delete window[API_KEY]; + }, + getState() { + return { + activeConversationId: state.activeConversationId, + lastReading: state.lastReading, + cachedConversationIds: Array.from(state.readingsByConversationId.keys()), + animatedConversationIds: Array.from(state.lastAnimatedUsedByConversationId.keys()), + backgroundSampledConversationIds: state.contextUsageBackgroundSampleConversationIds.slice(), + backgroundSampledAt: state.contextUsageBackgroundSampleAt, + hasAppSignalScope: isAppSignalScope(state.appSignalScope), + hasAppSignalModules: !!state.appSignalModules, + appSignalTokenUsageSelectorExport: state.appSignalTokenUsageSelectorExport, + providerSummary: state.providerSummary, + }; + }, + }; + + restoreLegacyCaptureHooks(); + installStyle(); + installProviderSummaryListener(); + updateMeter(); + installObserver(); + state.timer = window.setInterval(updateMeter, UPDATE_INTERVAL_MS); +})(); diff --git a/Resources/script-market-sources/codex-token-usage.js b/Resources/script-market-sources/codex-token-usage.js new file mode 100644 index 0000000..3ca17dd --- /dev/null +++ b/Resources/script-market-sources/codex-token-usage.js @@ -0,0 +1,1815 @@ +(() => { + "use strict"; + + const SCRIPT_ID = "codex-token-usage"; + const SCRIPT_VERSION = "0.1.7"; + const BADGE_CLASS = "codex-token-usage-badge"; + const STYLE_ID = "codex-token-usage-style"; + const RECENT_LIMIT = 20; + const DEBUG_LIMIT = 50; + const LEDGER_LIMIT = 500; + const CONTEXT_POLL_INTERVAL_MS = 1000; + const TURN_IDLE_TIMEOUT_MS = 120000; + const CONTEXT_MERGE_WINDOW_MS = 30000; + const CROSS_SOURCE_DEDUPE_WINDOW_MS = 3000; + const STORAGE_KEY = "__codexTokenUsageRecentDetails"; + + if (window.__codexTokenUsageScriptInstalled && window.__codexTokenUsageVersion === SCRIPT_VERSION) return; + window.__codexTokenUsageScriptInstalled = true; + window.__codexTokenUsageVersion = SCRIPT_VERSION; + + const state = { + lastMetric: null, + lastMetricKey: "", + recent: [], + ledger: [], + byConversation: Object.create(null), + byScope: Object.create(null), + turnsByScope: Object.create(null), + activeProjectId: "", + activeConversationId: "", + currentTurn: null, + eventSeq: 0, + turnSeq: 0, + turnStartedAt: 0, + contextPollTimer: 0, + pendingTurnStartAt: 0, + historyRestoreState: Object.create(null), + debug: [], + }; + + window.__codexTokenUsageDebug = state.debug; + window.__codexTokenUsage = { + version: SCRIPT_VERSION, + last: null, + currentTurn: null, + recent: [], + debug: state.debug, + export: () => ({ + version: SCRIPT_VERSION, + activeProjectId: currentProjectId(), + activeConversationId: currentConversationId(), + activeScopeKey: currentScopeKey(), + last: null, + currentTurn: null, + calls: [], + ledgerEvents: [], + recent: [], + debug: state.debug.slice(), + storedDetails: readStoredDetails(), + turns: [], + }), + }; + + function normalizeNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0; + } + + function stableEventId(value) { + const candidate = value?.id ?? value?.response_id ?? value?.responseId ?? value?.request_id ?? value?.requestId; + const eventId = String(candidate ?? "").trim(); + return eventId && eventId.length <= 240 ? eventId : ""; + } + + function withEventId(usage, eventId) { + return usage && eventId ? { ...usage, eventId } : usage; + } + + function normalizeUsage(raw) { + if (!raw || typeof raw !== "object") return null; + const inputTokens = normalizeNumber(raw.input_tokens ?? raw.inputTokens ?? raw.prompt_tokens ?? raw.promptTokens); + const outputTokens = normalizeNumber( + raw.output_tokens ?? raw.outputTokens ?? raw.completion_tokens ?? raw.completionTokens, + ); + const explicitTotal = raw.total_tokens ?? raw.totalTokens ?? raw.usedTokens ?? raw.used_tokens ?? raw.used; + const totalEstimated = explicitTotal == null && !!(inputTokens || outputTokens); + const totalTokens = normalizeNumber(explicitTotal ?? inputTokens + outputTokens); + const cachedTokens = normalizeNumber( + raw.cached_tokens ?? + raw.cachedTokens ?? + raw.cached_input_tokens ?? + raw.cachedInputTokens ?? + raw.prompt_tokens_details?.cached_tokens ?? + raw.promptTokensDetails?.cachedTokens ?? + raw.input_tokens_details?.cached_tokens ?? + raw.inputTokensDetails?.cachedTokens, + ); + const cacheReadTokens = normalizeNumber(raw.cache_read_input_tokens ?? raw.cacheReadInputTokens); + const cacheCreationTokens = normalizeNumber(raw.cache_creation_input_tokens ?? raw.cacheCreationInputTokens); + const cachedReadTokens = cacheReadTokens || cachedTokens; + const explicitInputTotal = normalizeNumber( + raw.input_total_tokens ?? raw.inputTotalTokens ?? raw.prompt_total_tokens ?? raw.promptTotalTokens, + ); + const contextUsed = normalizeNumber(raw.contextUsed ?? raw.context_used ?? raw.usedTokens ?? raw.used_tokens ?? raw.used); + const contextLimit = normalizeNumber( + raw.contextLimit ?? raw.context_limit ?? raw.modelContextWindow ?? raw.model_context_window ?? raw.contextWindow ?? raw.context_window ?? raw.limit, + ); + if ( + !inputTokens && + !outputTokens && + !totalTokens && + !cachedTokens && + !cacheReadTokens && + !cacheCreationTokens && + !contextLimit + ) { + return null; + } + const inputFromTotal = totalTokens && outputTokens && totalTokens > outputTokens ? totalTokens - outputTokens : 0; + let inputTotalTokens = Math.max(explicitInputTotal, inputTokens, inputFromTotal); + if (cachedReadTokens > inputTotalTokens) { + inputTotalTokens += cachedReadTokens + cacheCreationTokens; + } + return { + inputTokens, + inputTotalTokens, + outputTokens, + outputTotalTokens: outputTokens, + totalTokens, + requestTotalTokens: totalTokens, + cachedTokens, + cachedReadTokens, + cacheReadTokens, + cacheCreationTokens, + totalEstimated, + hasBreakdown: !!(inputTokens || outputTokens || cachedTokens || cacheReadTokens || cacheCreationTokens), + contextUsed: contextUsed || totalTokens, + contextLimit, + }; + } + + function findUsageInObject(value, depth = 0) { + if (!value || depth > 8) return null; + if (Array.isArray(value)) { + for (const item of value) { + const usage = findUsageInObject(item, depth + 1); + if (usage) return usage; + } + return null; + } + if (typeof value !== "object") return null; + + const tokenStatus = value.last || value.lastUsage || value.lastTokenUsage || value.last_token_usage; + if (tokenStatus && (value.modelContextWindow || value.model_context_window || value.contextWindow || value.context_window)) { + const statusUsage = normalizeUsage({ + ...tokenStatus, + modelContextWindow: value.modelContextWindow ?? value.model_context_window, + contextWindow: value.contextWindow ?? value.context_window, + }); + if (statusUsage) return statusUsage; + } + + for (const key of ["usage", "last", "lastUsage", "lastTokenUsage", "last_token_usage"]) { + const direct = normalizeUsage(value[key]); + if (direct) return direct; + } + + const self = normalizeUsage(value); + if (self) return self; + + for (const key of [ + "response", + "data", + "body", + "message", + "result", + "event", + "params", + "tokenUsage", + "token_usage", + "contextUsage", + "context_usage", + "info", + ]) { + const usage = findUsageInObject(value[key], depth + 1); + if (usage) return usage; + } + return null; + } + + function collectUsagesInObject(value, depth = 0, usages = [], seen = new WeakSet(), inheritedEventId = "") { + if (!value || depth > 8) return usages; + if (Array.isArray(value)) { + value.forEach((item) => collectUsagesInObject(item, depth + 1, usages, seen, inheritedEventId)); + return usages; + } + if (typeof value !== "object") return usages; + if (seen.has(value)) return usages; + seen.add(value); + const eventId = stableEventId(value) || inheritedEventId; + + const tokenStatus = value.last || value.lastUsage || value.lastTokenUsage || value.last_token_usage; + if (tokenStatus && (value.modelContextWindow || value.model_context_window || value.contextWindow || value.context_window)) { + const statusUsage = withEventId(normalizeUsage({ + ...tokenStatus, + modelContextWindow: value.modelContextWindow ?? value.model_context_window, + contextWindow: value.contextWindow ?? value.context_window, + }), eventId); + if (statusUsage) { + usages.push(statusUsage); + return usages; + } + } + + const directKeys = ["usage", "last", "lastUsage", "lastTokenUsage", "last_token_usage"]; + const consumedKeys = new Set(); + for (const key of directKeys) { + const direct = withEventId(normalizeUsage(value[key]), eventId); + if (direct) { + usages.push(direct); + consumedKeys.add(key); + } + } + + const self = withEventId(normalizeUsage(value), eventId); + if (self) { + usages.push(self); + return usages; + } + + for (const key of [ + "response", + "data", + "body", + "message", + "result", + "event", + "params", + "tokenUsage", + "token_usage", + "contextUsage", + "context_usage", + "info", + ]) { + if (consumedKeys.has(key)) continue; + collectUsagesInObject(value[key], depth + 1, usages, seen, eventId); + } + return usages; + } + + function extractJsonFragmentsFromSse(text) { + return String(text || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .filter((line) => line && line !== "[DONE]"); + } + + function extractUsages(payload) { + if (typeof payload === "string") { + try { + const parsed = JSON.parse(payload); + const usages = collectUsagesInObject(parsed); + if (usages.length) return usages; + } catch (_) { + // Treat non-JSON text as a possible SSE stream below. + } + const usages = []; + for (const fragment of extractJsonFragmentsFromSse(payload)) { + try { + collectUsagesInObject(JSON.parse(fragment), 0, usages); + } catch (_) { + // Ignore malformed stream fragments. + } + } + return usages; + } + return collectUsagesInObject(payload); + } + + function extractUsage(payload) { + return extractUsages(payload)[0] || null; + } + + function formatNumber(value) { + return normalizeNumber(value).toLocaleString("en-US"); + } + + function formatSeconds(elapsedMs) { + const seconds = Math.max(0, normalizeNumber(elapsedMs)) / 1000; + if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)}h`; + if (seconds >= 60) return `${(seconds / 60).toFixed(1)}min`; + return `${seconds.toFixed(1)}s`; + } + + function usageHasBreakdown(usage) { + return !!( + usage && + (usage.hasBreakdown || + usage.inputTokens || + usage.outputTokens || + usage.cachedTokens || + usage.cacheReadTokens || + usage.cacheCreationTokens) + ); + } + + function formatCacheDetails(usage) { + const cacheTokens = usage.cachedReadTokens || usage.cachedTokens || usage.cacheReadTokens || 0; + if (!cacheTokens) return []; + const details = [`缓存读 ${formatNumber(cacheTokens)}`]; + const inputTokens = usage.inputTotalTokens || usage.inputTokens || 0; + if (inputTokens) { + const ratio = Math.min(100, Math.max(0, (cacheTokens / inputTokens) * 100)); + details.push(`缓存命中率 ${ratio.toFixed(1)}%`); + } + if (usage.cacheCreationTokens) details.push(`缓存写 ${formatNumber(usage.cacheCreationTokens)}`); + return details; + } + + function formatBadgeText(metric) { + if (metric?.status === "running") return "运行中 · 正在统计本次回复 token..."; + const usage = metric?.usage || {}; + const requestTotal = usage.requestTotalTokens || usage.totalTokens || 0; + const estimatedLabel = usage.totalEstimated ? "(估算)" : ""; + const parts = [`本轮调用合计 ${formatNumber(requestTotal)}${estimatedLabel}`]; + if (usageHasBreakdown(usage)) { + parts.push( + `输入 ${formatNumber(usage.inputTotalTokens || usage.inputTokens)}`, + `输出 ${formatNumber(usage.outputTotalTokens || usage.outputTokens)}`, + ...formatCacheDetails(usage), + ); + } else { + parts.push("输入 -", "输出 -"); + } + if (usage.contextLimit) { + const contextUsed = usage.contextUsed || usage.totalTokens; + const contextPercent = usage.contextLimit ? ` (${((contextUsed / usage.contextLimit) * 100).toFixed(1)}%)` : ""; + parts.push(`上下文 ${formatNumber(contextUsed)}/${formatNumber(usage.contextLimit)}${contextPercent}`); + } + if (metric?.callCount >= 1) parts.push(`调用 ${formatNumber(metric.callCount)} 次`); + parts.push(`耗时 ${Number.isFinite(metric?.elapsedMs) && metric.elapsedMs > 0 ? formatSeconds(metric.elapsedMs) : "-"}`); + return parts.join(" · "); + } + + function parseElapsedMs(text) { + const value = String(text || ""); + const patterns = [ + /(?:已处理|处理耗时|耗时|Processed)\s*(?:(\d+(?:\.\d+)?)\s*(?:m|min|分钟|分))?\s*(?:(\d+(?:\.\d+)?)\s*(?:s|sec|秒))?/gi, + /(?:已处理|处理耗时|耗时|Processed)\s*(\d+(?:\.\d+)?)\s*(?:s|sec|秒)?/gi, + ]; + let best = 0; + for (const pattern of patterns) { + let match = pattern.exec(value); + while (match) { + const first = Number(match[1] || 0); + const second = Number(match[2] || 0); + const seconds = match.length > 2 ? first * 60 + second : first; + if (Number.isFinite(seconds) && seconds > best) best = seconds; + match = pattern.exec(value); + } + } + return best ? Math.round(best * 1000) : 0; + } + + function nowMs() { + return window.performance?.now ? window.performance.now() : Date.now(); + } + + function isCodexApiUrl(url) { + const text = String(url || ""); + return /\/(responses|chat\/completions|conversation|thread|api)\b/i.test(text) || /codex/i.test(text); + } + + function requestUrl(input) { + if (typeof input === "string") return input; + if (input?.url) return input.url; + return String(input || ""); + } + + function normalizeConversationId(value) { + const text = String(value || "").trim(); + if (!text || text === "__proto__" || text === "prototype" || text === "constructor") return ""; + return /^[A-Za-z0-9_.:-]{3,180}$/.test(text) ? text : ""; + } + + function normalizeProjectId(value) { + return normalizeConversationId(value); + } + + function parseObservedAt(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const time = Date.parse(String(value || "")); + return Number.isFinite(time) ? time : nowMs(); + } + + function projectIdFromLocation() { + const locationText = `${window.location?.pathname || ""}${window.location?.search || ""}${window.location?.hash || ""}`; + const match = locationText.match(/(?:project|workspace)(?:\/|=|:|-)([A-Za-z0-9_.:-]+)/i); + return normalizeProjectId(match?.[1]); + } + + function projectIdFromActiveRow() { + try { + const row = document.querySelector?.( + "[data-app-action-sidebar-project-active='true'],[data-project-id],[data-workspace-id]", + ); + const id = row?.getAttribute?.("data-project-id") + || row?.getAttribute?.("data-workspace-id") + || row?.getAttribute?.("data-testid"); + return normalizeProjectId(id); + } catch (_) { + return ""; + } + } + + function conversationIdFromLocation() { + const locationText = `${window.location?.pathname || ""}${window.location?.search || ""}${window.location?.hash || ""}`; + const match = locationText.match(/(?:session|conversation|thread)(?:\/|=|:|-)([A-Za-z0-9_.:-]+)/i) + || locationText.match(/\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:[/?#]|$)/) + || locationText.match(/\/([A-Za-z0-9_-]{12,})(?:[/?#]|$)/); + return normalizeConversationId(match?.[1]); + } + + function conversationIdFromActiveRow() { + try { + const row = document.querySelector?.( + "[data-app-action-sidebar-thread-active='true'],[aria-current='page'],[aria-current='true']", + ); + const id = row?.getAttribute?.("data-app-action-sidebar-thread-id") + || row?.getAttribute?.("data-session-id") + || row?.getAttribute?.("data-testid"); + return normalizeConversationId(id); + } catch (_) { + return ""; + } + } + + function currentConversationId() { + const live = conversationIdFromActiveRow() || conversationIdFromLocation(); + return live || state.activeConversationId; + } + + function currentProjectId() { + const live = projectIdFromActiveRow() || projectIdFromLocation(); + return live || state.activeProjectId; + } + + function scopeKeyFor(projectId, conversationId) { + const conversation = normalizeConversationId(conversationId); + if (!conversation) return ""; + const project = normalizeProjectId(projectId); + return project ? `${project}:${conversation}` : conversation; + } + + function currentScopeKey() { + return scopeKeyFor(currentProjectId(), currentConversationId()); + } + + function isSameOrMissingIdentity(currentValue, nextValue) { + return !currentValue || !nextValue || currentValue === nextValue; + } + + function canAdoptScopeIdentity(turn, projectId, conversationId) { + if (!turn) return false; + return ( + isSameOrMissingIdentity(turn.projectId, normalizeProjectId(projectId)) && + isSameOrMissingIdentity(turn.conversationId, normalizeConversationId(conversationId)) + ); + } + + function applyTurnScopeIdentity(turn, projectId, conversationId) { + if (!turn) return turn; + const nextProjectId = normalizeProjectId(projectId) || turn.projectId || ""; + const nextConversationId = normalizeConversationId(conversationId) || turn.conversationId || ""; + turn.projectId = nextProjectId; + turn.conversationId = nextConversationId; + turn.scopeKey = scopeKeyFor(nextProjectId, nextConversationId); + return turn; + } + + function scopedMetric(metric) { + const projectId = normalizeProjectId(metric?.projectId) || currentProjectId(); + const conversationId = normalizeConversationId(metric?.conversationId) || currentConversationId(); + const scopeKey = scopeKeyFor(projectId, conversationId); + return conversationId ? { ...metric, projectId, conversationId, scopeKey } : metric; + } + + function conversationMatchesActive(metric) { + const active = currentConversationId(); + const metricConversationId = normalizeConversationId(metric?.conversationId); + if (active && metricConversationId !== active) return false; + const activeScope = currentScopeKey(); + return activeScope && metric?.scopeKey ? metric.scopeKey === activeScope : true; + } + + function aggregateLedgerEvents(events, scopeKey, conversationId, projectId) { + if (!events.length) return null; + const orderedEvents = events.slice().sort((left, right) => (left.observedAt || 0) - (right.observedAt || 0)); + const usageEvents = []; + const contextEvents = []; + orderedEvents.forEach((event) => { + if (usageHasBreakdown(event.usage)) usageEvents.push(event); + else if (event.usage?.contextLimit || event.usage?.contextUsed) contextEvents.push(event); + }); + const calls = []; + usageEvents.forEach((event) => { + const existing = calls.find((call) => { + const identity = strongCallIdentity(event); + const callIdentity = strongCallIdentity(call); + if (identity && callIdentity && identity === callIdentity) return true; + if (!sameUsageDetails(event, call)) return false; + if (event.source === call.source) return false; + return Math.abs((event.observedAt || 0) - (call.observedAt || 0)) <= CROSS_SOURCE_DEDUPE_WINDOW_MS; + }); + if (existing) { + Object.assign(existing, mergeMetric(event, existing), { + observedAt: Math.min(existing.observedAt || event.observedAt || 0, event.observedAt || 0), + sourceSet: Array.from(new Set([...(existing.sourceSet || [existing.source]), event.source].filter(Boolean))), + }); + } else { + calls.push({ + ...event, + sourceSet: [event.source].filter(Boolean), + }); + } + }); + const usage = calls.reduce( + (total, event) => { + const item = event.usage || {}; + total.inputTokens += item.inputTokens || 0; + total.inputTotalTokens += item.inputTotalTokens || item.inputTokens || 0; + total.outputTokens += item.outputTokens || 0; + total.outputTotalTokens += item.outputTotalTokens || item.outputTokens || 0; + total.totalTokens += item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.requestTotalTokens += item.requestTotalTokens || item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.cachedTokens += item.cachedTokens || 0; + total.cachedReadTokens += item.cachedReadTokens || item.cacheReadTokens || item.cachedTokens || 0; + total.cacheReadTokens += item.cacheReadTokens || 0; + total.cacheCreationTokens += item.cacheCreationTokens || 0; + total.totalEstimated = total.totalEstimated || !!item.totalEstimated; + return total; + }, + { + inputTokens: 0, + inputTotalTokens: 0, + outputTokens: 0, + outputTotalTokens: 0, + totalTokens: 0, + requestTotalTokens: 0, + cachedTokens: 0, + cachedReadTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalEstimated: false, + }, + ); + const lastUsageEvent = calls[calls.length - 1] || orderedEvents[orderedEvents.length - 1]; + const contextEvent = contextEvents[contextEvents.length - 1] || lastUsageEvent; + usage.hasBreakdown = calls.length > 0; + usage.contextUsed = contextEvent?.usage?.contextUsed || contextEvent?.usage?.totalTokens || lastUsageEvent?.usage?.contextUsed || usage.totalTokens; + usage.contextLimit = contextEvent?.usage?.contextLimit || lastUsageEvent?.usage?.contextLimit || 0; + const latest = orderedEvents[orderedEvents.length - 1]; + return { + usage, + elapsedMs: Math.max(...orderedEvents.map((event) => event.elapsedMs || 0), 0), + source: "turn-aggregate", + projectId: projectId || latest?.projectId || "", + conversationId: conversationId || latest?.conversationId || "", + scopeKey: scopeKey || latest?.scopeKey || "", + turnId: latest?.turnId || "", + calls: calls.map((event) => ({ ...event, __usageCallKey: undefined })), + callCount: calls.length, + confidence: calls.some((event) => event.usage?.totalEstimated) ? "estimated" : "observed", + }; + } + + function syncLedgerTurnIdentity(turn) { + if (!turn?.id) return; + state.ledger.forEach((event) => { + if (event.turnId !== turn.id) return; + event.projectId = turn.projectId || event.projectId || ""; + event.conversationId = turn.conversationId || event.conversationId || ""; + event.scopeKey = turn.scopeKey || event.scopeKey || ""; + }); + } + + function deriveTurnsFromLedger(activeScope, activeConversationId) { + const scopedEvents = state.ledger.filter((event) => { + if (!event?.turnId) return false; + if (activeScope) return event.scopeKey === activeScope; + return activeConversationId ? event.conversationId === activeConversationId : false; + }); + if (!scopedEvents.length) return []; + const grouped = []; + const byTurnId = new Map(); + scopedEvents.forEach((event) => { + if (!byTurnId.has(event.turnId)) { + const bucket = []; + byTurnId.set(event.turnId, bucket); + grouped.push(bucket); + } + byTurnId.get(event.turnId).push(event); + }); + return grouped + .map((events) => { + const latest = events[events.length - 1]; + return aggregateLedgerEvents( + events, + latest?.scopeKey || activeScope || "", + latest?.conversationId || activeConversationId || "", + latest?.projectId || "", + ); + }) + .filter((metric) => metric && (metric.callCount >= 1 || metric.usage?.contextLimit)); + } + + function deriveLatestMetricFromLedger(activeScope, activeConversationId) { + const turns = deriveTurnsFromLedger(activeScope, activeConversationId); + return turns.length ? turns[turns.length - 1] : null; + } + + function adoptLedgerScopeIdentity(projectId, conversationId) { + const normalizedProjectId = normalizeProjectId(projectId); + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedProjectId || !normalizedConversationId) return false; + let changed = false; + state.ledger.forEach((event) => { + if (event.conversationId !== normalizedConversationId) return; + if (event.projectId && event.projectId !== normalizedProjectId) return; + const nextScopeKey = scopeKeyFor(normalizedProjectId, normalizedConversationId); + if (event.projectId !== normalizedProjectId || event.scopeKey !== nextScopeKey) { + event.projectId = normalizedProjectId; + event.scopeKey = nextScopeKey; + changed = true; + } + }); + return changed; + } + + function metricForActiveConversation() { + const active = currentConversationId(); + const activeScope = currentScopeKey(); + let completedMetric = null; + if (activeScope) { + completedMetric = deriveLatestMetricFromLedger(activeScope, active) || state.byScope[activeScope] || null; + } else { + completedMetric = + deriveLatestMetricFromLedger("", active) + || (active && state.byConversation[active]) + || (conversationMatchesActive(state.lastMetric) ? state.lastMetric : null); + } + if (state.currentTurn && !state.currentTurn.calls.length && state.currentTurn.status === "running") { + if ( + (!active || !state.currentTurn.conversationId || active === state.currentTurn.conversationId) && + (!activeScope || !state.currentTurn.scopeKey || activeScope === state.currentTurn.scopeKey) + ) { + if (completedMetric?.callCount >= 1) return completedMetric; + return { + status: "running", + projectId: state.currentTurn.projectId || currentProjectId(), + conversationId: state.currentTurn.conversationId || active, + scopeKey: state.currentTurn.scopeKey || activeScope, + startedAt: state.currentTurn.startedAt, + elapsedMs: elapsedSinceTurnStarted(), + source: "turn-running", + }; + } + } + return completedMetric; + } + + function setActiveProjectId(projectId) { + const next = normalizeProjectId(projectId); + const previous = state.activeProjectId; + if (previous === next) return; + state.activeProjectId = next; + if (next && canAdoptScopeIdentity(state.currentTurn, next, state.currentTurn?.conversationId)) { + applyTurnScopeIdentity(state.currentTurn, next, state.currentTurn?.conversationId); + syncLedgerTurnIdentity(state.currentTurn); + } + if (next && currentConversationId() && adoptLedgerScopeIdentity(next, currentConversationId())) { + const restored = deriveLatestMetricFromLedger(scopeKeyFor(next, currentConversationId()), currentConversationId()); + if (restored) publishMetric(restored, false); + } + scheduleRender(); + } + + function setActiveConversationId(conversationId) { + const next = normalizeConversationId(conversationId); + const previous = state.activeConversationId; + if (!next && state.currentTurn) { + scheduleRender(); + return; + } + if (previous === next) return; + state.activeConversationId = next; + if (next && canAdoptScopeIdentity(state.currentTurn, state.currentTurn?.projectId, next)) { + applyTurnScopeIdentity(state.currentTurn, state.currentTurn?.projectId, next); + syncLedgerTurnIdentity(state.currentTurn); + } + if (next && currentProjectId() && adoptLedgerScopeIdentity(currentProjectId(), next)) { + const restored = deriveLatestMetricFromLedger(scopeKeyFor(currentProjectId(), next), next); + if (restored) publishMetric(restored, false); + } + if (typeof window.queueMicrotask === "function") { + window.queueMicrotask(() => { + restoreHistoryForConversation(next).catch(() => {}); + }); + } else { + Promise.resolve().then(() => restoreHistoryForConversation(next).catch(() => {})); + } + scheduleRender(); + } + + function metricKey(metric) { + const usage = metric?.usage || {}; + return [ + metric?.scopeKey || "", + metric?.conversationId || "", + metric?.source || "", + usage.totalTokens || 0, + usage.inputTokens || 0, + usage.outputTokens || 0, + usage.cachedTokens || 0, + usage.cacheReadTokens || 0, + usage.cacheCreationTokens || 0, + usage.contextUsed || 0, + usage.contextLimit || 0, + metric?.callCount || 0, + metric?.elapsedMs || 0, + ].join(":"); + } + + function usageCallKey(metric) { + const usage = metric?.usage || {}; + return [ + metric?.callId || metric?.eventId || metric?.requestId || metric?.responseId || "", + metric?.scopeKey || "", + metric?.conversationId || "", + usage.totalTokens || 0, + usage.inputTokens || 0, + usage.outputTokens || 0, + usage.cachedTokens || 0, + usage.cacheReadTokens || 0, + usage.cacheCreationTokens || 0, + ].join(":"); + } + + function createTurn(started = nowMs()) { + state.turnSeq += 1; + const projectId = currentProjectId(); + const conversationId = currentConversationId(); + return { + id: `${Date.now()}-${state.turnSeq}`, + startedAt: started, + lastUpdatedAt: started, + calls: [], + callKeys: new Set(), + contextUsage: null, + projectId, + conversationId, + scopeKey: scopeKeyFor(projectId, conversationId), + elapsedMs: 0, + status: "running", + }; + } + + function beginTurn(started = nowMs()) { + state.currentTurn = createTurn(started); + state.turnStartedAt = started; + state.pendingTurnStartAt = 0; + return state.currentTurn; + } + + function ensureTurnStarted(started = nowMs()) { + if ( + !state.currentTurn || + state.pendingTurnStartAt || + (!state.currentTurn.calls.length && started - state.currentTurn.lastUpdatedAt > TURN_IDLE_TIMEOUT_MS) + ) { + return beginTurn(started); + } + if (!state.turnStartedAt) state.turnStartedAt = state.currentTurn.startedAt || started; + return state.currentTurn; + } + + function markTurnStarted(started = nowMs()) { + beginTurn(started); + scheduleRender(); + } + + function markUserTurnPending(started = nowMs()) { + state.pendingTurnStartAt = started; + } + + function markNetworkTurnStarted(started = nowMs()) { + const turn = ensureTurnStarted(started); + if (!turn.calls.length) scheduleRender(); + } + + function elapsedSinceTurnStarted() { + return state.turnStartedAt ? nowMs() - state.turnStartedAt : 0; + } + + function sameUsage(metric, other) { + const usage = metric?.usage || {}; + const otherUsage = other?.usage || {}; + if (metric?.scopeKey && other?.scopeKey && metric.scopeKey !== other.scopeKey) return false; + if (!usage.totalTokens || !otherUsage.totalTokens) return false; + if (usage.totalTokens !== otherUsage.totalTokens) return false; + if (metric.conversationId && other.conversationId && metric.conversationId !== other.conversationId) return false; + return true; + } + + function sameUsageDetails(metric, other) { + const usage = metric?.usage || {}; + const otherUsage = other?.usage || {}; + return !!( + usage.totalTokens && + otherUsage.totalTokens && + usage.totalTokens === otherUsage.totalTokens && + (usage.inputTokens || 0) === (otherUsage.inputTokens || 0) && + (usage.outputTokens || 0) === (otherUsage.outputTokens || 0) && + (usage.cachedTokens || 0) === (otherUsage.cachedTokens || 0) && + (usage.cacheReadTokens || 0) === (otherUsage.cacheReadTokens || 0) && + (usage.cacheCreationTokens || 0) === (otherUsage.cacheCreationTokens || 0) + ); + } + + function strongCallIdentity(metric) { + return metric?.callId || metric?.eventId || metric?.requestId || metric?.responseId || ""; + } + + function shouldDedupeCall(metric, existing) { + const identity = strongCallIdentity(metric); + const existingIdentity = strongCallIdentity(existing); + if (identity && existingIdentity && identity === existingIdentity) return true; + if (!sameUsageDetails(metric, existing)) return false; + if (metric.scopeKey && existing.scopeKey && metric.scopeKey !== existing.scopeKey) return false; + if (metric.source === existing.source) return false; + const elapsedDelta = Math.abs((metric.elapsedMs || 0) - (existing.elapsedMs || 0)); + return elapsedDelta <= CROSS_SOURCE_DEDUPE_WINDOW_MS; + } + + function mergeUsage(preferredUsage, fallbackUsage) { + const preferredHasBreakdown = usageHasBreakdown(preferredUsage); + const fallbackHasBreakdown = usageHasBreakdown(fallbackUsage); + const detailUsage = preferredHasBreakdown || !fallbackHasBreakdown ? preferredUsage : fallbackUsage; + const contextUsage = preferredUsage.contextLimit ? preferredUsage : fallbackUsage.contextLimit ? fallbackUsage : preferredUsage.contextUsed ? preferredUsage : fallbackUsage; + return { + inputTokens: detailUsage.inputTokens || 0, + inputTotalTokens: detailUsage.inputTotalTokens || detailUsage.inputTokens || 0, + outputTokens: detailUsage.outputTokens || 0, + outputTotalTokens: detailUsage.outputTotalTokens || detailUsage.outputTokens || 0, + totalTokens: detailUsage.totalTokens || contextUsage.totalTokens || 0, + requestTotalTokens: detailUsage.requestTotalTokens || detailUsage.totalTokens || contextUsage.totalTokens || 0, + cachedTokens: detailUsage.cachedTokens || 0, + cachedReadTokens: detailUsage.cachedReadTokens || detailUsage.cacheReadTokens || detailUsage.cachedTokens || 0, + cacheReadTokens: detailUsage.cacheReadTokens || 0, + cacheCreationTokens: detailUsage.cacheCreationTokens || 0, + totalEstimated: !!detailUsage.totalEstimated, + hasBreakdown: usageHasBreakdown(detailUsage), + contextUsed: contextUsage.contextUsed || contextUsage.totalTokens || detailUsage.totalTokens || 0, + contextLimit: contextUsage.contextLimit || detailUsage.contextLimit || 0, + }; + } + + function mergeMetric(preferred, fallback) { + return { + ...fallback, + ...preferred, + usage: mergeUsage(preferred.usage || {}, fallback.usage || {}), + elapsedMs: preferred.elapsedMs || fallback.elapsedMs || 0, + projectId: preferred.projectId || fallback.projectId || "", + conversationId: preferred.conversationId || fallback.conversationId || "", + scopeKey: preferred.scopeKey || fallback.scopeKey || "", + source: preferred.source || fallback.source, + }; + } + + function findMergeCandidate(metric) { + const matches = [...state.recent, ...readStoredDetails()].filter((item) => conversationMatchesActive(item) && sameUsageDetails(metric, item)); + return matches.find((item) => usageHasBreakdown(item.usage)) || matches[0] || null; + } + + function readStoredDetails() { + try { + const parsed = JSON.parse(window.sessionStorage?.getItem(STORAGE_KEY) || "[]"); + return Array.isArray(parsed) + ? parsed.filter((item) => item?.usage && (item.callCount >= 1 || item.source === "turn-aggregate")) + : []; + } catch (_) { + return []; + } + } + + function writeStoredDetails(metric) { + if (!usageHasBreakdown(metric?.usage)) return; + if (!(metric.callCount >= 1 || metric.source === "turn-aggregate")) return; + try { + const recent = [metric, ...readStoredDetails().filter((item) => !sameUsage(metric, item))].slice(0, RECENT_LIMIT); + window.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(recent)); + } catch (_) { + // Storage can be unavailable in restricted renderer contexts. + } + } + + function usageDebugSummary(usage) { + return { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + totalTokens: usage.totalTokens || 0, + cachedTokens: usage.cachedTokens || usage.cacheReadTokens || 0, + contextLimit: usage.contextLimit || 0, + hasBreakdown: usageHasBreakdown(usage), + }; + } + + function appendLedgerEvent(kind, metric, extra = {}) { + const usage = metric?.usage || {}; + state.eventSeq += 1; + const entry = { + id: `ledger-${state.eventSeq}`, + kind, + source: metric?.source || "", + observedAt: extra.observedAt ?? nowMs(), + projectId: extra.projectId ?? metric?.projectId ?? "", + conversationId: extra.conversationId ?? metric?.conversationId ?? "", + scopeKey: extra.scopeKey ?? metric?.scopeKey ?? "", + turnId: extra.turnId ?? "", + eventId: extra.eventId ?? metric?.eventId ?? "", + elapsedMs: metric?.elapsedMs || 0, + usage: metric?.usage || null, + rawSummary: { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + totalTokens: usage.totalTokens || 0, + cachedTokens: usage.cachedReadTokens || usage.cachedTokens || usage.cacheReadTokens || 0, + contextLimit: usage.contextLimit || 0, + }, + }; + state.ledger.push(entry); + if (state.ledger.length > LEDGER_LIMIT) state.ledger = state.ledger.slice(-LEDGER_LIMIT); + } + + function hasLedgerForConversation(conversationId) { + const normalizedConversationId = normalizeConversationId(conversationId); + return !!(normalizedConversationId && state.ledger.some((event) => event.conversationId === normalizedConversationId)); + } + + function appendHistoryLedgerEvent(item, fallbackConversationId, fallbackProjectId) { + const usage = normalizeUsage(item?.usage); + if (!usage) return false; + const conversationId = normalizeConversationId(item?.conversation_id || item?.conversationId || fallbackConversationId); + if (!conversationId) return false; + const projectId = normalizeProjectId(fallbackProjectId); + const scopeKey = scopeKeyFor(projectId, conversationId); + const turnId = String(item?.turn_id || item?.turnId || ""); + const observedAt = parseObservedAt(item?.observed_at || item?.observedAt); + const duplicate = state.ledger.some( + (event) => + event.turnId === turnId && + event.conversationId === conversationId && + event.observedAt === observedAt && + (event.usage?.totalTokens || 0) === (usage.totalTokens || 0) && + (event.usage?.inputTokens || 0) === (usage.inputTokens || 0) && + (event.usage?.outputTokens || 0) === (usage.outputTokens || 0), + ); + if (duplicate) return false; + appendLedgerEvent( + "usage", + { + usage, + elapsedMs: normalizeNumber(item?.elapsedMs || item?.elapsed_ms), + source: item?.source || "rollout-history", + conversationId, + projectId, + scopeKey, + }, + { + observedAt, + turnId, + conversationId, + projectId, + scopeKey, + }, + ); + return true; + } + + async function requestBridge(path, payload) { + const bridge = window.__codexSessionDeleteBridge; + if (typeof bridge === "function") return bridge(path, payload || {}); + throw new Error("bridge unavailable"); + } + + async function restoreHistoryForConversation(conversationId, options = {}) { + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedConversationId) return null; + const restoreState = state.historyRestoreState[normalizedConversationId] || (state.historyRestoreState[normalizedConversationId] = {}); + if (restoreState.promise) return restoreState.promise; + if (!options.force && (restoreState.completed || hasLedgerForConversation(normalizedConversationId))) { + return deriveLatestMetricFromLedger(currentScopeKey(), normalizedConversationId); + } + restoreState.promise = (async () => { + try { + const result = await requestBridge("/thread-usage-history", { + session_id: normalizedConversationId, + title: "", + }); + if (!result || result.status !== "ok" || !Array.isArray(result.history)) { + return null; + } + const fallbackProjectId = currentProjectId(); + let appended = 0; + result.history.forEach((item) => { + if (appendHistoryLedgerEvent(item, normalizedConversationId, fallbackProjectId)) appended += 1; + }); + if (!fallbackProjectId && currentProjectId()) { + adoptLedgerScopeIdentity(currentProjectId(), normalizedConversationId); + } + restoreState.completed = true; + pushDebug({ + type: "history-restore", + conversationId: normalizedConversationId, + appended, + source: "bridge", + }); + const metric = deriveLatestMetricFromLedger(currentScopeKey(), normalizedConversationId) + || deriveLatestMetricFromLedger("", normalizedConversationId); + if (metric) publishMetric(metric, false); + return metric; + } catch (error) { + pushDebug({ + type: "history-restore-failed", + conversationId: normalizedConversationId, + message: String(error?.message || error), + }); + return null; + } finally { + restoreState.promise = null; + } + })(); + return restoreState.promise; + } + + function pushDebug(entry) { + state.debug.unshift({ + at: new Date().toISOString(), + activeConversationId: currentConversationId(), + currentCallCount: state.currentTurn?.calls.length || 0, + pendingTurn: !!state.pendingTurnStartAt, + ...entry, + }); + state.debug = state.debug.slice(0, DEBUG_LIMIT); + window.__codexTokenUsageDebug = state.debug.slice(); + if (window.__codexTokenUsage) window.__codexTokenUsage.debug = state.debug.slice(); + } + + function aggregateTurnMetric(turn) { + const usage = turn.calls.reduce( + (total, call) => { + const item = call.usage || {}; + total.inputTokens += item.inputTokens || 0; + total.inputTotalTokens += item.inputTotalTokens || item.inputTokens || 0; + total.outputTokens += item.outputTokens || 0; + total.outputTotalTokens += item.outputTotalTokens || item.outputTokens || 0; + total.totalTokens += item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.requestTotalTokens += item.requestTotalTokens || item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.cachedTokens += item.cachedTokens || 0; + total.cachedReadTokens += item.cachedReadTokens || item.cacheReadTokens || item.cachedTokens || 0; + total.cacheReadTokens += item.cacheReadTokens || 0; + total.cacheCreationTokens += item.cacheCreationTokens || 0; + total.totalEstimated = total.totalEstimated || !!item.totalEstimated; + return total; + }, + { + inputTokens: 0, + inputTotalTokens: 0, + outputTokens: 0, + outputTotalTokens: 0, + totalTokens: 0, + requestTotalTokens: 0, + cachedTokens: 0, + cachedReadTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalEstimated: false, + }, + ); + const lastCallUsage = turn.calls[turn.calls.length - 1]?.usage || {}; + const contextUsage = turn.contextUsage || (lastCallUsage.contextLimit ? lastCallUsage : null); + usage.hasBreakdown = turn.calls.length > 0; + usage.contextUsed = contextUsage?.contextUsed || contextUsage?.totalTokens || lastCallUsage.contextUsed || usage.totalTokens; + usage.contextLimit = contextUsage?.contextLimit || lastCallUsage.contextLimit || 0; + return { + usage, + elapsedMs: turn.elapsedMs, + source: "turn-aggregate", + projectId: turn.projectId, + conversationId: turn.conversationId, + scopeKey: turn.scopeKey, + turnId: turn.id, + calls: turn.calls.map((call) => ({ ...call, __usageCallKey: undefined })), + callCount: turn.calls.length, + confidence: turn.calls.some((call) => call.usage?.totalEstimated) ? "estimated" : "observed", + }; + } + + function rememberTurnMetric(metric) { + if (!metric?.scopeKey || !metric.turnId || metric.source !== "turn-aggregate") return; + const turns = state.turnsByScope[metric.scopeKey] || []; + const nextMetric = { + ...metric, + calls: (metric.calls || []).map((call) => ({ ...call })), + }; + const existingIndex = turns.findIndex((item) => item.turnId === metric.turnId); + if (existingIndex >= 0) turns[existingIndex] = nextMetric; + else turns.push(nextMetric); + state.turnsByScope[metric.scopeKey] = turns.slice(-RECENT_LIMIT); + } + + function exportUsage() { + const activeScope = currentScopeKey(); + const currentTurn = state.currentTurn + ? { + id: state.currentTurn.id, + startedAt: state.currentTurn.startedAt, + lastUpdatedAt: state.currentTurn.lastUpdatedAt, + callCount: state.currentTurn.calls.length, + projectId: state.currentTurn.projectId, + conversationId: state.currentTurn.conversationId, + scopeKey: state.currentTurn.scopeKey, + } + : null; + const activeMetric = metricForActiveConversation(); + return { + version: SCRIPT_VERSION, + activeProjectId: currentProjectId(), + activeConversationId: currentConversationId(), + activeScopeKey: activeScope, + last: activeMetric || state.lastMetric, + currentTurn, + calls: (state.currentTurn?.scopeKey === activeScope ? state.currentTurn.calls : activeMetric?.calls || []).map((call) => ({ ...call, __usageCallKey: undefined })), + ledgerEvents: state.ledger.slice().map((event) => ({ ...event })), + recent: state.recent.slice(), + debug: state.debug.slice(), + storedDetails: readStoredDetails(), + turns: deriveTurnsFromLedger(activeScope, currentConversationId()), + }; + } + + function publishMetric(metric, storeDetails = true) { + metric = scopedMetric(metric); + if (metric?.source !== "turn-running") { + const derived = deriveLatestMetricFromLedger(metric?.scopeKey || "", metric?.conversationId || ""); + if (derived) metric = scopedMetric(derived); + } + const nextKey = metricKey(metric); + if (nextKey && nextKey === state.lastMetricKey) { + scheduleRender(); + return; + } + state.lastMetricKey = nextKey; + state.lastMetric = { + ...metric, + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + createdAt: new Date().toISOString(), + }; + if (state.lastMetric.scopeKey) { + state.byScope[state.lastMetric.scopeKey] = state.lastMetric; + rememberTurnMetric(state.lastMetric); + } + if (state.lastMetric.conversationId) state.byConversation[state.lastMetric.conversationId] = state.lastMetric; + state.recent.unshift(state.lastMetric); + state.recent = state.recent.slice(0, RECENT_LIMIT); + window.__codexTokenUsage = { + version: SCRIPT_VERSION, + last: state.lastMetric, + currentTurn: state.currentTurn + ? { + id: state.currentTurn.id, + startedAt: state.currentTurn.startedAt, + lastUpdatedAt: state.currentTurn.lastUpdatedAt, + callCount: state.currentTurn.calls.length, + projectId: state.currentTurn.projectId, + conversationId: state.currentTurn.conversationId, + scopeKey: state.currentTurn.scopeKey, + } + : null, + recent: state.recent.slice(), + debug: state.debug.slice(), + export: exportUsage, + }; + if (storeDetails) writeStoredDetails(state.lastMetric); + scheduleRender(); + } + + function rememberContextMetric(metric) { + metric = scopedMetric(metric); + const activeTurnMatches = + state.currentTurn?.calls.length && + (!metric.scopeKey || !state.currentTurn.scopeKey || metric.scopeKey === state.currentTurn.scopeKey); + if (activeTurnMatches) { + appendLedgerEvent("context", metric, { + turnId: state.currentTurn.id, + projectId: state.currentTurn.projectId || metric.projectId, + conversationId: state.currentTurn.conversationId || metric.conversationId, + scopeKey: state.currentTurn.scopeKey || metric.scopeKey, + }); + state.currentTurn.contextUsage = metric.usage; + applyTurnScopeIdentity(state.currentTurn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(state.currentTurn); + state.currentTurn.elapsedMs = Math.max(state.currentTurn.elapsedMs || 0, metric.elapsedMs || 0); + state.currentTurn.lastUpdatedAt = nowMs(); + publishMetric(aggregateTurnMetric(state.currentTurn), false); + return; + } + appendLedgerEvent("context", metric); + if ( + state.lastMetric && + (!metric.scopeKey || !state.lastMetric.scopeKey || metric.scopeKey === state.lastMetric.scopeKey) && + nowMs() - (state.currentTurn?.lastUpdatedAt || 0) <= CONTEXT_MERGE_WINDOW_MS + ) { + publishMetric(mergeMetric(state.lastMetric, metric), false); + return; + } + publishMetric({ ...metric, callCount: 0 }, false); + } + + function rememberUsageMetric(metric) { + metric = scopedMetric(metric); + const turn = ensureTurnStarted(); + if (canAdoptScopeIdentity(turn, metric.projectId, metric.conversationId)) { + applyTurnScopeIdentity(turn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(turn); + } + if ( + (metric.conversationId && turn.conversationId && metric.conversationId !== turn.conversationId) || + (metric.scopeKey && turn.scopeKey && metric.scopeKey !== turn.scopeKey) + ) { + beginTurn(); + return rememberUsageMetric(metric); + } + appendLedgerEvent("usage", metric, { + turnId: turn.id, + projectId: turn.projectId || metric.projectId, + conversationId: turn.conversationId || metric.conversationId, + scopeKey: turn.scopeKey || metric.scopeKey, + }); + const key = usageCallKey(metric); + const existing = turn.calls.find((call) => shouldDedupeCall(metric, call)); + if (existing) { + const merged = mergeMetric(metric, existing); + Object.assign(existing, merged, { __usageCallKey: existing.__usageCallKey || key, dedupeReason: strongCallIdentity(metric) ? "identity" : "cross-source-window" }); + } else { + const candidate = findMergeCandidate(metric); + if (candidate) { + metric = mergeMetric(metric, candidate); + } + turn.calls.push({ ...metric, __usageCallKey: key }); + turn.callKeys.add(key); + } + applyTurnScopeIdentity(turn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(turn); + turn.status = "complete"; + turn.elapsedMs = Math.max(turn.elapsedMs || 0, metric.elapsedMs || elapsedSinceTurnStarted()); + turn.lastUpdatedAt = nowMs(); + publishMetric(aggregateTurnMetric(turn)); + } + + function rememberMetric(metric) { + if (!metric?.usage) return; + if (usageHasBreakdown(metric.usage)) { + rememberUsageMetric(metric); + } else { + rememberContextMetric(metric); + } + } + + function rememberUsages(usages, baseMetric) { + let captured = false; + usages.forEach((usage) => { + rememberMetric({ ...baseMetric, usage, eventId: usage.eventId || "" }); + captured = true; + }); + return captured; + } + + function processPayload(payload, source, conversationId, elapsedMs, url) { + const usages = extractUsages(payload); + pushDebug({ + type: "payload", + source, + conversationId: conversationId || "", + url: url || "", + elapsedMs: elapsedMs || 0, + usageCount: usages.length, + usages: usages.map(usageDebugSummary), + }); + return rememberUsages(usages, { elapsedMs, source, conversationId, url }); + } + + function parseResponseText(text, elapsedMs, url) { + processPayload(text, "network", "", elapsedMs, url); + } + + function inspectPayload(payload, source, conversationId) { + return processPayload(payload, source, conversationId, elapsedSinceTurnStarted()); + } + + function inspectPayloadText(text, source, conversationId) { + return inspectPayload(text, source, conversationId); + } + + function installFetchObserver() { + if (typeof window.fetch !== "function" || window.fetch.__codexTokenUsageWrapped === SCRIPT_VERSION) return; + const baseFetch = window.fetch.__codexTokenUsageOriginal || window.fetch; + const originalFetch = baseFetch.bind(window); + function wrappedFetch(input, init) { + const url = requestUrl(input); + const started = nowMs(); + if (isCodexApiUrl(url)) markNetworkTurnStarted(started); + return originalFetch(input, init).then((response) => { + if (isCodexApiUrl(url) && response?.clone) { + response + .clone() + .text() + .then((text) => parseResponseText(text, nowMs() - started, url)) + .catch(() => {}); + } + return response; + }); + } + wrappedFetch.__codexTokenUsageWrapped = SCRIPT_VERSION; + wrappedFetch.__codexTokenUsageOriginal = baseFetch; + window.fetch = wrappedFetch; + } + + function installXhrObserver() { + const Xhr = window.XMLHttpRequest; + if (!Xhr || Xhr.prototype.__codexTokenUsageWrapped === SCRIPT_VERSION) return; + const originalOpen = Xhr.prototype.__codexTokenUsageOriginalOpen || Xhr.prototype.open; + const originalSend = Xhr.prototype.__codexTokenUsageOriginalSend || Xhr.prototype.send; + Xhr.prototype.open = function open(method, url, ...rest) { + this.__codexTokenUsageUrl = url; + return originalOpen.call(this, method, url, ...rest); + }; + Xhr.prototype.send = function send(...args) { + const started = nowMs(); + if (isCodexApiUrl(this.__codexTokenUsageUrl)) markNetworkTurnStarted(started); + this.addEventListener?.("loadend", () => { + const url = this.__codexTokenUsageUrl; + if (!isCodexApiUrl(url)) return; + try { + parseResponseText(this.responseText || "", nowMs() - started, url); + } catch (_) { + // Ignore unreadable XHR bodies. + } + }); + return originalSend.apply(this, args); + }; + Xhr.prototype.__codexTokenUsageOriginalOpen = originalOpen; + Xhr.prototype.__codexTokenUsageOriginalSend = originalSend; + Xhr.prototype.__codexTokenUsageWrapped = SCRIPT_VERSION; + } + + function isEditableTarget(target) { + return !!( + target && + (target.tagName === "TEXTAREA" || + target.tagName === "INPUT" || + target.isContentEditable || + target.closest?.("textarea,input,[contenteditable='true']")) + ); + } + + function isSendTrigger(event) { + const target = event.target; + if (event.type === "submit") return true; + if (event.type === "keydown") { + return event.key === "Enter" && !event.shiftKey && isEditableTarget(target); + } + if (event.type === "click") { + const label = `${target?.getAttribute?.("aria-label") || ""} ${target?.textContent || ""}`; + return /^(发送|提交|Send|Submit)$|send|submit/i.test(label); + } + return false; + } + + function installTurnPendingObserver() { + if (window.__codexTokenUsageTurnPendingObserver === SCRIPT_VERSION) return; + const handler = (event) => { + try { + if (!isSendTrigger(event)) return; + markUserTurnPending(); + pushDebug({ type: "pending-turn", source: event.type }); + } catch (_) { + // Keep page input handling untouched. + } + }; + ["click", "submit", "keydown"].forEach((type) => { + document.addEventListener?.(type, handler, true); + }); + window.__codexTokenUsageTurnPendingObserver = SCRIPT_VERSION; + } + + function installPostMessageObserver() { + if (window.__codexTokenUsageMessageObserver === SCRIPT_VERSION) return; + window.addEventListener?.( + "message", + (event) => { + try { + inspectPayload(event.data, "post-message"); + } catch (_) { + // Ignore unrelated window messages. + } + }, + true, + ); + window.__codexTokenUsageMessageObserver = SCRIPT_VERSION; + } + + function installWebSocketObserver() { + if (typeof window.WebSocket !== "function" || window.__codexTokenUsageWebSocketWrapped === SCRIPT_VERSION) return; + const NativeWebSocket = window.__codexTokenUsageNativeWebSocket || window.WebSocket; + + function TokenUsageWebSocket(...args) { + const socket = new NativeWebSocket(...args); + socket.addEventListener?.("message", (event) => { + try { + if (typeof event.data === "string") { + inspectPayloadText(event.data, "websocket"); + } else if (event.data instanceof Blob && event.data.size <= 512000) { + event.data.text().then((text) => inspectPayloadText(text, "websocket")).catch(() => {}); + } + } catch (_) { + // Keep socket delivery untouched. + } + }); + return socket; + } + + try { + TokenUsageWebSocket.prototype = NativeWebSocket.prototype; + Object.defineProperty(TokenUsageWebSocket, "CONNECTING", { value: NativeWebSocket.CONNECTING }); + Object.defineProperty(TokenUsageWebSocket, "OPEN", { value: NativeWebSocket.OPEN }); + Object.defineProperty(TokenUsageWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING }); + Object.defineProperty(TokenUsageWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED }); + } catch (_) { + // Constants are best-effort compatibility helpers. + } + + window.WebSocket = TokenUsageWebSocket; + window.__codexTokenUsageNativeWebSocket = NativeWebSocket; + window.__codexTokenUsageWebSocketWrapped = SCRIPT_VERSION; + } + + function normalizeContextReading(reading) { + if (!reading || typeof reading !== "object") return null; + const used = normalizeNumber(reading.used ?? reading.usedTokens ?? reading.used_tokens); + const limit = normalizeNumber(reading.limit ?? reading.contextWindow ?? reading.context_window); + if (!used && !limit) return null; + return { + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: used, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: false, + contextUsed: used, + contextLimit: limit, + }, + elapsedMs: elapsedSinceTurnStarted(), + source: reading.source || "context-meter", + conversationId: reading.conversationId || "", + }; + } + + function rememberContextReading(reading) { + const metric = normalizeContextReading(reading); + if (metric) rememberMetric(metric); + } + + function readContextMeterMetric() { + try { + const meterState = window.__codexContextMeter?.getState?.(); + rememberContextReading(meterState?.lastReading); + } catch (_) { + // Ignore unavailable or changing third-party script state. + } + } + + function installContextMeterObserver() { + const captureState = window.__codexContextMeterCaptureState; + if (captureState && captureState.__codexTokenUsageWrapped !== SCRIPT_VERSION) { + const originalInspectText = captureState.__codexTokenUsageOriginalInspectText || captureState.inspectText; + if (typeof originalInspectText === "function") { + captureState.inspectText = function codexTokenUsageInspectText(text, source, conversationId) { + const started = elapsedSinceTurnStarted(); + try { + processPayload(text, source || "context-capture", conversationId, started); + } catch (_) { + // Keep the upstream context meter path intact. + } + return originalInspectText.apply(this, arguments); + }; + } + + const originalInspectValue = captureState.__codexTokenUsageOriginalInspectValue || captureState.inspectValue; + if (typeof originalInspectValue === "function") { + captureState.inspectValue = function codexTokenUsageInspectValue(value, source, conversationId) { + let reading = null; + try { + processPayload(value, source || "context-value", conversationId, elapsedSinceTurnStarted()); + } catch (_) { + // Continue to the original inspector. + } + reading = originalInspectValue.apply(this, arguments); + rememberContextReading(reading); + return reading; + }; + } + captureState.__codexTokenUsageOriginalInspectText = originalInspectText; + captureState.__codexTokenUsageOriginalInspectValue = originalInspectValue; + captureState.__codexTokenUsageWrapped = SCRIPT_VERSION; + } + + readContextMeterMetric(); + if (!state.contextPollTimer) { + state.contextPollTimer = window.setInterval?.(() => { + installContextMeterObserver(); + readContextMeterMetric(); + }, CONTEXT_POLL_INTERVAL_MS); + window.__codexTokenUsageContextPollTimer = state.contextPollTimer; + } + } + + function ensureStyle() { + let style = document.getElementById?.(STYLE_ID); + if (!style) { + style = document.createElement("style"); + style.id = STYLE_ID; + document.head?.appendChild(style); + } + style.textContent = ` + .${BADGE_CLASS} { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 8px 0 0; + padding: 5px 9px; + border: 1px solid rgba(20, 184, 166, .3); + border-radius: 7px; + background: rgba(20, 184, 166, .08); + color: inherit; + font: 12px/1.35 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + opacity: .9; + letter-spacing: 0; + } + .${BADGE_CLASS}[data-status="running"] { + border-color: rgba(245, 158, 11, .36); + background: rgba(245, 158, 11, .1); + } + .${BADGE_CLASS}[data-placement="message-actions"] { + display: flex; + width: fit-content; + margin: 6px 0 0; + } + main > .${BADGE_CLASS}, + body > .${BADGE_CLASS} { + display: none !important; + } + `; + } + + function visibleRect(node) { + if (!(node instanceof Element)) return null; + const rect = node.getBoundingClientRect(); + if (!rect.width && !rect.height) return null; + return rect; + } + + function isConversationActionButton(node) { + if (!(node instanceof Element)) return false; + const label = node.getAttribute("aria-label") || ""; + return /^(复制|喜欢|不喜欢|从此处开始分叉|Copy|Good response|Bad response|Branch from here)$/i.test(label); + } + + function isPrimaryConversationActionButton(node) { + if (!(node instanceof Element)) return false; + const label = node.getAttribute("aria-label") || ""; + return /^(喜欢|不喜欢|从此处开始分叉|Good response|Bad response|Branch from here)$/i.test(label); + } + + function scoreAssistantContainer(node) { + if (!(node instanceof Element)) return -1; + const rect = visibleRect(node); + if (!rect || rect.width < 240 || rect.height < 48) return -1; + const text = node.innerText || node.textContent || ""; + if (!text || text.length < 20) return -1; + if (node.querySelector?.("textarea,[contenteditable='true']")) return -1; + if (/thread-scroll-container|main-surface|app-shell|timeline/i.test(String(node.className || ""))) return -1; + + let score = 0; + if (node.querySelector?.("button[aria-label='复制'],button[aria-label='Copy']")) score += 6; + if (node.querySelector?.("button[aria-label='喜欢'],button[aria-label='不喜欢']")) score += 3; + if (/group flex min-w-0 flex-col/.test(String(node.className || ""))) score += 5; + if (node.querySelector?.("p,li,pre,code")) score += 2; + if (rect.height > 80) score += 1; + score -= Math.max(0, text.length / 2000); + return score; + } + + function closestAssistantContainer(fromNode) { + let best = null; + let bestScore = -1; + for (let node = fromNode; node && node !== document.body; node = node.parentElement) { + const score = scoreAssistantContainer(node); + if (score > bestScore) { + best = node; + bestScore = score; + } + if (score >= 10) break; + } + return bestScore > 0 ? best : null; + } + + function latestAssistantFromActionBar() { + const buttons = Array.from(document.querySelectorAll("button")).filter(isConversationActionButton); + const primaryButtons = buttons.filter(isPrimaryConversationActionButton); + const searchButtons = primaryButtons.length ? primaryButtons : buttons; + const visibleButtons = searchButtons.filter((button) => { + const rect = visibleRect(button); + return rect && rect.width > 0 && rect.height > 0; + }); + for (let index = visibleButtons.length - 1; index >= 0; index -= 1) { + const container = closestAssistantContainer(visibleButtons[index]); + if (container) return container; + } + for (let index = searchButtons.length - 1; index >= 0; index -= 1) { + const container = closestAssistantContainer(searchButtons[index]); + if (container) return container; + } + return null; + } + + function latestAssistantNode() { + const actionBarTarget = latestAssistantFromActionBar(); + if (actionBarTarget) return actionBarTarget; + + const selectors = [ + '[data-message-author-role="assistant"]', + '[data-testid*="assistant"]', + 'article:has([data-message-author-role="assistant"])', + "main article", + "main [class*='message']", + ]; + for (const selector of selectors) { + try { + const nodes = Array.from(document.querySelectorAll(selector)).filter((node) => node instanceof Element); + if (nodes.length) return nodes[nodes.length - 1]; + } catch (_) { + // Some Chromium builds do not support every selector shape. + } + } + return null; + } + + function elapsedFromAssistantNode(node) { + for (let current = node; current && current !== document.body; current = current.parentElement) { + const text = current.innerText || current.textContent || ""; + if (text.length > 6000) break; + const elapsedMs = parseElapsedMs(text); + if (elapsedMs) return elapsedMs; + } + return 0; + } + + function removeBadges() { + document.querySelectorAll?.(`.${BADGE_CLASS}`).forEach((node) => node.remove()); + } + + function renderMetric(metric = metricForActiveConversation()) { + if (!metric) { + removeBadges(); + return; + } + if (!conversationMatchesActive(metric)) { + removeBadges(); + return; + } + if (!metric) return; + ensureStyle(); + const target = latestAssistantNode(); + if (!target) return; + const displayMetric = { + ...metric, + elapsedMs: elapsedFromAssistantNode(target) || metric.elapsedMs, + }; + document.querySelectorAll(`main > .${BADGE_CLASS}, body > .${BADGE_CLASS}`).forEach((node) => node.remove()); + let badge = target.querySelector?.(`:scope > .${BADGE_CLASS}`); + if (!badge) { + badge = document.createElement("div"); + badge.className = BADGE_CLASS; + target.appendChild(badge); + } + badge.dataset.metricId = displayMetric.id || ""; + badge.dataset.status = displayMetric.status || "complete"; + badge.dataset.conversationId = displayMetric.conversationId || ""; + badge.dataset.version = SCRIPT_VERSION; + badge.dataset.placement = target === document.querySelector("main") ? "fallback" : "message-actions"; + badge.textContent = formatBadgeText(displayMetric); + document.querySelectorAll(`.${BADGE_CLASS}`).forEach((node) => { + if (node !== badge) node.remove(); + }); + } + + function scheduleRender() { + clearTimeout(window.__codexTokenUsageRenderTimer); + window.__codexTokenUsageRenderTimer = setTimeout(() => renderMetric(), 120); + } + + function installDomObserver() { + if (!window.MutationObserver || window.__codexTokenUsageDomObserverVersion === SCRIPT_VERSION) return; + window.__codexTokenUsageDomObserver?.disconnect?.(); + window.__codexTokenUsageDomObserver = new MutationObserver(() => { + const nextConversationId = conversationIdFromActiveRow() || conversationIdFromLocation(); + if (nextConversationId && nextConversationId !== state.activeConversationId) setActiveConversationId(nextConversationId); + if (metricForActiveConversation()) scheduleRender(); + }); + const start = () => { + const root = document.querySelector("main") || document.body || document.documentElement; + if (root) window.__codexTokenUsageDomObserver.observe(root, { childList: true, subtree: true }); + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start, { once: true }); + } else { + start(); + } + window.__codexTokenUsageDomObserverVersion = SCRIPT_VERSION; + } + + function installRouteObserver() { + if (window.__codexTokenUsageRouteObserver === SCRIPT_VERSION) return; + window.__codexTokenUsageRouteObserver = SCRIPT_VERSION; + const sync = () => { + setActiveConversationId(conversationIdFromActiveRow() || conversationIdFromLocation()); + restoreHistoryForConversation(currentConversationId()).catch(() => {}); + }; + const originals = window.__codexTokenUsageRouteOriginals || {}; + window.__codexTokenUsageRouteOriginals = originals; + const routeHistory = window.history; + ["pushState", "replaceState"].forEach((method) => { + const original = originals[method] || routeHistory?.[method]; + originals[method] = original; + if (typeof original !== "function") return; + routeHistory[method] = function codexTokenUsagePatchedHistory(...args) { + const result = original.apply(routeHistory, args); + setTimeout(sync, 0); + return result; + }; + }); + window.addEventListener?.("popstate", sync, true); + window.addEventListener?.("hashchange", sync, true); + sync(); + } + + installFetchObserver(); + installXhrObserver(); + installTurnPendingObserver(); + installPostMessageObserver(); + installWebSocketObserver(); + installContextMeterObserver(); + installRouteObserver(); + installDomObserver(); + restoreHistoryForConversation(currentConversationId()).catch(() => {}); + + if (window.__CODEX_TOKEN_USAGE_SCRIPT_TEST__) { + window.__codexTokenUsageScriptTest = { + extractUsage, + formatBadgeText, + mergeMetric, + normalizeUsage, + normalizeContextReading, + parseElapsedMs, + processPayload, + rememberMetric, + markTurnStarted: markNetworkTurnStarted, + setActiveProjectId, + setActiveConversationId, + dispatchDocumentEvent: (type, event) => document.listeners?.[type]?.({ type, ...event }), + exportUsage, + getDisplayMetric: metricForActiveConversation, + getStoredDetails: readStoredDetails, + getTurnsForActiveConversation: () => deriveTurnsFromLedger(currentScopeKey(), currentConversationId()), + getTokenUsage: () => window.__codexTokenUsage, + restoreHistoryForConversation, + resetDerivedStatePreservingLedger: () => { + state.lastMetric = null; + state.lastMetricKey = ""; + state.recent = []; + state.byConversation = Object.create(null); + state.byScope = Object.create(null); + state.turnsByScope = Object.create(null); + state.currentTurn = null; + state.turnStartedAt = 0; + }, + }; + } +})(); From ad22eaa9b70bfaf08d019ac5bcf4f3b57889635a Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:07:52 +0800 Subject: [PATCH 07/10] harden release downloads and preserve monitor source hashes --- .gitattributes | 1 + .github/workflows/online-release.yml | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 9a1b3e7..0099425 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,5 +4,6 @@ windows/tests/fixtures/payload-root/** -text windows/tests/fixtures/payload-manifest.*.json -text windows/tests/fixtures/end-to-end/script-market/** -text +Resources/script-market-sources/** -text windows/vendor/plugin-seeds/** -text patches/CodexPlusPlus/*.patch -text diff --git a/.github/workflows/online-release.yml b/.github/workflows/online-release.yml index d85da57..f6ab041 100644 --- a/.github/workflows/online-release.yml +++ b/.github/workflows/online-release.yml @@ -51,8 +51,13 @@ jobs: - name: Download Codex++ Windows installer id: cpp shell: pwsh + env: + GH_TOKEN: ${{ github.token }} run: | - $release = Invoke-RestMethod -Headers @{'User-Agent'='Uni-codex'} -Uri 'https://api.github.com/repos/BigPizzaV3/CodexPlusPlus/releases/latest' + $release = Invoke-RestMethod -Headers @{ + 'User-Agent'='Uni-codex' + 'Authorization'="Bearer $env:GH_TOKEN" + } -Uri 'https://api.github.com/repos/BigPizzaV3/CodexPlusPlus/releases/latest' $asset = @($release.assets | Where-Object name -match 'windows-x64-setup\.exe$') if ($asset.Count -ne 1) { throw 'Codex++ Windows asset missing or ambiguous' } $path = Join-Path $env:RUNNER_TEMP $asset[0].name @@ -77,10 +82,13 @@ jobs: - uses: actions/checkout@v6 - name: Download Codex++ macOS installers shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | release_json="$RUNNER_TEMP/codex-plus.json" curl --fail --location --retry 3 --connect-timeout 20 --max-time 1800 \ -H 'User-Agent: Uni-codex' \ + -H "Authorization: Bearer $GH_TOKEN" \ 'https://api.github.com/repos/BigPizzaV3/CodexPlusPlus/releases/latest' \ -o "$release_json" python3 - "$release_json" "$RUNNER_TEMP" <<'PY' From d14e476cb45f5bd512600a721561cccf26f101c4 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:41:11 +0800 Subject: [PATCH 08/10] fix Windows v1.2.44 compatibility policy --- .github/workflows/windows-ci.yml | 4 +- ...build-codex-plus-compatibility-payload.ps1 | 2 +- .../Services/CodexPlusPlusInstaller.cs | 10 ++-- .../Services/PayloadCatalogService.cs | 12 +++-- .../ApplicationInstallerTests.cs | 46 +++++++++++++++---- .../InstallPlannerTests.cs | 26 +++++------ .../InstallerEngineTests.cs | 2 +- .../PayloadCatalogTests.cs | 16 ++++++- .../PreflightTests.cs | 2 +- .../tests/CodexPlusCompatibility.Tests.ps1 | 4 +- windows/tests/DocumentationTests.ps1 | 40 ++-------------- 11 files changed, 90 insertions(+), 74 deletions(-) diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index a244e8f..01c86e9 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -70,12 +70,14 @@ jobs: }).Count -ne 1) { throw "NSIS makensis.exe must report exact version v3.12" } $root >> $env:GITHUB_PATH - name: Core full serialized tests + timeout-minutes: 15 shell: pwsh run: | New-Item -ItemType Directory -Path test-results/core -Force | Out-Null dotnet test windows/tests/CodexOneClick.Core.Tests/CodexOneClick.Core.Tests.csproj ` -c Release --no-restore ` -m:1 -p:TestTfmsInParallel=false ` + --blame-hang --blame-hang-timeout 2m ` --logger "trx;LogFileName=core-targeted.trx" ` --results-directory test-results/core - name: WPF startup, view-model, and layout tests @@ -139,7 +141,7 @@ jobs: name: windows-pr-test-evidence if-no-files-found: error path: | - test-results/core/*.trx + test-results/core/** test-results/layout/*.trx test-results/layout/screenshots/*.png test-results/runtime/*.json diff --git a/windows/scripts/build-codex-plus-compatibility-payload.ps1 b/windows/scripts/build-codex-plus-compatibility-payload.ps1 index 6184b0d..e0741a0 100644 --- a/windows/scripts/build-codex-plus-compatibility-payload.ps1 +++ b/windows/scripts/build-codex-plus-compatibility-payload.ps1 @@ -39,7 +39,7 @@ $ExpectedTag = 'v1.2.44' $ExpectedSourceArchiveSha256 = ` '2c9a1900b24e838ed7b9405534be15efc81a670636cd97d4de8a16cab17a73cb' $ExpectedPatchSha256 = ` - '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15' + '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15' $UpstreamVersion = '1.2.44' $PatchRevision = 'codexkit.1' $PayloadVersion = '1.2.44+codexkit.1' diff --git a/windows/src/InstallerCore/Services/CodexPlusPlusInstaller.cs b/windows/src/InstallerCore/Services/CodexPlusPlusInstaller.cs index 587f22d..ed329e7 100644 --- a/windows/src/InstallerCore/Services/CodexPlusPlusInstaller.cs +++ b/windows/src/InstallerCore/Services/CodexPlusPlusInstaller.cs @@ -130,18 +130,18 @@ Task InspectAsync( public sealed class CodexPlusPlusInstaller { - public const string PayloadVersion = "1.2.43+codexkit.1"; + public const string PayloadVersion = "1.2.44+codexkit.1"; public const string CompatibilityRevision = "cross-provider-content-v1"; - public const string UpstreamTag = "v1.2.43"; + public const string UpstreamTag = "v1.2.44"; public const string PatchSha256 = - "5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739"; + "4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15"; public const string SetupProvenanceSchema = "CODEXKIT-SETUP-PROVENANCE-V1"; public const string SetupFileName = - "CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe"; + "CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe"; private const string PayloadId = "codex-plus-plus-windows-x64"; private const string CanonicalRelativePath = - "apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe"; + "apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe"; private readonly string _payloadRoot; private readonly string _installRoot; diff --git a/windows/src/InstallerCore/Services/PayloadCatalogService.cs b/windows/src/InstallerCore/Services/PayloadCatalogService.cs index d5cd63f..4f41ea6 100644 --- a/windows/src/InstallerCore/Services/PayloadCatalogService.cs +++ b/windows/src/InstallerCore/Services/PayloadCatalogService.cs @@ -10,6 +10,8 @@ public sealed class PayloadCatalogService { private const string ExpectedPackageFamily = "OpenAI.Codex_2p2nqsd0c76g0"; private const string ExpectedCompatibilityRevision = "cross-provider-content-v1"; + private const string ExpectedCodexPlusPlusPayloadVersion = "1.2.44+codexkit.1"; + private const string ExpectedCodexPlusPlusArtifactVersion = "1.2.44-codexkit.1"; private const string ExpectedScriptCommit = "b3c1d16d7d75145b9cf5b0e34000316436d905dd"; private const string ExpectedScriptIndexSha = "0776ecee1165babd3794fa3a740433b5326549bc8c5d8546aa4f8bb52374d540"; @@ -75,9 +77,9 @@ public sealed class PayloadCatalogService { ["codex-windows-x64"] = new("apps/Codex.msix", "msix"), ["codex-plus-plus-windows-x64"] = new( - "apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe", "exe"), + $"apps/CodexPlusPlus-{ExpectedCodexPlusPlusArtifactVersion}-windows-x64-setup.exe", "exe"), ["codex-plus-plus-source"] = new( - "sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz", "archive"), + $"sources/CodexPlusPlus-v{ExpectedCodexPlusPlusArtifactVersion}-source.tar.gz", "archive"), ["model-catalog"] = new("model-catalog.json", "json"), ["plugin-marketplaces"] = new("plugins", "directory"), ["script-market"] = new("script-market", "directory") @@ -87,9 +89,9 @@ public sealed class PayloadCatalogService { ["codex-windows-x64"] = new("apps/Codex.msix", "msix"), ["codex-plus-plus-windows-x64"] = new( - "apps/CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe", "exe"), + $"apps/CodexPlusPlus-{ExpectedCodexPlusPlusArtifactVersion}-windows-x64-setup.exe", "exe"), ["codex-plus-plus-source"] = new( - "sources/CodexPlusPlus-v1.2.43-codexkit.1-source.tar.gz", "archive"), + $"sources/CodexPlusPlus-v{ExpectedCodexPlusPlusArtifactVersion}-source.tar.gz", "archive"), ["model-catalog"] = new("model-catalog.json", "json"), ["plugin-marketplaces"] = new("plugins", "directory"), ["script-market"] = new("script-market", "directory") @@ -308,7 +310,7 @@ private static async Task LoadPayloadLockAsync( || RequiredString(cpp, "compatibilityRevision") != ExpectedCompatibilityRevision || RequiredString(cpp, "licenseID") != "AGPL-3.0-only" - || cppVersion != "1.2.43+codexkit.1") + || cppVersion != ExpectedCodexPlusPlusPayloadVersion) throw Invalid("Codex++ lock rule differs from compiled policy"); var plugins = ReadPluginSet(value); diff --git a/windows/tests/CodexOneClick.Core.Tests/ApplicationInstallerTests.cs b/windows/tests/CodexOneClick.Core.Tests/ApplicationInstallerTests.cs index b3fcd56..67ad1b2 100644 --- a/windows/tests/CodexOneClick.Core.Tests/ApplicationInstallerTests.cs +++ b/windows/tests/CodexOneClick.Core.Tests/ApplicationInstallerTests.cs @@ -10,10 +10,10 @@ public sealed class ApplicationInstallerTests : IDisposable private const string CodexPublisher = "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B"; private const string CodexVersion = "26.7.27.0"; - private const string CodexPlusPlusVersion = "1.2.43+codexkit.1"; + private const string CodexPlusPlusVersion = "1.2.44+codexkit.1"; private const string CompatibilityRevision = "cross-provider-content-v1"; private const string SetupName = - "CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe"; + "CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe"; private readonly string _temporaryRoot = Path.Combine( Path.GetTempPath(), @@ -660,6 +660,35 @@ public async Task CodexPlusPlus_executes_only_reviewed_setup_with_S_and_no_api_k probe.RequestedInstallRoot); } + [Fact] + public async Task Checked_in_v1_2_44_catalog_is_accepted_by_application_installer() + { + var payloadRoot = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "../../../../fixtures/payload-root")); + var catalog = await new PayloadCatalogService().ValidateAsync( + payloadRoot, + fixtureMode: true); + var backend = new FakeProcessExecutionBackend(); + var installer = CreateCodexPlusPlusInstaller( + backend, + new FakeApplicationTransactionCoordinator(), + new FakeCodexPlusPlusInstallationProbe(), + payloadRoot); + + var result = await installer.ApplyAsync( + Plan( + "codex-plus-plus-windows-x64", + PlanAction.Install, + installedVersion: null), + catalog); + + Assert.True(result.Changed); + Assert.Equal( + Path.Combine(payloadRoot, "apps", SetupName), + backend.Request!.FileName); + } + [Fact] public async Task CodexPlusPlus_setup_lease_remains_open_through_process_completion() { @@ -916,7 +945,7 @@ public void Task3_executable_metadata_reader_accepts_reviewed_downstream_contrac path, """ - CODEXKIT-EXECUTABLE-METADATA-V1:{"schemaVersion":1,"payloadVersion":"1.2.43+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","component":"launcher","fixtureOnly":false} + CODEXKIT-EXECUTABLE-METADATA-V1:{"schemaVersion":1,"payloadVersion":"1.2.44+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","component":"launcher","fixtureOnly":false} """); @@ -945,7 +974,7 @@ public void Setup_provenance_reader_accepts_exact_per_user_contract() path, """ - CODEXKIT-SETUP-PROVENANCE-V1:{"schema":"CODEXKIT-SETUP-PROVENANCE-V1","schemaVersion":1,"setupFileName":"CodexPlusPlus-1.2.43-codexkit.1-windows-x64-setup.exe","upstreamTag":"v1.2.43","patchSha256":"5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739","payloadVersion":"1.2.43+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","perUser":true,"executionLevel":"user","installDir":"$LOCALAPPDATA\\Programs\\Codex++","registryHive":"HKCU","shortcutScope":"currentUser","requiresElevation":false,"rawCreateProcessCompatible":true,"fixtureOnly":false,"executables":[{"name":"codex-plus-plus.exe","component":"launcher","sha256":"1111111111111111111111111111111111111111111111111111111111111111","payloadVersion":"1.2.43+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","metadataMagic":"CODEXKIT-EXECUTABLE-METADATA-V1:"},{"name":"codex-plus-plus-manager.exe","component":"manager","sha256":"2222222222222222222222222222222222222222222222222222222222222222","payloadVersion":"1.2.43+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","metadataMagic":"CODEXKIT-EXECUTABLE-METADATA-V1:"}]} + CODEXKIT-SETUP-PROVENANCE-V1:{"schema":"CODEXKIT-SETUP-PROVENANCE-V1","schemaVersion":1,"setupFileName":"CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe","upstreamTag":"v1.2.44","patchSha256":"4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15","payloadVersion":"1.2.44+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","perUser":true,"executionLevel":"user","installDir":"$LOCALAPPDATA\\Programs\\Codex++","registryHive":"HKCU","shortcutScope":"currentUser","requiresElevation":false,"rawCreateProcessCompatible":true,"fixtureOnly":false,"executables":[{"name":"codex-plus-plus.exe","component":"launcher","sha256":"1111111111111111111111111111111111111111111111111111111111111111","payloadVersion":"1.2.44+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","metadataMagic":"CODEXKIT-EXECUTABLE-METADATA-V1:"},{"name":"codex-plus-plus-manager.exe","component":"manager","sha256":"2222222222222222222222222222222222222222222222222222222222222222","payloadVersion":"1.2.44+codexkit.1","compatibilityRevision":"cross-provider-content-v1","architecture":"x64","metadataMagic":"CODEXKIT-EXECUTABLE-METADATA-V1:"}]} """); @@ -1117,9 +1146,10 @@ public void Dispose() private CodexPlusPlusInstaller CreateCodexPlusPlusInstaller( IProcessExecutionBackend backend, IApplicationTransactionCoordinator directories, - ICodexPlusPlusInstallationProbe probe) => + ICodexPlusPlusInstallationProbe probe, + string? payloadRoot = null) => new( - _temporaryRoot, + payloadRoot ?? _temporaryRoot, LocalAppData(), new ProcessRunner(backend), directories, @@ -1255,9 +1285,9 @@ private static CodexPlusPlusSetupProvenance ReviewedSetupProvenance() => Schema: "CODEXKIT-SETUP-PROVENANCE-V1", SchemaVersion: 1, SetupFileName: SetupName, - UpstreamTag: "v1.2.43", + UpstreamTag: "v1.2.44", PatchSha256: - "5a411571c2c950a3ce5f8b1ed3a72a0f42bb4c4de2f9ea3ba5de8d767e14f739", + "4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15", PayloadVersion: CodexPlusPlusVersion, CompatibilityRevision, Architecture: "x64", diff --git a/windows/tests/CodexOneClick.Core.Tests/InstallPlannerTests.cs b/windows/tests/CodexOneClick.Core.Tests/InstallPlannerTests.cs index dcd392b..2000497 100644 --- a/windows/tests/CodexOneClick.Core.Tests/InstallPlannerTests.cs +++ b/windows/tests/CodexOneClick.Core.Tests/InstallPlannerTests.cs @@ -95,7 +95,7 @@ public void Codex_with_a_malformed_package_version_is_repaired(string version) [Fact] public void Matching_codex_plus_plus_version_is_preserved() { - var installed = InstalledCodexPlusPlus("1.2.43+codexkit.1"); + var installed = InstalledCodexPlusPlus("1.2.44+codexkit.1"); var plan = Plan(installed); @@ -110,7 +110,7 @@ public void Matching_codex_plus_plus_version_is_preserved() [Fact] public void Older_codex_plus_plus_version_is_upgraded() { - var installed = InstalledCodexPlusPlus("1.2.42"); + var installed = InstalledCodexPlusPlus("1.2.43+codexkit.1"); var plan = Plan(installed); @@ -125,8 +125,8 @@ public void Older_codex_plus_plus_version_is_upgraded() [Theory] [InlineData("unknown")] [InlineData("1.2")] - [InlineData("1.2.43+other.9")] - [InlineData("1.2.43+codexkit.x")] + [InlineData("1.2.44+other.9")] + [InlineData("1.2.44+codexkit.x")] public void Malformed_codex_plus_plus_version_is_repaired(string version) { var installed = InstalledCodexPlusPlus(version); @@ -143,11 +143,11 @@ public void Malformed_codex_plus_plus_version_is_repaired(string version) [Theory] [InlineData( - "1.2.43+codexkit.2", + "1.2.44+codexkit.2", PlanAction.Preserve, InstallReasonCodes.CodexPlusPlusNewer)] [InlineData( - "1.2.43+codexkit.0", + "1.2.44+codexkit.0", PlanAction.Upgrade, InstallReasonCodes.CodexPlusPlusOutdated)] public void Codexkit_revision_participates_in_version_ordering( @@ -223,10 +223,10 @@ public void Duplicate_codex_records_without_exact_choose_highest_valid_version() public void Duplicate_codex_plus_plus_records_choose_current_regardless_of_order() { var damagedCurrent = InstalledCodexPlusPlus( - "1.2.43+codexkit.1", + "1.2.44+codexkit.1", signatureValid: false); - var healthyOlder = InstalledCodexPlusPlus("1.2.43+codexkit.0"); - var healthyCurrent = InstalledCodexPlusPlus("1.2.43+codexkit.1"); + var healthyOlder = InstalledCodexPlusPlus("1.2.44+codexkit.0"); + var healthyCurrent = InstalledCodexPlusPlus("1.2.44+codexkit.1"); var forward = Plan(damagedCurrent, healthyOlder, healthyCurrent); var reversed = Plan(healthyCurrent, healthyOlder, damagedCurrent); @@ -243,8 +243,8 @@ public void Duplicate_codex_plus_plus_records_choose_current_regardless_of_order [Fact] public void Duplicate_codex_plus_plus_records_choose_highest_valid_version() { - var older = InstalledCodexPlusPlus("1.2.43+codexkit.0"); - var newer = InstalledCodexPlusPlus("1.2.43+codexkit.2"); + var older = InstalledCodexPlusPlus("1.2.44+codexkit.0"); + var newer = InstalledCodexPlusPlus("1.2.44+codexkit.2"); var forward = Plan(older, newer); var reversed = Plan(newer, older); @@ -316,11 +316,11 @@ private static PayloadCatalog Catalog() => PreflightService.CodexPackageFamilyName), Entry( "codex-plus-plus-windows-x64", - "1.2.43+codexkit.1", + "1.2.44+codexkit.1", packageFamilyName: null), Entry( "codex-plus-plus-source", - "1.2.43+codexkit.1", + "1.2.44+codexkit.1", packageFamilyName: null) ]); diff --git a/windows/tests/CodexOneClick.Core.Tests/InstallerEngineTests.cs b/windows/tests/CodexOneClick.Core.Tests/InstallerEngineTests.cs index 87a34a4..79ac63d 100644 --- a/windows/tests/CodexOneClick.Core.Tests/InstallerEngineTests.cs +++ b/windows/tests/CodexOneClick.Core.Tests/InstallerEngineTests.cs @@ -245,7 +245,7 @@ public async Task Cancellation_rolls_back_the_outer_transaction() DeepSeekRequest(), new Progress(_ => { }), cancellation.Token); - await entered.Task; + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => install); diff --git a/windows/tests/CodexOneClick.Core.Tests/PayloadCatalogTests.cs b/windows/tests/CodexOneClick.Core.Tests/PayloadCatalogTests.cs index 87f795f..115e8f0 100644 --- a/windows/tests/CodexOneClick.Core.Tests/PayloadCatalogTests.cs +++ b/windows/tests/CodexOneClick.Core.Tests/PayloadCatalogTests.cs @@ -13,7 +13,7 @@ public sealed class PayloadCatalogTests : IDisposable public PayloadCatalogTests() => Directory.CreateDirectory(_temporaryRoot); [Fact] - public async Task Valid_schema_2_catalog_and_payload_tree_pass() + public async Task Checked_in_v1_2_44_fixture_validates_against_compiled_policy() { var root = CopyValidFixture(); @@ -21,6 +21,18 @@ public async Task Valid_schema_2_catalog_and_payload_tree_pass() Assert.Equal(2, catalog.SchemaVersion); Assert.Equal(6, catalog.Entries.Count); + var setup = catalog.Entries.Single(entry => + entry.Id == "codex-plus-plus-windows-x64"); + var source = catalog.Entries.Single(entry => + entry.Id == "codex-plus-plus-source"); + Assert.Equal("1.2.44+codexkit.1", setup.Version); + Assert.Equal( + "apps/CodexPlusPlus-1.2.44-codexkit.1-windows-x64-setup.exe", + setup.RelativePath); + Assert.Equal("1.2.44+codexkit.1", source.Version); + Assert.Equal( + "sources/CodexPlusPlus-v1.2.44-codexkit.1-source.tar.gz", + source.RelativePath); } [Fact] @@ -126,7 +138,7 @@ await Assert.ThrowsAsync( [InlineData("codex-plus-plus-windows-x64", "apps/setup.exe", "exe")] [InlineData("codex-plus-plus-windows-x64", "apps/CodexPlusPlus-setup.exe", "archive")] [InlineData("codex-plus-plus-source", "sources/source.tar.gz", "archive")] - [InlineData("codex-plus-plus-source", "sources/CodexPlusPlus-v1.2.43.tar.gz", "exe")] + [InlineData("codex-plus-plus-source", "sources/CodexPlusPlus-v1.2.44.tar.gz", "exe")] [InlineData("model-catalog", "metadata/models.json", "json")] [InlineData("model-catalog", "model-catalog.json", "file")] [InlineData("plugin-marketplaces", "metadata/plugins", "directory")] diff --git a/windows/tests/CodexOneClick.Core.Tests/PreflightTests.cs b/windows/tests/CodexOneClick.Core.Tests/PreflightTests.cs index 0ce515e..2877fc9 100644 --- a/windows/tests/CodexOneClick.Core.Tests/PreflightTests.cs +++ b/windows/tests/CodexOneClick.Core.Tests/PreflightTests.cs @@ -141,7 +141,7 @@ public void Installed_component_queries_are_injected_and_preserved_in_the_snapsh [ new InstalledPackage( PreflightService.CodexPlusPlusComponentIdentity, - "1.2.43+codexkit.1", + "1.2.44+codexkit.1", true, @"C:\Users\fixture\AppData\Local\Programs\Codex++\Codex++.exe") ]; diff --git a/windows/tests/CodexPlusCompatibility.Tests.ps1 b/windows/tests/CodexPlusCompatibility.Tests.ps1 index 8dbfed7..9ef04d2 100644 --- a/windows/tests/CodexPlusCompatibility.Tests.ps1 +++ b/windows/tests/CodexPlusCompatibility.Tests.ps1 @@ -37,7 +37,7 @@ $ExpectedTag = 'v1.2.44' $ExpectedVersion = '1.2.44+codexkit.1' $ExpectedRevision = 'cross-provider-content-v1' $ExpectedPatchSha256 = ` - '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15' + '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15' $ExecutableMetadataMagic = 'CODEXKIT-EXECUTABLE-METADATA-V1:' $SetupProvenanceSchema = 'CODEXKIT-SETUP-PROVENANCE-V1' $SetupProvenanceMagic = "$SetupProvenanceSchema`:" @@ -616,7 +616,7 @@ if ($sourceIndex -lt 0 -or $sourceIndex + 1 -ge $Rest.Count) { exit 91 } $sourceRoot = $Rest[$sourceIndex + 1] $patchPath = $Rest[$Rest.Count - 1] $patchSha = (Get-FileHash -LiteralPath $patchPath -Algorithm SHA256).Hash.ToLowerInvariant() -if ($patchSha -cne '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edebbe03d0c732a53e15') { +if ($patchSha -cne '4a5d84b215ecf729b61a1b675d29af8f11dc3c86698edeebbe03d0c732a53e15') { exit 92 } $patchText = Get-Content -LiteralPath $patchPath -Raw diff --git a/windows/tests/DocumentationTests.ps1 b/windows/tests/DocumentationTests.ps1 index 6b62cd1..350f489 100644 --- a/windows/tests/DocumentationTests.ps1 +++ b/windows/tests/DocumentationTests.ps1 @@ -142,7 +142,6 @@ foreach ($value in @( '3.', '4.', '5.', - 'SHA256SUMS.txt', '更多信息', '仍要运行', '自动回滚', @@ -151,37 +150,15 @@ foreach ($value in @( Assert-Contains $windowsOpen $value 'Windows open guide steps' } foreach ($value in @( - 'build-codex-one-click-installer.sh', - 'tests/run-all-tests.sh', - 'windows/scripts/build-installer.ps1', - 'windows/tests/run-all-tests.ps1', - '项目结构', + '.github/workflows/online-release.yml', + 'macos/', 'windows/', 'Resources/', '.github/workflows/' )) { Assert-Contains $rootReadme $value 'root maintainer documentation' } -Assert-Contains $rootReadme '-OutputDir dist' 'root Windows build example' -Assert-True (-not $rootReadme.Contains('-OutputDir windows/dist')) ` - 'root Windows build example double-prefixes WindowsRoot' -Assert-Contains $rootReadme "``````bash`n# macOS 构建与测试" ` - 'root macOS bash fence' -Assert-Contains $rootReadme ( - "``````powershell`n" + - '# Windows 开发构建与测试(从仓库根目录,在 Windows x64)' -) 'root Windows PowerShell fence' -$powerShellFence = $rootReadme.IndexOf( - "``````powershell`n", - [StringComparison]::Ordinal) -$windowsCompat = $rootReadme.IndexOf( - "`$compat = 'windows/build/codex-plus-compat'", - [StringComparison]::Ordinal) -Assert-True ( - $powerShellFence -ge 0 -and $windowsCompat -gt $powerShellFence -) 'root Windows chain is not inside its PowerShell fence' $developerDocs = [ordered]@{ - root = $rootReadme windows = $windowsReadme } foreach ($entry in $developerDocs.GetEnumerator()) { @@ -214,14 +191,6 @@ foreach ($entry in $developerDocs.GetEnumerator()) { '-PayloadRoot\s+(windows/vendor/offline-payloads|\.\\vendor\\offline-payloads)' ) "$($entry.Key) README passes the payload container directly" } -foreach ($value in @( - '-Patch patches/CodexPlusPlus/v1.2.44-cross-provider-history.patch', - '-OutputRoot windows/vendor/offline-payloads', - "GetFullPath('windows/vendor/offline-payloads')", - '-BuiltRoot $compat' -)) { - Assert-Contains $rootReadme $value 'root checkout-relative Windows chain' -} foreach ($value in @( '-Patch ..\patches\CodexPlusPlus\v1.2.44-cross-provider-history.patch', '-OutputRoot .\vendor\offline-payloads', @@ -348,9 +317,10 @@ foreach ($script in $legacyScripts) { Assert-True ($exitCode -eq 64) ` "legacy entry runtime exit was ${exitCode}: $script" $outputText = @($output | ForEach-Object { "$_" }) -join "`n" - Assert-Contains $outputText 'Codex-One-Click-Windows-x64-Offline-Setup.exe' ` + Assert-Contains $outputText 'https://apps.microsoft.com/detail/9PLM9XGG6VKS' ` + "legacy runtime output $script" + Assert-Contains $outputText '暂不镜像官方 Codex 离线安装文件' ` "legacy runtime output $script" - Assert-Contains $outputText 'SHA256SUMS.txt' "legacy runtime output $script" } $global:LASTEXITCODE = 0 From d18f113320853412a47bd16b788a059f8861453a Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:54:41 +0800 Subject: [PATCH 09/10] prepare v1.1.0 online release --- .github/workflows/online-release.yml | 2 +- README.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/online-release.yml b/.github/workflows/online-release.yml index f6ab041..b197770 100644 --- a/.github/workflows/online-release.yml +++ b/.github/workflows/online-release.yml @@ -12,7 +12,7 @@ on: release_tag: description: Release tag required: true - default: v1.0.0-online + default: v1.1.0-online push: tags: - 'v*-online' diff --git a/README.md b/README.md index ede8abe..ea2b2b6 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ 不用 Git,不用命令行,不用配置开发环境。下载、双击,跟着提示完成安装。 -[![Windows](https://img.shields.io/badge/Windows-下载安装包-0078D4?style=for-the-badge&logo=windows11&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-Windows-x64-Online-Setup.exe) -[![macOS](https://img.shields.io/badge/macOS-下载安装包-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-macOS-Online.dmg) +[![Windows](https://img.shields.io/badge/Windows-下载安装包-0078D4?style=for-the-badge&logo=windows11&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.1.0-online/Uni-codex-Windows-x64-Online-Setup.exe) +[![macOS](https://img.shields.io/badge/macOS-下载安装包-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/fancr-code/Uni-codex/releases/download/v1.1.0-online/Uni-codex-macOS-Online.dmg) [查看最新版本](https://github.com/fancr-code/Uni-codex/releases/latest) · [问题反馈](https://github.com/fancr-code/Uni-codex/issues) @@ -33,7 +33,7 @@ 支持 **Windows 10 1809 及以上版本、Windows 11(x64)**。 -1. 下载 [Windows 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-Windows-x64-Online-Setup.exe)。 +1. 下载 [Windows 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.1.0-online/Uni-codex-Windows-x64-Online-Setup.exe)。 2. 双击 `Uni-codex-Windows-x64-Online-Setup.exe`。 3. 按安装向导提示完成安装。 @@ -43,7 +43,7 @@ 支持 **Apple Silicon(M 系列)和 Intel Mac**。 -1. 下载 [macOS 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.0.0-online/Uni-codex-macOS-Online.dmg)。 +1. 下载 [macOS 一键安装包](https://github.com/fancr-code/Uni-codex/releases/download/v1.1.0-online/Uni-codex-macOS-Online.dmg)。 2. 打开 `Uni-codex-macOS-Online.dmg`。 3. 按窗口中的说明完成安装。 From 044fcf65f451b9454b7d2459fe421f0704046239 Mon Sep 17 00:00:00 2001 From: fancr-code <265698714+fancr-code@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:06:57 +0800 Subject: [PATCH 10/10] stabilize monitor DOM contract --- tests/script-runtime/script-runtime-contract.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/script-runtime/script-runtime-contract.mjs b/tests/script-runtime/script-runtime-contract.mjs index f19ab38..cdd930d 100644 --- a/tests/script-runtime/script-runtime-contract.mjs +++ b/tests/script-runtime/script-runtime-contract.mjs @@ -111,6 +111,13 @@ try { "OpenAI Responses usage should render", ); await poll(() => page.locator(".codex-token-usage-badge").isVisible(), true, "token badge should be visible"); + await poll( + async () => /本轮调用合计\s+150(?:\D|$)/.test( + (await page.locator(".codex-token-usage-badge").textContent()) || "", + ), + true, + "token badge DOM text should render the OpenAI total of 150", + ); const tokenText = await page.locator(".codex-token-usage-badge").textContent(); assertFiniteDomNumber(tokenText, "token badge DOM text should contain finite numeric values"); assert.match(tokenText || "", /本轮调用合计\s+150(?:\D|$)/, "token badge DOM text should render the OpenAI total of 150");