diff --git a/external_plugins/zcode-token-usage-statusbar/.claude-plugin/plugin.json b/external_plugins/zcode-token-usage-statusbar/.claude-plugin/plugin.json new file mode 100644 index 0000000..75d6657 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/.claude-plugin/plugin.json @@ -0,0 +1,61 @@ +{ + "name": "zcode-token-usage-statusbar", + "version": "1.5.3", + "description": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "author": { + "name": "xhwxt/ByteRax" + }, + "homepage": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "repository": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "license": "MIT", + "keywords": [ + "token", + "usage", + "statusbar", + "monitor", + "zusage" + ], + "commands": "commands", + "skills": "skills", + "packageLayout": { + "schemaVersion": 1, + "files": [ + "LICENSE", + "marketplace.json", + "plugins/zcode-token-usage-statusbar/.claude-plugin/plugin.json", + "plugins/zcode-token-usage-statusbar/.mcp.json", + "plugins/zcode-token-usage-statusbar/.zcode-plugin/plugin.json", + "plugins/zcode-token-usage-statusbar/README.md", + "plugins/zcode-token-usage-statusbar/commands/install-overlay.md", + "plugins/zcode-token-usage-statusbar/commands/uninstall-overlay.md", + "plugins/zcode-token-usage-statusbar/commands/usage.md", + "plugins/zcode-token-usage-statusbar/hooks/hooks.json", + "plugins/zcode-token-usage-statusbar/hooks/prompt-submit.py", + "plugins/zcode-token-usage-statusbar/hooks/session-start.py", + "plugins/zcode-token-usage-statusbar/inject-main.cjs", + "plugins/zcode-token-usage-statusbar/install_monitor.py", + "plugins/zcode-token-usage-statusbar/overlay.js", + "plugins/zcode-token-usage-statusbar/overlay_install.py", + "plugins/zcode-token-usage-statusbar/patch_install.py", + "plugins/zcode-token-usage-statusbar/plugin-release.cjs", + "plugins/zcode-token-usage-statusbar/plugin-storage.cjs", + "plugins/zcode-token-usage-statusbar/plugin-updater.cjs", + "plugins/zcode-token-usage-statusbar/plugin_auto_update.py", + "plugins/zcode-token-usage-statusbar/quota-client.cjs", + "plugins/zcode-token-usage-statusbar/release_unpack.py", + "plugins/zcode-token-usage-statusbar/renderer_bridge.py", + "plugins/zcode-token-usage-statusbar/restart_notice.py", + "plugins/zcode-token-usage-statusbar/runtime_lock.py", + "plugins/zcode-token-usage-statusbar/skills/zcode-token-usage/SKILL.md", + "plugins/zcode-token-usage-statusbar/token_rate.py", + "plugins/zcode-token-usage-statusbar/turn-stats.js", + "plugins/zcode-token-usage-statusbar/usage_mcp.py", + "plugins/zcode-token-usage-statusbar/usage_stats.py", + "plugins/zcode-token-usage-statusbar/zusage.py" + ] + }, + "description_i18n": { + "en": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "zh-CN": "ZCode token 用量查询(token_usage MCP 工具 + /usage 命令 + 技能,读本地 usage 数据库);缺配置文件或缺字段时默认为桌面客户端自动安装/修复悬浮用量状态条(修改 app.asar,成功后需完全退出并重开;显式 false 保留,环境变量优先,卸载豁免阻止重注入,无环境变量覆盖时损坏/非布尔配置不开启);每次回答末尾自动追加逐问速率行(由前端确定性渲染,依赖 renderer 桥;即时 tok/s 含思考、首字延迟、输出 token、生成耗时、段数/峰值与会话累计,--current 守卫防旧数据充数)。" + } +} diff --git a/external_plugins/zcode-token-usage-statusbar/.mcp.json b/external_plugins/zcode-token-usage-statusbar/.mcp.json new file mode 100644 index 0000000..8f4384f --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "zusage": { + "type": "stdio", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/usage_mcp.py" + ], + "enabled": true + } + } +} diff --git a/external_plugins/zcode-token-usage-statusbar/.zcode-plugin/plugin.json b/external_plugins/zcode-token-usage-statusbar/.zcode-plugin/plugin.json new file mode 100644 index 0000000..75d6657 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/.zcode-plugin/plugin.json @@ -0,0 +1,61 @@ +{ + "name": "zcode-token-usage-statusbar", + "version": "1.5.3", + "description": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "author": { + "name": "xhwxt/ByteRax" + }, + "homepage": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "repository": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "license": "MIT", + "keywords": [ + "token", + "usage", + "statusbar", + "monitor", + "zusage" + ], + "commands": "commands", + "skills": "skills", + "packageLayout": { + "schemaVersion": 1, + "files": [ + "LICENSE", + "marketplace.json", + "plugins/zcode-token-usage-statusbar/.claude-plugin/plugin.json", + "plugins/zcode-token-usage-statusbar/.mcp.json", + "plugins/zcode-token-usage-statusbar/.zcode-plugin/plugin.json", + "plugins/zcode-token-usage-statusbar/README.md", + "plugins/zcode-token-usage-statusbar/commands/install-overlay.md", + "plugins/zcode-token-usage-statusbar/commands/uninstall-overlay.md", + "plugins/zcode-token-usage-statusbar/commands/usage.md", + "plugins/zcode-token-usage-statusbar/hooks/hooks.json", + "plugins/zcode-token-usage-statusbar/hooks/prompt-submit.py", + "plugins/zcode-token-usage-statusbar/hooks/session-start.py", + "plugins/zcode-token-usage-statusbar/inject-main.cjs", + "plugins/zcode-token-usage-statusbar/install_monitor.py", + "plugins/zcode-token-usage-statusbar/overlay.js", + "plugins/zcode-token-usage-statusbar/overlay_install.py", + "plugins/zcode-token-usage-statusbar/patch_install.py", + "plugins/zcode-token-usage-statusbar/plugin-release.cjs", + "plugins/zcode-token-usage-statusbar/plugin-storage.cjs", + "plugins/zcode-token-usage-statusbar/plugin-updater.cjs", + "plugins/zcode-token-usage-statusbar/plugin_auto_update.py", + "plugins/zcode-token-usage-statusbar/quota-client.cjs", + "plugins/zcode-token-usage-statusbar/release_unpack.py", + "plugins/zcode-token-usage-statusbar/renderer_bridge.py", + "plugins/zcode-token-usage-statusbar/restart_notice.py", + "plugins/zcode-token-usage-statusbar/runtime_lock.py", + "plugins/zcode-token-usage-statusbar/skills/zcode-token-usage/SKILL.md", + "plugins/zcode-token-usage-statusbar/token_rate.py", + "plugins/zcode-token-usage-statusbar/turn-stats.js", + "plugins/zcode-token-usage-statusbar/usage_mcp.py", + "plugins/zcode-token-usage-statusbar/usage_stats.py", + "plugins/zcode-token-usage-statusbar/zusage.py" + ] + }, + "description_i18n": { + "en": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "zh-CN": "ZCode token 用量查询(token_usage MCP 工具 + /usage 命令 + 技能,读本地 usage 数据库);缺配置文件或缺字段时默认为桌面客户端自动安装/修复悬浮用量状态条(修改 app.asar,成功后需完全退出并重开;显式 false 保留,环境变量优先,卸载豁免阻止重注入,无环境变量覆盖时损坏/非布尔配置不开启);每次回答末尾自动追加逐问速率行(由前端确定性渲染,依赖 renderer 桥;即时 tok/s 含思考、首字延迟、输出 token、生成耗时、段数/峰值与会话累计,--current 守卫防旧数据充数)。" + } +} diff --git a/external_plugins/zcode-token-usage-statusbar/README.md b/external_plugins/zcode-token-usage-statusbar/README.md new file mode 100644 index 0000000..e137990 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/README.md @@ -0,0 +1,53 @@ +# ZCode Token Usage Statusbar + +See model speed, token usage, context size, plan quota, and Git status while you work in ZCode. + +> **Important:** installing the status bar **modifies the ZCode desktop client's `app.asar`** (injects a loader and an overlay; the patch is removable and keeps a backup). After install you must **fully quit and reopen ZCode** — closing the window is not enough. Usage statistics are read from the local database and never uploaded. If you only want the query tools, skip the status bar (see [Turn off or remove](#turn-off-or-remove)). + +## What you get + +- Floating status bar (desktop): generation speed (tok/s), per-turn / session / today token usage, cache hit rate, sub-agent breakdown, context usage, plan-quota chip, Git status chip. Tooltips show details; the ⚙ panel toggles what is shown. +- Per-answer rate line appended under each reply: tok/s, time to first token, output tokens, duration, session totals. +- `/usage` command (today / week / models / current), the `token_usage` MCP tool, and a skill that teaches the agent how to query usage. + +## Requirements + +- Python **3.8+** with the standard library only — no pip packages. +- Hook commands fall back `python3` → `python` → `py -3` (Windows friendly). The stdio MCP server is fixed to `python3`; if your machine has no `python3` on PATH, edit `command` in the installed copy's `.mcp.json` to your interpreter (the MCP tool is optional; commands and hooks work without it). + +## Install & first run + +1. Install this plugin from the marketplace and keep it enabled. +2. Open (or reopen) a session. The plugin then installs / repairs the status bar automatically. **This modifies `app.asar` — only proceed if you trust this project.** +3. **Fully quit and reopen ZCode.** + +Automatic install is on by default (missing config file or missing field = enabled). An explicit `"plugin_auto_install_overlay": false` in `~/.zcode/zcode-token-usage-statusbar/config.json` is preserved; `ZUSAGE_PLUGIN_AUTO_INSTALL=1/0` overrides the config; `/uninstall-overlay` leaves an opt-out marker that blocks re-injection. With no environment override, a corrupted or non-boolean config does **not** enable auto-install. If automatic install fails, run `/install-overlay` and follow its output; a full restart is still required. + +## How it works & side effects + +| Area | Behavior | +|---|---| +| Hooks | `SessionStart` syncs runtime files, checks for updates, and may show a restart notice; `PromptSubmit` records the prompt timestamp and appends the per-answer rate line. | +| MCP server | `.mcp.json` starts `usage_mcp.py` as a stdio JSON-RPC server exposing `token_usage(scope)`. It reads the local usage database only. | +| Files written | `~/.zcode/zcode-token-usage-statusbar/` (runtime files, config, per-session state; secrets file is `0600`); a patch + backup inside the ZCode client directory (`app.asar`); overlay settings in `~/.zcode/zusage3`. Uninstalling the plugin does not delete personal config. | +| Network | Local statistics never leave your machine (read-only SQLite). The plan-quota chip decrypts the host's stored OAuth credentials **in memory only** and calls BigModel / Z.ai usage endpoints directly to show your plan quota; credentials are never written to config, logs, or diagnostics. Plugin updates download code from the configured source. Optional SSH/WSL remote queries go through the connection channel you configured; the plugin never decrypts or reuses host passwords or key passphrases and never auto-deploys. The Git chip runs local read-only `git` queries (no fetch, no network). | +| Executed commands | Python scripts shipped with the plugin; `git` (read-only status queries); `ssh`/`wsl.exe` only for remote usage you configured. | +| Third-party code | None bundled — plain Python 3.8 stdlib, plain JavaScript (Electron main-process CommonJS). MIT licensed; source at [ByteRax/zcode-token-usage-statusbar](https://github.com/ByteRax/zcode-token-usage-statusbar). | + +Remote sessions (SSH/WSL) need a matching `zusage.py` deployed manually on each remote host; see the [user guide](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/docs/user-guide.md) for the full remote, quota-source, and multi-window details. + +## Turn off or remove + +- `/uninstall-overlay` — removes the status bar, keeps the opt-out marker, keeps personal config. +- `"plugin_auto_install_overlay": false` — keep the query tools only. +- `"answer_rate_line": false` — drop the per-answer rate line (takes effect on the next prompt). +- `"plugin_auto_update": false` — disable update checks. +- Full removal: turn off auto-update, run `/uninstall-overlay`, then uninstall the plugin from ZCode settings and fully restart. **Uninstalling the plugin alone does not remove the status bar.** + +## Configuration + +Default runtime config lives in `~/.zcode/zcode-token-usage-statusbar/config.json` — merge only the fields you change. Beyond the switches above it also covers remote targets, quota sources, and display options; see [`config.example.json`](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/config.example.json) and the [user guide](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/docs/user-guide.md). + +## Source & license + +MIT — [ByteRax/zcode-token-usage-statusbar](https://github.com/ByteRax/zcode-token-usage-statusbar). Bug reports and PRs welcome (Chinese or English). diff --git a/external_plugins/zcode-token-usage-statusbar/README_CN.md b/external_plugins/zcode-token-usage-statusbar/README_CN.md new file mode 100644 index 0000000..7c91c71 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/README_CN.md @@ -0,0 +1,53 @@ +# ZCode Token Usage Statusbar(Token 用量状态栏) + +在 ZCode 里实时查看模型速度、token 用量、上下文容量、套餐额度与 Git 状态。 + +> **重要提示:** 安装悬浮状态条会**修改 ZCode 桌面客户端的 `app.asar`**(注入加载器与浮层;补丁可移除且保留备份)。安装成功后必须**完全退出并重开 ZCode**,只关闭窗口不算退出。用量统计只读取本地数据库,绝不上传。如果只要查询工具、不要状态条,见[关闭或移除](#关闭或移除)。 + +## 你能得到什么 + +- 桌面悬浮状态条:生成速度(tok/s)、本轮 / 会话 / 今日 token 用量、缓存命中率、子代理明细、上下文容量、套餐额度 chip、Git 状态 chip。悬停看明细,⚙ 面板可调整显示项。 +- 每条回答末尾自动追加逐问速率行:tok/s、首字延迟、输出 token、生成耗时、会话累计。 +- `/usage` 命令(today / week / models / current)、`token_usage` MCP 工具,以及一个教会智能体查询用量的技能。 + +## 环境要求 + +- Python **3.8+**,仅标准库,无需安装任何 pip 包。 +- Hook 命令按 `python3` → `python` → `py -3` 自动回退(兼容 Windows)。stdio MCP 服务固定使用 `python3`;若机器 PATH 里没有 `python3`,可把安装副本 `.mcp.json` 的 `command` 改成可用解释器(MCP 工具是可选的,命令与 Hook 不依赖它)。 + +## 安装与首次运行 + +1. 从插件市场安装本插件并保持启用。 +2. 新建或重新打开会话,插件会自动安装 / 修复状态条。**这一步会修改 `app.asar`,请确认信任此项目。** +3. **完全退出并重开 ZCode。** + +自动安装默认开启(配置文件不存在或缺字段视为开启)。`~/.zcode/zcode-token-usage-statusbar/config.json` 中已有的显式 `"plugin_auto_install_overlay": false` 会保留;环境变量 `ZUSAGE_PLUGIN_AUTO_INSTALL=1/0` 优先于配置;`/uninstall-overlay` 会留下卸载豁免标记阻止自动重注入。未设置环境变量时,损坏配置或非布尔字段**不会**触发自动安装。自动安装失败时执行 `/install-overlay` 按输出排查,成功后仍需完全重启。 + +## 工作原理与副作用 + +| 方面 | 行为 | +|---|---| +| Hook | `SessionStart` 同步运行文件、检查更新、必要时给出重启提醒;`PromptSubmit` 记录提问时间戳并追加逐问速率行。 | +| MCP 服务 | `.mcp.json` 以 stdio JSON-RPC 启动 `usage_mcp.py`,提供 `token_usage(scope)` 工具,只读本地用量数据库。 | +| 文件写入 | `~/.zcode/zcode-token-usage-statusbar/`(运行文件、配置、会话状态;凭据文件权限 `0600`);ZCode 客户端目录内的补丁与备份(`app.asar`);`~/.zcode/zusage3` 中的浮层设置。卸载插件不删除个人配置。 | +| 网络 | 本地统计绝不出本机(只读 SQLite)。套餐额度 chip 在**内存中**解密宿主持有的 OAuth 凭据,直连 BigModel / Z.ai 用量端点展示套餐额度;凭据绝不写入配置、日志或诊断。插件更新会从已配置来源下载代码。可选的 SSH/WSL 远端查询走你自行配置的连接通道;插件不解密复用宿主密码或私钥口令,也不自动部署。Git chip 只在本机只读执行 `git` 查询(不 fetch、不联网)。 | +| 执行的命令 | 随插件分发的 Python 脚本;`git`(只读状态查询);仅对你配置过的远端查询使用 `ssh`/`wsl.exe`。 | +| 第三方代码 | 不捆绑任何第三方代码——纯 Python 3.8 标准库 + 原生 JavaScript(Electron 主进程 CommonJS)。MIT 许可证;源码在 [ByteRax/zcode-token-usage-statusbar](https://github.com/ByteRax/zcode-token-usage-statusbar)。 | + +SSH/WSL 远端会话需要在每台远端手工部署同版本 `zusage.py`;远端、额度源与多窗口的完整说明见[用户指南](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/docs/user-guide.md)。 + +## 关闭或移除 + +- `/uninstall-overlay`——移除状态条,保留卸载豁免标记,保留个人配置。 +- `"plugin_auto_install_overlay": false`——只保留查询工具。 +- `"answer_rate_line": false`——关闭逐问速率行(下一问生效)。 +- `"plugin_auto_update": false`——关闭更新检查。 +- 彻底移除:先关自动更新,执行 `/uninstall-overlay`,再在 ZCode 设置里卸载插件并完全重启。**只卸载插件不会移除状态条。** + +## 配置 + +默认运行配置在 `~/.zcode/zcode-token-usage-statusbar/config.json`——只合并需要修改的字段。除上述开关外还覆盖远端目标、额度源与显示选项;见 [`config.example.json`](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/config.example.json) 与[用户指南](https://github.com/ByteRax/zcode-token-usage-statusbar/blob/main/docs/user-guide.md)。 + +## 源码与许可证 + +MIT — [ByteRax/zcode-token-usage-statusbar](https://github.com/ByteRax/zcode-token-usage-statusbar)。欢迎中英文 issue 与 PR。 diff --git a/external_plugins/zcode-token-usage-statusbar/commands/install-overlay.md b/external_plugins/zcode-token-usage-statusbar/commands/install-overlay.md new file mode 100644 index 0000000..02fdb8f --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/commands/install-overlay.md @@ -0,0 +1,29 @@ +--- +description: 自动安装失败时手动安装/更新本机 ZCode 悬浮用量状态条(修改 app.asar,完全退出并重开生效) +--- +帮助用户在默认自动安装/修复失败时,手动把 zcode-token-usage-statusbar 的悬浮状态条安装到本机 ZCode 桌面客户端;此命令是兜底,不是默认必经步骤。 + +执行:用 Bash 运行安装器(插件根目录 = 本命令文件所在目录的上一级): + + python3 "<插件根目录>/overlay_install.py" install + +结果处理(按以下优先级,不能仅凭退出码为 0 判断需要重启): + +- 失败、待收尾或输出安装/更新尚未完成、记录无法确认的提醒:优先转述实际输出,不得宣称安装成功或仅重启即可修复。 +- 输出插件重启提醒:优先于「已安装,注入行已指向当前目录」。在最终回复正文的第一行,用引用块原样加粗安装器的实际提醒(不要使用 HTML 颜色标签;状态条加载后会将这条提示正文显示为红色)。通常为: + + > **🔴 zcode-token-usage-statusbar 插件提醒:用量状态条已安装或更新。请完全退出 ZCode(所有窗口):从 ZCode 菜单选择「退出」,然后重新打开 ZCode,让窗口底部的用量状态条加载新版本。只关窗口、刷新页面或重开会话无效。** + + 安装器可能弹出监控窗口,确认生效后会自动关闭,属正常现象。 +- 仅输出「已安装,注入行已指向当前目录」,且没有待加载/待修复提醒:只表示无需重新注入;此时才可回复无需操作。若安装器已确认加载且无提醒,也不要额外要求重启。 +- 失败:把安装器输出原样转述。权限不足(Linux /opt 等系统位置)时按提示建议 `sudo python install.py`(仓库一键安装器,会自动定位)。 +- 若输出「运行中替换失败 / 待收尾」:说明完全退出 ZCode 后重跑同一条命令,或等监控窗口自动收尾。 + +补充说明(用户问起时再展开,不必每次输出): + +- 卸载:`/uninstall-overlay`(留下卸载豁免,阻止自动重注入与安装提醒)。 +- 自动安装/修复默认开启:配置文件不存在或缺少 `plugin_auto_install_overlay` 字段时启用,已有的显式 `false` 会保留;环境变量 `ZUSAGE_PLUGIN_AUTO_INSTALL=1` / `0` 优先于配置,但不能绕过卸载豁免。未设置环境变量时,损坏配置或非布尔字段不开启。自动安装会修改 `app.asar`,成功后仍需完全退出并重开;失败时才使用本命令兜底,仅重启无法修复尚未成功安装的状态条。 +- 只需要查询与回答速率时,在 `~/.zcode/zcode-token-usage-statusbar/config.json` 中合并 `"plugin_auto_install_overlay": false`(保留其它设置),或执行卸载命令豁免。 +- 该安装只影响桌面客户端;SSH 远程会话、Web 版与本插件无关。 + +用户附加要求:$ARGUMENTS diff --git a/external_plugins/zcode-token-usage-statusbar/commands/uninstall-overlay.md b/external_plugins/zcode-token-usage-statusbar/commands/uninstall-overlay.md new file mode 100644 index 0000000..bd80a57 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/commands/uninstall-overlay.md @@ -0,0 +1,18 @@ +--- +description: 从本机 ZCode 桌面客户端卸载悬浮用量状态条(重启客户端生效,此后 hook 不再提示) +--- +从本机 ZCode 桌面客户端卸载 zcode-token-usage-statusbar 的悬浮状态条。 + +执行:用 Bash 运行(插件根目录 = 本命令文件所在目录的上一级): + + python3 "<插件根目录>/overlay_install.py" remove + +结果处理: + +- 成功(含"当前 asar 未注入"):告知用户「重启 ZCode 后浮层消失」。卸载会写豁免标记,SessionStart hook 此后不再提示、也不再自动安装。 +- 失败(替换失败待收尾):让用户完全退出 ZCode 后重跑同一条命令。 +- 若用户只是暂时不想看到提示(不想卸载浮层):改为创建豁免标记即可,不要执行 remove: + `mkdir -p ~/.zcode/zcode-token-usage-statusbar && touch ~/.zcode/zcode-token-usage-statusbar/.plugin-overlay-optout` + 说明:删除该文件可恢复提示。 + +用户附加要求:$ARGUMENTS diff --git a/external_plugins/zcode-token-usage-statusbar/commands/usage.md b/external_plugins/zcode-token-usage-statusbar/commands/usage.md new file mode 100644 index 0000000..2048a92 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/commands/usage.md @@ -0,0 +1,24 @@ +--- +description: 显示 ZCode token 用量(当前会话/今日/近几天/按会话/按工作区/指定会话明细) +argument-hint: [today|week|days:N|sessions|models|session:|workspace:<子串>] +--- +查询 ZCode token 用量,把返回的报表原样整理后回复;用户要详细表格时用完整数值(带千分位),不要自行缩写。 + +取数顺序: + +1. 优先调用 MCP 工具 `token_usage`(本插件 zcode-token-usage-statusbar 提供的 stdio server)。 +2. 若该工具不可用(未启用/未注册),退回用 Bash 直接跑 CLI,插件根目录即本命令文件所在目录的上一级: + `python3 "<插件根目录>/zusage.py" <子命令>` + +参数规则($ARGUMENTS),括号内为 CLI 子命令对照: + +- 为空或 "now" → scope=current(子命令 now) +- "today" → scope=today(子命令 today) +- "week" → scope=week;"近N天" → scope=days:N(N=天数;用户没给 N 就用 week)(子命令 days N) +- "sessions" → scope=sessions;"最近N个会话" → scope=sessions:N(子命令 sessions [N]) +- "models" → scope=models;"按模型近N天" → scope=models:N(子命令 models [days]) +- "session:<会话id前缀>" → 原样传入(返回该会话总量+按模型+子代理明细)(子命令 session <前缀>) +- 统计某个工作区/项目(如"XX 工作区/项目用了多少 token")→ scope=workspace:<目录名子串>。用户用中文或简称称呼工作区时,先从需求推断实际目录名的特征子串(缩写式称呼可直接传,工具支持单词首字母缩写匹配);未命中时工具会列出候选目录,照着换更准的子串重试(子命令 workspace <关键词>) +- 其他输入 → 原样作为 scope 传入 + +用户附加要求:$ARGUMENTS diff --git a/external_plugins/zcode-token-usage-statusbar/hooks/hooks.json b/external_plugins/zcode-token-usage-statusbar/hooks/hooks.json new file mode 100644 index 0000000..43b1657 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/hooks/hooks.json @@ -0,0 +1,32 @@ +{ + "description": "zcode-token-usage-statusbar: 会话启动时同步运行时文件;缺配置文件或缺 plugin_auto_install_overlay 字段时默认自动安装/修复悬浮状态条,会修改 app.asar,成功后需完全退出并重开;显式 false 保留,ZUSAGE_PLUGIN_AUTO_INSTALL=1/0 优先于配置,卸载豁免仍阻止重注入;无环境变量覆盖时损坏/非布尔配置不开启;自动安装失败时用 install-overlay 兜底;仅已注入且待加载时提醒重启,uninstall-overlay 可豁免提醒;每次提问记录时刻并注入上一轮速率背景行(回答末尾统计行由前端确定性渲染,依赖 renderer 桥,--current 守卫防止拿上一轮数据充数);hook 解释器按 python3→python→py -3 自动回退(Windows 无 python3 别名)", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "shell": true, + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.py\" || python \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.py\" || py -3 \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.py\"", + "timeoutMs": 60000, + "statusMessage": "zcode-token-usage-statusbar: 检查悬浮状态条…" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "shell": true, + "command": "python3 \"${ZCODE_PLUGIN_ROOT}/hooks/prompt-submit.py\" || python \"${ZCODE_PLUGIN_ROOT}/hooks/prompt-submit.py\" || py -3 \"${ZCODE_PLUGIN_ROOT}/hooks/prompt-submit.py\"", + "timeoutMs": 8000, + "statusMessage": "zcode-token-usage-statusbar: 记录提问时刻…" + } + ] + } + ] + } +} diff --git a/external_plugins/zcode-token-usage-statusbar/hooks/prompt-submit.py b/external_plugins/zcode-token-usage-statusbar/hooks/prompt-submit.py new file mode 100644 index 0000000..2a21138 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/hooks/prompt-submit.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""UserPromptSubmit hook:每次用户发消息时 +1) 记录"用户最后所处的会话"与提问时间戳到状态文件(--current 守卫依赖该时间戳) +2) 注入上一轮速率作为模型上下文(背景行) +速率统计行已改为确定性渲染(overlay.js 经 renderer 只读桥读取会话状态,完成的 turn +在消息流末尾由前端插入统计行),不再向模型下达"本问统计"指令——模型零配合、零额外 +工具调用,回复末尾也不再有模型贴的统计行。 +⚙ 面板开关:运行时 config.json 的 answer_rate_line(缺省=开启);关闭后每次提问 +仍记录时刻(守卫数据保持新鲜),且不再注入背景行——下一问即生效,无需重启。 +输出只包含 hook 协议字段,诊断不得混入 stdout。 +""" +import json +import os +import sys +import threading +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent + + +def read_stdin(timeout_s=2.0): + """消费 stdin(协议要求),设超时兜底:客户端不给 EOF 也不挂起。内容不使用。""" + result = {"raw": ""} + + def pump(): + try: + result["raw"] = sys.stdin.read() + except Exception: + pass + + t = threading.Thread(target=pump, daemon=True) + t.start() + t.join(timeout_s) + return result["raw"] + + +def emit(ctx): + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": ctx, + } + }, ensure_ascii=False)) + + +def rate_line_enabled(): + """⚙ 面板开关:运行时 config.json 的 answer_rate_line(缺省=开启)。 + + 运行时目录与 overlay_install.default_runtime() 同源,含 ZUSAGE_PLUGIN_RUNTIME 测试接缝; + 文件缺失/损坏一律视为开启(开关失效不吞功能)。""" + runtime = Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") + or Path.home() / ".zcode" / "zcode-token-usage-statusbar") + try: + cfg = json.loads((runtime / "config.json").read_text(encoding="utf-8")) + except Exception: + return True + return not (isinstance(cfg, dict) and cfg.get("answer_rate_line") is False) + + +def main(): + read_stdin() + enabled = rate_line_enabled() # 先判开关:关闭时不再开库查"上轮行"(白查还占锁) + sid = os.environ.get("ZCODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "" + try: + sys.path.insert(0, str(PLUGIN_ROOT)) + import token_rate + if sid: + token_rate.write_session_state(sid, "prompt-submit") + prev = None if not enabled else token_rate.format_line(token_rate.query_latest(sid or None)) + except Exception: + prev = None + try: + from restart_notice import reminder_context + notice = reminder_context() + except Exception: + notice = "" + try: + head = (notice + "\n") if notice else "" + if not enabled: + emit(head) # 关:只留重启提醒(若有),不注入背景行、不下达统计指令 + return + emit(head + "【内部背景·勿展示】上一条回复:" + (prev or "暂无已完成的模型请求")) + except Exception: + emit("") + + +if __name__ == "__main__": + # Windows 默认(无全局 PYTHONUTF8)下中文输出会按区域编码(GBK 等)写管道, + # 宿主按 UTF-8 解码 hook stdout 会得到乱码——与 usage_mcp.py 同款防护。 + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + main() diff --git a/external_plugins/zcode-token-usage-statusbar/hooks/session-start.py b/external_plugins/zcode-token-usage-statusbar/hooks/session-start.py new file mode 100644 index 0000000..7459b87 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/hooks/session-start.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""SessionStart hook:stdin 收 hook 入参 JSON,stdout 只输出 hook 协议 JSON。 + +职责(由 overlay_install.run_hook 承担,见其 docstring): + 同步运行时文件(热更新);已注入则按需提醒重启,未注入则按开关自动安装或每次提示安装。 +任何异常都转成合法 JSON 输出,绝不抛出、绝不影响会话。 +""" +import json +import os +import sys +import threading +import time +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent + + +def read_stdin(timeout_s=2.0): + """消费 stdin(协议要求),设超时兜底:客户端不给 EOF 也不挂起。内容不使用。""" + result = {"raw": ""} + + def pump(): + try: + result["raw"] = sys.stdin.read() + except Exception: + pass + + t = threading.Thread(target=pump, daemon=True) + t.start() + t.join(timeout_s) + return result["raw"] + + +def main(): + raw = read_stdin() + try: + payload = json.loads(raw or "{}") + except ValueError: + payload = {} + try: # 记录会话启动时刻到速率状态文件(token_rate --current 守卫的会话跟随依据) + sid = os.environ.get("ZCODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") + if sid: + sys.path.insert(0, str(PLUGIN_ROOT)) + import token_rate + token_rate.write_session_state(sid, "session-start") + except Exception: # noqa: BLE001 — 状态文件写不进去就跳过,不影响主职责 + pass + try: + sys.path.insert(0, str(PLUGIN_ROOT)) + import overlay_install + result = overlay_install.run_hook() + except Exception as e: # noqa: BLE001 — hook 绝不能抛 + message = f"zusage 插件自检失败(已忽略):{e}" + result = {"suppressOutput": True} + try: + runtime = Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or Path.home() / ".zcode/zcode-token-usage-statusbar") + (runtime / ".plugin-overlay-optout").stat() + opted_out = True + except FileNotFoundError: + opted_out = False + except (OSError, RuntimeError): + opted_out = True + if opted_out: + print("[zusage-plugin] " + message, file=sys.stderr) + else: + result.update(systemMessage=message, hookSpecificOutput={ + "hookEventName": "SessionStart", "additionalContext": message}) + try: + runtime = Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or Path.home() / ".zcode/zcode-token-usage-statusbar") + record = {"time": time.time(), "pluginRoot": str(PLUGIN_ROOT), + "source": payload.get("source", "") if isinstance(payload, dict) else "", + "sessionId": os.environ.get("ZCODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "", + "result": result} + overlay_install._atomic_write(runtime / "plugin-session-start.json", + json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8")) + except Exception as e: + print(f"[zusage-plugin] 会话启动诊断写入失败:{e}", file=sys.stderr) + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == "__main__": + # Windows 默认(无全局 PYTHONUTF8)下中文输出会按区域编码(GBK 等)写管道, + # 宿主按 UTF-8 解码 hook stdout 会得到乱码——与 usage_mcp.py 同款防护。 + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + main() diff --git a/external_plugins/zcode-token-usage-statusbar/inject-main.cjs b/external_plugins/zcode-token-usage-statusbar/inject-main.cjs new file mode 100644 index 0000000..5c2da3f --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/inject-main.cjs @@ -0,0 +1,1258 @@ +/* ZCode token 用量注入 loader v2(主进程)。 + * 由 patch_install.py 在 app.asar 入口尾部追加一行 import 引入。 + * 职责:1) 向每个窗口注入 turn-stats.js + overlay.js(前者先注入:renderer 会话 + * store 的只读投影统计,overlay 读 globalThis.ZUsageTurnStats;mtime 热更新); + * 2) 触发式拉取:fs.watch 监听 db 目录,db.sqlite/-wal 一有写入(去抖 300ms)就 + * 查询并推送。限频 config.activity_min_ms(默认 1500ms),限频到点 + * 自动补拉,最后一笔写入不漏;完全空闲时零进程零轮询,仅兜底心跳 + * (config.heartbeat_ms,默认 30s)防文件事件丢失(Node 官方文档:fs.watch + * 事件不保证送达)。任何失败只打日志,不影响客户端。 + * 3) 每窗口当前会话(v6):渲染端 workspace shell 把焦点会话 id 经 IPC + * "zcode:sync-active-task-session" 推给主进程(客户端自带通道,实测见 app bundle), + * 这里追加监听记录 webContents.id → 会话 id:查询时把全部窗口的会话强制纳入快照, + * 推送时给每个窗口注入 mine 字段 —— 多窗口各自显示自己的会话,不再猜"全局最近活跃"。 * + * v8:查询改常驻 python(zusage.py serve,stdin/stdout 行协议)——省每次 ~60-100ms 的 + * 解释器启动费;意外退出自动重启(30s 内连续 3 次判不稳定则回退一次性 spawn); + * zusage.py mtime 变化自动重启(升级生效);stdin EOF = 泵退出,常驻进程自清理。 */ +"use strict"; +const path = require("path"); +const fs = require("fs"); +const { spawn, execFile } = require("child_process"); +const { createHash } = require("crypto"); +const { app, BrowserWindow, webContents } = require("electron"); +const { fileURLToPath } = require("url"); + +const HERE = __dirname; // 本 loader 被 asar 注入行按绝对 file:// URL import,__dirname 即真实目录(克隆到任意路径无需改代码) +const CONFIG = path.join(HERE, "config.json"); +const OVERLAY = path.join(HERE, "overlay.js"); +const TURN_STATS = path.join(HERE, "turn-stats.js"); +const ZUSAGE = path.join(HERE, "zusage.py"); +const TOKEN_RATE = path.join(HERE, "token_rate.py"); +const LOG = (...a) => console.error("[zusage]", ...a); +const loadedHashes = { _loadedAt: Date.now() - process.uptime() * 1000 }; +for (const name of ["inject-main.cjs", "plugin-updater.cjs", "plugin-release.cjs", "plugin-storage.cjs", "quota-client.cjs", "overlay.js", "zusage.py"]) { + try { loadedHashes[name] = createHash("sha256").update(fs.readFileSync(path.join(HERE, name))).digest("hex"); } + catch (e) { LOG("startup fingerprint unavailable:", name, e.message); } +} +function acknowledgeRestart() { + const helper = path.join(HERE, "runtime_lock.py"); + if (!fs.existsSync(helper)) return; + execFile(readCfg().python_path || (process.platform === "win32" ? "python" : "python3"), + [helper, "--ack-restart", HERE, JSON.stringify(loadedHashes)], + { windowsHide: true, timeout: 15000, maxBuffer: 65536 }, + (error) => { if (error) LOG("restart acknowledgement failed:", error.message); }); +} + +function readCfg() { + const c = { resident: true }; + try { Object.assign(c, JSON.parse(fs.readFileSync(CONFIG, "utf8"))); } catch (e) { } + /* 缺省/空值统一按平台定解释器:Windows 无 python3,macOS/Linux 未必有 python; + 三个 spawn 点直接读 python_path,归一只能在这里做 */ + c.python_path = c.python_path || (process.platform === "win32" ? "python" : "python3"); + return c; +} + +/* config.json 内容缓存:热更新定时器每 2s 触发,改为 stat mtime 判变化后才读文件 */ +let cfgMtime = 0; +let cfgCache = null; +function readCfgCached() { + let m = 0; + try { m = fs.statSync(CONFIG).mtimeMs; } catch (e) { } + if (!cfgCache || m !== cfgMtime) { + cfgMtime = m; + cfgCache = readCfg(); + } + return cfgCache; +} + +/* 页面注入源 + 热更新:mtime 变了就重注入(移除旧条+复位守卫)。 + * 载入前用 new Function 校验语法,避免把写到一半的文件载入页面。 + * turn-stats.js(renderer 会话 store 的只读投影统计)必须先于 overlay.js 注入: + * overlay 启动时读 globalThis.ZUsageTurnStats。两资产任一 mtime 变化即整体重注入; + * 都不变时零动作(overlay 无需重建)。 */ +function loadValidatedAsset(file) { + try { + const m = fs.statSync(file).mtimeMs; + const src = fs.readFileSync(file, "utf8"); + new Function(src); + return { mtime: m, src: src }; + } catch (e) { return null; } +} +let overlaySrc = ""; +let overlayMtime = 0; +let turnStatsSrc = ""; +let turnStatsMtime = 0; +{ + const ov = loadValidatedAsset(OVERLAY); + if (ov) { overlaySrc = ov.src; overlayMtime = ov.mtime; } + else LOG("overlay.js missing"); + const ts = loadValidatedAsset(TURN_STATS); + if (ts) { turnStatsSrc = ts.src; turnStatsMtime = ts.mtime; } + else LOG("turn-stats.js missing (live/deterministic stats disabled)"); +} + +function hotReloadIfChanged() { + let changed = false; + for (const asset of [{ file: TURN_STATS, srcKey: "turnStats" }, { file: OVERLAY, srcKey: "overlay" }]) { + let m = 0; + try { m = fs.statSync(asset.file).mtimeMs; } catch (e) { continue; } + const known = asset.srcKey === "turnStats" ? turnStatsMtime : overlayMtime; + if (m === known) continue; // mtime 未变:不读文件(每 2s 心跳零读盘) + const loaded = loadValidatedAsset(asset.file); + if (!loaded) continue; // 语法没写完:保留旧 mtime,下轮重试 + if (asset.srcKey === "turnStats") { turnStatsMtime = loaded.mtime; turnStatsSrc = loaded.src; } + else { overlayMtime = loaded.mtime; overlaySrc = loaded.src; } + changed = true; + } + if (!changed) return; + LOG("overlay assets changed, hot-reloading"); + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed()) continue; + injectOverlay(wc, true); + } +} + +function isMainWindowState(wc) { + try { return wc.getType() === "window"; } catch (e) { return false; } +} + +function pushOnce(wc, payload) { + if (!isMainWindowState(wc) || wc.isDestroyed() || !payload || typeof payload !== "object") return; + /* 按窗口注入 mine(本窗口焦点会话 id,可为空串 = 该窗口当前无会话): + * overlay 收到 mine 字段就优先显示它,不再多窗口共享同一个猜测。 + * 必须对象合并后序列化,禁止对 JSON 字符串做括号手术——v34 首版 slice 掐尾少补一个 + * 右括号,生成语法错误代码且失败只进静默 catch,全部窗口停在初始零值(实测翻车)。 */ + const obj = Object.assign({}, payload, { + ...(activeSid.has(wc.id) ? { mine: activeSid.get(wc.id) || "" } : {}), + scope_bindings: scopeBindings.get(wc.id) || {}, + /* 未收集过按“可能有远端”处理(保守 fail-closed);收集后无任何远端证据才是 false。 */ + remote_evidence: remoteEvidence.get(wc.id) !== false, + }); + /* U+2028/2029 转义:JSON.stringify 会输出裸字符,放进 executeJavaScript 字符串字面量 + * 有语法风险(zusage.py 侧 ensure_ascii 的同款考量;会话标题可能含任意文本) */ + const js = `window.__zusageUpdate && window.__zusageUpdate(${JSON.stringify(obj).replace(/[\u2028\u2029]/g, (c) => c === "\u2028" ? "\\u2028" : "\\u2029")})`; + wc.executeJavaScript(js, true).catch(() => { }); +} + +/* 每窗口焦点会话:客户端渲染端把 activeTaskId 经此通道同步给主进程(CUA PiP 所用通道), + * 追加监听器不影响原有处理。值为 "" = 窗口已同步但当前无会话(新空会话/非任务页); + * 不在表里 = 该窗口从未同步(辅助窗口),推送时不带 mine,overlay 走本地键启发式。 */ +const IPC_ACTIVE_SID = "zcode:sync-active-task-session"; +const activeSid = new Map(); // webContents.id → 会话 id 或 "" +const windowWorkspaces = new Map(); +const windowTasks = new Map(); +const scopeBindings = new Map(); +const windowQuotaSelections = new Map(); // webContents.id → {paneId: {sid, providerId, modelId}} +/* 本窗是否出现过任何远端工作区/任务证据:issue #40 的无桥本地兜底只在确认 + * “本窗只能查本地”时启用,有远端证据时 overlay 仍 fail-closed。 */ +const remoteEvidence = new Map(); +/* 可信远端身份白名单:ssh(3.15.x)/ wsl、docker(直接传输)/ server(v3.16 起所有传输 + * 连接握手后统一升级为 remote:server::,remote:wsl: 等只留作 legacy)。 + * 前缀只决定“是否远端”,路由键是整串身份;未知前缀仍 fail-closed 按本地处理。 */ +const REMOTE_IDENTITY_RE = /^remote:(?:ssh|wsl|docker|server):/; +function trustedSender(e) { + try { + if (!e || !e.sender || e.sender.isDestroyed() || !isMainWindowState(e.sender)) return false; + const url = e.sender.getURL(); + if (!url.startsWith("file:")) return false; + const relative = path.relative(process.resourcesPath, fileURLToPath(url)); + return !relative.startsWith("..") && !path.isAbsolute(relative); + } catch (e) { return false; } +} +try { + const { ipcMain } = require("electron"); + // 只旁听宿主公开的单向同步,不读取凭据或 Electron 私有 handler 状态。 + ipcMain.on("zcode:sync-web-remote-control-workspaces", (e, rows) => { + if (!trustedSender(e) || !Array.isArray(rows)) return; + const normalized = rows.filter((r) => r && typeof r.workspacePath === "string") + .map((r) => ({ kind: r.kind, identity: typeof r.workspaceIdentity === "string" ? r.workspaceIdentity.trim() : "" })); + windowWorkspaces.set(e.sender.id, normalized); + /* 远端证据立即生效:不等下一轮 wants 收集,缩小“查询在途时推送旧 false”的窗口。 */ + if (normalized.some((r) => r.kind === "remote" && REMOTE_IDENTITY_RE.test(r.identity))) { + remoteEvidence.set(e.sender.id, true); + } + scheduleRetry(300); + }); + ipcMain.on("zcode:sync-web-remote-control-tasks", (e, rows) => { + if (!trustedSender(e) || !Array.isArray(rows)) return; + const tasks = new Map(); + for (const r of rows) { + if (!r || typeof r.taskId !== "string") continue; + const identity = r.workspaceKind === "remote" && typeof r.workspaceIdentity === "string" ? r.workspaceIdentity.trim() : ""; + if (!tasks.has(r.taskId)) tasks.set(r.taskId, new Set()); + tasks.get(r.taskId).add(identity); + } + windowTasks.set(e.sender.id, tasks); + let remoteTask = false; + for (const scopes of tasks.values()) { + if ([...scopes].some((id) => REMOTE_IDENTITY_RE.test(id))) { remoteTask = true; break; } + } + if (remoteTask) remoteEvidence.set(e.sender.id, true); + scheduleRetry(300); + }); + ipcMain.on(IPC_ACTIVE_SID, (e, sid) => { + if (!e || !e.sender || e.sender.isDestroyed()) return; + const s = typeof sid === "string" ? sid.trim() : ""; + const val = s && s.length <= 255 && /^[A-Za-z0-9_-]+$/.test(s) ? s : ""; + const prev = activeSid.get(e.sender.id); + activeSid.set(e.sender.id, val); + if (prev !== val) scheduleRetry(800); // 会话切换/窗口就绪:尽快按新会话拉一次(限频器仍生效) + }); +} catch (e) { LOG("ipc hook failed:", e.message); } + +/* db 活动戳:db.sqlite / -wal / -shm 的最新 mtime。WAL 模式下写入主要落在 -wal。 */ +let DB_DIR = null; +function dbDir() { + if (!DB_DIR) DB_DIR = path.join(app.getPath("home"), ".zcode", "cli", "db"); + return DB_DIR; +} +function dbStamp() { + let m = 0; + const dir = dbDir(); + for (const f of ["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"]) { + try { m = Math.max(m, fs.statSync(path.join(dir, f)).mtimeMs); } catch (e) { } + } + return m; +} + +/* 远端状态按 (sid, workspaceIdentity) 隔离;显式可信 scope 首查失败也续查。 + * 请求直接携带 identity,不依赖上一份 payload 的临时目标序号。 + * 最后成功 remote 标记保留 10 分钟,pending 保证失败后持续恢复。 */ +const REMOTE_STICKY_MS = 10 * 60 * 1000; +const remoteSids = new Map(); // sid → lastSeen ms +const pendingSids = new Map(); // sid → next attempt ms +let targetByIdentity = new Map(); // workspaceIdentity → 目标键(最近一次 payload) +function isRemoteSid(s) { + const t = remoteSids.get(s); + if (t === undefined) return false; + if (Date.now() - t > REMOTE_STICKY_MS) { remoteSids.delete(s); return false; } + return true; +} +function remotePollMs() { + const r = readCfgCached().remote; + return Math.max(1000, (r && r.poll_ms | 0) || 3000); +} + +let busy = false; +let pushCount = 0; +let lastSpawn = 0; // 上次发起查询时刻(activity_min_ms 限频用) +let lastStamp = 0; // 上次拉取时的 db mtime(去重:无新写入不拉) +let lastWants = ""; // 上次查询用的会话 id 集合(变化即拉,防"会话切了但 db 没写入"滞留旧数据) +let lastRequest = { sids: [], scopes: [] }; +let lastSourceStamp = ""; +function scopeKey(sid, identity) { return JSON.stringify([sid, identity || ""]); } +function requestKeys(request) { + return request.sids.map((sid) => scopeKey(sid, "")).concat(request.scopes.map((s) => scopeKey(s.sid, s.workspaceIdentity))); +} +function sourceStamp() { + const home = (process.env && process.env.ZCODE_DESKTOP_HOME_DIR) || app.getPath("home"); + const files = [CONFIG, path.join(home, ".zcode", "v2", "setting.json")]; + return files.map((file) => { try { const s = fs.statSync(file); return `${s.mtimeMs}:${s.size}`; } catch (e) { return "missing"; } }).join("|"); +} +let lastHadRemote = false; + +function remoteDisabled() { + const remote = readCfgCached().remote || {}; + return remote.auto === false && remote.enabled === false; +} +/* v96 额度开关真值:config.json 的 quota_enabled(缺省关)。只认显式 true——读凭据联网 + * 属能力升级,缺字段/异常值一律按关闭处理,主进程不解密、不发请求。 */ +function quotaEnabled() { return readCfgCached().quota_enabled === true; } +function clearRemoteRecovery() { + remoteSids.clear(); + pendingSids.clear(); + lastHadRemote = false; + clearTimeout(retryTimer); + retryTimer = 0; +} +function syncQuotaProviders() { + if (shuttingDown || !quotaClient || !quotaEnabled()) return; + const providers = new Set(); + let hasSelections = false, hasWindow = false; + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed()) continue; + hasWindow = true; + if (windowQuotaSelections.has(wc.id)) hasSelections = true; + for (const sel of Object.values(windowQuotaSelections.get(wc.id) || {})) { + if (sel.providerId) providers.add(sel.providerId); + } + } + if (hasWindow && !hasSelections && lastGoodPayload) { // 旧渲染器未上报时,按各会话最近完成请求兼容 + for (const row of [lastGoodPayload.session, ...(lastGoodPayload.recent || [])]) { + const id = row && row.last && row.last.provider_id; + if (id) providers.add(id); + } + } + quotaClient.setProviders(Array.from(providers)); + if (hasWindow) quotaClient.tick(); +} +function handleQuotaProviders(wc, req) { + const raw = req.panes; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; + const entries = Object.entries(raw); + if (entries.length > 4) return; + const panes = Object.create(null); + const valid = (v, max) => typeof v === "string" && v.length <= max && !/[\x00-\x1f\x7f]/.test(v); + for (const [pid, sel] of entries) { + if (!/^[A-Za-z0-9_-]{1,96}$/.test(pid) || !sel || typeof sel !== "object" || + !valid(sel.sid, 80) || !valid(sel.providerId, 160) || !valid(sel.modelId, 160) || + (sel.sid && !/^[A-Za-z0-9_-]+$/.test(sel.sid))) return; + panes[pid] = { sid: sel.sid, providerId: sel.providerId, modelId: sel.modelId }; + } + windowQuotaSelections.set(wc.id, panes); // 全量替换:关 pane/导航后旧选择不会残留 + syncQuotaProviders(); + if (lastGoodPayload && quotaClient && quotaEnabled()) { + const payload = Object.assign({}, lastGoodPayload, { quota: quotaClient.view() }); + for (const target of webContents.getAllWebContents()) pushOnce(target, payload); + } +} +function handlePayload(payload) { + const disabled = remoteDisabled(); + if (disabled) clearRemoteRecovery(); + // serve 的 error 是查询失败,不是空快照;不可清掉重试状态和最后成功数据显示。 + if (!payload || typeof payload !== "object" || payload.error) return; + lastGoodPayload = payload; + if (quotaClient && quotaEnabled()) { + if (payload.scnet_estimate !== undefined) quotaClient.noteSnapshotExtras({ scnet: payload.scnet_estimate }); + syncQuotaProviders(); + payload = Object.assign({}, payload, { quota: quotaClient.view() }); + } + const now = Date.now(); + const marks = new Set(); + for (const r of (payload.recent || [])) if (r && r.remote && r.sid && !r.remote_error && !r.remote_missing) { + const key = scopeKey(r.sid, r.workspace_identity); + marks.add(key); + remoteSids.set(key, now); + } + const idents = new Map(); + for (const t of (payload.remote_targets || [])) { + if (!t || !t.key) continue; + for (const id of (t.identities || [])) if (id) idents.set(String(id), String(t.key)); + } + targetByIdentity = idents; + const notLocal = new Set(Array.isArray(payload.not_local) ? payload.not_local : []); + const poll = remotePollMs(); + const keys = requestKeys(lastRequest); + for (const key of keys) { + const [sid, identity] = JSON.parse(key); + if (marks.has(key)) { pendingSids.delete(key); continue; } + const remote = readCfgCached().remote || {}; + if (identity && !(remote.auto === false && remote.enabled === false) || (notLocal.has(sid) && payload.remote_enabled && remote.enabled === true)) pendingSids.set(key, now + poll); + else { pendingSids.delete(key); remoteSids.delete(key); } + } + for (const key of pendingSids.keys()) if (!keys.includes(key)) pendingSids.delete(key); + if (disabled) clearRemoteRecovery(); // 关闭期间返回的迟到成功快照也不能重建粘性标记。 + else lastHadRemote = keys.some((key) => isRemoteSid(key) || pendingSids.has(key)); + for (const wc of webContents.getAllWebContents()) pushOnce(wc, payload); + // 定位诊断:每 ~15 次拉取收集每个窗口各自的 __zusageDiag,附加泵侧证据后写 diag-.json(多窗口互不覆盖) + if (++pushCount % 15 === 1) { + let idx = 0; + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed()) continue; + const file = path.join(HERE, "diag-" + idx + ".json"); + idx++; + wc.executeJavaScript("JSON.stringify(window.__zusageDiag||null)", true) + .then((s) => { + if (s && s !== "null") { + try { + const obj = JSON.parse(s); + obj.pump = { + version: 10, + resident: !!pyRes, + activeMap: Array.from(activeSid.entries()).slice(0, 12), + wants: lastWants, + }; + fs.writeFileSync(file, JSON.stringify(obj)); + } catch (e2) { + try { fs.writeFileSync(file, s); } catch (e) { } + } + } + }) + .catch(() => { }); + } + } +} + +/* ---------- 常驻查询进程(v8):zusage.py serve,stdin 请求行 / stdout 响应行 ---------- */ +let pyRes = null; // { proc, buf, waiters: [fn], born } +let pyFail = 0; // 启动 30s 内退出次数(>=3 判不稳定,回退一次性 spawn) +let pyBackoff = 1000; +let residentRestartTimer = 0, residentIdleTimer = 0; +let shuttingDown = false; +function hasWindows() { + return webContents.getAllWebContents().some((wc) => isMainWindowState(wc) && !wc.isDestroyed()); +} +function residentAllowed() { + return !shuttingDown && readCfgCached().resident !== false && pyFail < 3 && hasWindows(); +} +function stopResident(restart = false) { + clearTimeout(residentRestartTimer); + clearTimeout(residentIdleTimer); + const state = pyRes; + if (!state) return; + state.intentional = true; + state.restart = restart; + try { state.proc.kill(); } catch (e) { } + state.finish(); +} +function armResidentIdle() { + clearTimeout(residentIdleTimer); + if (pyRes) residentIdleTimer = setTimeout(() => stopResident(), 30000); +} +function startResident() { + if (pyRes || !residentAllowed()) return; + let proc; + try { proc = spawn(readCfgCached().python_path, [ZUSAGE, "serve"], { windowsHide: true }); } + catch (e) { LOG("resident spawn failed:", e.message); pyFail++; return; } + const state = { proc, buf: "", waiters: [], born: Date.now(), finished: false, intentional: false, restart: false }; + pyRes = state; + LOG("resident python starting"); + state.finish = () => { + if (state.finished) return; + state.finished = true; + if (pyRes === state) pyRes = null; + clearTimeout(residentIdleTimer); + const pending = state.waiters; + state.waiters = []; + pending.forEach((w) => w(null)); + if (state.intentional) { + if (state.restart && residentAllowed()) residentRestartTimer = setTimeout(startResident, 0); + return; + } + if (Date.now() - state.born < 30000) { + pyFail++; + if (pyFail >= 3) { LOG("resident unstable (3 early exits), fallback to one-shot spawn"); return; } + if (residentAllowed()) residentRestartTimer = setTimeout(startResident, pyBackoff); + pyBackoff = Math.min(pyBackoff * 2, 30000); + } else { + pyFail = 0; + pyBackoff = 1000; + if (residentAllowed()) residentRestartTimer = setTimeout(startResident, 0); + } + }; + const fail = (e) => { + LOG("resident failed:", e.message); + try { proc.kill(); } catch (e2) { } + state.finish(); + }; + proc.on("error", fail); + proc.stdin.on("error", fail); + proc.on("close", state.finish); + proc.stdout.on("data", (d) => { + if (state.finished) return; + state.buf += d.toString("utf8"); + let i; + while ((i = state.buf.indexOf("\n")) >= 0) { + const line = state.buf.slice(0, i); + state.buf = state.buf.slice(i + 1); + const w = state.waiters.shift(); + if (!w) continue; + let payload = null; + try { payload = JSON.parse(line); } catch (e) { } + w(payload); + } + }); + proc.stderr.on("data", () => { }); + armResidentIdle(); + /* 进程级错误处理在 startResident 体内统一走 fail/close(state.finish 带退避重启与 + * intentional 标记),main 侧旧版分段处理不再需要 */ +} +function killResidentForUpgrade() { stopResident(true); } + +/* 统一查询入口:resident 可用走 JSON 行协议(scopes 路由请求),否则一次性 spawn。 + * 每笔请求只结算一次;error 载荷转 pendingSids 由远端重试链兜底。 */ +function runQuery(request) { + busy = true; + let settled = false; + const done = (payload) => { + if (settled) return; + settled = true; + busy = false; + const keys = requestKeys(request); + if (payload && !payload.error) { + if (!shuttingDown && hasWindows()) handlePayload(payload); + } else if (remoteDisabled()) { + clearRemoteRecovery(); + } else { + for (const key of keys) pendingSids.set(key, Date.now() + remotePollMs()); + lastHadRemote = keys.length > 0; + } + if (!shuttingDown && lastHadRemote && hasWindows()) scheduleRetry(remotePollMs()); + armResidentIdle(); + }; + const line = JSON.stringify(request); + if (residentAllowed()) { + clearTimeout(residentRestartTimer); + if (!pyRes) startResident(); + if (pyRes && pyRes.proc.stdin.writable) { + clearTimeout(residentIdleTimer); + const state = pyRes; + const waiter = (payload) => { clearTimeout(timer); done(payload); }; + const timer = setTimeout(() => { + LOG("resident query timeout, restarting it"); + stopResident(true); + }, 15000); + state.waiters.push(waiter); + try { + state.proc.stdin.write(line + "\n", (error) => { + if (error && !state.finished) { + LOG("resident write failed:", error.message); + try { state.proc.kill(); } catch (e) { } + state.finish(); + } + }); + } catch (e) { + LOG("resident write failed:", e.message); + try { state.proc.kill(); } catch (e2) { } + state.finish(); + } + return; + } + } + legacySpawn(line, done); +} + +/* 回退路径:沿用同款 JSON scope 请求(resident=false 不丢隔离),error/close/timeout 共用单次结算。 */ +function legacySpawn(line, done) { + const args = [ZUSAGE, "json"]; + if (line) args.push(line); + let py; + try { py = spawn(readCfgCached().python_path, args, { windowsHide: true }); } + catch (e) { LOG("spawn failed:", e.message); done(null); return; } + let out = "", finished = false; + const finish = (payload) => { + if (finished) return; + finished = true; + clearTimeout(killTimer); + done(payload); + }; + const killTimer = setTimeout(() => { + LOG("query timeout, killing"); + try { py.kill(); } catch (e) { } + finish(null); + }, 15000); + py.stdout.on("data", (d) => { out += d; }); + py.stderr.on("data", () => { }); + py.on("error", (e) => { LOG("spawn failed:", e.message); finish(null); }); + py.on("close", () => { + let payload = null; + try { payload = JSON.parse(out); } catch (e) { } + finish(payload); + }); +} + +/* 触发式拉取:db 有新写入(mtime 变化)才 spawn 查询;限频不满足时安排补拉, + * 保证限频窗口结束后最后一笔写入的数据一定被取到。 */ +let retryTimer = 0; +function scheduleRetry(ms) { + clearTimeout(retryTimer); + retryTimer = setTimeout(maybeSpawn, ms); +} +let collectingWants = false; +function maybeSpawn() { + if (shuttingDown) return; + if (!hasWindows()) { + clearTimeout(retryTimer); + lastHadRemote = false; + stopResident(); + activeSid.clear(); + lastWants = ""; + lastStamp = 0; + return; + } + if (busy || collectingWants) { scheduleRetry(300); return; } + const st = dbStamp(); + const cfg = readCfgCached(); + if (remoteDisabled()) clearRemoteRecovery(); + const wait = Math.max(300, cfg.activity_min_ms | 0 || 1500); + /* 先取各窗口的"当前会话 id":主进程 IPC 映射(应用自己上报的焦点会话,v6)优先, + * overlay 从 localStorage 键读出的 __zusageWantSid 兜底;无窗口时直接短路(空闲零进程的 + * 设计意图);executeJavaScript 挂 1s 超时,防渲染进程卡死导致泵永不 settle。 + * v8:全部窗口都已在 IPC 映射表里(含"已同步但无会话"的空串)时跳过整轮 eval —— + * mine 模式下 WantSid 已非主数据源,省掉活跃期每 1.5s × N 窗口的页面求值。 */ + const wcs = webContents.getAllWebContents().filter((wc) => isMainWindowState(wc) && !wc.isDestroyed()); + if (!wcs.length) return; + const liveIds = new Set(wcs.map((wc) => wc.id)); + for (const id of activeSid.keys()) if (!liveIds.has(id)) activeSid.delete(id); + for (const id of remoteEvidence.keys()) if (!liveIds.has(id)) remoteEvidence.delete(id); + const withTimeout = (p, ms) => { + let timer; + return Promise.race([p, new Promise((r) => { timer = setTimeout(() => r(""), ms); })]) + .finally(() => clearTimeout(timer)); + }; + /* 各窗口的 pane 会话 id(v10,__zusageWantSids 数组,分屏多实例状态条用)+ 旧单值兜底; + * v12 附带 __zusageSidWs(sid→workspaceIdentity)与 __zusagePaneScopes(pane 绑定, + * 多服务器路由)。 */ + collectingWants = true; + const readWantsJs = '(function(){var a=window.__zusageWantSids;if(!a||!a.length){a=window.__zusageWantSid?[window.__zusageWantSid]:[]}return JSON.stringify({s:a,w:window.__zusageSidWs||{},p:window.__zusagePaneScopes||{}})})()'; + /* Promise.resolve().then 包裹:renderer 同步销毁时 executeJavaScript 直接 throw, + * 转成 rejected promise 交给 catch 吞掉,别把整轮收集锁死 */ + Promise.all(wcs.map((wc) => withTimeout(Promise.resolve().then(() => wc.executeJavaScript(readWantsJs, true)).catch(() => ""), 1000))) + .then((vals) => { + if (shuttingDown || !hasWindows()) { stopResident(); return; } + /* 汇总全部窗口的全部 pane 会话(去重):request.sids 无 identity、 + * request.scopes 带 workspaceIdentity(远端路由键);add 的去重按 (sid, identity) 对。 */ + const request = { sids: [], scopes: [] }; + const add = (sid, identity) => { + if (identity) { + if (!request.scopes.some((s) => s.sid === sid && s.workspaceIdentity === identity)) request.scopes.push({ sid, workspaceIdentity: identity }); + } else if (!request.sids.includes(sid)) request.sids.push(sid); + }; + wcs.forEach((wc, i) => { + let parsed = {}; + try { parsed = JSON.parse(vals[i] || "{}"); } catch (e) { } + const wants = Array.isArray(parsed.s) ? parsed.s.slice() : []; + if (activeSid.get(wc.id)) wants.push(activeSid.get(wc.id)); + const workspaces = windowWorkspaces.get(wc.id) || []; + const trusted = new Set(workspaces.filter((w) => w.kind === "remote" && REMOTE_IDENTITY_RE.test(w.identity)).map((w) => w.identity)); + const tasks = windowTasks.get(wc.id); + const bindings = {}; + for (const [pid, pane] of Object.entries(parsed.p || {})) { + if (!pane || !wants.includes(pane.sid) || typeof pane.sid !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(pane.sid)) continue; + bindings[pid] = ""; // 本地/未知 pane 显式隔离,不能从同 sid 远端绑定继承。 + if (pane.identity === "" || (pid === "workspace-main" && !Object.prototype.hasOwnProperty.call(pane, "identity"))) { + // 工作区同步可能只含 connected remotes,不能证明主 pane 当前属于远端。 + add(pane.sid, ""); + continue; + } + if (!trusted.has(pane.identity)) continue; + const taskScopes = tasks && tasks.get(pane.sid); + if (taskScopes && !taskScopes.has(pane.identity)) continue; + bindings[pid] = pane.identity; + add(pane.sid, pane.identity); + } + for (const sid of wants) { + if (typeof sid !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(sid)) continue; + const raw = parsed.w && parsed.w[sid]; + const candidates = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).filter((id) => trusted.has(id)); + const taskScopes = tasks && tasks.get(sid); + const eligible = [...new Set(candidates)].filter((id) => !taskScopes || taskScopes.has(id)); + const primary = parsed.p && parsed.p["workspace-main"]; + if (primary && primary.sid === sid && !Object.prototype.hasOwnProperty.call(primary, "identity")) continue; + if (eligible.length === 1) { + bindings[sid] = eligible[0]; + add(sid, eligible[0]); + } else if (!request.scopes.some((s) => s.sid === sid)) { + // 本地/未知不能自动借唯一历史SSH通道;Python JSON无scope默认只查本地。 + add(sid, ""); + } + } + scopeBindings.set(wc.id, bindings); + /* 远端任务证据只取本窗 wants 里的 sid:无关 sid 的远端任务不能把本地 pane 一并判死。 */ + let evidence = trusted.size > 0; + if (!evidence && tasks) { + for (const sid of wants) { + const scopes = tasks.get(sid); + if (scopes && [...scopes].some((id) => REMOTE_IDENTITY_RE.test(id))) { evidence = true; break; } + } + } + remoteEvidence.set(wc.id, evidence); + }); + request.sids.sort(); + request.scopes.sort((a, b) => scopeKey(a.sid, a.workspaceIdentity).localeCompare(scopeKey(b.sid, b.workspaceIdentity))); + const key = JSON.stringify(request); + const src = sourceStamp(); + const keys = requestKeys(request); + const now = Date.now(); + const remoteActive = keys.some((s) => isRemoteSid(s)); + const pendingDue = keys.some((s) => { const t = pendingSids.get(s); return t !== undefined && t <= now; }); + if (key === lastWants && src === lastSourceStamp && st && st === lastStamp && !remoteActive && !pendingDue) return; + const effWait = (remoteActive || pendingDue) ? remotePollMs() : wait; + if (now - lastSpawn < effWait) { scheduleRetry(effWait - (now - lastSpawn) + 50); return; } + lastWants = key; + lastSourceStamp = src; + lastRequest = request; + lastHadRemote = remoteActive || pendingDue || request.scopes.length > 0; + if (st) lastStamp = st; + lastSpawn = now; + runQuery(request); + }) + .catch(() => { }) + .finally(() => { collectingWants = false; }); +} + +/* 监听 db 目录:WAL/主库一有写入立刻(去抖 300ms)触发拉取;完全空闲时零进程零轮询 */ +let debounceTimer = 0; +let dbWatcher = null; +function watchDb() { + if (dbWatcher) return; + try { + dbWatcher = fs.watch(dbDir(), (ev, f) => { + if (f && !/db\.sqlite/.test(f)) return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(maybeSpawn, 300); + }); + dbWatcher.on("error", () => { + LOG("db watcher error, re-arm in 5s"); + try { dbWatcher.close(); } catch (e) { } + dbWatcher = null; + setTimeout(watchDb, 5000); + }); + LOG("watching", dbDir()); + } catch (e) { + LOG("fs.watch failed, retry in 5s:", e.message); + setTimeout(watchDb, 5000); + } +} + +/* overlay / zusage.py 热更新检查(stat mtime,开销可忽略);config.hot_reload=false 可关闭 + * (发布/稳定形态:改文件需重启 ZCode 才生效),开关改动随下次心跳生效。 + * zusage.py 变化时重启常驻进程(serve 进程驻内存的是旧代码);顺带 stat config mtime。 */ +let overlayMtimeKnown = false; +let zusageMtime = 0; +setInterval(() => { + const cfg = readCfgCached(); + if (!residentAllowed()) stopResident(); + if (cfg.hot_reload === false) return; + hotReloadIfChanged(); + try { + const m = fs.statSync(ZUSAGE).mtimeMs; + if (overlayMtimeKnown && m !== zusageMtime && pyRes) { + LOG("zusage.py changed, restarting resident python"); + killResidentForUpgrade(); + } + zusageMtime = m; + overlayMtimeKnown = true; + } catch (e) { } +}, 2000); +setInterval(maybeSpawn, Math.max(10000, readCfg().heartbeat_ms | 0 || 30000)); // 兜底心跳:防文件事件丢失 +watchDb(); +maybeSpawn(); // 启动先拉一次 + +let pluginUpdater = null; +let pluginRequestBusy = false; +let lastGoodPayload = null; // 额度结算后无需重查库即可向各窗口补推(payload 已含 quota) +let quotaClient = null; +function pushPluginState(wc, state) { + if (!isMainWindowState(wc) || wc.isDestroyed()) return; + /* rateLine / quotaEnabled 随插件状态一起推送:⚙ 面板两个开关的真值都来自 config.json */ + const payload = { ...state, rateLine: readCfgCached().answer_rate_line !== false, quotaEnabled: quotaEnabled(), + quotaSources: quotaClient ? quotaClient.sourcesConfig() : [] }; + wc.executeJavaScript(`window.__zusagePluginUpdate && window.__zusagePluginUpdate(${JSON.stringify(payload)})`, true).catch(() => { }); +} +try { + const { createUpdater } = require("./plugin-updater.cjs"); + pluginUpdater = createUpdater({ runtime: HERE, home: app.getPath("home"), + resourcesPath: process.resourcesPath, executable: process.execPath, + notify: (state) => webContents.getAllWebContents().forEach((wc) => pushPluginState(wc, state)) }); +} catch (e) { + delete loadedHashes["plugin-updater.cjs"]; + delete loadedHashes["plugin-release.cjs"]; + delete loadedHashes["plugin-storage.cjs"]; + LOG("plugin updater unavailable:", e.message); +} +acknowledgeRestart(); + +/* ---------- v96 额度客户端(BigModel/Z.ai 个人编码套餐):解密宿主 OAuth 凭据 → + * monitor 端点 → 归一化 → 快照 quota 字段。独立模块,加载失败只少额度不拖垮主链 + * (overlay 收不到 quota 字段即隐藏 chip)。凭据只在 quota-client 进程内存流转; + * 诊断只记 status/HTTP code/错误摘要,token 与请求头绝不进日志。 */ +try { + const { createQuotaClient, resolveQuotaPaths } = require("./quota-client.cjs"); + const quotaPaths = resolveQuotaPaths({ defaultHome: app.getPath("home") }); + const QUOTA_SECRETS = path.join(HERE, "quota-secrets.json"); + quotaClient = createQuotaClient({ + ...quotaPaths, + secretsPath: QUOTA_SECRETS, + log: (...a) => LOG("[quota]", ...a), + onUpdate: () => { // 手动刷新/解冻等结算:拿最近成功快照补推(不触发 Python 重查) + if (!lastGoodPayload) return; + const withQuota = Object.assign({}, lastGoodPayload, { quota: quotaClient.view() }); + for (const wc of webContents.getAllWebContents()) pushOnce(wc, withQuota); + }, + }); + /* 监听实际凭据与 builtin/personal 目录(原子替换文件也能收到),配置变更重判归属并解冻。 */ + const quotaWatchDirs = new Map(); + for (const [file, dir] of [[quotaPaths.credsPath, quotaPaths.credsDir], + [quotaPaths.customCfgPath, quotaPaths.customCfgDir], [quotaPaths.builtinCfgPath, quotaPaths.builtinCfgDir]]) { + if (!file) continue; + if (!quotaWatchDirs.has(dir)) quotaWatchDirs.set(dir, new Set()); + const filePath = /^(?:[a-z]:[\\/]|\\\\)/i.test(file) ? path.win32 : path.posix; + quotaWatchDirs.get(dir).add(filePath.basename(file)); + } + let quotaCredDebounce = 0; + for (const [dir, names] of quotaWatchDirs) { + let watcher = null; + const watchQuotaCredentials = () => { + if (watcher) return; + try { + watcher = fs.watch(dir, (ev, f) => { + if (f && !names.has(String(f))) return; + clearTimeout(quotaCredDebounce); + quotaCredDebounce = setTimeout(() => { + if (!quotaClient || !quotaEnabled()) return; + quotaClient.credentialsChanged(); + syncQuotaProviders(); // 重读配置后重判 provider 绑定(内含 tick) + }, 300); + }); + watcher.on("error", () => { + try { watcher.close(); } catch (e) { } + watcher = null; + setTimeout(watchQuotaCredentials, 5000); + }); + } catch (e) { setTimeout(watchQuotaCredentials, 5000); } + }; + watchQuotaCredentials(); + } + setInterval(() => { // 兜底:not_configured 低频重探/退避到点补拉(活跃时 handlePayload 也会 tick) + if (hasWindows() && quotaEnabled()) syncQuotaProviders(); + }, 60000); +} catch (e) { + delete loadedHashes["quota-client.cjs"]; + LOG("quota client unavailable:", e.message); +} + +/* 逐问速率行开关(⚙ 面板):原子写运行时 config.json 的 answer_rate_line(缺省=开启)。 + prompt-submit hook 每次提问时读同一文件——开关下一问即生效,无需重启客户端。 */ +function broadcastPluginState() { + const base = pluginUpdater ? pluginUpdater.status() : {}; + webContents.getAllWebContents().forEach((wc) => pushPluginState(wc, base)); +} +async function setAnswerRateLine(enabled) { + try { + const { updateConfig } = require("./plugin-updater.cjs"); + await updateConfig(HERE, { answer_rate_line: enabled }, readCfgCached().python_path || (process.platform === "win32" ? "python" : "python3")); + cfgCache = null; // 写后置空,下一次 readCfgCached 立即重读(mtime 判变的双保险) + broadcastPluginState(); + } catch (e) { LOG("answer_rate_line toggle failed:", e.message); } +} +/* 阶段 3 源配置:quota-secrets.json(运行目录 0600,凭据只进此文件,不进 config.json/日志/诊断)。 + * 同目录原子替换(tmp+rename),字段白名单防任意键;写入后 credentialsChanged 解冻重读。 */ +const QUOTA_SECRET_FIELDS = new Set(["apiKey", "cookie", "token", "accessKeyId", "secretAccessKey", "baseUrl", "planCredits", "planStart", "region", "modelPatterns"]); +function readQuotaSecrets() { + try { + const d = JSON.parse(fs.readFileSync(path.join(HERE, "quota-secrets.json"), "utf8")); + return d && typeof d === "object" && d.sources && typeof d.sources === "object" ? d : { sources: {} }; + } catch (e) { return { sources: {} }; } +} +function writeQuotaSecrets(mutate) { + const d = readQuotaSecrets(); + mutate(d.sources); + const file = path.join(HERE, "quota-secrets.json"); + const tmp = file + ".tmp"; + try { + fs.writeFileSync(tmp, JSON.stringify(d, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmp, file); + try { fs.chmodSync(file, 0o600); } catch (e2) { /* Windows/权限失败忽略:tmp 已按 0600 创建 */ } + } catch (e) { LOG("quota secrets write failed:", e.message); return; } + if (quotaClient) { + quotaClient.credentialsChanged(); + if (quotaEnabled()) { + quotaClient.tick(); // 总开关关闭时不触发联网(与手动刷新同口径) + if (lastGoodPayload) { + const withQuota = Object.assign({}, lastGoodPayload, { quota: quotaClient.view() }); + for (const wc of webContents.getAllWebContents()) pushOnce(wc, withQuota); + } + } + } + broadcastPluginState(); // 面板状态无论开关都要刷新(写入结果回显) +} +function handleQuotaSourceRequest(req) { + const id = String(req.id || ""); + if (!id || !/^[a-z][a-z0-9-]{0,31}$/.test(id)) return; // 源 id 白名单 + if (req.action === "quota-src-refresh") { + if (quotaClient && quotaEnabled()) quotaClient.refreshSource(id); + return; + } + if (req.action === "quota-src" && typeof req.enabled === "boolean") { + writeQuotaSecrets((sec) => { sec[id] = Object.assign({}, sec[id] || {}, { enabled: req.enabled }); }); + return; + } + if (req.action === "quota-src-key") { + const field = String(req.field || ""); + if (!QUOTA_SECRET_FIELDS.has(field)) return; + const value = req.value == null ? "" : String(req.value).slice(0, 4096); + writeQuotaSecrets((sec) => { + sec[id] = Object.assign({}, sec[id] || {}); + const isCred = field === "apiKey" || field === "cookie" || field === "token" || field === "secretAccessKey"; + if (value === "" && (isCred || field === "modelPatterns")) { delete sec[id][field]; return; } + if (field === "planCredits") { if (value === "") delete sec[id][field]; else sec[id][field] = Number(value) || 0; } + else if (field === "planStart" || field === "region" || field === "baseUrl") { if (value === "") delete sec[id][field]; else sec[id][field] = value; } + else if (field === "modelPatterns") { + try { const parsed = JSON.parse(value); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) sec[id][field] = parsed; } catch (e) { /* 非法 JSON 忽略 */ } + } + else sec[id][field] = value; + }); + } +} +/* v96 套餐额度开关(⚙ 面板):原子写 config.json 的 quota_enabled(缺省关)。开启即刻 tick + * 一次(结算后经 onUpdate 补推);关闭后 handlePayload 不再 tick/附加,chip 随之隐藏。 */ +async function setQuotaEnabled(enabled) { + try { + const { updateConfig } = require("./plugin-updater.cjs"); + await updateConfig(HERE, { quota_enabled: enabled }, readCfgCached().python_path || (process.platform === "win32" ? "python" : "python3")); + cfgCache = null; + broadcastPluginState(); + if (enabled && quotaClient) syncQuotaProviders(); + } catch (e) { LOG("quota_enabled toggle failed:", e.message); } +} +/* 确定性历史行的数据库富文本(v75):渲染端只从 turn 容器拿到 DOM turn id, + * 桥接数据里没有首字延迟/分段/峰值/累计等字段,拼不出模型时代那行统计;这里按 + * turn 身份一次性查库(token_rate.py --turn --session --turn-id,纯文本),把结果 + * 推回请求它的那个渲染进程。用一次性 spawn 而非常驻泵:这是低频(每个完成的轮次 + * 至多几次重试)、与主数据泵完全并行的旁路查询,不能占用泵的 stdin 行协议。 + * 查不到(数据库还没追上)时回推空串,渲染端据此限流重试。 */ +/* 确定性历史行的富文本旁路查询:桥接数据里没有首字延迟/分段/峰值/累计等字段,拼不出 + * 模型时代那行统计;数据库的 turn_id(turn_)和渲染器投影的 turnId/productTurnId + * (msg_,DOM data-turn-id 同源)是两套互不相通的标识,前端没有、也查不到后者 + * 对应的数据库主键——不能按显式 turn-id 查库。改为复用 Hook 原本依赖的 --current 时间戳 + * 推断路径(token_rate.py 按 session 记录的"提问时刻"反查最新一轮,session 级映射, + * 并行会话不串台),这正是模型以前实际在跑的同一条查询,口径已被验证过。 + * 用一次性 spawn 而非常驻泵:低频(每个完成的轮次至多几次重试)、与主数据泵完全并行 + * 的旁路查询,不占用泵的 stdin 行协议。查不到(数据库还没追上/没有有效提问时刻/ + * 这一轮已被新提问越过)时回推空串,不是错误,渲染端据此限流重试。 */ +function queryTurnText(sid, wc, plainUnits) { + const deliver = (text) => { + if (!wc || wc.isDestroyed()) return; + wc.executeJavaScript("window.__zusageDeliverTurnText && window.__zusageDeliverTurnText(" + + JSON.stringify(sid) + "," + JSON.stringify(text || "") + ")", true).catch(() => { }); + }; + if (typeof sid !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(sid)) return; + let py; + try { + /* --no-cn-units:渲染端万/亿开关(localStorage,主进程无真值)显式关闭时才传; + 缺省(含旧渲染端不带 cn 字段)与产品默认一致,Python 侧自行用万/亿 */ + py = spawn(readCfgCached().python_path, + [TOKEN_RATE, "--turn", "--current", "--session", sid].concat(plainUnits ? ["--no-cn-units"] : []), { windowsHide: true }); + } catch (e) { LOG("turn text spawn failed:", e.message); deliver(""); return; } + let out = "", finished = false; + const finish = (text) => { + if (finished) return; + finished = true; + clearTimeout(killTimer); + deliver(text); + }; + const killTimer = setTimeout(() => { + LOG("turn text query timeout, killing"); + try { py.kill(); } catch (e) { } + finish(""); + }, 15000); + py.stdout.on("data", (d) => { out += d; }); + py.stderr.on("data", () => { }); + py.on("error", (e) => { LOG("turn text spawn failed:", e.message); finish(""); }); + py.on("close", () => { finish(String(out).trim()); }); // 空串 = 库里还没这一轮,不是错误 +} + +/* ---------- Git 状态旁路查询(v96):工具行 Git chip 的数据源 ---------- + * 渲染端把各 pane 的本地工作区路径写进 window.__zusageGitPaths(远端 pane 不上报;开关关闭为空), + * 这里每 10s 取各主窗口并集逐路径跑 git(一次性 execFile,与 Python 泵无关,不占 stdin 行协议), + * 结果按路径推回每个声明过该路径的窗口(window.__zusageGitState)。渲染端 git-refresh 请求 + * (回合后节流 / chip 点击)立即重查。git 缺失 / 非仓库按 reason 回推,渲染端据此不挂载; + * 超时与其它错误也回推,渲染端保留最近成功态并标 stale。 + * 命令:status --porcelain --branch(分支/上游/ahead-behind/各类文件计数)+ diff HEAD --shortstat + * (工作区相对 HEAD 的插入/删除行数,含已暂存;HEAD 无效的无提交仓库退回 diff + diff --cached + * 求和,即 cc-pretty-statusline 的原始做法)。GIT_OPTIONAL_LOCKS=0 不写 index 锁(后台轮询不与 + * 用户的 git 操作抢 index.lock),LC_ALL=C 让 shortstat 文案可解析,GIT_TERMINAL_PROMPT=0 杜绝交互。 + * 多仓聚合:workspace 自身非仓库(status 报 notrepo)时枚举直接子目录中的 git 仓库——0 仓维持 + * notrepo(chip 隐藏);恰 1 仓对该子仓走完整单仓管线(成功结果附 repoPath);≥2 仓每仓只跑一条 + * status --porcelain --branch(Promise.all 并行,不跑 diff 行数),按字母序聚合为 kind:"multi", + * 上限 GIT_REPO_CAP=32,超出折叠进 more;子仓失败只在对应项记 err,不拖垮整体与其余仓。 */ +const GIT_TICK_MS = 10000, GIT_TIMEOUT_MS = 8000, GIT_MAX_BUFFER = 4 * 1024 * 1024; +const GIT_ENV = Object.assign({}, process.env, { GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" }); +const GIT_CONFLICT = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]); +const GIT_REPO_CAP = 32; // 多仓聚合上限:父目录非仓库时最多查询 32 个直接子仓库,超出折叠进 more +let gitTickBusy = false, gitRefreshPending = false, gitNoBinaryLogged = false; + +function parseGitPorcelain(text) { + const out = { branch: "", upstream: "", ahead: 0, behind: 0, detached: false, noCommits: false, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, changed: 0 }; + for (const raw of String(text || "").split("\n")) { + const line = raw.replace(/\r$/, ""); + if (!line) continue; + if (line.startsWith("## ")) { + const head = line.slice(3); + if (head === "HEAD (no branch)") { out.detached = true; continue; } + const fresh = head.match(/^(?:No commits yet|Initial commit) on (.+)$/); + if (fresh) { out.noCommits = true; out.branch = fresh[1]; continue; } + const m = head.match(/^(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/); // 分支名不含 "..",按 "..." 切上游是安全的 + if (!m) continue; + out.branch = m[1]; + out.upstream = m[2] || ""; + const a = m[3] && m[3].match(/ahead (\d+)/), b = m[3] && m[3].match(/behind (\d+)/); + if (a) out.ahead = parseInt(a[1], 10); + if (b) out.behind = parseInt(b[1], 10); + continue; + } + if (line.length < 2) continue; + const x = line[0], y = line[1]; + if (x === "?" && y === "?") { out.untracked++; continue; } + if (x === "!" && y === "!") continue; // 仅 --ignored 时出现 + out.changed++; // 非未跟踪/非忽略的条目行计 1:MM 部分暂存也只算一行,不与 staged/unstaged 相加重复计 + if (GIT_CONFLICT.has(x + y)) { out.conflicts++; continue; } + if (x !== " ") out.staged++; + if (y !== " ") out.unstaged++; + } + return out; +} +function parseShortstat(text) { + const s = String(text || ""); + const ins = s.match(/(\d+) insertions?\(\+\)/), del = s.match(/(\d+) deletions?\(-\)/); + return { ins: ins ? parseInt(ins[1], 10) : 0, del: del ? parseInt(del[1], 10) : 0 }; +} +function gitPathOk(p) { + if (typeof p !== "string" || !p || p.length > 4096 || /[\x00-\x1f\x7f]/.test(p) || !path.isAbsolute(p)) return false; + try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } +} +function gitExec(cwd, args) { + return new Promise((resolve) => { + try { + execFile("git", ["-C", cwd].concat(args), { timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true, env: GIT_ENV }, + (error, stdout, stderr) => resolve({ error, stdout: String(stdout || ""), stderr: String(stderr || "") })); + } catch (e) { resolve({ error: e, stdout: "", stderr: "" }); } + }); +} +function gitFailure(r) { // null = 成功;否则渲染端可识别的 reason + if (!r.error) return null; + if (r.error.code === "ENOENT") return "nogit"; + if (r.error.killed || r.error.signal) return "timeout"; + if (/not a git repository/i.test(r.stderr)) return "notrepo"; + return "error"; +} +/* 单仓管线(queryGitStatus 的原有主体):status + diff HEAD --shortstat,无提交仓库退回 + * diff + diff --cached 求和。成功返回统一附 repoPath(渲染端 tooltip 目录行用)与 ts(成功 + * 时刻)与 op(进行中的 Git 操作),失败形状不变。 */ +async function gitSingleStatus(cwd) { + const st = await gitExec(cwd, ["status", "--porcelain", "--branch"]); + const bad = gitFailure(st); + if (bad) { + if (bad === "nogit" && !gitNoBinaryLogged) { gitNoBinaryLogged = true; LOG("git not found in PATH; git chip stays hidden"); } + return { ok: false, reason: bad, ts: Date.now() }; + } + const d1 = await gitExec(cwd, ["diff", "HEAD", "--shortstat"]); + let counts = null; + if (!d1.error) counts = parseShortstat(d1.stdout); + else if (gitFailure(d1) === "error") { // HEAD 无效(无提交仓库)等非超时失败:退回两段式求和 + const a = await gitExec(cwd, ["diff", "--shortstat"]); + const b = a.error ? a : await gitExec(cwd, ["diff", "--cached", "--shortstat"]); + if (!a.error && !b.error) { + const pa = parseShortstat(a.stdout), pb = parseShortstat(b.stdout); + counts = { ins: pa.ins + pb.ins, del: pa.del + pb.del }; + } + } + if (!counts) return { ok: false, reason: gitFailure(d1) || "error", ts: Date.now() }; + return Object.assign({ ok: true, ts: Date.now(), ins: counts.ins, del: counts.del, repoPath: cwd, op: detectGitOp(cwd) }, parseGitPorcelain(st.stdout)); +} +/* 进行中的 Git 操作探测:解析 .git 目录(目录或 worktree 链接文件都认),按官方触发器文件 + * 存在性判操作类型。纯 fs 探针零子进程;rebase-apply 目录被 rebase --apply 与 git am 共用, + * 以 applying 文件区分。探测不到返回空串(不代表没有操作,只代表本探测不可见)。 */ +function gitDotDir(cwd) { + const dot = path.join(cwd, ".git"); + try { + const st = fs.statSync(dot); + if (st.isDirectory()) return dot; + const m = /^gitdir: (.+)\r?\n?$/.exec(fs.readFileSync(dot, "utf8")); + return m ? m[1].trim() : null; + } catch (e) { return null; } +} +function detectGitOp(cwd) { + const g = gitDotDir(cwd); + if (!g) return ""; + const has = (p) => { try { return fs.existsSync(path.join(g, p)); } catch (e) { return false; } }; + if (has("rebase-merge")) return "rebase"; + if (has("rebase-apply")) return has("applying") ? "am" : "rebase"; + if (has("MERGE_HEAD")) return "merge"; + if (has("CHERRY_PICK_HEAD")) return "cherry-pick"; + if (has("REVERT_HEAD")) return "revert"; + if (has("BISECT_LOG")) return "bisect"; + return ""; +} +/* 父目录自身非仓库时枚举直接子目录中的 git 仓库:readdir/stat 异常返回空或跳过该项,不让异常 + * 穿出 git tick;子项 statSync 须为目录(跟随符号链接),其下存在 .git(目录或 worktree 链接 + * 文件都认)才算仓库。名字按 Array#sort() 默认码点序,前 GIT_REPO_CAP 个进入查询,剩余计入 more。 */ +function discoverChildRepos(parent) { + let names; + try { names = fs.readdirSync(parent); } catch (e) { return { repos: [], more: 0 }; } + names.sort(); + const repos = []; + for (const name of names) { + const child = path.join(parent, name); + try { if (!fs.statSync(child).isDirectory()) continue; } catch (e) { continue; } + if (!fs.existsSync(path.join(child, ".git"))) continue; + repos.push({ name, path: child }); + } + return { repos: repos.slice(0, GIT_REPO_CAP), more: Math.max(0, repos.length - GIT_REPO_CAP) }; +} +/* 入口三分支:workspace 自身是仓库 → 单仓管线原样返回;status 判定 notrepo → 枚举直接子仓库 + * 聚合(0 仓维持 notrepo 语义、恰 1 仓对该子仓走完整单仓管线、≥2 仓每仓只跑一条 status 有界 + * 并行聚合);nogit/timeout/error 等其余失败原样返回,不触发发现。 + * 逐仓失败携带最近一次成功快照(stale:true 标注历史属性,渲染端不把旧冲突算已确认); + * 逐仓成功附 ts(最近成功时刻),渲染端据此区分"最近尝试"与"最近成功"。 */ +const GIT_REPO_CONCURRENCY = 6; // 有界并发:一次 tick 最多同时 6 个 git 子进程 +const gitRepoLastGood = new Map(); // repo path → 最近一次成功条目(不含 err) +async function queryGitStatus(cwd) { + const first = await gitSingleStatus(cwd); + if (first.ok !== false || first.reason !== "notrepo") return first; + const found = discoverChildRepos(cwd); + if (!found.repos.length) return first; // 没有任何子仓库:维持 notrepo,chip 隐藏 + if (found.repos.length === 1) return await gitSingleStatus(found.repos[0].path); + const repos = found.repos.slice(); // 已按名字码点序 + let cursor = 0; + const runOne = async (r) => { + const s = await gitExec(r.path, ["status", "--porcelain", "--branch"]); // 多仓只跑 status,不跑 diff 行数 + const bad = gitFailure(s); + if (bad) { + const good = gitRepoLastGood.get(r.path); + const entry = { name: r.name, path: r.path, err: bad, tsAttempt: Date.now() }; + if (good) return Object.assign({ name: r.name, path: r.path, err: bad, stale: true }, good); // 失败携带旧快照(明确标注) + return entry; + } + const entry = Object.assign({ name: r.name, path: r.path, ts: Date.now(), op: detectGitOp(r.path) }, parseGitPorcelain(s.stdout)); + gitRepoLastGood.set(r.path, entry); + return entry; + }; + const out = new Array(repos.length); + const worker = async () => { while (cursor < repos.length) { const i = cursor++; out[i] = await runOne(repos[i]); } }; + await Promise.all(Array.from({ length: Math.min(GIT_REPO_CONCURRENCY, repos.length) }, worker)); + return { ok: true, kind: "multi", ts: Date.now(), more: found.more, repos: out }; +} +function isLocalHostPage(wc) { // 与 pollPluginRequests 同口径:只信宿主本地 file: 页面上的渲染端全局 + try { + const url = wc.getURL(); + if (!url.startsWith("file:")) return false; + const relative = path.relative(process.resourcesPath, fileURLToPath(url)); + return !relative.startsWith("..") && !path.isAbsolute(relative); + } catch (e) { return false; } +} +async function readGitPaths(wc) { + try { + const raw = await Promise.race([ + wc.executeJavaScript('(function(){var p=window.__zusageGitPaths;return JSON.stringify(Array.isArray(p)?p.slice(0,8):[])})()', true), + new Promise((resolve) => setTimeout(() => resolve("[]"), 1000)), + ]); + const arr = JSON.parse(String(raw || "[]")); + return Array.isArray(arr) ? arr.filter(gitPathOk) : []; + } catch (e) { return []; } +} +function pushGitState(wc, p, st) { + if (!wc || wc.isDestroyed()) return; + const js = ("window.__zusageGitState && window.__zusageGitState(" + JSON.stringify(p) + "," + JSON.stringify(st) + ")") + .replace(/[\u2028\u2029]/g, (c) => c === "\u2028" ? "\\u2028" : "\\u2029"); + wc.executeJavaScript(js, true).catch(() => { }); +} +async function gitTick(fromRefresh) { + if (gitTickBusy) { if (fromRefresh) gitRefreshPending = true; return; } // 在途时的手动刷新:结束后补跑一轮 + if (shuttingDown || !hasWindows()) return; + gitTickBusy = true; + try { + const wants = []; // [wc, paths] + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) continue; + const paths = await readGitPaths(wc); + if (paths.length) wants.push([wc, paths]); + } + const all = new Set(); + for (const [, ps] of wants) for (const p of ps) all.add(p); + await Promise.all([...all].map(async (p) => { // 同一仓库多窗口/多 pane 只查一次,结果分发 + const st = await queryGitStatus(p); + for (const [wc, ps] of wants) if (ps.includes(p)) pushGitState(wc, p, st); + })); + } catch (e) { LOG("git status tick failed:", e.message); } + finally { + gitTickBusy = false; + if (gitRefreshPending) { gitRefreshPending = false; gitTick(true).catch(() => { }); } + } +} +setInterval(() => { gitTick(false).catch(() => { }); }, GIT_TICK_MS); + +async function pollPluginRequests() { + if (pluginRequestBusy) return; + pluginRequestBusy = true; + try { + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed()) continue; + // 仅在宿主本地窗口读取固定动作;外部网页不能触发插件更新或配置写入。 + const url = wc.getURL(); + if (!url.startsWith("file:")) continue; + const local = fileURLToPath(url); + const relative = path.relative(process.resourcesPath, local); + if (relative.startsWith("..") || path.isAbsolute(relative)) continue; + const requests = await Promise.race([ + wc.executeJavaScript('(function(){var q=window.__zusagePluginRequests;return Array.isArray(q)?q.splice(0,8):[]})()', true), + new Promise((resolve) => setTimeout(() => resolve([]), 1000)), + ]); + if (!Array.isArray(requests)) continue; + for (const req of requests) { + if (req && ["auto", "check", "update"].includes(req.action)) { + if (pluginUpdater) pluginUpdater.request(req).catch((e) => LOG("plugin update action failed:", e.message)); + } else if (req && req.action === "rate-line" && typeof req.enabled === "boolean") { + setAnswerRateLine(req.enabled); + } else if (req && req.action === "quota-providers") { + handleQuotaProviders(wc, req); + } else if (req && req.action === "quota" && typeof req.enabled === "boolean") { + setQuotaEnabled(req.enabled); + } else if (req && (req.action === "quota-src" || req.action === "quota-src-key" || req.action === "quota-src-refresh")) { + handleQuotaSourceRequest(req); + } else if (req && req.action === "quota-refresh") { // ⚙/chip 手动刷新:绕过 TTL 与软冻结 + if (quotaClient && quotaEnabled()) { + if (typeof req.sourceId === "string" && req.sourceId.length <= 256) quotaClient.refreshSource(req.sourceId); + else quotaClient.refresh(); + } + } else if (req && req.action === "turn-text" && typeof req.sid === "string") { + queryTurnText(req.sid, wc, req.cn === 0); + } else if (req && req.action === "git-refresh") { // v96:Git chip 点击 / 快照后节流:立即重查全部已关注路径 + gitTick(true).catch(() => { }); + } + } + } + } catch (e) { LOG("plugin update request failed:", e.message); } + finally { pluginRequestBusy = false; } +} +setInterval(pollPluginRequests, 1000); +setInterval(() => { if (pluginUpdater) pluginUpdater.tick().catch((e) => LOG("plugin auto update:", e.message)); }, 60000); +if (pluginUpdater) pluginUpdater.tick().catch((e) => LOG("plugin auto update:", e.message)); + +function injectOverlay(wc, reload = false) { + if (!overlaySrc || !isMainWindowState(wc) || wc.isDestroyed()) return; + const cleanup = '(function(){if(window.__zusageDispose)window.__zusageDispose();else{document.querySelectorAll(".zusage-root,.zusage-tip-root,.zusage-exc").forEach(function(e){e.remove()});if(window.__zusageRateCleanup)window.__zusageRateCleanup()}window.__zusageOverlay=false;})();\n'; + /* turn-stats.js 先于 overlay.js 注入(IIFE 幂等,重复执行只是重挂 ZUsageTurnStats): + * overlay 启动即读取该全局做实时/确定性统计;overlay 缺它时自动降级为 DB 轮询路径。 */ + wc.executeJavaScript((reload ? cleanup : "") + turnStatsSrc + "\n" + overlaySrc, true).then(() => { + /* 无条件推送:updater 不可用时也要让面板拿到 rateLine 真值(缺省开) */ + pushPluginState(wc, pluginUpdater ? pluginUpdater.status() : {}); + }).catch(() => { }); +} + +const hook = (wc) => { + if (!isMainWindowState(wc)) return; + wc.on("destroyed", () => { + activeSid.delete(wc.id); + windowQuotaSelections.delete(wc.id); + syncQuotaProviders(); + if (!hasWindows()) { + clearTimeout(retryTimer); + lastHadRemote = false; + lastWants = ""; + lastStamp = 0; + stopResident(); + } + }); + wc.on("did-start-navigation", (event, url, inPlace, isMainFrame) => { + if (isMainFrame === false || inPlace) return; + windowQuotaSelections.set(wc.id, {}); + syncQuotaProviders(); + }); + wc.on("did-finish-load", () => injectOverlay(wc)); + if (wc.isLoading()) return; + injectOverlay(wc); +}; +app.on("web-contents-created", (e, wc) => hook(wc)); +for (const wc of webContents.getAllWebContents()) hook(wc); + +/* 测试接缝:vm 沙箱加载后取内部引用复现真实控制流;生产加载方(asar 动态 import)不用它。 */ +if (typeof module === "object" && module && module.exports) { + module.exports.__zusageTest = { + handlePayload, maybeSpawn, isRemoteSid, remoteSids, pendingSids, + targetByIdentity: () => targetByIdentity, + parseGitPorcelain, parseShortstat, queryGitStatus, gitTick, discoverChildRepos, detectGitOp, + }; +} + +LOG("injected v12 (auto remote targets + per-sid multi-server routing, per-pane wants array, resident query mode with idle shutdown, renderer bridge probe, answer_rate_line config write, plugin request polling, quota entitlement client, git status chip)"); + +app.on("before-quit", () => { + shuttingDown = true; + clearTimeout(retryTimer); + clearTimeout(debounceTimer); + stopResident(); + if (quotaClient) quotaClient.dispose(); + if (dbWatcher) { try { dbWatcher.close(); } catch (e) { } dbWatcher = null; } +}); diff --git a/external_plugins/zcode-token-usage-statusbar/install_monitor.py b/external_plugins/zcode-token-usage-statusbar/install_monitor.py new file mode 100644 index 0000000..8ccb424 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/install_monitor.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +"""「ZCode Token 用量状态栏」安装监控窗口(安装完成后自动弹出的常驻命令行)。 + +每 10 秒检测一次,全程只读(唯一的写操作是 TMP 替换,见下),不联网: +1. 存在待替换 TMP(客户端运行中导致原子替换失败的场景):等 ZCode 退出后自动完成 + asar 替换 —— 取代旧的 schtasks 计划任务收尾方案(finalize_if_closed.py 已删)。 +2. asar 已替换:持续提醒重启 ZCode;检测到 ZCode 换了新进程(进程集合与启动采集的 + 不相交)后,用 diag-.json 的更新时间确认注入链路已加载,显示成功并自动退出。 + diag 是启动后第一次数据拉取就回写的(pushCount==1),空闲也会写,不依赖用户发消息。 + +手动运行:python install_monitor.py [install_epoch 秒] [app.asar 路径] [运行时目录] +(缺省用当前时间/默认 asar 位置;diag 在运行时目录——标准安装是数据目录副本所在, + --dev 安装传仓库目录,不传则用本脚本所在目录) +退出:生效确认后自动退出;或 Ctrl+C / 直接关闭窗口。 + +输出语言:运行时目录 config.json 的 "lang" 字段(zh/en,缺省 zh)——与安装器/CLI 同一来源。 +""" +import csv +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +HERE = Path(__file__).parent.resolve() +IS_MAC = sys.platform == "darwin" +IS_LINUX = sys.platform.startswith("linux") +# 缺省仅兜底(patch_install.py 拉起时必传 argv[2]):按平台取常见安装位置 +ASAR = Path(sys.argv[2]) if len(sys.argv) > 2 else ( + Path("/Applications/ZCode.app/Contents/Resources/app.asar") if IS_MAC + else Path("/opt/ZCode/resources/app.asar") if IS_LINUX + else Path(r"D:\ZCode\resources\app.asar")) +RUNTIME_DIR = Path(sys.argv[3]) if len(sys.argv) > 3 else HERE +TMP = ASAR.with_name("app.asar.zusage.tmp") +POLL = 10 # 检测间隔(秒) +CONFIRM_WAIT = 120 # 重启后等 diag 确认的超时(秒) +BYE = 8 # 成功提示停留秒数 +QUIET = os.environ.get("ZUSAGE_MONITOR_QUIET") == "1" + + +def load_lang(): + try: + lang = json.loads((RUNTIME_DIR / "config.json").read_text(encoding="utf-8")).get("lang") + return lang if lang in ("zh", "en") else "zh" + except (OSError, ValueError): + return "zh" + + +LANG = load_lang() + + +def L(zh, en): + """双语输出:按 LANG 返回对应文案(与 install.py 同款约定)。""" + return en if LANG == "en" else zh + + +def zcode_pids(): + """ZCode 主进程 PID 集合(Windows=tasklist,macOS/Linux=pgrep);探测命令不可用时返回 None。""" + if os.name == "nt": + try: + r = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq ZCode.exe", "/FO", "CSV", "/NH"], + capture_output=True, + ) + except OSError: + return None + pids = set() + for row in csv.reader(r.stdout.decode("gbk", "replace").splitlines()): + if len(row) >= 2 and row[0].strip().lower() == "zcode.exe": + pids.add(row[1].strip()) + return pids + try: + r = subprocess.run(["pgrep", "-x", "ZCode"], capture_output=True) + except OSError: + return None + if r.returncode == 1: + return set() # pgrep:无匹配进程 + if r.returncode != 0: + return None # pgrep 自身出错 + return {t.strip() for t in r.stdout.decode().split() if t.strip()} + + +def wait_any_exit(pids, timeout_s): + """阻塞到任一 ZCode 进程退出或超时。Windows 用进程句柄事件驱动等待,消灭固定 + 轮询间隔可能错过的「退出→立即重启」空档(等错过后监控只会看到新进程,永远收不了尾); + 采集后已退出的 PID 视为退出已发生,立即返回;句柄不可得(权限/句柄数超限/非 Windows) + 时退化为普通睡眠。""" + if os.name != "nt" or not pids: + time.sleep(timeout_s) + return + import ctypes + from ctypes import wintypes + SYNCHRONIZE = 0x00100000 + ERROR_INVALID_PARAMETER = 87 # OpenProcess:该 PID 已没有进程 + MAX_WAIT = 60 # WaitForMultipleObjects 上限 64,留余量;进程数更多时退化为睡眠 + try: + pids = [int(pid) for pid in pids] + except ValueError: + time.sleep(timeout_s) + return + if len(pids) > MAX_WAIT: + time.sleep(timeout_s) + return + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.WaitForMultipleObjects.argtypes = [ + wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE), wintypes.BOOL, wintypes.DWORD] + kernel32.WaitForMultipleObjects.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + handles = [] + try: + for pid in pids: + handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) + if handle: + handles.append(handle) + elif ctypes.get_last_error() == ERROR_INVALID_PARAMETER: + return # 采集后已退出:「任一退出」已经发生,立即复查 + if not handles: + # 进程都在但全部无权等待(如其它用户会话的 ZCode):按轮询间隔睡, + # 立即返回会让监控忙转。 + time.sleep(timeout_s) + return + array = (wintypes.HANDLE * len(handles))(*handles) + kernel32.WaitForMultipleObjects(len(handles), array, False, int(timeout_s * 1000)) + finally: + for handle in handles: + kernel32.CloseHandle(handle) + + +def diag_fresh(since): + """返回安装时刻之后更新过的 diag 文件名(注入链路已加载的证据),没有则 None。""" + for f in sorted(RUNTIME_DIR.glob("diag-*.json")): + try: + if f.stat().st_mtime >= since: + return f.name + except OSError: + pass + return None + + +def patch_module(): + import patch_install as pi + pi.set_target(ASAR) + pi.set_runtime(RUNTIME_DIR) + return pi + + +def finalize_tmp(verified=None): + """监控按 sidecar 的 install/remove 收尾,不吞掉不可重试的完整性错误。""" + pi = patch_module() + return pi.finalize_tmp(operation=None, client_closed=lambda: zcode_pids() == set(), + verified=verified) + + +def draw(lines): + os.system("cls" if os.name == "nt" else "clear") + print("\n".join(lines), flush=True) + + +def quiet_finalize(): + """hook 无窗口收尾:来源由 repack 前 sidecar 决定,不用监控启动时间推断。""" + pi = patch_module() + verified = {} + deadline = time.monotonic() + 86400 + while TMP.exists() and time.monotonic() < deadline: + try: + pi.pending_tmp(operation=None) # 等待/重试只读 stat,不每 10 秒 hash 大包。 + pids = zcode_pids() + if pids == set() and finalize_tmp(verified=verified): + return + if pids: # 客户端在跑:事件驱动等任一进程退出,立即复查(见 wait_any_exit) + wait_any_exit(pids, POLL) + continue + except pi.PendingTmpError: + return # 完整性/来源失效立刻取消;不删旧包,更不能回滚客户端新版。 + except OSError: + pass # 文件占用等系统错误才重试,最多一天。 + time.sleep(POLL) + + +def main(): + if QUIET: + quiet_finalize() + return + epoch = float(sys.argv[1]) if len(sys.argv) > 1 else time.time() + if os.name == "nt": + try: + subprocess.run(["cmd", "/c", "title", L("ZCode Token 用量状态栏 - 安装监控", "ZCode Token Usage Status Bar - Install Monitor")]) + except OSError: + pass + pids0 = zcode_pids() or set() # 启动采集 ≈ 安装时刻的进程集合(install 完成即拉起本窗口) + t0 = time.time() + new_since = None + pi = patch_module() + verified = {} + deadline = time.monotonic() + 86400 + while True: + pids = zcode_pids() + now = time.time() + done = False + pending = None + if TMP.exists(): + try: + pending = pi.pending_tmp(operation=None) + if time.monotonic() >= deadline: + draw([L("[!] 待替换包收尾已超过一天,停止自动重试。文件保留,请重新安装。", + "[!] Pending finalization timed out after one day; files kept. Re-run install.")]) + return + except pi.PendingTmpError as e: + draw([L(f"[!] 待替换包来源/身份失效,取消收尾,文件保留:{e}", + f"[!] Pending source/identity invalid; finalization cancelled, files kept: {e}"), + L("请重新运行 install/remove,不要手动覆盖新版 asar。", + "Re-run install/remove; do not manually overwrite a newer asar.")]) + return + except OSError as e: + draw([L(f"[!] 暂时无法检查待替换包,稍后重试:{e}", + f"[!] Cannot check pending package yet; will retry: {e}")]) + if time.monotonic() >= deadline: + return + time.sleep(POLL) + continue + if pids is None: + lines = [L("[!] 无法检测 ZCode 进程(探测命令不可用)。", + "[!] Cannot detect ZCode processes (detection command unavailable).")] + elif pending is not None: + # 和 quiet/--finalize 共用来源、TMP hash、自检及退出校验。 + if not pids: + try: + finished = finalize_tmp(verified=verified) + except pi.PendingTmpError as e: + draw([L(f"[!] 待替换包已失效,取消收尾,文件保留:{e}", + f"[!] Pending package invalid; finalization cancelled, files kept: {e}")]) + return + except OSError: + finished = False + if finished: + lines = [L("[OK] ZCode 已退出,asar 替换完成。", "[OK] ZCode has exited; app.asar replacement finished."), + "", + L("请启动 ZCode,悬浮条即可显示。", "Start ZCode and the floating bar will appear.")] + if pending["operation"] == "remove": + lines[-1] = L("注入已移除;重新启动 ZCode 后生效。", + "Injection removed; takes effect when ZCode starts again.") + done = True + else: + lines = [L("[!] ZCode 已退出但替换失败(文件仍被占用?)", + "[!] ZCode has exited but the replacement failed (file still locked?)"), + L("稍后自动重试;也可手动执行:", "Will retry automatically; you can also run manually:"), + " python patch_install.py " + + (f'remove --asar "{ASAR}"' if pending["operation"] == "remove" + else f'install --finalize --asar "{ASAR}" --runtime "{RUNTIME_DIR}"')] + else: + lines = [L("[等待] 有待替换文件,请完全退出 ZCode。", + "[waiting] A pending replacement file exists; please exit ZCode completely."), + L("退出后本窗口将自动完成 asar 替换。", + "This window will finish the asar replacement automatically once you exit."), + "", L("当前 ZCode 进程数:%d" % len(pids), "Current ZCode process count: %d" % len(pids))] + elif not pids: + lines = [L("ZCode 未运行。启动 ZCode 后悬浮条即生效。", + "ZCode is not running. The floating bar activates once you start ZCode.")] + elif pids & pids0: + lines = [L(">>>>> 请 重 启 Z Code <<<<<", ">>>>> PLEASE RESTART ZCODE <<<<<"), + "", + L("安装已完成,但当前还是旧实例(%d 个进程)," % len(pids), + "Installation finished, but the old instance is still running (%d processes)," % len(pids)), + L("请完全退出 ZCode(所有窗口)后重新打开。", + "please quit ZCode completely (all windows) and reopen it.")] + else: + if new_since is None: + new_since = now + d = diag_fresh(epoch) + if d: + lines = [L("[OK] ZCode 已重启,注入已加载(%s 已更新)。" % d, + "[OK] ZCode restarted, injection loaded (%s updated)." % d), + L("悬浮条应已出现在输入框下方。", "The floating bar should now be visible."), + "", L("本窗口即将自动关闭。", "This window will close automatically.")] + done = True + elif now - new_since > CONFIRM_WAIT: + lines = [L("[?] ZCode 已重启,但 %d 秒内未检测到注入加载。" % CONFIRM_WAIT, + "[?] ZCode restarted, but no injection load detected within %ds." % CONFIRM_WAIT), + L("若 ZCode 刚升级过,asar 会被官方版本覆盖,", + "If ZCode was just upgraded, app.asar was overwritten by the official build —"), + L(f'请重新运行:python patch_install.py install --asar "{ASAR}"', + f'please re-run: python patch_install.py install --asar "{ASAR}"'), + L("(继续检测中,diag 一更新即确认)", "(still watching; will confirm as soon as diag updates)")] + else: + lines = [L("ZCode 已重启(新实例),正在确认注入加载……", + "ZCode restarted (new instance); confirming injection load…")] + el = int(now - t0) + lines += ["", "-" * 52, + L("已等待 %02d:%02d | 每 %d 秒检测一次 | 生效后自动退出" % (el // 60, el % 60, POLL), + "waited %02d:%02d | checking every %ds | exits automatically once confirmed" % (el // 60, el % 60, POLL)), + L("本窗口可随时关闭,不影响安装结果。", "You can close this window at any time; the installation result is unaffected.")] + draw(lines) + if done: + time.sleep(BYE) + return + if pending is not None and pids: + wait_any_exit(pids, POLL) # 有待替换且客户端在跑:任一进程退出立即醒来复查 + else: + time.sleep(POLL) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass diff --git a/external_plugins/zcode-token-usage-statusbar/overlay.js b/external_plugins/zcode-token-usage-statusbar/overlay.js new file mode 100644 index 0000000..a011db0 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/overlay.js @@ -0,0 +1,4680 @@ +/* ZCode token 用量状态条 v58(渲染进程注入,自包含 IIFE)。 + * 形态:输入框视觉卡片正下方的悬浮胶囊条 —— 给卡片加 margin-bottom 上移让位, + * 条 fixed 悬浮在卡片边框外、窗口底边上的空带里,rAF 每帧跟随, + * 左右两缘都与卡片对齐(宽=卡片实测宽;内容超宽先藏「本轮/会话累计」 + * 的缓存命中率,仍装不下右缘渐隐)。 + * (条不能放在输入框中心点所在矩形内:命中检测自遮挡 = 周期性闪烁,v12-v14 实测。) + * 视觉(v28):半透明胶囊底 + 组间 │ 分隔 + 上下文微进度条(三档色)+ 子代理呼吸灯; + * 条面只放主数值,明细进 hover title。 + * 视觉(v54 重写):玻璃拟态胶囊(blur+内高光+分层投影)、发丝分隔线替代 │ 字符、 + * 条目悬浮底色提示可交互、行内 SVG 线性图标、tabular 数字、exc 呼吸闪烁、 + * 面板/tooltip/气泡统一圆角玻璃系;行为机制(tooltip 生命周期/遮挡豁免/面板互斥)零改动。 + * 主题(v58):配色全量跟随客户端主题 —— 颜色一律 var(--color-*, 兜底值) 引用客户端语义 + * 变量( 的 .dark 类翻转整套值,zai-light/zai-dark 特殊主题同样适配), + * MutationObserver 观察该类同步 .zu-light(仅剩阴影/光晕程度性差异)。 + * 字体(v64):字族/字号跟随客户端 —— font-family 一律 var(--font-mono, 旧硬编码栈兜底) + * (客户端 :root 上的等宽令牌,macOS 解析为 SF Mono,含中文回退);字号一律 + * var(--ui-font-size, 14px) 按应用 text-ui 档位加减偏移(设置页「界面字号」 + * 运行时写 内联 --ui-font-size,改动实时联动),不再硬编码 px。 + * 数据:主进程泵推 window.__zusageUpdate;显示项可在 ⚙ 面板配置(localStorage 持久化)。 + * 分屏(v61.1):协调器每 600ms 扫描可见输入框,按 pane(data-pane-id)派发实例—— + * 一个 pane 一条,各自显示 composer 祖先链 data-session-id 指向的会话; + * 各 pane 会话 id 汇总挂 window.__zusageWantSids 交泵强制拉取(旧泵忽略)。 + * 当前会话(v34):泵按窗口注入 payload.mine(客户端渲染端经 IPC 向主进程上报的焦点会话 id, + * 可为空串);overlay 优先显示 mine 对应的池条目,池里还没有(新会话没数据)就显示 + * 零值 —— 绝不回退到"别的会话"的数字(多窗口共享 localStorage 时旧启发式必错,实测)。 + * mine 缺失(辅助窗口/旧泵)才走 localStorage 键启发式 pickCurrent。 + * 热更新:主进程 mtime 检测 + new Function 语法校验后重注入;代际守卫防旧实例打架。 + * 原生按钮增强(v92):输入框工具行的原生上下文按钮(chat-context-usage-trigger) + * 环填充改状态条 cbar 同款(r9/描边3/圆帽/四档色/超限闪烁),百分比数值挂在 + * trigger 按钮内(环右侧,悬停自动享受原生 hover 效果与「上下文容量」面板)—— + * 档位口径沿用原状态条上下文格的 ctxBandOf(v93 起该格已删,口径唯一承载处);仅注入 + * data-* 属性与末尾追加节点(React 不清理),React 数据更新重写 dasharray 由 + * render/heavy 双 reapply 兜回;cw 缺失还原原生形态。 + * Git 状态 chip(v96):挂在工具行「电脑操作」按钮(v4-composer-cua-entry)右侧,样式移植 + * cc-pretty-statusline 的 git 段:⎇ 分支 ↑领先 ↓落后 (+插入行 -删除行 ?未跟踪文件)。 + * 数据由主进程旁路 git 查询按工作区路径推送(window.__zusageGitState),渲染端把各 pane + * 的本地路径写 window.__zusageGitPaths 供主进程取并集;远端 pane / 非仓库 / 无 git 不挂载。 + * 诊断:异常与挂载信息写 window.__zusageDiag,由主进程泵取回写 diag-.json。 */ +(function () { + if (window.__zusageOverlay) return; + /* 代际守卫保底;正常热更新先主动释放上一代资源。 */ + if (window.__zusageDispose) window.__zusageDispose(); + window.__zusageOverlay = true; + var disposed = false, coordinatorTimer = 0, bootTimer = 0; + var MY_GEN = (window.__zusageGen = (window.__zusageGen || 0) + 1); + function stale() { return disposed || MY_GEN !== window.__zusageGen; } + if (window.__zusageRateCleanup) window.__zusageRateCleanup(); + + var FATAL = (window.__zusageDiag = {}); + /* 多实例(v61.1):热更新时泵只按 id 清 0 号条,其余实例的 DOM(条/tip/气泡/面板) + * 在新代码启动时按类名统一自清;旧实例闭包靠 stale() 罢工。 + * 确定性历史行([data-zusage-deterministic],挂在消息流 turn 容器末尾)一并自清。 + * v80:面板(.zusage-panel)挂 body 不随条走,清理选择器单列。 */ + try { + document.querySelectorAll(".zusage-root,.zusage-tip-root,.zusage-exc,.zusage-rate-tip,.zusage-quota-tip,.zu-quota,.zu-git,.zu-compact-btn,.zu-compact-dlg,.zusage-git-tip,.zusage-panel,[data-zusage-deterministic],[data-zusage-loading-status]").forEach(function (el) { el.remove(); }); + } catch (e) { } + + try { + document.querySelectorAll("[data-zu-pad],[data-zu-old-pad]").forEach(function (el) { + el.style.paddingBottom = el.dataset.zuPad || el.dataset.zuOldPad || ""; + delete el.dataset.zuPad; + delete el.dataset.zuOldPad; + }); + document.querySelectorAll("[data-zu-cardpad]").forEach(function (el) { + el.style.marginBottom = el.dataset.zuCardPad || ""; + delete el.dataset.zuCardPad; + }); + } catch (e) { } + + /* ---------- 共享状态(多实例一致):显示项/语言/上下文覆盖/最近一次数据 ---------- */ + var LS = { show: "zusage3.show", ctxOv: "zusage3.ctxOv", lang: "zusage3.lang", cnNum: "zusage3.cnNum", sec: "zusage3.sec" }; + var state = { + show: { win: 1, turn: 1, today: 1, sub: 1, tools: 1, git: 1 }, // git:工具行 Git 状态 chip(v96,默认开) + ctxOv: "", data: null, + lang: "zh", pluginUpdate: window.__zusagePluginState || null, + /* 万/亿单位开关:缺省开启,仅显式 "0" 关闭;只在中文界面展示并生效(英文始终 K/M/B) */ + cnNum: 1, + /* v99 设置面板当前分区(非法值在 buildPanel 回退首分区,不在这里校验) */ + settingsSec: "display", + /* 逐问速率行开关:真值在运行时 config.json 的 answer_rate_line(主进程随插件状态推送 + rateLine 字段),渲染端只做展示与请求转发;缺省开启 */ + rateLine: !(window.__zusagePluginState && window.__zusagePluginState.rateLine === false), + /* v96 套餐额度开关:真值在 config.json 的 quota_enabled(主进程据此决定是否解密凭据 + 联网查询,并随插件状态推送 quotaEnabled);缺省关闭——读凭据联网属能力升级,须显式开启 */ + quotaEnabled: !!(window.__zusagePluginState && window.__zusagePluginState.quotaEnabled === true), + /* 阶段 3 多源配置列表(id/label/enabled/keyConfigured/status...,主进程 sourcesConfig 推送) */ + quotaSources: (window.__zusagePluginState && Array.isArray(window.__zusagePluginState.quotaSources) + ? window.__zusagePluginState.quotaSources : []), + }; + /* ---------- i18n(v57):L(中文, English) 内联双语文案,语言存 localStorage ---------- */ + function L(zh, en) { return state.lang === "en" ? en : zh; } + /* 万/亿单位是否生效:状态条 fmt()、占位统计行与 turn-text 请求共用同一判定 */ + function cnUnits() { return state.lang !== "en" && !!state.cnNum; } + function ls(k, d) { try { return localStorage.getItem(k) ?? d; } catch (e) { return d; } } + function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } + try { + var s0 = JSON.parse(ls(LS.show, "null")); + if (s0 && typeof s0 === "object") { + for (var k0 in state.show) if (k0 in s0) state.show[k0] = s0[k0] ? 1 : 0; + } + state.ctxOv = ls(LS.ctxOv, ""); + if (ls(LS.lang, "") === "en") state.lang = "en"; + if (ls(LS.cnNum, "") === "0") state.cnNum = 0; + var s1 = ls(LS.sec, ""); + if (s1) state.settingsSec = s1; + } catch (e) { } + function persist() { + lsSet(LS.show, JSON.stringify(state.show)); + lsSet(LS.ctxOv, state.ctxOv); + lsSet(LS.lang, state.lang); + lsSet(LS.cnNum, state.cnNum ? "1" : "0"); + lsSet(LS.sec, state.settingsSec); + } + + /* ---------- 多实例注册表(v61.1):每个分屏 pane 一个条实例 ---------- */ + var instances = []; // spawnInstance 返回的 api:{pid, attach, render, ...} + var nextSlot = 0; // 实例序号;0 号保留兼容 id(#zusage-bar,泵热更新按它清理) + function renderAll(rebuildPanels) { + if (rateTipFor) showRateTip(rateTipFor); + for (var i = 0; i < instances.length; i++) { + if (rebuildPanels) instances[i].rebuild(); // 语言切换等文案重建(各实例面板独立 DOM) + if (state.data) instances[i].render(state.data); + } + } + var remoteCache = {}; + var remoteTodayCache = {}; + /* v84(issue #30):渲染端当下读到的主叶工作台归属(refreshWants 每轮刷新)。泵推送的 + * scope_bindings 取自上一轮收集,主叶身份刚切换/撤销时旧绑定可能随迟到的远端响应到达; + * sessionFor 以此做守卫,不让旧代际绑定重新点亮远端视图。 */ + var mainScopeNow = { sid: "", identity: "" }; + function sessionFor(d, sid, pid) { + var binding = d.scope_bindings || {}; + // 空 identity 是显式本地绑定;真实 pane 缺绑定时也不能继承同 sid 的远端。 + var identity = Object.prototype.hasOwnProperty.call(binding, pid) ? binding[pid] + : (pid === "auto" && Object.prototype.hasOwnProperty.call(binding, sid) ? binding[sid] : ""); + var current; + if (pid !== "auto") { + current = window.__zusagePaneScopes && window.__zusagePaneScopes[pid]; + for (var n = 0; n < instances.length; n++) { + if (instances[n].pid !== pid || !instances[n].getScope) continue; + var scope = instances[n].getScope(); + current = scope || { sid: sid }; + break; + } + if (current) { + /* issue #40:无桥的客户端 refreshWants 只写 {sid}(拿不到身份),泵对同一 + * pane 只能查本地;但只有本窗完全没有远端证据(remote_evidence === false) + * 才按本地身份匹配,否则本地会话仍会被误杀或误亮。泵绑定非空(远端)而渲染 + * 端看不到身份时保持 fail-closed,不借同 sid 的本地快照。 */ + var hasIdentity = Object.prototype.hasOwnProperty.call(current, "identity"); + var currentIdentity = hasIdentity ? current.identity : ""; + if (current.sid !== sid || currentIdentity !== identity) return null; + if (!hasIdentity && d.remote_evidence !== false) return null; + } + } + if (!current && pid === "workspace-main" && typeof mainScopeNow !== "undefined" && mainScopeNow && mainScopeNow.known === false) return null; + /* v84(issue #30)主叶迟到载荷守卫。typeof 兼容测试夹具按源码片段抽取运行(无模块级声明): + * 夹具里 mainScopeNow 由 refreshWants 赋值成为全局;生产环境恒有模块级声明。 */ + if (!current && pid === "workspace-main" && identity && + (typeof mainScopeNow === "undefined" || !mainScopeNow || + mainScopeNow.sid !== sid || mainScopeNow.identity !== identity)) identity = ""; + var recent = d.recent || [], found = null; + for (var i = 0; i < recent.length; i++) { + var r = recent[i]; + if (r.sid === sid && (r.workspace_identity || "") === identity) { found = r; break; } + } + if (!identity) return found; + var target = found && found.remote_target || ""; + var key = JSON.stringify([sid, identity, target]); + if (found && found.remote && !found.remote_error && !found.remote_missing) { + if (target) remoteCache[key] = found; + return found; + } + // 只复用同目标的成功快照;identity 改指其它目标或目标未知时不能继承旧源数据。 + if (target && remoteCache[key]) return Object.assign({}, remoteCache[key], { + remote_error: (found && found.remote_error) || "remote data unavailable; showing last successful snapshot", + remote_missing: true, remote_stale: true, + }); + return found || { sid: sid, workspace_identity: identity, remote: true, remote_missing: true }; + } + window.__zusageUpdate = function (d) { + if (stale() || !d || d.error) return; + if (d.remote_today) for (var key in d.remote_today) { + if (d.remote_today[key] && typeof d.remote_today[key] === "object") remoteTodayCache[key] = d.remote_today[key]; + } + state.data = d; + for (var i = 0; i < instances.length; i++) { + try { instances[i].render(d); } catch (e) { FATAL.updateErr = String((e && e.stack) || e); } + } + }; + window.__zusagePluginRequests = []; + function pluginRequest(action, extra) { + var o = { action: action }; + if (extra !== undefined) { + if (extra && typeof extra === "object") Object.assign(o, extra); + else o.enabled = extra; // 旧调用(action, boolean)线格式不变 + } + window.__zusagePluginRequests.push(o); + } + /* ---------- Git 状态 chip 数据通道(v96):主进程旁路 git 查询 → 按工作区路径缓存 ---------- + * 主进程每 10s 取各窗口 window.__zusageGitPaths 并集逐路径查询,结果推 window.__zusageGitState; + * 渲染端只缓存与分发,实例按自身 scope.workspacePath 取用。失败不清空最近成功态(标 stale), + * 只有明确「非仓库 / 无 git」才视为无数据不挂载。 */ + var gitStateByPath = {}, gitLastGood = {}, gitAskedAt = 0; + var GIT_ASK_MIN_MS = 5000; // 快照驱动的重查节流;点击刷新与路径集变化不受限 + function gitViewFor(p) { + var cur = gitStateByPath[p]; + if (!cur) return null; + if (cur.ok) return { st: cur, stale: false }; + if (cur.reason === "notrepo" || cur.reason === "nogit") return null; + var good = gitLastGood[p]; + return good ? { st: good, stale: true } : null; + } + function askGitRefresh(force) { + var paths = window.__zusageGitPaths; + if (!paths || !paths.length) return; + var now = Date.now(); + if (!force && now - gitAskedAt < GIT_ASK_MIN_MS) return; + gitAskedAt = now; + pluginRequest("git-refresh"); + } + window.__zusageGitState = function (p, st) { + if (stale() || typeof p !== "string" || !st || typeof st !== "object") return; + gitStateByPath[p] = st; + if (st.ok) gitLastGood[p] = st; + for (var i = 0; i < instances.length; i++) { + try { instances[i].gitTouch(); } catch (e) { FATAL.gitErr = String((e && e.stack) || e); } + } + }; + window.__zusagePluginUpdate = function (s) { + if (stale() || JSON.stringify(state.pluginUpdate) === JSON.stringify(s)) return; + window.__zusagePluginState = state.pluginUpdate = s; + if (s && typeof s.rateLine === "boolean") state.rateLine = s.rateLine; + if (s && typeof s.quotaEnabled === "boolean") state.quotaEnabled = s.quotaEnabled; + if (s && Array.isArray(s.quotaSources)) state.quotaSources = s.quotaSources; // v95:chip 门控随主进程真值(heavy 对账点内生效) + scanRateLines(); + instances.forEach(function (inst) { inst.syncUpdate(); }); + }; + /* 工作区身份来自当前会话节点;共享 localStorage 不能证明本窗口的 pane 归属。 */ + function collectSidWs() { + var map = {}; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i], sid = inst.getSid(); + var scope = inst.getScope && inst.getScope(); + if (!sid || !scope || scope.sid !== sid || !REMOTE_IDENTITY_RE.test(scope.identity)) continue; + if (!map[sid]) map[sid] = []; + if (map[sid].indexOf(scope.identity) < 0) map[sid].push(scope.identity); + } + return map; + } + /* 可信远端身份前缀白名单(与泵侧 REMOTE_IDENTITY_RE 同族):ssh/wsl/docker/server。 + * v3.16 客户端把所有传输连接握手后统一升级为 remote:server:,wsl/docker 是直接传输身份。 */ + var REMOTE_IDENTITY_RE = /^remote:(?:ssh|wsl|docker|server):/; + function readPaneScope(el, sid, pid) { + if (!el || !sid || sid === "draft" || !el.isConnected) return null; + try { + var root = el; + while (root && !(root.hasAttribute && root.hasAttribute("data-session-id"))) root = root.parentElement; + if (!root || root.getAttribute("data-session-id") !== sid || + root.getAttribute("data-zusage-scope-version") !== "1" || + root.getAttribute("data-zusage-pane-id") !== pid) return null; + var path = root.getAttribute("data-zusage-workspace-path"); + var identity = root.getAttribute("data-zusage-workspace-identity"); + var remote = root.getAttribute("data-zusage-remote-session-id"); + if (typeof path !== "string" || !path.trim() || typeof identity !== "string" || + typeof remote !== "string" || /[\x00-\x1f\x7f]/.test(path + identity + remote)) return null; + identity = identity.trim(); + if (remote && (!identity || /\s/.test(remote))) return null; + if (identity && !REMOTE_IDENTITY_RE.test(identity)) return null; + return { sid: sid, identity: identity, workspacePath: path, remoteSessionId: remote }; + } catch (e) { return null; } + } + var quotaSelectionsSig = ""; + function reportQuotaSelections() { + if (stale()) return; + var panes = {}; + for (var i = 0; i < instances.length; i++) { + var selection = instances[i].getQuotaSelection && instances[i].getQuotaSelection(); + if (selection) panes[instances[i].pid] = selection; + } + var sig = JSON.stringify(panes); + if (sig === quotaSelectionsSig) return; + quotaSelectionsSig = sig; + FATAL.quotaSelections = { time: new Date().toISOString(), panes: panes }; + var queue = window.__zusagePluginRequests || []; + for (var j = queue.length - 1; j >= 0; j--) if (queue[j].action === "quota-providers") queue.splice(j, 1); + pluginRequest("quota-providers", { panes: panes }); + } + function refreshWants() { + try { + var arr = [], panes = {}; + mainScopeNow = { sid: "", identity: "", known: false }; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i], sid = inst.getSid(); + if (!sid) continue; + if (arr.indexOf(sid) < 0) arr.push(sid); + var scope = inst.getScope && inst.getScope(); + if (!scope || scope.sid !== sid) scope = null; + panes[inst.pid] = scope ? { sid: sid, identity: scope.identity } : { sid: sid }; + if (inst.pid === "workspace-main") mainScopeNow = { sid: sid, identity: scope ? scope.identity : "", known: !!scope }; + } + window.__zusageWantSids = arr; + window.__zusageWantSid = arr[0] || ""; + window.__zusageSidWs = collectSidWs(); + window.__zusagePaneScopes = panes; + publishGitPaths(); + } catch (e) { } + } + /* v96:Git chip 关注的本地工作区路径集 → window.__zusageGitPaths(主进程每 10s 取并集查询)。 + * identity 为空才是本地;远端 pane 不上报(主进程不替宿主跨机跑 git);开关关闭为空集。 + * 每次 render 都经 refreshWants 到这里:路径集变化立即请求首查,否则按快照节奏节流重查 + * (快照到达 = 刚有请求落库,模型可能刚改过文件)。 */ + function publishGitPaths() { + try { + var gitPaths = []; + if (state.show.git) for (var i = 0; i < instances.length; i++) { + var inst = instances[i], sid = inst.getSid(); + var scope = sid && inst.getScope ? inst.getScope() : null; + if (scope && scope.sid === sid && !scope.identity && gitPaths.indexOf(scope.workspacePath) < 0) gitPaths.push(scope.workspacePath); + } + var changed = JSON.stringify(gitPaths) !== JSON.stringify(window.__zusageGitPaths || []); + window.__zusageGitPaths = gitPaths; + askGitRefresh(changed); + } catch (e) { } + } + + /* ---------- 回复末尾速率行的悬停解释(v67):给消息流里的统计行挂同款 tooltip ---------- + * 统计行是模型贴的 Markdown 引用块(⚡ … tok/s(本轮)),本身没有 hover 能力; + * 浮层是注入 DOM 的,所以由它扫描消息流、给匹配节点挂自绘 tooltip(与状态条同套路: + * 挂 body 的 fixed 浮层,免疫消息流层叠上下文)。document 级、只此一份,不随 pane 实例走。 */ + var rateTip = document.createElement("div"); + rateTip.className = "tip zusage-rate-tip"; + /* .tip 的类样式挂在条实例的 ' + + '
⚡ ' + L("本问速率 · 各数值含义", "Rate line · what each figure means") + "
" + + row("token/s", L("本问各段加权平均:总输出 ÷ 总生成时长,工具等待不计入", "weighted average of all segments: total output ÷ total generation time, tool waits excluded")) + + row(L("首字", "TTFT"), L("第一段从发出请求到首个 token 的等待", "wait from request to the first token of the first segment")) + + row(L("输出", "Output"), L("本问各段输出合计(思考 token 已含其中)", "sum of segment output (thinking tokens already included)")) + + row(L("生成", "Gen"), L("各段生成窗口合计:含思考(含 Claude 这类不单独上报思考的模型)用 completed_at − started_at,否则用 completed_at − first_token_at;不含段间工具等待", "sum of segment generation windows: with thinking (including models such as Claude that don't report it separately) use completed_at − started_at, otherwise completed_at − first_token_at; tool waits between segments excluded")) + + row(L("段 / 峰", "Seg/Peak"), L("段=模型请求次数;峰=最快一段的速率", "segments = model requests; peak = fastest segment")) + + row(L("累计", "Session"), L("本会话已完成请求的输出总量(不含子代理)", "cumulative output of completed requests (sub-agents excluded)")) + + row("⏱", L("采样时刻——写正文前自测,收尾正文段未计入", "sample time — self-tested before the final text, so that segment is excluded")) + + '
' + L("状态条「生成速度」是最近一段的瞬时值,与本行的整问均值不同,数值有差异属正常。⚙ 设置可关。", + "The bar's \"generation speed\" is the latest segment's instant value; this line is the whole-answer average — a difference is expected. Toggle in ⚙ settings.") + "
"; + } + function showRateTip(el) { + if (stale() || !state.rateLine || !isRateLine(el)) { hideRateTip(); return; } + rateTip.innerHTML = rateTipHTML(); + rateTip.style.display = "block"; + var r = el.getBoundingClientRect(), tw = rateTip.offsetWidth, th = rateTip.offsetHeight; + var vw = window.innerWidth, vh = window.innerHeight; + var left = Math.max(8, Math.min(r.left, vw - tw - 8)); + var top = r.top - th - 8; // 优先弹在统计行上方 + if (top < 8) top = Math.min(r.bottom + 8, vh - th - 8); // 上方放不下改下方 + rateTip.style.left = Math.round(left) + "px"; + rateTip.style.top = Math.round(top) + "px"; + rateTipFor = el; + } + function hideRateTip() { + rateTip.style.display = "none"; + rateTipFor = null; + } + + /* ---------- v96 套餐额度 chip 的悬停说明:rateTip 同款 document 级独立浮层 ---------- + * chip 挂在宿主工具栏(条外),状态条 .it 的 tooltip 事件委托覆盖不到它;mouseenter/ + * leave 自管理,内容按 chip.__zuQ(render 时写入的最近快照)在悬停时即时生成。 */ + var quotaTip = document.createElement("div"); + quotaTip.className = "tip zusage-quota-tip"; + quotaTip.style.cssText = rateTip.style.cssText; + document.body.appendChild(quotaTip); + var quotaTipFor = null; + function qTime(v) { // 统一 yyyy-MM-dd HH:mm;接受毫秒数或 ISO 字符串 + if (v == null || v === "") return ""; + var d = typeof v === "number" ? new Date(v) : new Date(String(v)); + if (isNaN(d.getTime())) return ""; + var pad = function (n) { return (n < 10 ? "0" : "") + n; }; + return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()); + } + function quotaFamilyOf(provider) { // 内置套餐家族:OAuth 的 account:* 与 apiKey 模式的 bigmodel-api[-N] / zai-api[-N] + var p = String(provider || ""); + if (p.indexOf("account:zai") === 0 || /^zai-api(-\d+)?$/.test(p)) return "zai"; + if (p.indexOf("account:bigmodel") === 0 || /^bigmodel-api(-\d+)?$/.test(p)) return "bigmodel"; + return ""; + } + function quotaFamilyLabel(provider) { return quotaFamilyOf(provider) === "zai" ? "Z.ai" : "BigModel"; } + function quotaLevelLabel(level) { + var m = { max: "Max", pro: "Pro", lite: "Lite" }; + return m[String(level || "").toLowerCase()] || String(level || ""); + } + function quotaStatusText(st) { + return { + auth_failed: L("认证失效:重新登录 ZCode 后自动恢复", "Authentication failed: recovers after signing in to ZCode again"), + rate_limited: L("接口限流,稍后自动重试", "Rate limited; retrying later"), + network_error: L("网络错误,稍后自动重试", "Network error; retrying later"), + parse_error: L("接口返回格式变化,已降低查询频率", "Unexpected response format; polling slowed down"), + no_plan: L("当前账号未订阅编码套餐", "No coding plan on this account"), + }[st] || ""; + } + function quotaTipHTML(d) { + var esc = function (s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }; + var row = function (label, text) { return '
' + label + "" + text + "
"; }; + var css = ''; + if (d.mode.kind === "team") { + return css + '
⊘ ' + L("团队套餐额度", "Team plan quota") + "
" + + '
' + L("团队套餐需要组织/项目维度的查询,当前版本暂不支持;个人编码套餐已支持。", "Team plans need organization/project-scoped queries, which this version does not support yet; individual coding plans are supported.") + "
"; + } + var q = d.q || {}; + var isBalance = d.mode.kind === "balance"; + var title = isBalance ? L("账户余额", "Account balance") : L("套餐额度", "Plan quota"); + /* 标题:有订阅元数据(productName/version/valid)时拼成一行;缺失回退 family+档位 */ + var pl = q.plan; + /* 周期标签:renewAt=自动续费的下次扣费日(不是最终到期,宿主同款互斥语义); + * validEnd=非自动续费的当前权益结束日;两者都缺(周期无法确认)只显示套餐名。 */ + var periodLabel = pl && pl.renewAt + ? L(" · 下次续费 ", " · renews ") + esc(pl.renewAt) + : pl && pl.validEnd ? L(" · 有效期至 ", " · valid until ") + esc(pl.validEnd) : ""; + var head = (pl && (pl.productName || pl.version)) + ? [pl.productName, pl.version].filter(Boolean).map(esc).join(" · ") + periodLabel + : esc(q.label || (String(d.provider || "").indexOf("account:") === 0 || quotaFamilyOf(d.provider) ? quotaFamilyLabel(d.provider) : L("自定义供应商", "Custom provider"))) + + (q.level ? " " + esc(quotaLevelLabel(q.level)) : ""); + var out = css + '
' + title + " · " + head + "
"; + var w = q.windows || {}; + var winRow = function (label, e) { + if (!e) return ""; + var pct = typeof e.percent === "number" ? (Math.round(e.percent * 10) / 10) + "%" : "—"; + var rs = qTime(e.resetsAt); + var detail = (e.used != null && e.total != null && e.remaining != null) + ? " · " + quotaBalanceText({ amount: e.used, currency: e.currency }) + " / " + + quotaBalanceText({ amount: e.total, currency: e.currency }) + " · " + L("剩余 ", "remaining ") + + quotaBalanceText({ amount: e.remaining, currency: e.currency }) + : ""; // sub2api 等带金额明细的窗口拼进行内;BigModel 套餐窗只有百分比则不拼 + return row(label, L("已用 ", "used ") + pct + detail + (rs ? " · " + L("重置 ", "resets ") + rs : "")); + }; + out += winRow(L("每日窗口", "Daily window"), w.daily) + winRow(L("5小时窗口", "5-hour window"), w.fiveHour) + + winRow(L("本周窗口", "This week"), w.weekly) + winRow(L("月度窗口", "Monthly"), w.monthly); + if (q.quotaSummary) { + var summary = q.quotaSummary; + out += row(L("Key 总额度", "Key quota"), L("已用 ", "used ") + + quotaBalanceText({ amount: summary.used, currency: summary.currency }) + " / " + + quotaBalanceText({ amount: summary.limit, currency: summary.currency }) + " · " + L("剩余 ", "remaining ") + + quotaBalanceText({ amount: summary.remaining, currency: summary.currency })); + } + if (q.subscription && q.subscription.expiresAt) { + out += row(L("订阅截止", "Subscription ends"), esc(q.subscription.expiresAt)); + } + if (q.balance && typeof q.balance.amount === "number" && isFinite(q.balance.amount)) { + var balTxt = quotaBalanceText(q.balance); + if (q.balance.parts && q.balance.parts.length) { + balTxt += "(" + q.balance.parts.map(function (pp) { return esc(pp.label) + " " + pp.amount; }).join(" / ") + ")"; + } + out += row(L("余额", "Balance"), esc(balTxt)); + } + if (d.all && d.all.sources && typeof d.all.sources === "object") { // 其余启用源的摘要(tooltip 分家列全) + var others = []; + for (var sid in d.all.sources) { + if (!Object.prototype.hasOwnProperty.call(d.all.sources, sid) || sid === d.all.active) continue; + var sv = d.all.sources[sid]; + if (!sv || sv.status !== "ok") continue; + var sum = sv.balance && typeof sv.balance.amount === "number" ? quotaBalanceText(sv.balance) + : (sv.windows ? ["fiveHour", "weekly", "monthly"].map(function (k) { + return sv.windows[k] && typeof sv.windows[k].percent === "number" ? Math.round(sv.windows[k].percent) + "%" : null; + }).filter(Boolean).join(" ") : "✓"); + others.push((d.srcLabels && d.srcLabels[sid] || sid) + " " + sum); + } + if (others.length) out += row(L("其他源", "Other sources"), esc(others.join(" · "))); + } + if (q.tools && (q.tools.total != null || q.tools.used != null)) { + var tr = qTime(q.tools.resetsAt); + out += row(L("内置工具", "Built-in tools"), (q.tools.used == null ? "—" : q.tools.used) + " / " + (q.tools.total == null ? "—" : q.tools.total) + + L(" 次", " calls") + (tr ? " · " + L("重置 ", "resets ") + tr : "")); + } + var models = Array.isArray(q.models) ? q.models : [], cur = String(d.model || ""); + var scope = models.length + ? models.map(function (m) { return String(m).toLowerCase() === cur.toLowerCase() ? '' + esc(m) + "" : esc(m); }).join(", ") + : L("套餐内全部模型", "all models in the plan") + (cur ? L("(当前 ", " (current ") + '' + esc(cur) + "" + L(")", ")") : ""); + if (isBalance) out += row(L("当前模型", "Current model"), esc(cur || "—")); + else out += row(L("覆盖范围", "Covers"), scope); + var foot = (q.fetchedAt ? L("更新于 ", "updated ") + qTime(q.fetchedAt) : "") + (q.stale ? L("(已过期,等待刷新)", " (stale, refreshing)") : ""); + var err = q.status && q.status !== "ok" && q.status !== "idle" ? quotaStatusText(q.status) : ""; + if (err) foot += (foot ? " · " : "") + '⚠ ' + err + ""; + out += '
' + (foot ? foot + "
" : "") + + (isBalance + ? L("余额属于当前供应商账号或 API Key,不是单模型独享。点击 chip 立即刷新。", "Balance belongs to the provider account or API key, not one model. Click the chip to refresh.") + : L("套餐池为账号级:同账号所有模型共用,不是单个模型独享。点击 chip 立即刷新。", "The pool is account-level: shared by every model on this account, not per model. Click the chip to refresh.")) + "
"; + return out; + } + function showQuotaTip(el) { + if (stale() || !el || !el.isConnected || !el.__zuQ) { hideQuotaTip(); return; } + quotaTip.innerHTML = quotaTipHTML(el.__zuQ); + quotaTip.style.display = "block"; + var r = el.getBoundingClientRect(), tw = quotaTip.offsetWidth, th = quotaTip.offsetHeight; + var vw = window.innerWidth, vh = window.innerHeight; + var left = Math.max(8, Math.min(r.left, vw - tw - 8)); + var top = r.top - th - 8; // 优先弹在 chip 上方 + if (top < 8) top = Math.min(r.bottom + 8, vh - th - 8); + quotaTip.style.left = Math.round(left) + "px"; + quotaTip.style.top = Math.round(top) + "px"; + quotaTipFor = el; + } + function hideQuotaTip() { + quotaTip.style.display = "none"; + quotaTipFor = null; + } + function onQuotaEnter() { showQuotaTip(this); } + /* 余额文本(文档级:tooltip 与实例 chip 共用)。CNY→¥、USD→$,其它带代码前缀。 */ + function quotaBalanceText(bal) { + var cur = String(bal.currency || ""); + var sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : (cur ? cur + " " : ""); + return sym + Number(bal.amount).toFixed(2); + } + /* 客户端会过滤回复 HTML 的 style/class;提醒先用 Markdown 加粗,加载浮层后再着色。 */ + var noticeStyle = document.createElement("style"); + noticeStyle.textContent = '[data-zusage-notice="' + MY_GEN + '"], [data-zusage-notice="' + MY_GEN + '"] * {' + + "color:#ef4444!important;font-weight:700!important}"; + document.body.appendChild(noticeStyle); + var noticeNodes = []; + function isPluginNotice(el) { + var parent = el.parentElement; + return document.body.contains(el) && parent && /^(P|BLOCKQUOTE)$/.test(parent.tagName) && + (el.textContent || "").trim().indexOf("🔴 zcode-token-usage-statusbar 插件提醒:") === 0 && + (parent.textContent || "").trim() === (el.textContent || "").trim() && + el.closest('blockquote') && el.closest('[class~="group/assistant-row"]') && + !el.closest('pre, code, [data-v4-user-input-bubble], [data-markdown-preview], .zusage-root, .zusage-rate-tip'); + } + function clearNotice(el) { + if (el.dataset.zusageNotice === String(MY_GEN)) delete el.dataset.zusageNotice; + } + function scanPluginNotices() { + noticeNodes = noticeNodes.filter(function (el) { + if (isPluginNotice(el)) return true; + clearNotice(el); + return false; + }); + document.querySelectorAll("blockquote strong").forEach(function (el) { + if (el.dataset.zusageNotice === String(MY_GEN) || !isPluginNotice(el)) return; + el.dataset.zusageNotice = String(MY_GEN); + noticeNodes.push(el); + }); + } + var rateBindings = []; + function isRateLine(el) { + var t = el.textContent || ""; + /* 「tok/s(本轮)」是 v85 前的旧标记:模型时代贴进历史的行与升级前已缓存的富文本仍带它 */ + return document.body.contains(el) && (t.indexOf("token/s(本轮)") >= 0 || t.indexOf("tok/s(本轮)") >= 0) && t.indexOf("⚡") >= 0 && + !(el.tagName === "BLOCKQUOTE" && el.querySelector("p")); + } + function unbindRateLine(binding) { + var el = binding.el; + el.removeEventListener("mouseenter", onRateEnter); + el.removeEventListener("mouseleave", hideRateTip); + if (el.dataset.zusageRateTip === String(MY_GEN)) delete el.dataset.zusageRateTip; + if (el.style.cursor === "help") el.style.cursor = binding.cursor; + } + function onRateEnter() { showRateTip(this); } + function scanRateLines() { + if (stale()) { cleanupRateTip(); return; } + /* chip 随宿主重建被替换时浏览器不发 mouseleave,tooltip 会悬挂;豁免滚动收起(v106) + * 后此处是 detach 场景的兜底健康检查(500ms 周期,与速率行扫描共用足迹) */ + if (quotaTipFor && !quotaTipFor.isConnected) hideQuotaTip(); + scanPluginNotices(); + rateBindings = rateBindings.filter(function (binding) { + if (state.rateLine && isRateLine(binding.el)) return true; + unbindRateLine(binding); + return false; + }); + if (!state.rateLine) { hideRateTip(); return; } + var nodes = document.querySelectorAll("blockquote p, blockquote, p"); + for (var i = 0; i < nodes.length; i++) { + var el = nodes[i]; + if (el.dataset.zusageRateTip === String(MY_GEN) || !isRateLine(el)) continue; + rateBindings.push({ el: el, cursor: el.style.cursor }); + el.dataset.zusageRateTip = String(MY_GEN); + el.style.cursor = "help"; + el.addEventListener("mouseenter", onRateEnter); + el.addEventListener("mouseleave", hideRateTip); + } + if (rateTipFor && !isRateLine(rateTipFor)) hideRateTip(); + } + function cleanupRateTip() { + clearInterval(rateScanTimer); + rateBindings.forEach(unbindRateLine); + rateBindings = []; + noticeNodes.forEach(clearNotice); + noticeNodes = []; + noticeStyle.remove(); + hideRateTip(); + rateTip.remove(); + hideQuotaTip(); + quotaTip.remove(); + window.removeEventListener("scroll", hideTipsOnScroll, true); + if (window.__zusageRateCleanup === cleanupRateTip) delete window.__zusageRateCleanup; + } + /* 捕获:任意容器滚动即收——仅速率行浮层需要(宿主在消息流正文内,滚动即移位,fixed 浮层 + * 不跟随即指向错误位置)。额度 chip 与 git chip 同款固定在 composer 工具行,消息流滚动 + * 不移动它,豁免(v106):思考中流式输出驱动聊天容器持续自动滚动,若一并收起则 tooltip + * 悬停弹出即灭。quotaTip 由 mouseleave/blur/代际清理/开关关闭收起;chip 随宿主重建被 + * 替换(无 mouseleave)由 scanRateLines 的 isConnected 健康检查兜底。 */ + function hideTipsOnScroll() { hideRateTip(); } + window.addEventListener("scroll", hideTipsOnScroll, true); + /* v88:扫描与心跳统一 0.5s(用户要求计时 0.5s 步进;富文本重试上限同步放大保持 ~12s 等库窗口) */ + var rateScanTimer = setInterval(scanRateLines, 500); + window.__zusageRateCleanup = cleanupRateTip; + scanRateLines(); + + /* ---------- Git chip 的悬停说明(v96):document 级单例,同 rateTip 套路(挂 body、内联兜底样式) ---------- + * 内容按 chip.__zuGit({path, st, stale})渲染;分支名/上游/路径是用户数据,一律过 gitEsc。 */ + var gitTip = document.createElement("div"); + gitTip.className = "tip zusage-git-tip"; + gitTip.style.cssText = rateTip.style.cssText; + document.body.appendChild(gitTip); + var gitTipFor = null; + function gitEsc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } + function gitTimeStr(ts) { // yyyy-MM-dd HH:mm:ss(与额度 chip 的 tooltip 时间格式约定一致,用户要求带秒) + try { + var t = new Date(ts), p = function (n) { return ("0" + n).slice(-2); }; + return t.getFullYear() + "-" + p(t.getMonth() + 1) + "-" + p(t.getDate()) + " " + p(t.getHours()) + ":" + p(t.getMinutes()) + ":" + p(t.getSeconds()); + } catch (e) { return ""; } + } + /* 进行中 Git 操作的展示名(主进程 fs 探针 detectGitOp 的代码 → 双语标签;am 主栏短标签「补丁」) */ + var GIT_OP_LABELS = { rebase: ["变基", "rebase"], merge: ["合并", "merge"], "cherry-pick": ["拣选", "cherry-pick"], revert: ["还原", "revert"], bisect: ["二分", "bisect"], am: ["补丁", "patch"] }; + function gitOpLabel(code) { var m = GIT_OP_LABELS[code]; return m ? L(m[0], m[1]) : code; } + /* v96 multi:多仓库聚合口径(chip/aria/tooltip 共用)——总数、脏仓数(成功仓 changed>0 或 + * untracked>0)、改动/未跟踪文件求和、失败仓数、任一成功仓有冲突;聚合数由渲染端算,主进程不提供。 */ + function gitMultiAgg(st) { + var repos = st && Array.isArray(st.repos) ? st.repos : []; + /* known=已确认仓库(成功或携带旧快照的失败仓);changed/conflict/op 只在确认数据上统计; + * 旧缓存(stale)里的冲突不汇总为已确认冲突,但其改动计入已知改动。 */ + var agg = { total: repos.length, known: 0, changed: 0, conflict: 0, op: 0, failed: 0, staleAny: false, m: 0, q: 0 }; + for (var i = 0; i < repos.length; i++) { + var r = repos[i]; + if (r.err) { + agg.failed++; + if (r.stale) { + agg.known++; agg.staleAny = true; + if ((r.changed | 0) > 0 || (r.untracked | 0) > 0) agg.changed++; + } + continue; + } + agg.known++; + if (r.stale) agg.staleAny = true; + if ((r.changed | 0) > 0 || (r.untracked | 0) > 0) agg.changed++; + if ((r.conflicts | 0) > 0 && !r.stale) agg.conflict++; + if (r.op && !r.stale) agg.op++; + agg.m += r.changed | 0; + agg.q += r.untracked | 0; + } + return agg; + } + /* tooltip 行样式单/多仓共用:rt-row 补 min-width:0 允许 flex 行收缩防长仓库名溢出; + * zu-gname=多仓行仓库名(可收缩断行,main 前景色 600 字重),zu-gconf=冲突红, + * c-mod/c-unt=已跟踪变更/未跟踪(黄),c-warn=失败/过期/未知(黄),c-op=操作名(中性), + * zu-git-rows=多仓逐仓列表滚动容器;data-zusage-light 切浅色语义档(showGitTip 随 chip 同步)。 */ + var GIT_TIP_CSS = ''; + function gitTipHTML(view) { + if (view.st.kind === "multi") return gitMultiTipHTML(view); + var st = view.st, row = function (label, text) { return '
' + label + "" + text + "
"; }; + var items = function (n) { return n + L(" 项", n === 1 ? " entry" : " entries"); }; // porcelain 条目:重命名计 1、未跟踪目录折叠计 1,不冒充文件数 + var up = st.upstream ? gitEsc(st.upstream) + (st.ahead > 0 ? " ↑" + st.ahead : "") + (st.behind > 0 ? " ↓" + st.behind : "") : L("未设置上游分支(↑↓ 不可用)", "no upstream set (ahead/behind unavailable)"); + var when = gitTimeStr(st.ts); + return GIT_TIP_CSS + + '
⎇ ' + L("Git 状态", "Git status") + (view.stale ? ' ' + L("· 上次查询失败,显示的是最近成功结果", "· last query failed; showing the last good result") + "" : "") + "
" + + row(L("分支", "Branch"), st.detached ? L("游离 HEAD(不在任何分支上)", "detached HEAD (on no branch)") : gitEsc(st.branch || "") + (st.noCommits ? L("(尚无首次提交)", " (no initial commit yet)") : "")) + + row(L("上游", "Upstream"), up) + + row(L("已暂存", "Staged"), items(st.staged)) + + row(L("未暂存", "Unstaged"), items(st.unstaged)) + + row(L("未跟踪", "Untracked"), items(st.untracked) + L("(目录折叠计 1)", " (collapsed dirs count as 1)")) + + (st.conflicts > 0 ? row(L("冲突", "Conflicts"), '!' + items(st.conflicts) + "") : "") + + (st.op ? row(L("进行中操作", "Git operation"), gitEsc(gitOpLabel(st.op))) : "") + + row(L("改动行数", "Diff lines"), "+" + st.ins + " / -" + st.del + L(" 行(工作区相对 HEAD 的合计口径,非净差异)", " lines (worktree vs HEAD, combined—not a net diff)")) + + row(L("目录", "Directory"), gitEsc(st.repoPath || view.path)) + // v96:repoPath=真实仓库绝对路径,缺省回退工作区路径 + (when ? row(L("更新于", "Updated"), when) : "") + + '
' + L("只读本地仓库,不自动 fetch(上游比较基于本地引用);远端会话不显示。⚙ 设置可关。", + "Local repositories read-only, never auto-fetches (upstream compares against local refs); hidden for remote sessions. Toggle in ⚙ settings.") + "
"; + } + /* v96 multi tooltip:概览(标题 / 有改动仓库与非零冲突·操作·失败 / 覆盖行)→ 逐仓全量列表 + * (优先级:冲突 0 < 失败或过期 1 < 操作中 2 < 有改动 3 < 上游差异 4 < 干净 5;同级码点序, + * 不用 localeCompare;干净组前插分隔行,所有行都在滚动容器内可访问)→ 元信息(工作目录 / + * 更新时间 / 口径)。失败仓携带的旧快照标「过期」,其冲突不汇总为已确认。仓库名/分支/路径 + * 是用户数据,一律过 gitEsc/属性转义。 */ + function gitMultiTipHTML(view) { + var st = view.st, agg = gitMultiAgg(st); + var row = function (label, text) { return '
' + label + "" + text + "
"; }; + var attr = function (s) { return gitEsc(s).replace(/"/g, """); }; // title 属性需转义引号 + var rrow = function (name, text, title) { return '
" + name + '' + text + "
"; }; + var rank = function (r) { + if (r.err || r.stale) return 1; + if ((r.conflicts | 0) > 0) return 0; + if (r.op) return 2; + if ((r.changed | 0) > 0 || (r.untracked | 0) > 0) return 3; + if ((r.ahead | 0) > 0 || (r.behind | 0) > 0) return 4; + return 5; + }; + var repos = (st.repos || []).slice(); // 排序不动主进程推送的原数组;与 gitMultiAgg 同款防御 + repos.sort(function (a, b) { var ra = rank(a), rb = rank(b); return ra !== rb ? ra - rb : a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }); + var h = GIT_TIP_CSS + '
⎇ ' + L("Git 状态", "Git status") + L(" · " + agg.total + " 个仓库", " · " + agg.total + " repos") + "
"; + var ov = L("有改动 " + agg.changed + " 个仓库", "" + agg.changed + " repos with changes"); + if (agg.conflict > 0) ov += ' · !' + agg.conflict + '' + L(" 个仓库冲突", " repos conflicted") + ""; + if (agg.op > 0) ov += ' · ' + agg.op + '' + L(" 个仓库操作中", " repos mid-operation") + ""; + if (agg.failed > 0) ov += ' · ' + agg.failed + '' + L(" 个查询失败", " failed") + ""; + if (view.stale) ov += ' · ' + L("上次查询失败,显示的是最近成功结果", "last query failed; showing the last good result") + ""; + h += '
' + ov + "
"; + if (agg.m > 0 || agg.q > 0) { + var sm = []; + if (agg.m > 0) sm.push(L("已跟踪变更 " + agg.m + " 项", "" + agg.m + " tracked-change entries")); + if (agg.q > 0) sm.push(L("未跟踪 " + agg.q + " 项", "" + agg.q + " untracked entries")); + h += '
' + sm.join(' · ') + "
"; + } + if (agg.known < agg.total) h += '
' + L("已确认 " + agg.known + "/" + agg.total + " 个仓库", "Confirmed " + agg.known + "/" + agg.total + " repos") + (agg.failed ? " · " + L(agg.failed + " 个查询失败", agg.failed + " failed") : "") + "
"; + var cleanTotal = 0, focused = 0, divDone = false, rows = ""; + for (var i = 0; i < repos.length; i++) { if (rank(repos[i]) === 5) cleanTotal++; else focused++; } + for (var j = 0; j < repos.length; j++) { + var r = repos[j], rk = rank(r); + if (rk === 5 && focused > 0 && cleanTotal > 0 && !divDone) { // 干净组分隔行(其余仓仍全部可访问) + rows += '
' + L("其余 " + cleanTotal + " 个仓库,无本地改动", cleanTotal + " more repos with no local changes") + "
"; + divDone = true; + } + if (r.err) { + var msg = { notrepo: L("非 Git 仓库", "not a git repository"), nogit: L("未找到 git", "git not found"), timeout: L("超时", "timed out"), error: L("出错", "error") }[r.err] || r.err; + var t = '⚠ ' + L("查询失败", "query failed") + "(" + gitEsc(msg) + ")"; + if (r.stale) { // 携带旧快照:标注历史属性并展示上次分支与改动(旧冲突不作已确认,不在此渲染) + t += ' ' + L("过期;以下为上次结果", "stale; showing last good result") + ""; + if (r.branch) t += ' ' + gitEsc(r.branch) + ""; + if ((r.changed | 0) > 0) t += ' ' + L("改 " + r.changed + " 项", r.changed + " mod") + ""; + if ((r.untracked | 0) > 0) t += ' ' + L("新 " + r.untracked + " 项", r.untracked + " new") + ""; + } + rows += rrow(gitEsc(r.name), t, attr(r.path)); + continue; + } + var seg = '' + (r.detached ? L("HEAD(游离)", "HEAD (detached)") : gitEsc(r.branch || (r.noCommits ? L("初始", "init") : ""))) + (r.noCommits && !r.detached ? L("(尚无首次提交)", " (no initial commit yet)") : "") + ""; + if (r.op && !r.stale) seg += ' ' + gitEsc(gitOpLabel(r.op)) + ""; + if (r.stale) seg += ' ' + L("过期", "stale") + ""; + if ((r.conflicts | 0) > 0) seg += ' !' + r.conflicts + L(" 冲突", " conflicted") + ""; + if ((r.changed | 0) > 0) seg += ' ' + L("改 " + r.changed + " 项", r.changed + " mod") + ""; + if ((r.untracked | 0) > 0) seg += ' ' + L("新 " + r.untracked + " 项", r.untracked + " new") + ""; + if ((r.ahead | 0) > 0) seg += " ↑" + r.ahead; + if ((r.behind | 0) > 0) seg += " ↓" + r.behind; + rows += rrow(gitEsc(r.name), seg, attr(r.path)); // title=完整路径(宿主原生提示可访问完整值) + } + if (st.more > 0) rows += '
' + L("另有 " + st.more + " 个子仓库未统计", st.more + " more repos not scanned") + "
"; + h += '
' + rows + "
"; + h += row(L("工作目录", "Work dir"), gitEsc(view.path) + L(",下含 " + agg.total + " 个仓库", " · " + agg.total + " repos")); + var when = gitTimeStr(st.ts); + h += when ? row(L("更新于", "Updated"), when + L(" · 每 10 秒及每轮回复后自动刷新,点击 chip 立即刷新", " · auto-refreshes every 10s and after each reply; click chip to refresh now")) : ""; + return h + '
' + L("口径:改=已跟踪变更条目(含增删改名与冲突文件),新=未跟踪条目(目录折叠计 1,文件与目录混合计数)。仓库列表全量展开,超出屏幕可滚动。", + "Units: mod = tracked-change entries (add/delete/rename/conflict), new = untracked entries (collapsed dirs count as 1). The repo list is fully expanded and scrolls when taller than the screen.") + "
"; + } + function showGitTip(chip) { + var view = chip && chip.__zuGit; + if (stale() || !view || !chip.isConnected) { hideGitTip(); return; } + /* 内容签名不变就跳过重建:enhanceGitChip 每 600ms 对账、每次推送都会走到这里, + * 无条件重写 innerHTML 会打断滚动条拖拽并与滚轮滚动互抢(卡顿根因);数据本身 + * 10 秒才变一次, hover 期间几乎总是跳过。浅色档与位置每帧照常同步。 */ + var html = gitTipHTML(view); + if (gitTip.__zuTipSig !== html) { + var keep = 0; // 重建时保持列表滚动位置(不重置阅读位置) + if (gitTipFor === chip) { try { var prevRows = gitTip.querySelector(".zu-git-rows"); if (prevRows) keep = prevRows.scrollTop; } catch (e) { } } + gitTip.innerHTML = html; + gitTip.__zuTipSig = html; + try { var rowsEl = gitTip.querySelector(".zu-git-rows"); if (rowsEl && keep) rowsEl.scrollTop = keep; } catch (e) { } + } + if (chip.getAttribute("data-zusage-light") === "1") gitTip.setAttribute("data-zusage-light", "1"); // 语义色浅色档随 chip 主题(与内容无关,每次同步) + else gitTip.removeAttribute("data-zusage-light"); + gitTip.style.display = "block"; + var r = chip.getBoundingClientRect(), tw = gitTip.offsetWidth, th = gitTip.offsetHeight; + var vw = window.innerWidth, vh = window.innerHeight; + var left = Math.max(8, Math.min(r.left, vw - tw - 8)); + var top = r.top - th - 8; // 优先弹在 chip 上方(工具行贴窗口底部) + if (top < 8) top = Math.min(r.bottom + 8, vh - th - 8); + gitTip.style.left = Math.round(left) + "px"; + gitTip.style.top = Math.round(top) + "px"; + gitTipFor = chip; + } + function hideGitTip() { + clearTimeout(gitTipHideTimer); + gitTip.style.display = "none"; + gitTipFor = null; + } + /* 鼠标从 chip 移入 tooltip 的宽限桥:延迟收起,移入即取消(支持移入悬浮内容继续阅读); + * Esc 关闭后 gitTipFor 已空,普通数据刷新不会重新弹出(只有再次悬停 chip 才会)。 */ + var gitTipHideTimer = 0; + function hideGitTipSoon() { clearTimeout(gitTipHideTimer); gitTipHideTimer = setTimeout(hideGitTip, 200); } + function onGitTipEnter() { clearTimeout(gitTipHideTimer); } + function onGitKeydown(e) { if (e && e.key === "Escape") hideGitTip(); } + window.addEventListener("keydown", onGitKeydown, true); + gitTip.addEventListener("mouseenter", onGitTipEnter); + gitTip.addEventListener("mouseleave", hideGitTip); + /* chip 固定在 composer 工具行,消息流滚动不移动它,无需 rateTip 那样的 scroll 收起;离开/失焦即收 */ + function cleanupGitTip() { + window.removeEventListener("keydown", onGitKeydown, true); + clearTimeout(gitTipHideTimer); + hideGitTip(); + gitTip.remove(); + } + + /* ---------- renderer 会话 store 桥(v71):实时统计 + 确定性历史行 ---------- + * 只读桥由 renderer 侧资产注入:__zusageGetExistingConversationStore(scope) 返回 + * {getState,subscribe,off}|null;__zusageConversationDiagnostics() 枚举存活 registry + * (sessions[].key = " ",topic = "conversation/")。 + * 统计内核在 turn-stats.js(先于本文件注入,globalThis.ZUsageTurnStats)。 + * API 缺失 / 无匹配 session / 无内核:整体降级为 DB 轮询路径(悬浮条行为不变)。 */ + var TURN_STATS = null; // 注入序保证先于本文件;缺失即禁用桥功能 + try { TURN_STATS = window.ZUsageTurnStats || globalThis.ZUsageTurnStats || null; } catch (e) { } + function zuBridgeApi() { + try { + var diag = window.__zusageConversationDiagnostics, get = window.__zusageGetExistingConversationStore; + if (typeof diag !== "function" || typeof get !== "function") return null; + return { diag: diag, get: get }; + } catch (e) { return null; } + } + /* scope 的 workspace 半边从 diagnostics 的 key 解析(renderer_live_check 实测格式: + * " ",本地会话 remote="__base__")。workspacePath / workspaceIdentity + * 两种键各试一次,命中即返回 store。 */ + function zuTryBindStore(sid) { + var api = zuBridgeApi(); + if (!api || !sid) return null; + var diag = null; + try { diag = api.diag(); } catch (e) { return null; } + var sessions = (diag && diag.sessions) || []; + var topic = "conversation/" + sid; + for (var i = 0; i < sessions.length; i++) { + var entry = sessions[i]; + if (!entry || entry.topic !== topic) continue; + var key = String(entry.key || ""), sp = key.indexOf(" "); + if (sp < 0) continue; + var remote = key.slice(0, sp), workspace = key.slice(sp + 1); + if (!remote || !workspace.trim()) continue; + var scopes = remote === "__base__" + ? [{ sessionId: sid, workspacePath: workspace }, { sessionId: sid, workspaceIdentity: workspace }] + : [{ sessionId: sid, workspacePath: workspace, remoteSessionId: remote }, + { sessionId: sid, workspaceIdentity: workspace, remoteSessionId: remote }]; + for (var j = 0; j < scopes.length; j++) { + try { var store = api.get(scopes[j]); if (store) return store; } catch (e) { } + } + } + return null; + } + /* 活跃 turn:快照窗口里最后一个 turnHeader(key 与 turn-stats 一致 = productTurnId 优先) */ + function zuActiveTurnId(snap) { + var rows = snap && snap.rows && snap.rows.window; + if (!Array.isArray(rows)) return ""; + for (var i = rows.length - 1; i >= 0; i--) { + var r = rows[i]; + if (r && r.kind === "turnHeader" && typeof r.turnId === "string") return r.productTurnId || r.turnId; + } + return ""; + } + /* 完成态摘要:与 Python 统计行同款标记「token/s(本轮)」——可被 isRateLine 识别并复用悬停解释 */ + function zuFinalText(stats) { + var text = TURN_STATS.format(stats, cnUnits()); + return text.indexOf("token/s") >= 0 ? text.replace("token/s", "token/s(本轮)") : text + "(本轮)"; + } + function zuUnavailableText() { + return "⚡ " + L("本轮统计不可用", "turn stats unavailable") + "(本轮)"; + } + function zuIsUnavailableText(t) { // 不比对整串:行可能是另一语言下插的 + t = String(t || ""); + return t.indexOf("本轮统计不可用") >= 0 || t.indexOf("turn stats unavailable") >= 0; + } + /* v74:完成但零输出(如纯控制/后台轮次,无可见正文)时不编造"输出 0 token · 耗时 + * XXXs"这种无意义数字——与"记录已清"同样处理为不可用,而不是把字段值直接拼进文案。 */ + function zuHasMeaningfulOutput(stats) { + return !!stats && typeof stats.outputTokens === "number" && stats.outputTokens > 0; + } + /* v73:悬浮条「生成速度」合并 chip 专用——与 zuFinalText(对话内确定性 + * 历史行用,格式不可变)分开。两态统一用 tok/s 单位,避免生成中/完成切换单位: + * 生成中按「字符/s ÷ 每 token 字符数」估算(c2t),每 token 字符数由本会话最近一次 + * 完成的真实轮次自校准(bridge handle 级,session/store 换代即重置);首轮尚无样本 + * 时退回 DEFAULT_CHARS_PER_TOKEN 粗略估计。完成态直接用 tracker 给出的真实 tok/s。 */ + var DEFAULT_CHARS_PER_TOKEN = 2; // 校准前的粗略估计(中英混排粗略量级),有样本后立即被取代 + /* 返回 null = 调用方应把这当成"没有可展示的桥接视图"处理(回退 DB/不渲染), + * 而不是当成"确定性地算出了没有速度"。v97:第三参 subSum = 子代理速率桥的合计 + * 视图({rate, at, count})——Agent 工具运行期优先展示**所有运行中子代理**的 + * 估算 tok/s 之和(并行 ≥2 个时动词后缀 ×N;运行中请求未入库,DB 无数据; + * 3 秒未刷新即视为过期不显示),缺席时退回工具状态词 + 耗时。 */ + function zuBarSpeedView(stats, charsPerToken, subSum) { + if (!stats.complete) { + // 工具执行期间优先显示与消息流同款的状态词(正在读取/正在编辑…+已耗时), + // 让悬浮条与 toolCall 行状态对应;没有工具在跑才退回字符估算/存活心跳。 + if (stats.runningTool) { + var verb = zuToolVerb(stats.runningTool.name); + if (stats.runningTool.name === "Agent" && subSum && subSum.rate != null && subSum.count > 0 && + typeof subSum.at === "number" && Date.now() - subSum.at <= 3000) + return { cls: "dim", + text: verb + (subSum.count > 1 ? " ×" + subSum.count : "") + + L(" · 估 ", " · est ") + subSum.rate.toFixed(1) + L(" t/s", " t/s"), + phase: "tool" }; + var st = stats.runningTool.startedAt; + if (typeof st === "number" && st > 0 && Date.now() >= st) + verb += " · " + ((Date.now() - st) / 1000).toFixed(1) + "s"; + return { cls: "dim", text: verb, phase: "tool" }; + } + // 无工具且无可测字符流(首段未流出/字符停滞>2s,多数模型思考期对桥不可见, + // 看到的就是时间在走):按用户语义定为"思考中"——动效脑图标 + 本轮存活时长。 + // 时长从首观测起算,不是精确思考时长。 + if (stats.characterRate == null) { + var seconds = Math.floor(stats.observedMs / 1000); + var hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds / 60) % 60; + seconds %= 60; + // v99:计时随语言本地化——中文「1 时 2 分 3 秒」/英文「1 h 2 m 3 s」,空格分隔,零段照旧省略 + var uh = L("时", "h"), um = L("分", "m"), us = L("秒", "s"); + var parts = []; + if (hours) parts.push(hours + " " + uh); + if (minutes) parts.push(minutes + " " + um); + if (seconds || !parts.length) parts.push(seconds + " " + us); + return { cls: "dim", text: L("思考中…", "thinking…") + parts.join(" "), + phase: "thinking" }; + } + var est = stats.characterRate / (charsPerToken || DEFAULT_CHARS_PER_TOKEN); + return { cls: "dim", text: est.toFixed(1) + L(" t/s", " t/s"), + /* phase 兜底 thinking:新轮刚开始(最后活动行还是 userInput)时 + * stats.phase 为 null,不兜底会让图标在这一瞬回闪电再切回脑(闪烁)。 */ + phase: stats.phase || "thinking" }; + } + /* 切换/重新绑定到一个会话时,桥接首次订阅就可能直接捞到一个老早之前已完成的 + * 轮次(这次 ingest 从未见过它处于未完成状态)。这种"历史存量"不是我们亲历的 + * 完成过程,详细字段也可能已被宿主精简——不该把它当成"刚刚发生、算出来没速度" + * 显示给用户(会被误读成这个会话突然坏了),交回调用方当作无桥数据处理。 */ + if (!stats.observedLive) return null; + /* 完成但缺口径(宿主完成时 outputTokens/modelDurationMs 是可选字段,可能缺席) + * 或零输出:同样回退 DB 最近一段值,绝不显示刺眼的"速度不可用"——那会被读成 + * "这个会话坏了",而真相只是"这一轮的详细字段暂时没有"。DB 也没有值时调用方 + * 自然整枚 chip 不渲染。 */ + if (stats.tokenRate == null || !zuHasMeaningfulOutput(stats)) return null; + var cls = stats.tokenRate >= 70 ? "ok" : stats.tokenRate >= 40 ? "warm" : "hot"; + return { cls: cls, text: stats.tokenRate.toFixed(1) + L(" t/s", " t/s"), phase: null }; + } + /* 思考态动效图标(用户提供的设计稿):呼吸脑轮廓 + 流动信号 + 脉冲节点,纯 CSS + * 动画内嵌在 SVG 里(样式随节点走,不依赖外部样式表,宿主清 head 也不受影响)。 + * 类名一律 zu-tk-* 前缀,防与宿主/插件其它样式互相污染;prefers-reduced-motion + * 时停播(动画 none,信号线半透明保持可读)。 */ + function zuIcoThinking() { + return ''; + } + /* v75:数据库富文本覆盖层。桥接数据只有 outputTokens/modelMs 等有限字段,拼不出 + * 模型时代那行(首字/分段/峰值/累计/采样时刻)——这些只在 usage 库里。所以完成态 + * 历史行先用桥接简化文案占位,同时按 session 向主进程要一次纯文本统计行;拿到后 + * 覆盖占位(见 zuRequestTurnText / window.__zusageDeliverTurnText)。 + * 查询刻意不带显式 turn-id:数据库 turn_id(turn_)和渲染器投影的 + * turnId/productTurnId(msg_,DOM data-turn-id 同源)是两套互不相通的标识, + * 前端查不到后者对应的数据库主键。改用 token_rate.py 原本给 Hook 用的 --current + * 时间戳推断(按 session 记录的"提问时刻"反查最新一轮),这正是模型以前实际在跑 + * 的同一条查询。key 用扫描循环里的 DOM tid(statsFor(tid) 的入参,虚拟列表重挂后 + * 还能对应回同一个容器);请求/交付按 sid 关联(richTextRequestTids 是 sid→tid 的 + * 反向映射,同一 session 同一时刻只认最新发起的那个 tid——如果请求在途时又有新的 + * 一轮在同一 session 完成,交付结果可能被关联到新 tid 上而非发起时的旧 tid,这是 + * 一个已知的、极窄时间窗口内的小概率错位,权衡实现复杂度后不做进一步纠偏)。 */ + var richTextCache = {}, richTextAttempts = {}, richTextRequestTids = {}; + var RICH_TEXT_MAX_ATTEMPTS = 24; // 0.5s 一 tick × 24 ≈ 12s 窗口等数据库追上(v88 扫描提速后按时间等价放大),之后永久用简化文案兜底 + function zuResetRichText() { + richTextCache = {}; + richTextAttempts = {}; + richTextRequestTids = {}; + } + /* 每个 scan tick 对「已完成 + 有输出 + 尚无富文本 + 未超重试上限」的 turn 补发一次请求: + * 只靠首次完成时那一次会漏(主进程没收到/当时无窗口/查询失败),逐 tick 补发才能自愈。 */ + function zuRequestTurnText(tid, stats) { + if (!tid || richTextCache[tid] || (richTextAttempts[tid] || 0) >= RICH_TEXT_MAX_ATTEMPTS) return; + var sid = stats && typeof stats.sessionId === "string" ? stats.sessionId : ""; + if (!sid) return; + richTextRequestTids[sid] = tid; + /* cn:万/亿开关当前值;为 0 时主进程给 token_rate.py 加 --no-cn-units(Python 读不到渲染端 localStorage,缺省万/亿) */ + var req = { action: "turn-text", sid: sid, cn: cnUnits() ? 1 : 0 }; + if (window.__zusagePluginRequests) window.__zusagePluginRequests.push(req); + else window.__zusagePluginRequests = [req]; + richTextAttempts[tid] = (richTextAttempts[tid] || 0) + 1; + } + /* 主进程回推:非空 = 富文本到位,写缓存并立即重扫(不等下一个 0.5s tick);空 = 库里还没 + * 追到这一轮(或没有有效的提问时刻可推断),什么都不做——重试由 scanDeterministicLines + * 的定时扫描自然驱动,这里绝不自建重试队列(否则两个驱动源叠加会绕开 attempts 上限)。 */ + window.__zusageDeliverTurnText = function (sid, text) { + if (stale() || typeof sid !== "string") return; + var tid = richTextRequestTids[sid]; + if (!tid || typeof text !== "string" || !text) return; + richTextCache[tid] = text; + delete richTextAttempts[tid]; + scanDeterministicLines(); + }; + /* ---------- 确定性历史行(v71):完成的 turn 在消息流末尾插统计行 ---------- + * 数据来自 tracker(renderer store 投影),不再要求模型自跑统计脚本(hook 已去指令)。 + * 虚拟列表回收重挂后由扫描重建;旧消息(tracker 无记录)不补造; + * 同容器已有模型历史统计行(isRateLine)则跳过,避免双行。 */ + var ZU_DET_FONT_SIZE = "0.8125rem"; // v78:宿主正文号偏大,用户要求缩小(13px) + var detTimer = 0; + /* v79:统计行在 turn 容器内的落点——hover 工具条之前、data-state="closed" 弹出层 + * 之后(工具条类名 opacity-0 … group-hover/assistant-turn:opacity-100,Tailwind + * 类名含 : / [ ] 特殊字符,querySelector 转义易碎 → 按子节点 className 子串匹配)。 + * v82:新开会话的 turn 容器里工具条不在直接子层,嵌在 group/assistant-turn + * flex w-full flex-col gap-5 → flex flex-col gap-5 内容列末尾——直接子层找不到时 + * 深度找工具条,insertBefore 落到锚点的父容器(即内容列内、工具条之上)。 + * 都找不到(宿主改版)回退 appendChild(旧行为,容器末尾)。 */ + function zuDetAnchor(box) { + try { + var kids = box.children, i, cls; + for (i = kids.length - 1; i >= 0; i--) { + cls = String(kids[i].className || ""); + if (cls.indexOf("group-hover/assistant-turn") >= 0 && cls.indexOf("opacity-0") >= 0) + return kids[i]; + } + var deep = box.querySelectorAll("div"); + for (var j = 0; j < deep.length; j++) { + cls = String(deep[j].className || ""); + if (cls.indexOf("group-hover/assistant-turn") >= 0 && cls.indexOf("opacity-0") >= 0) + return deep[j]; + } + for (i = kids.length - 1; i >= 0; i--) { + if (kids[i].getAttribute && kids[i].getAttribute("data-state") === "closed") + return kids[i].nextSibling; // null = 已到末尾,insertBefore(node, null) 即 append + } + } catch (e) { } + return null; + } + function zuDetPlace(box, node) { + try { + var anchor = zuDetAnchor(box); + if (anchor && anchor === node.nextSibling) return; // 已在位(React 重排兜底快速路径) + if (anchor) { anchor.parentElement.insertBefore(node, anchor); return; } // v82:深度锚点挂锚的父容器 + if (node.parentElement !== box) box.appendChild(node); + } catch (e) { FATAL.detErr = String((e && e.stack) || e); } + } + function scanDeterministicLines() { + if (stale()) return; + try { + if (!state.rateLine || !TURN_STATS) { // 开关关闭:撤下自己插的行(模型历史行不动) + document.querySelectorAll("[data-zusage-deterministic]").forEach(function (el) { el.remove(); }); + return; + } + /* 两种选择器可能同时命中同一个 turn(外层 [data-v4-turn-unit] 包着内层 + * section,二者带同一个 data-turn-id):按 tid 去重,section 更具体,优先用它 + * 当容器,避免同一轮被当成两个独立容器各插一条,出现重复行(真机实测过)。 */ + var boxes = [], seen = {}; + document.querySelectorAll("section[data-turn-id]").forEach(function (el) { + var t = el.getAttribute("data-turn-id"); + if (t && !seen[t]) { seen[t] = 1; boxes.push(el); } + }); + document.querySelectorAll("[data-v4-turn-unit][data-turn-id]").forEach(function (el) { + var t = el.getAttribute("data-turn-id"); + if (t && !seen[t]) { seen[t] = 1; boxes.push(el); } + }); + /* v86 诊断(≥10s 一次写 __zusageDiag.det,主进程泵取回写 diag-.json):每轮容器的行类型 + * (rich/ph/na/keep/model/none)与 tracker 视图、各 pane 的桥接快照窗口——排"历史轮不可用"类问题用 */ + var wantDiag = Date.now() - (scanDeterministicLines.diagAt || 0) > 10000, diagTurns = wantDiag ? [] : null; + for (var i = 0; i < boxes.length; i++) { + var box = boxes[i]; + var tid = box.getAttribute && box.getAttribute("data-turn-id"); + if (!tid) continue; + var rec = diagTurns ? { tid: tid.slice(-8) } : null; + if (rec) diagTurns.push(rec); + var mine = null; + try { mine = box.querySelector("[data-zusage-deterministic]"); } catch (e) { } + if (mine) { + /* 已插过:重挂/数据演进后从 tracker 重建文本;观测过但记录已清(换代)、 + * 或完成但确实零输出(无意义数字,如"耗时 939.0s"配"输出 0 token")则明示不可用 */ + var again = null; + for (var n = 0; n < instances.length; n++) { again = instances[n].statsFor(tid); if (again) break; } + var againOk = !!again && again.complete && zuHasMeaningfulOutput(again); + if (!againOk && !(again && again.observedLive)) { + /* v86:tracker 无记录(快照窗口滑出/store 换代/热更新换 tracker)或重绑后一步到位捞到的 + * 历史存量轮缺 outputTokens(缺字段 ≠ 零输出)——都不据此改判"不可用":已完成轮的数字不会 + * 过时,保留已有占位/富文本;此前误插的"不可用"行直接撤下。仅亲历过的真实零输出轮仍明示 */ + var keptLine = mine.querySelector("p") || mine; + if (richTextCache[tid]) { + if ((keptLine.textContent || "") !== richTextCache[tid]) keptLine.textContent = richTextCache[tid]; + if (!keptLine.style.fontSize) keptLine.style.fontSize = ZU_DET_FONT_SIZE; + zuDetPlace(box, mine); + } else if (zuIsUnavailableText(keptLine.textContent)) mine.remove(); + else zuDetPlace(box, mine); + if (rec) { rec.line = richTextCache[tid] ? "rich" : "keep"; rec.st = zuDiagStats(again); } + continue; + } + var againText = againOk ? zuFinalText(again) : zuUnavailableText(); + if (againOk) zuRequestTurnText(tid, again); // 仍缺富文本就补发(限流见函数内) + var text = richTextCache[tid] || againText; + var line = mine.querySelector("p") || mine; + if ((line.textContent || "") !== text) line.textContent = text; + if (!line.style.fontSize) line.style.fontSize = ZU_DET_FONT_SIZE; // 旧代节点补样式(热更间隙) + zuDetPlace(box, mine); // React 重排后兜住用户指定位置(工具条之前) + if (rec) { rec.line = richTextCache[tid] ? "rich" : (againOk ? "ph" : "na"); rec.st = zuDiagStats(again); } + continue; + } + var hasModel = false; + try { + box.querySelectorAll("blockquote p, blockquote, p").forEach(function (el) { + if (!hasModel && isRateLine(el)) hasModel = true; + }); + } catch (e) { } + if (hasModel) { if (rec) rec.line = "model"; continue; } // 模型历史统计行在位:不叠双行 + var stats = null; + for (var k = 0; k < instances.length; k++) { stats = instances[k].statsFor(tid); if (stats) break; } + if (rec) { rec.line = "none"; rec.st = zuDiagStats(stats); } + if (!stats || !stats.complete) continue; // 未完成 / 旧消息(tracker 无记录)不插 + var statsOk = zuHasMeaningfulOutput(stats); + if (!statsOk && !stats.observedLive && !richTextCache[tid]) continue; // v86:历史零值或缺字段不判失败;已缓存的完整统计仍可重建 + var quote = document.createElement("blockquote"); + quote.dataset.zusageDeterministic = "1"; + var p = document.createElement("p"); + p.dataset.zusageDeterministic = "1"; + /* 样式基本交还宿主默认(模型时代贴的 Markdown 引用块就是这套渲染:彩色 ⚡、 + * 正文亮度、引用线);唯一例外字号(v78):用户反馈宿主正文号太大,内联缩到 + * 0.8125rem(13px)。必须内联——本节点长在宿主消息流 prose 容器内,bar 内部 + * 样式表够不着,挂 head 的外来样式又会被 React 清掉(历史根因)。 */ + p.style.fontSize = ZU_DET_FONT_SIZE; + if (statsOk) zuRequestTurnText(tid, stats); // 先占位,向主进程要这轮的数据库富文本 + p.textContent = richTextCache[tid] || (statsOk ? zuFinalText(stats) : zuUnavailableText()); + if (rec) rec.line = richTextCache[tid] ? "rich" : (statsOk ? "ph" : "na"); + quote.appendChild(p); + zuDetPlace(box, quote); + } + if (wantDiag) { + scanDeterministicLines.diagAt = Date.now(); + FATAL.det = { + time: new Date().toISOString(), boxes: boxes.length, rich: Object.keys(richTextCache).length, + bridge: instances.map(function (inst) { return inst.bridgeInfo ? inst.bridgeInfo() : null; }), + turns: diagTurns.slice(0, 40), + }; + } + } catch (e) { FATAL.detErr = String((e && e.stack) || e); } + } + function zuDiagStats(s) { + return s ? { c: s.complete ? 1 : 0, live: s.observedLive ? 1 : 0, out: s.outputTokens, st: s.state, + sa: s.startedAt, ea: s.endedAt, mm: s.modelMs, dm: s.durationMs } : null; + } + function cleanupDeterministic() { + clearInterval(detTimer); + zuResetRichText(); + try { + document.querySelectorAll("[data-zusage-deterministic],[data-zusage-loading-status]").forEach(function (el) { el.remove(); }); + } catch (e) { } + } + /* ---------- 工具动词表(v76 引入,v78 起仅悬浮条合并 chip 使用):zuBarSpeedView + * 在工具执行期显示"正在读取 · 1.2s"同款状态词,数据源为桥接 tracker 的 runningTool + * (本轮最后一个活动行=进行中的工具调用,宿主从 tool_input_start 起算"进行中")。 + * v76 的消息流 loading 插槽插入([data-zusage-loading-status])已删,见 zuToolVerb 下方注。 */ + var ZU_TOOL_VERBS = { + Read: ["正在读取", "Reading"], + Write: ["正在写入", "Writing"], Edit: ["正在编辑", "Editing"], ApplyPatch: ["正在编辑", "Editing"], + Bash: ["正在执行", "Running command"], + Grep: ["正在搜索", "Searching"], Glob: ["正在搜索", "Searching"], + WebSearch: ["正在搜索", "Searching"], web_search: ["正在搜索", "Searching"], + WebFetch: ["正在获取网页", "Fetching page"], + TodoWrite: ["正在更新待办", "Updating todos"], TodoRead: ["正在读取待办", "Reading todos"], + Skill: ["正在运行技能", "Running skill"], + ReadSessionContext: ["正在读取上下文", "Reading context"], + Agent: ["子代理运行中", "Running agent"], Task: ["子代理运行中", "Running agent"], + SendMessage: ["正在发送消息", "Sending message"], + TaskOutput: ["正在获取任务输出", "Fetching task output"], + TaskStop: ["正在停止任务", "Stopping task"], + CreateWorkflow: ["正在编写工作流", "Writing workflow"], + }; + function zuToolVerb(name) { + if (name && ZU_TOOL_VERBS[name]) { var pair = ZU_TOOL_VERBS[name]; return L(pair[0], pair[1]); } + if (name && name.indexOf("mcp__computer-use__") === 0) return L("正在操作电脑", "Operating computer"); + if (name && name.indexOf("mcp__") === 0) + return L("正在调用 " + name.slice(5).replace(/__/g, " · "), "Calling " + name.slice(5).replace(/__/g, " · ")); + if (name && name.indexOf("js") === 0) return L("正在执行代码", "Executing code"); + return name ? L("正在执行 " + name, "Running " + name) : L("执行中", "Working"); + } + /* v78:消息流 loading 状态词(v76 的 [data-zusage-loading-status] 插槽插入)已删—— + * 悬浮条合并 chip 在工具执行期仍显示同款动词+耗时(zuBarSpeedView),消息流内不再插节点。 + * 清理选择器保留(根清理 + dispose):热更/重载后把旧版本可能残留的 span 清干净。 */ + function sharedTick() { + if (stale()) return; + scanDeterministicLines(); + } + detTimer = setInterval(sharedTick, 500); // v88:2s→0.5s,与速率行扫描/心跳同节奏 + sharedTick(); + + function disposeOverlay() { + if (disposed) return; + disposed = true; + clearTimeout(bootTimer); + clearInterval(coordinatorTimer); + instances.forEach(function (inst) { inst.dispose(); }); + instances = []; + quotaSelectionsSig = ""; + pluginRequest("quota-providers", { panes: {} }); + cleanupRateTip(); + cleanupGitTip(); + cleanupDeterministic(); + window.__zusageWantSids = []; + window.__zusageWantSid = ""; + window.__zusageGitPaths = []; // v96:主进程下轮 tick 读到空集即停查 + if (window.__zusageDispose === disposeOverlay) { + delete window.__zusageDispose; + delete window.__zusageUpdate; + delete window.__zusagePluginUpdate; + delete window.__zusageGitState; + window.__zusageOverlay = false; + } + } + window.__zusageDispose = disposeOverlay; + bootTimer = setTimeout(function () { + if (stale()) return; + try { coordinator(); } catch (e) { + FATAL.fatal = String((e && e.stack) || e); + var b = document.getElementById("zusage-bar"); + if (b) b.style.cssText = "position:fixed;right:16px;bottom:16px;font:12px monospace;color:#ff7a59;" + + "background:#1a0f0d;border:1px solid #ff7a59;border-radius:6px;padding:4px 10px;z-index:2147483647"; + } + }, 0); + + /* 设置页(MCP 配置等)内的编辑框不是聊天输入框:设置是同窗口覆盖层,激活时 + * 聊天视图保持挂载、仅 opacity-0+inert 视觉遮蔽——v61 幽灵判定管的是聊天壳, + * 设置层是其兄弟节点、够不着。设置框架内(settings-page 根 testid 或 + * content/panel 双 frame 标记,三层标记并集排除、跨宿主版本覆盖面最大)的 + * JSON/环境变量文本框(MCP 表单展开「环境变量(可选)」后的 rows=4 文本框) + * 落在视口下半部,会被协调器当聊天输入框派发,条冒在设置页底部(实测 bug)。 + * 带设置框架标记的候选一律不派发;聊天流(composer/权限面板/批准框)不在 + * 设置框架内,行为不受影响。 */ + function inSettingsView(el) { + try { return !!(el.closest && el.closest('[data-testid="settings-page"],[data-settings-content-frame],[data-settings-panel-frame]')); } catch (e) { return false; } + } + function coordinator() { + /* 协调器(v61.1):扫描主文档里全部下半屏可见输入框,按 pane 键(data-pane-id, + * 无属性回退 "auto")分组派发给实例;一个 pane 一条,pane 消失对应条自藏。 */ + function paneKeyOf(el) { + for (var p = el; p && p !== document.body; p = p.parentElement) { + if (p.hasAttribute && p.hasAttribute("data-pane-id")) return p.getAttribute("data-pane-id") || "auto"; + } + return "auto"; + } + function sessIdOf(el) { + for (var p = el; p && p !== document.body; p = p.parentElement) { + if (p.hasAttribute && p.hasAttribute("data-session-id")) return p.getAttribute("data-session-id") || ""; + } + return ""; + } + var SEL = 'textarea, [contenteditable="true"], [role="textbox"], .ProseMirror, .ql-editor'; + function tick() { + if (stale()) return; + try { + var best = {}; + document.querySelectorAll(SEL).forEach(function (el) { + if (el.closest && el.closest('[role="dialog"]')) return; // v62:弹窗内的编辑框不是聊天输入框,不派实例(issue #9) + if (inSettingsView(el)) return; // 设置页(MCP 配置等)的编辑框不是聊天输入框,不派实例 + var r = el.getBoundingClientRect(); + if (!(r.width > 40 && r.height > 8 && r.bottom > 0 && r.top < innerHeight)) return; + if (r.top < innerHeight * 0.45) return; // 聊天输入框特征:视口下半部(排除设置页等处元素) + var k = paneKeyOf(el); + if (!best[k] || r.top > best[k].top) best[k] = { el: el, top: r.top }; + }); + var keys = Object.keys(best); + if (!keys.length) { best["auto"] = { el: null, top: 0 }; keys = ["auto"]; } // shadow/iframe 兜底 + instances = instances.filter(function (inst) { + if (keys.indexOf(inst.pid) >= 0) return true; + inst.dispose(); + return false; + }); + keys.forEach(function (k) { + var inst = null; + for (var i = 0; i < instances.length; i++) if (instances[i].pid === k) { inst = instances[i]; break; } + if (!inst && instances.length < 4) inst = spawnInstance(k, nextSlot++); + if (inst) inst.attach(best[k].el, sessIdOf(best[k].el)); + }); + refreshWants(); + reportQuotaSelections(); + FATAL.insts = instances.map(function (a) { return a.pid + "=" + (a.getSid() ? a.getSid().slice(-8) : "-"); }).join(" | "); + } catch (e) { FATAL.coordErr = String((e && e.stack) || e); } + } + tick(); + coordinatorTimer = setInterval(tick, 600); + } + + /* ---------- 实例工厂(v61.1):一个 pane 一套 条/面板/气泡/tooltip/定位循环。 + * slot 0 保留 #zusage-bar/#zusage-tip 兼容 id(泵热更新按它清理),其余实例仅类名。 */ + function spawnInstance(pid, slot) { + var VERSION = "v110"; // v99.1(主线并流)apiKey 模式 BigModel/Z.ai(bigmodel-api[-N]/zai-api[-N])额度 chip 标题家族判定(issue #68;数据链在 quota-client) → v82 统计行锚点深度查找(新会话结构嵌套在 group/assistant-turn 内容列内)→ v83 万/亿单位开关(默认开、仅中文)+ K/M/B 的 B 档 → v84 主标签页远端路由(工作台分组唯一主绑定上报 + 迟到载荷守卫,issue #30)→ v85 回答末尾统计行跟随万/亿开关 + tok→token → v86 历史轮 tracker 无记录(窗口滑出/换代/热更)或缺输出字段不再翻成"本轮统计不可用",加 det 诊断 → v87 主叶归属增 last-session 回退(组零证据时唯一远端记录放行,issue #30 复测:单会话主标签页不建组)→ v88 运行态心跳与两处扫描 tick 2s→0.5s(富文本重试 6→24 次等价放大)→ v89 ⚙ 面板更新进度:downloading 阶段文案 + 下载(字节)/安装(校验→安装→同步)双进度条,缺 progress 的旧主进程自动隐藏 → v90 主标签页/分栏归属改按本窗会话节点身份(renderer 桥按版本/资产/锚点写 SessionPane 属性,移除 workbench-groups/last-session 共享回退)+ 心跳 0.5s→0.1s(扫描维持 0.5s)→ v91 设置页/权限面板冒条修复(PR #44:inSettingsView 三处派发排除 + anchorCardOf 面板锚点改判)收编收尾:排除标记并集(settings-page testid + content/panel 双 frame)、track 显隐第四处防御 → v92 ⚙ 更新区固定显示可复制的重装口令(源码安装无法自动更新时引导用户发给 agent)→ v93 原生上下文按钮(chat-context-usage-trigger)增强:环填充/四档色/右侧百分比镜像状态条上下文格(ctxBandOf 同源),cw 缺失还原原生形态 → v94 状态条删除「上下文容量」格(UI+tooltip,该口径由原生按钮增强承载),⚙ 面板同步去掉「上下文」开关,子代理格改始终展示(零值也显示)→ v95 ⚙ 更新区去掉 v92 重装口令提示块(源码清单的仓库来源改由宿主插件管理器直接更新,不再报渠道不一致),更新来源文案增「仓库源码」分支→ v96 套餐额度 chip(默认关):挂原生上下文按钮左侧(wrapper 内前插,与按钮增强同款双 reapply/还原),数据来自主进程 quota 字段(解密宿主 OAuth 凭据直查 monitor 端点,账号级套餐池口径),会话首个 main_turn 完成前不显示;自绘 tooltip 走 rateTip 独立浮层模式;chip 改多窗分段:每窗一段(中文 5小时/7天,英文 5h/7d)各自按 80/95/100 档位色,分隔符 |;余额类型单段渲染就绪(余额 ¥100.00,阶段 3 数据源接入即用);去掉环形,容器不着色 → v97 子代理速率(T1+T2):zusage sub 汇总加 rate_avg/每子代理 tps_avg(已完成段加权平均,口径同 _turn_rate),子代理格 tooltip/明细面板展示;Agent 运行期主 chip 附加「子代理运行中 · 估 X t/s」——turn-stats 透传 subagent 行 childSessionId,overlay 据此用同款 zuTryBindStore 绑子代理会话 store 做字符增量估算(运行中请求未入库,DB 无实时数据;150s 采样实锤零 running 行),思考文本不进 rows 时退回纯状态词;工具动词「正在运行子智能体」压成「子代理运行中」(web 同步文案);同日多槽位化:turn-stats 暴露 runningSubagents 全集(并行时 rowId 前 8 个),每子代理独立绑定/2s 节流,主 chip 显示合计速率(并行 ≥2 时动词后缀 ×N),chip tooltip 与子代理明细面板逐个实时展示运行中估算(tooltip 随原地改字同步 __tip 重画;面板 800ms tick 原地刷新 .subrate,汇总页运行块含 DB 尚无记录的新子代理) → v98 额度实时选源恢复(v4-model-config 观察 + quota-providers 全窗快照上报 + byProvider 定向刷新、空选择 fail-closed)与 sub2api 额度 UI(今日/本月窗口、窗口行内金额明细、Key 总额度、订阅截止、账户余额标题) → v99 思考中计时随语言本地化(中文「1 时 2 分 3 秒」/英文「1 h 2 m 3 s」,空格分隔、零段照旧省略);回答末尾行采样时刻改完整中文日期时间(⏱ 2026 年 10 月 23 日 11 点 20 分 28 秒,token_rate.py format_turn_line 同步改版) → v99b(主线并流)tooltip 伪边界 leave 过滤:Chrome 悬停链收缩/重算会补发 DOM 内部的 documentElement/tip mouseleave,tipLeaveAt 贴边判定后取触发时刻最深 :hover 元素甄别,仍悬停在本档具体元素上即伪 leave 一律忽略交回 mousemove 流(主线 v100 滚动豁免=本链 v106 同义,不另列) → v100 设置面板分区导航(ZCode 设置侧栏同款:左栏三组分区导航 + 内容区单分区显隐,激活项 aria-current="page",选择持久化 zusage3.sec;面板窄于 620px 时导航收成 icon rail) → v101 设置面板高度在打开瞬间按内容自然高锁定(切分区不再抖动,resize 只收紧不放大) → v102 额度源预设模板(CC Switch/magpie 同款选模板只填 Key:官方域变体 chip 一键写入 secrets baseUrl,minimax 可信域机制推广到 siliconflow 币种随域;deepseek/openrouter/kimi/anthropic/mimo/volcano 附控制台取 Key 链接,走 openExternal 桥) → v103 额度源列表卡片化布局(每源圆角卡片+间距,凭据框限宽 420px,长说明段落补水平边距;纯 CSS,结构行为不变) → v104 源状态文案补 disabled=已关闭映射 + 主进程对显式关闭源统一报 disabled(修「开关已关却显示待查询」矛盾);刷新/获取 Key 按钮补 title 提示,关闭源的刷新按钮禁用 → v105 「上下文容量」弹层(hover-card-content)内压缩上下文按钮:命中率行下方全宽胶囊按钮(原生 secondary 配色全走客户端语义变量),弹层关即卸载(Radix 无 forceMount)故 body MutationObserver + heavy 对账双触发、DOM 存在性幂等(多实例安全),插入锚=「剩余额度」border-t 段前(缺则容器尾)+位置兜回;点击弹原生 AlertDialog 视格确认框(遮罩/Esc/外点关闭、Enter 确认),确认后优先走 fiber 上原生残留的 onSendCompressionCommand("/compact")(SessionPane 压缩入口,不动输入框草稿),缺失回落官方 E2E 输入桥 __zcodeLexicalInputE2E.setText + 发送键(Lexical 直写 DOM 不生效,禁用该路径;草稿非空拒绝) → v106 额度 tooltip 滚动豁免:hideTipsOnScroll 不再收 quotaTip(chip 固定 composer 工具行与 gitTip 同理),修思考中流式输出自动滚动致悬停弹出即灭;chip 随宿主重建 detach(无 mouseleave)由 scanRateLines 加 isConnected 健康检查兜底收起 → v107 压缩确认框补行为提示:压缩进行中发送新消息/停止会打断压缩(实测 16:27 新 turn 启动同秒 compact turn TURN_CANCELLED,cancelled_by_user=1;引擎对该次摘要流挂起 134s 无首 token 也不重试 retryable=0),确认框加 hint 行中性告知 → v108 压缩主通道按用户指定改为字面 "/compact" 输入提交(E2E 桥 setText+发送键;流式中 v4-stop 可见/草稿非空/发送键 disabled 三重拒绝),fiber 命令信封降兜底;hint 升级为「通常需 2-4 分钟且无进度反馈」(DB 实证:全库 compact 请求无首 token 但正常完成,72s-9.6min,14:44 同会话同模型 165s 成功先例;16:25/16:43 两次「卡住」实为 2m20s 被新消息 TURN_CANCELLED,非故障) → v109 压缩主通道按用户指定改回直接调会话发送接口(fiber onSendCompressionCommand 命令信封:不碰输入框/发送键、流式中交引擎排队,修字面输入通道在生成中被发送键禁用卡住的问题),字面 "/compact" 输入提交降兜底(三重门控仅作用于兜底) → v110 压缩提交函数改在弹框打开瞬间(弹层必存活、锚点几何可信)解析并缓存,修复确认时弹层已被 Radix 卸载、几何重解析退化为「离视口左上角最近」致多分栏把 /compact 发进错误会话(P1);兜底字面输入通道的流式/发送键/草稿三重门控全部前置到 setText 之前(拒绝不再残留草稿);Enter 确认限定焦点在弹框内(无焦点陷阱下防焦点外 Enter 误触发确认) // 悬停 ⚙ 面板可见;版本号随代际递增 + var instanceDisposed = false, heavyTimer = 0, frame = 0, themeObserver = null; + var globalListeners = []; + function listenGlobal(target, type, fn, capture) { + target.addEventListener(type, fn, capture); + globalListeners.push([target, type, fn, capture]); + } + function inactive() { return instanceDisposed || stale(); } + /* ---------- 状态(实例级) ---------- */ + var excOn = false, // 本 pane 会话处于"上下文超限被拒"状态(render 时按 picked 会话计算) + excGone = false, // 用户点 ✕ 关闭了本次气泡;超限解除后自动复位 + nativeCtxVal = 0, // 本 pane 输入框卡片上读到的原生上下文总量 + lastSub = null, // 子代理明细面板数据源 + instSid = "", // 本 pane 当前会话(composer 祖先链 data-session-id) + pendingSid = "", wantedEl = null, scopeStamp = "", // 协调器派发的最新 composer/会话 + shrink = 0, // 分级收缩(v66):1 = 内容超卡片宽,「本轮/会话累计」缓存命中率已让位 + fadeOn = false, // 收缩后仍超宽:右缘渐隐(.zu-clip) + bridge = { handle: null, turnId: "", timer: 0 }, // v71 本 pane 的 renderer store 订阅 + lastBand = null, // v92:本 pane 上下文分档(ctxBandOf 结果,原生按钮增强的数据源) + ctxBtnCache = null, // v92:本实例上次增强的原生按钮(还原/对账引用,不依赖 cardCache 存活) + lastQuota = null, lastQuotaAll = null, quotaProvider = "", quotaModel = "", quotaFallback = "", quotaChipCache = null, + quotaSourceId = "", quotaSelection = null, quotaPaneRoot = null, quotaSelectionObserver = null, quotaMarkerSeen = false, // 当前 pane 模型选择与额度来源 + cuaBtnCache = null, gitChipCache = null, // v96:Git chip 的锚点按钮(电脑操作)与本实例的 chip 节点 + liveTextCur = "", liveStatsCur = null, liveKindCur = "fallback", liveEl = null, // v72:合并「生成速度」用 + livePhaseCur = "", liveIconCur = "bolt"; // v77:当前 chip 阶段与图标(think=思考动效/bolt=闪电)——相位切换需整条重渲染 + + /* ---------- DOM ---------- */ + /* 样式表挂在 bar 内部而非 head:React 可能清理 head 里的外来 style, + * CSS 一旦失效条就退化成 static 占位元素("输入框下方空白"的历史根因)。 */ + var style = document.createElement("style"); + style.textContent = + /* 胶囊条:半透明底 + 微边框(v80 起仅 below-card 兜底形态用;in-card 形态由 + * .zusage-root.zu-in 全部抹平)。配色全量跟随客户端主题(v58):颜色一律 + * var(--color-*, 旧值兜底) 引用客户端语义变量( 的 .dark 类翻转整套值, + * zai-light/zai-dark 特殊主题同样自动适配);color-mix 处加同值前置声明, + * 不支持 color-mix 的旧引擎落到实色行不至于透明。 */ + ".zusage-root{position:fixed;box-sizing:border-box;" + + "font-family:var(--font-mono,Consolas,'Cascadia Mono',Menlo,'Microsoft YaHei UI','Microsoft YaHei',monospace);" + + "font-size:var(--ui-font-size,14px);line-height:1.3;" + + "font-variant-numeric:tabular-nums;color:var(--color-foreground-subtle,#8791a3);" + + "background:rgba(15,18,25,.86);background:color-mix(in srgb,var(--color-background,#0f1219) 86%,transparent);" + + "backdrop-filter:blur(14px) saturate(1.3);-webkit-backdrop-filter:blur(14px) saturate(1.3);" + + "border:1px solid var(--color-border,rgba(255,255,255,.07));border-radius:9px;padding:2px 6px;user-select:none;" + + "box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 4px 14px rgba(0,0,0,.38),0 1px 3px rgba(0,0,0,.28);" + + "display:flex;align-items:center;gap:2px;white-space:nowrap;" + + /* 条不用 overflow:hidden:超宽截断由 #zu-main 自己负责(渐隐 .zu-clip)。 */ + "z-index:50}" + + /* in-card 形态(v80):条挂在输入卡片内部 group/toolbar 工具行之后自占一行, + * 与对话框融为一体——背景/边框/阴影/毛玻璃全抹掉(用户指定),宽度交由卡片 + * flex-col 的 stretch 撑满内宽(min/max-width 不再写死)。 */ + ".zusage-root.zu-in{position:static;background:none;border:0;border-radius:0;padding:1px 0 0;" + + "box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;min-width:0;max-width:none;z-index:auto}" + + ".zu-main{overflow:hidden;min-width:0;flex:1 1 auto;display:flex;align-items:center;gap:1px;justify-content:space-between}" + + /* 收缩兜底(v66):分级收缩后仍装不下时右缘 26px 渐隐,替代生硬的截断 */ + ".zu-main.zu-clip{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 26px),transparent);" + + "mask-image:linear-gradient(90deg,#000 calc(100% - 26px),transparent)}" + + /* zu-main 不得带 .it class:querySelectorAll('.it') 收集 tooltip 时会把它算进第 0 位, + * tips 全部错位一位且末项为 null —— v31"悬停只有空胶囊/内容错位"的根因 */ + ".it{display:flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;" + + "transition:background-color .12s ease-out}" + + ".it:hover{background:var(--color-hover,rgba(255,255,255,.06))}" + + ".sep{width:1px;height:15px;background:var(--color-border,rgba(255,255,255,.09));flex:0 0 auto;margin:0 1px}" + + ".k{color:#7e8899}.v{color:var(--color-foreground,#e9edf4);font-weight:600}" + + ".zu-live{color:var(--color-foreground,#e9edf4);font-weight:600}" + + ".pct{font-weight:700}.dim{color:var(--color-foreground-subtle,#667082)}" + + /* 三档状态色刻意不走客户端变量(v58.1):terminal-bright-* 是语法高亮色板, + * 绿档在条面小字号/细进度条上发白(用户实测);改用高对比自调档, + * 浅色加深版在 .zu-light 组。 */ + ".ok{color:#3ecf8e}.warm{color:#f5b944}.hot{color:#ff6b57}" + + /* 超限=亮红呼吸闪烁(v38 定性"亮红闪烁优先于一切档位",v54 落成动画) */ + "@keyframes zuexc{0%,100%{opacity:1}50%{opacity:.4}}" + + ".exc{color:var(--color-destructive,#ff2d55);text-shadow:0 0 8px rgba(255,45,85,.5);animation:zuexc 1.1s ease-in-out infinite}" + + ".btn{cursor:pointer;padding:2px 6px;border-radius:6px;color:var(--color-foreground-subtle,#7e8899);" + + "transition:background-color .12s ease-out,color .12s ease-out}" + + ".btn:hover{color:var(--color-foreground,#e9edf4);background:var(--color-hover,rgba(255,255,255,.07))}" + + ".btn:active{transform:scale(.96)}" + + /* v81:⚙ 原为 +3px(17px),比宿主 group/toolbar 行的任何文字(统一 + * text-ui-base = var(--ui-font-size))都大一号,改为同基准以对齐工具行观感 */ + ".zu-gear{flex:0 0 auto;font-size:var(--ui-font-size,14px);border-radius:6px}" + + /* 行内 SVG 线性图标(v54):currentColor 跟随文字色,一处定义全局换色 */ + ".ico{width:12px;height:12px;flex:0 0 auto;color:var(--color-foreground-subtle,#7e8899);opacity:.85}" + + /* 思考态动效图标(v77):自带配色(脑灰+紫信号),略大于常规线性图标—— + * 12px 下 4 个脉冲节点会缩没;不撑高条面(行高由文字主导)。 */ + ".zu-think{width:14px;height:14px;opacity:1}" + + /* 工具错误数徽标 */ + ".eb{background:rgba(255,107,87,.14);background:color-mix(in srgb,var(--color-destructive,#ff6b57) 14%,transparent);" + + "color:var(--color-destructive,#ff8a73);border-radius:999px;padding:0 6px;line-height:16px;font-weight:600;display:inline-flex;align-items:center;gap:2px}" + + ".eb .ico{color:inherit}" + + /* v92 原生上下文按钮增强(原状态条「上下文容量」格 v93 删除,档位口径由本处承载):四档色由 data-zusage-band 驱动 —— React + * 不清理非 props 的手动属性;!important 压原生 text-success/text-warning/ + * text-foreground-subtle 变调;静态几何(r/描边/圆帽/透明度)用 CSS 覆盖 + * presentation attribute(永远生效),动态 pathLength/dasharray 由 + * enhanceCtxButton 写入。exc 复用 zuexc 呼吸闪烁。数值挂按钮内(环右侧): + * 悬停自动享受原生 hover:bg-hover 与「上下文容量」面板,width 由固定方形放开。 */ + "[data-testid=chat-context-usage-trigger][data-zusage-armed]{color:#3ecf8e!important;" + + "width:auto!important;min-width:1.75rem;padding:0 3px;gap:4px}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed][data-zusage-band=warm]{color:#f5b944!important}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed][data-zusage-band=hot]{color:#ff6b57!important}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed][data-zusage-band=exc]{color:var(--color-destructive,#ff2d55)!important;" + + "text-shadow:0 0 8px rgba(255,45,85,.5);animation:zuexc 1.1s ease-in-out infinite}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed]>svg:not(.animate-spin) circle{opacity:1;stroke-width:3;r:9px}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed]>svg:not(.animate-spin) circle:first-of-type{stroke:var(--color-hover,rgba(255,255,255,.1))}" + + "[data-testid=chat-context-usage-trigger][data-zusage-armed]>svg:not(.animate-spin) circle:last-of-type{stroke:currentColor;stroke-linecap:round}" + + ".zu-ctxpct{font-family:var(--font-mono,Consolas,'Cascadia Mono',Menlo,'Microsoft YaHei UI','Microsoft YaHei',monospace);" + + "font-size:var(--ui-font-size,14px);font-weight:700;font-variant-numeric:tabular-nums;line-height:1;" + + "display:inline-flex;align-items:center;flex:0 0 auto}" + + /* 浅色档(与 .zu-light 组同源深化值):标记由 enhanceCtxButton 随 bar 主题同步到 + * button(pct span 与环都在按钮内,同为 data-zusage-light 后代)。 */ + "[data-zusage-light][data-zusage-band=ok]{color:#0f9d6c!important}" + + "[data-zusage-light][data-zusage-band=warm]{color:#b6791a!important}" + + "[data-zusage-light][data-zusage-band=hot]{color:#d8482f!important}" + + "[data-zusage-light][data-zusage-band=exc]{text-shadow:none}" + + "[data-zusage-light] .zu-ctxpct.ok{color:#0f9d6c}" + + "[data-zusage-light] .zu-ctxpct.warm{color:#b6791a}" + + "[data-zusage-light] .zu-ctxpct.hot{color:#d8482f}" + + "[data-zusage-light] .zu-ctxpct.exc{text-shadow:none}" + + /* v96 Git 状态 chip(工具行「电脑操作」按钮右侧):外形对齐原生 ghost 按钮(h-7/rounded-lg/ + * px-1.5/gap-1);配色移植 cc-pretty-statusline 的 git 段——图标+分支名暗灰,+插入绿 / + * -删除红 / ?未跟踪黄,括号与 ↑↓ 随暗灰;浅色档(Catppuccin Latte 同源值)由 syncCtxLite + * 写在 chip 自身的 data-zusage-light 切换。 */ + ".zu-git{display:inline-flex;align-items:center;gap:4px;height:1.75rem;padding:0 .375rem;border-radius:.5rem;" + + "flex:0 0 auto;box-sizing:border-box;cursor:pointer;user-select:none;white-space:nowrap;outline:none;" + + "font-family:var(--font-mono,Consolas,'Cascadia Mono',Menlo,'Microsoft YaHei UI','Microsoft YaHei',monospace);" + + "font-size:var(--ui-font-size,14px);line-height:1;font-variant-numeric:tabular-nums;color:#afafb2;transition:background-color .15s}" + + ".zu-git:hover,.zu-git:focus-visible{background:var(--color-hover,rgba(255,255,255,.08))}" + + ".zu-git>svg{width:16px;height:16px;flex:0 0 auto}" + + ".zu-git .zu-git-ins{color:#aae6aa}" + + ".zu-git .zu-git-del{color:#f09696}" + + ".zu-git .zu-git-unt{color:#f0e6a0}" + + ".zu-git-conflict{color:#f09696}" + + ".zu-git-warn{color:#f0e6a0}" + + ".zu-git-op{color:#afafb2}" + + ".zu-git-sep{opacity:.6}" + + ".zu-git.zu-compact .zu-git-name,.zu-git.zu-compact .zu-git-ab{display:none}" + + ".zu-git.zu-compact.zu-git-multi .zu-git-d{display:none}" + // v96 multi 紧凑:聚合计数段一并收起(单仓紧凑保留行数段) + "@keyframes zugitspin{to{transform:rotate(360deg)}}" + + ".zu-git.zu-gbusy>svg{animation:zugitspin .8s linear infinite}" + + ".zu-git[data-zusage-light]{color:#6c6f85}" + + ".zu-git[data-zusage-light] .zu-git-ins{color:#2da587}" + + ".zu-git[data-zusage-light] .zu-git-del{color:#d20f39}" + + ".zu-git[data-zusage-light] .zu-git-unt{color:#df8e1d}" + + ".zu-git[data-zusage-light] .zu-git-conflict{color:#d20f39}" + + ".zu-git[data-zusage-light] .zu-git-warn{color:#946200}" + + ".zu-git[data-zusage-light] .zu-git-op{color:#626579}" + + /* v96 multi:脏仓段黄(有冲突时 .zu-conflict 整段红)+ 聚合改动计数 M 黄;浅色档同源深化/加浓值 */ + ".zu-git .zu-git-dirty{color:#f0e6a0}" + + ".zu-git.zu-conflict .zu-git-dirty{color:#f09696}" + + ".zu-git .zu-git-m{color:#f0e6a0}" + + ".zu-git[data-zusage-light] .zu-git-dirty{color:#df8e1d}" + + ".zu-git[data-zusage-light].zu-conflict .zu-git-dirty{color:#d20f39}" + + ".zu-git[data-zusage-light] .zu-git-m{color:#df8e1d}" + + /* v96 套餐额度 chip(原生上下文按钮左侧,wrapper 内前插):多窗分段样式——每个窗口 + * 一段(中文 5小时/7天,英文 5h/7d)各自按 80/95/100 上档位色;余额类型单段 + * (阶段 3 数据源接入即显示);容器不再着色,颜色只落在分段上。 */ + ".zu-quota{display:inline-flex;align-items:center;gap:5px;height:1.75rem;padding:0 6px;border-radius:.5rem;cursor:pointer;user-select:none;flex:0 0 auto;" + + "font-family:var(--font-mono,Consolas,'Cascadia Mono',Menlo,'Microsoft YaHei UI','Microsoft YaHei',monospace);font-size:calc(var(--ui-font-size,14px) - 1px);font-weight:700;font-variant-numeric:tabular-nums;line-height:1}" + + ".zu-quota:hover,.zu-quota:focus-visible{background:var(--color-hover,rgba(255,255,255,.08));outline:none}" + + ".zu-qseg{color:#3ecf8e;white-space:nowrap}" + + ".zu-qseg.warm{color:#f5b944}.zu-qseg.hot{color:#ff6b57}" + + ".zu-qseg.exc{color:var(--color-destructive,#ff2d55);text-shadow:0 0 8px rgba(255,45,85,.5);animation:zuexc 1.1s ease-in-out infinite}" + + ".zu-qsep{color:var(--color-foreground-subtle,rgba(255,255,255,.28));font-weight:400}" + + ".zu-quota.zu-qstale{opacity:.6}" + + ".zu-quota.zu-qbusy{animation:zupulse 1s ease-in-out infinite}" + + ".zu-quota .zu-qwarn{font-size:.85em;line-height:1}" + + ".zu-quota[data-zusage-light] .zu-qseg{color:#0f9d6c}" + + ".zu-quota[data-zusage-light] .zu-qseg.warm{color:#b6791a}" + + ".zu-quota[data-zusage-light] .zu-qseg.hot{color:#d8482f}" + + ".zu-quota[data-zusage-light] .zu-qseg.exc{text-shadow:none}" + + /* 阶段 3 ⚙ 源列表(v103 布局):每源一张圆角小卡片(dsh QuotaCard 同款),卡片间距与 + * 内边距呼吸开;凭据框限宽不拉满;说明段落(card 直子 pnote)补水平边距 */ + ".zu-src-list{display:flex;flex-direction:column;gap:8px;padding:4px 12px 12px}" + + ".zu-src-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:10px 12px 11px;" + + "border:1px solid var(--color-border,rgba(255,255,255,.08));border-radius:10px}" + + ".zu-src-name{display:flex;align-items:center;gap:8px;flex:0 0 auto;font-weight:600}" + + ".zu-src-status{flex:1 1 auto;min-width:0;color:var(--color-tooltip-tag-foreground,#a6a6a6);font-size:calc(var(--ui-font-size,14px) - 2px)}" + + ".zu-src-refresh{background:none;border:none;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px}" + + ".zu-src-refresh:disabled{opacity:.4;cursor:default}" + + ".zu-src-refresh:hover{color:var(--color-foreground,#fafafa)}" + + ".zu-src-fields{flex-basis:100%;display:flex;gap:8px;flex-wrap:wrap}" + + ".zu-src-field{flex:1 1 220px;max-width:420px;min-width:0;background:rgba(0,0,0,.25);border:1px solid var(--color-border,rgba(255,255,255,.12));border-radius:6px;color:inherit;padding:4px 9px;font-family:var(--font-mono,monospace);font-size:calc(var(--ui-font-size,14px) - 2px)}" + + /* v102 预设模板:官方域变体一键填充 chip + 控制台取 Key 链接 */ + ".zu-src-presets{flex-basis:100%;display:flex;gap:6px;flex-wrap:wrap;align-items:center}" + + ".zu-src-preset{background:none;border:1px solid var(--color-border,rgba(255,255,255,.14));border-radius:999px;" + + "color:var(--color-foreground-subtle,#8791a3);font:inherit;font-size:calc(var(--ui-font-size,14px) - 2px);line-height:1.4;padding:1px 9px;cursor:pointer}" + + ".zu-src-preset:hover{color:var(--color-foreground,#e9edf4);border-color:var(--color-border-hover,rgba(255,255,255,.22))}" + + ".zu-src-preset.on{color:var(--color-brand,#57c7ff);border-color:var(--color-brand,#57c7ff)}" + + ".zu-src-keyurl{background:none;border:0;color:var(--color-brand,#57c7ff);font:inherit;font-size:calc(var(--ui-font-size,14px) - 2px);line-height:1.4;padding:2px 4px;cursor:pointer;white-space:nowrap}" + + ".zu-src-keyurl:hover{text-decoration:underline}" + + /* 子代理运行中呼吸灯 */ + "@keyframes zupulse{0%,100%{opacity:1}50%{opacity:.2}}" + + ".dot{animation:zupulse 1.6s ease-in-out infinite;font-size:var(--ui-font-size,14px);line-height:1}" + + /* 面板(v80 起 fixed 挂 body):原 absolute 挂 bar、bottom:calc(100%+10px) 向上展开, + * 但 in-card 形态下条长在输入卡片(overflow-hidden)内部,面板会被卡片裁掉—— + * 照 .tip/.zusage-exc 的 body+fixed 模式迁移,left/top 由 position*Panel 每帧按条矩形写。 */ + ".panel{position:fixed;background:rgba(19,22,30,.97);" + + "background:color-mix(in srgb,var(--color-card,#13161e) 96%,transparent);" + + "backdrop-filter:blur(18px) saturate(1.3);-webkit-backdrop-filter:blur(18px) saturate(1.3);" + + "border:1px solid var(--color-border,rgba(255,255,255,.09));border-radius:12px;padding:12px 14px;display:none;" + + "flex-direction:column;gap:4px;" + + "font-family:var(--font-mono,Consolas,Menlo,'Microsoft YaHei UI',monospace);" + + "font-size:calc(var(--ui-font-size,14px) - 1px);line-height:1.6;color:var(--color-foreground,#c6cdd9);" + + "box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 12px 32px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.35);" + + "min-width:280px;max-width:480px;max-height:72vh;overflow:auto;white-space:normal;z-index:2147483647;" + + "scrollbar-width:thin;scrollbar-color:var(--color-border,rgba(255,255,255,.14)) transparent}" + + ".panel.open{display:flex}" + + ".panel::-webkit-scrollbar{width:8px}" + + ".panel::-webkit-scrollbar-thumb{background:var(--color-border,rgba(255,255,255,.14));border-radius:999px}" + + ".panel label{display:flex;align-items:flex-start;gap:8px;cursor:pointer;padding:5px 8px;margin:0 -8px;" + + "border-radius:8px;transition:background-color .12s ease-out}" + + ".panel input[type=text]{width:110px;background:var(--color-input,rgba(0,0,0,.3));border:1px solid var(--color-border,rgba(255,255,255,.14));" + + "color:var(--color-foreground,#e9edf4);border-radius:6px;padding:2px 7px;font:inherit;outline:none}" + + ".panel input[type=text]:focus{border-color:var(--color-brand,#57c7ff);box-shadow:0 0 0 2px rgba(87,199,255,.15)}" + + ".panel .hr{border-top:1px solid var(--color-border,rgba(255,255,255,.08));margin:5px 0}" + + ".panel .cap{color:var(--color-foreground-subtlest,#6b7484);margin:4px 0 2px;font-size:calc(var(--ui-font-size,14px) - 2px);letter-spacing:.08em}" + + ".panel .phead{font-weight:700;color:var(--color-foreground,#eef2f8);font-size:calc(var(--ui-font-size,14px) + 2px);margin-bottom:5px;display:flex;align-items:center;gap:8px}" + + ".panel .pver{color:var(--color-brand,#57c7ff);font-weight:400;font-size:calc(var(--ui-font-size,14px) - 2px);background:rgba(87,199,255,.12);background:color-mix(in srgb,var(--color-brand,#57c7ff) 12%,transparent);border-radius:999px;padding:1px 8px}" + + ".panel label em{font-style:normal;color:var(--color-foreground-subtle,#6f7989);display:block}" + + ".panel input[type=checkbox]{accent-color:var(--color-brand,#57c7ff);margin-top:3px}" + + ".panel .pnote{line-height:1.6;color:var(--color-foreground-subtle,#6f7989);margin-top:3px}" + + ".panel .zu-update-actions{display:flex;gap:8px;margin:5px 0}" + + ".panel .zu-update-actions button{font:inherit;color:var(--color-foreground,#e9edf4);background:var(--color-hover,rgba(255,255,255,.07));border:1px solid var(--color-border,rgba(255,255,255,.14));border-radius:6px;padding:3px 10px;cursor:pointer}" + + ".panel .zu-update-actions button:disabled{opacity:.5;cursor:default}" + + ".panel .zu-update-status{overflow-wrap:anywhere;white-space:pre-line}" + + /* 更新进度(v89):下载条按字节,安装条按校验/安装/同步三段推进;宿主安装器没有 + * 字节进度,用扫光动画表示仍在活动。主进程未推送 progress(旧版 updater)时整块隐藏。 */ + ".panel .zu-update-progress{display:flex;flex-direction:column;gap:6px;margin:6px 0 2px}" + + ".panel .zu-update-progress[hidden]{display:none}" + + ".panel .zu-progress-row{display:flex;align-items:center;gap:8px;font-size:calc(var(--ui-font-size,14px) - 1px);color:var(--color-foreground-subtle,#6f7989)}" + + ".panel .zu-progress-label{flex:0 0 2.5em}" + + ".panel .zu-progress-track{flex:1;height:6px;border-radius:999px;background:var(--color-hover,rgba(255,255,255,.08));overflow:hidden}" + + ".panel .zu-progress-fill{height:100%;width:0;border-radius:999px;background:var(--color-brand,#57c7ff);transition:width .2s ease}" + + ".panel .zu-progress-fill.active{background-image:linear-gradient(90deg,transparent 0,rgba(255,255,255,.4) 50%,transparent 100%);background-size:48px 100%;background-repeat:no-repeat;animation:zuProgressSweep 1.1s linear infinite}" + + ".panel .zu-progress-value{flex:0 0 auto;min-width:3em;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}" + + "@keyframes zuProgressSweep{from{background-position:-48px 0}to{background-position:calc(100% + 48px) 0}}" + + ".panel.zu-settings{box-sizing:border-box;min-width:0;max-width:none;" + + "max-height:min(72vh,var(--zu-settings-height,72vh));padding:0;gap:0;overflow:hidden;line-height:1.5}" + + ".zu-settings .zu-settings-head{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px;" + + "padding:14px 16px;border-bottom:1px solid var(--color-border,rgba(255,255,255,.09))}" + + ".zu-settings .phead{margin:0;font-size:var(--ui-font-size,14px);font-weight:600}" + + ".zu-settings .zu-settings-close{background:none;border:0;font:inherit;color:var(--color-foreground-subtle,#8791a3);" + + "padding:2px 6px;border-radius:6px;cursor:pointer}" + + ".zu-settings .zu-settings-close:hover{background:var(--color-hover,rgba(255,255,255,.07))}" + + /* 分区布局(v100):左栏分组导航 + 右侧内容区(一次只显示一个分区),ZCode 设置页同款双栏 */ + ".zu-settings .zu-settings-layout{flex:1;min-height:0;display:flex}" + + ".zu-settings .zu-settings-nav{flex:0 0 auto;width:164px;overflow-y:auto;padding:2px 8px 12px;scrollbar-width:thin;" + + "scrollbar-color:var(--color-foreground-subtlest,#6b7484) transparent;border-right:1px solid var(--color-border,rgba(255,255,255,.09))}" + + ".zu-settings .zu-settings-nav::-webkit-scrollbar{width:8px}" + + ".zu-settings .zu-settings-nav::-webkit-scrollbar-thumb{background:var(--color-foreground-subtlest,#6b7484);border:2px solid transparent;background-clip:padding-box;border-radius:999px}" + + ".zu-settings .zu-settings-navgroup+.zu-settings-navgroup{margin-top:16px}" + + ".zu-settings .zu-navcap{padding:0 10px 5px;font-size:calc(var(--ui-font-size,14px) - 2px);font-weight:500;letter-spacing:0;" + + "color:var(--color-remote-foreground-muted,#8d95a5)}" + + /* 导航项:ZCode 侧栏同款 32px 行 + 12px 圆角 + surface-hover 激活底;图标恒为前景色 */ + ".zu-settings .zu-navitem{display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 10px;border:0;border-radius:12px;" + + "background:none;font:inherit;font-size:var(--ui-font-size,14px);line-height:1.4;text-align:left;cursor:pointer;" + + "color:var(--color-remote-foreground-muted,#8d95a5);transition:background-color .12s ease-out,color .12s ease-out}" + + ".zu-settings .zu-navitem:hover,.zu-settings .zu-navitem[aria-current]{background:var(--color-surface-hover,rgba(255,255,255,.08));color:var(--color-foreground,#e9edf4)}" + + ".zu-settings .zu-nav-ic{flex:0 0 16px;display:inline-flex;align-items:center;justify-content:center;color:var(--color-foreground,#e9edf4)}" + + ".zu-settings .zu-nav-ic svg{width:16px;height:16px;display:block}" + + ".zu-settings .zu-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" + + /* 窄条收成 icon rail(ZCode max-lg icon-only 同语义;阈值由 syncSettingsScroll 按面板实测宽切换) */ + ".zu-settings.zu-nav-icons .zu-settings-nav{width:52px;padding:2px 6px 12px}" + + ".zu-settings.zu-nav-icons .zu-navitem{justify-content:center;padding:0}" + + ".zu-settings.zu-nav-icons .zu-nav-label,.zu-settings.zu-nav-icons .zu-navcap{" + + "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}" + + ".zu-settings .zu-settings-body{flex:1;min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;" + + "padding:12px 12px 16px;scrollbar-width:thin;scrollbar-color:var(--color-foreground-subtlest,#6b7484) transparent}" + + ".zu-settings-body::-webkit-scrollbar{width:8px}" + + ".zu-settings-body::-webkit-scrollbar-thumb{background:var(--color-foreground-subtlest,#6b7484);border:2px solid var(--color-card,#13161e);border-radius:999px}" + + ".zu-settings.zu-settings-more .zu-settings-body{mask-image:linear-gradient(#000 calc(100% - 16px),transparent)}" + + /* 分区标题(v100):单分区视图下 cap 即该页标题,提级为前景色 + 略大字号 */ + ".zu-settings .cap{margin:0 0 8px;letter-spacing:0;color:var(--color-foreground,#e9edf4);font-weight:600;" + + "font-size:calc(var(--ui-font-size,14px) + 1px)}" + + ".zu-settings .zu-settings-card{border:1px solid var(--color-border,rgba(255,255,255,.09));border-radius:12px;" + + "background:var(--color-surface,rgba(255,255,255,.04));overflow:hidden}" + + ".zu-settings label{align-items:center;gap:12px;margin:0;padding:10px 12px;border-radius:0}" + + ".zu-settings .zu-settings-card>label+label{border-top:1px solid var(--color-border,rgba(255,255,255,.09))}" + + ".zu-settings label>span{flex:1;min-width:0;font-weight:500}" + + ".zu-settings label em{font-weight:400;margin-top:2px;font-size:calc(var(--ui-font-size,14px) - 2px);line-height:1.5}" + + ".zu-settings input[type=checkbox]{appearance:none;-webkit-appearance:none;order:2;position:relative;flex:0 0 28px;" + + "width:28px;height:16px;margin:0;border:1px solid var(--color-border,rgba(255,255,255,.18));border-radius:999px;" + + "background:var(--color-input,rgba(255,255,255,.12));cursor:pointer}" + + ".zu-settings input[type=checkbox]::before{content:'';position:absolute;left:2px;top:2px;width:10px;height:10px;" + + "border-radius:50%;background:var(--color-foreground-subtle,#8791a3)}" + + ".zu-settings input[type=checkbox]:checked{background:var(--color-brand,#57c7ff);border-color:var(--color-brand,#57c7ff)}" + + ".zu-settings input[type=checkbox]:checked::before{left:14px;background:var(--color-background,#13161e)}" + + ".zu-settings input:focus-visible,.zu-settings button:focus-visible{outline:2px solid var(--color-brand,#57c7ff);outline-offset:2px}" + + ".zu-settings input:disabled{opacity:.4;cursor:default}" + + ".zu-settings .zu-ctxov{order:2;box-sizing:border-box;width:120px;min-width:0;padding:5px 7px}" + + ".zu-settings .zu-settings-langs{display:flex;gap:8px;padding:10px 12px}" + + ".zu-settings .zu-settings-langs label{flex:1;padding:5px 8px;border:1px solid var(--color-border,rgba(255,255,255,.14));border-radius:6px}" + + ".zu-settings input[type=radio]{accent-color:var(--color-brand,#57c7ff);margin:0}" + + ".zu-settings .zu-settings-detail{padding:0 12px 10px}" + + ".zu-settings .zu-settings-card>.pnote{padding:2px 12px 4px}" + + ".zu-settings .zu-settings-detail .pnote{font-size:calc(var(--ui-font-size,14px) - 2px)}" + + ".zu-settings .zu-settings-foot{flex:0 0 auto;display:flex;align-items:center;gap:10px;border-top:1px solid var(--color-border,rgba(255,255,255,.09));padding:9px 12px}" + + ".zu-settings .zu-settings-scroll{display:block;flex:1;min-width:0;width:auto;border:0;background:none;font:inherit;" + + "font-size:calc(var(--ui-font-size,14px) - 2px);color:var(--color-brand,#57c7ff);padding:2px;cursor:pointer}" + + ".zu-settings .zu-settings-scroll:disabled{color:var(--color-foreground-subtle,#8791a3);cursor:default}" + + /* 反馈按钮(v70):常驻底栏右缘,点击经 window.open 交由系统默认浏览器打开 issues */ + ".zu-settings .zu-settings-feedback{flex:0 0 auto;font:inherit;font-size:calc(var(--ui-font-size,14px) - 2px);" + + "color:var(--color-foreground,#e9edf4);background:var(--color-hover,rgba(255,255,255,.07));" + + "border:1px solid var(--color-border,rgba(255,255,255,.14));border-radius:6px;padding:3px 10px;cursor:pointer}" + + /* 子代理明细面板(v49):点击条目弹出的固定面板(与设置面板同机制,互斥打开); + * 面板不随鼠标消失,绕开悬停+tab 的全部几何问题。 */ + ".zu-sub{cursor:pointer}" + + ".zu-sub:hover .v{color:var(--color-foreground,#fff)}" + + ".panel.subp{width:420px;max-width:60vw;gap:8px}" + + ".subrow{padding:8px 10px;background:var(--color-surface,rgba(255,255,255,.03));border:1px solid var(--color-border,rgba(255,255,255,.06));border-radius:8px}" + + ".subname{font-weight:600;color:var(--color-foreground,#e9edf4);font-size:var(--ui-font-size,14px);overflow-wrap:anywhere}" + + ".substat{color:var(--color-foreground-subtle,#8791a3);margin-top:2px}" + + ".sublive{color:#3ecf8e;font-size:calc(var(--ui-font-size,14px) - 2px);font-weight:400;background:rgba(62,207,142,.12);border-radius:999px;padding:0 7px;margin-left:7px}" + + /* 自绘 tooltip(v31):向上弹出(原生 title 方向不可控且会被窗口下缘遮挡), + * 支持多 tab;white-space:pre-line 保留数据里的 \n 换行。 + * v32:fixed 挂 body —— 原 absolute 挂 bar,被页面消息流的层叠上下文盖住 + * (diag 实证 disp=block 但不可见),挂 body 用视口坐标独立定位。 + * v81:底色/边框/文字改宿主 Radix Tooltip 同款(bg-tooltip + border-border + + * text-tooltip-foreground,反色底:暗色主题白底黑字、浅色主题黑底白字), + * 内部次级文字用宿主 tooltip-tag 系变量;毛玻璃在实色底下无意义,删。 */ + ".tip{position:fixed;background:var(--color-tooltip,#171717);" + + "border:1px solid var(--color-border,transparent);border-radius:10px;padding:8px 12px;" + + "font-family:var(--font-mono,Consolas,'Microsoft YaHei UI',monospace);" + + "font-size:calc(var(--ui-font-size,14px) - 1px);line-height:1.6;color:var(--color-tooltip-foreground,#fafafa);" + + "box-shadow:0 10px 28px rgba(0,0,0,.5),0 2px 6px rgba(0,0,0,.3);" + + "white-space:pre-line;z-index:2147483646;max-width:560px;display:none;scrollbar-width:thin}" + + /* 结构化 tooltip 行排版(v67.1,与统计行悬停解释同款;仅状态条 .tip 作用域) + * v81:前景色系改 tooltip 语境(反色底上 --color-foreground 会与底同色不可读) */ + ".tip .rt-h{font-weight:600;color:var(--color-tooltip-foreground,#fafafa);margin:0 0 6px}" + + ".tip .rt-row{display:flex;gap:10px;margin:3px 0}" + + ".tip .rt-row b{flex:0 0 auto;min-width:86px;color:var(--color-tooltip-foreground,#fafafa);font-weight:600}" + + ".tip .rt-row span{color:var(--color-tooltip-tag-foreground,#a6a6a6)}" + + ".tip .rt-note{margin-top:7px;padding-top:6px;border-top:1px solid var(--color-border,transparent);color:var(--color-tooltip-tag-foreground,#a6a6a6)}" + + ".ttabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px}" + + ".ttab{cursor:pointer;padding:2px 10px;border-radius:999px;background:var(--color-tooltip-tag,#262626);" + + "color:var(--color-tooltip-tag-foreground,#a6a6a6);white-space:nowrap;font-size:calc(var(--ui-font-size,14px) - 1px);" + + "transition:background-color .12s ease-out,color .12s ease-out}" + + ".ttab:hover{color:var(--color-tooltip-foreground,#fafafa);background:color-mix(in srgb,var(--color-tooltip-foreground,#fafafa) 12%,transparent)}" + + ".ttab.on{background:color-mix(in srgb,var(--color-brand,#5ac8ff) 16%,transparent);color:var(--color-brand,#5ac8ff)}" + + ".tbody{max-height:60vh;overflow:auto;scrollbar-width:thin}" + + ".tbody::-webkit-scrollbar{width:8px}" + + ".tbody::-webkit-scrollbar-thumb{background:var(--color-border,rgba(255,255,255,.14));border-radius:999px}" + + /* 超限告警气泡(v39):挂 body 的独立浮层(与 .tip 同套路,免受条面重建影响), + * 红边警示 + 建议步骤 + 可点击复制的会话 ID;user-select:text 允许手动选中兜底。 + * 底色 = 客户端 destructive 掺背景色:深色下深红黑、浅色下淡粉,自动适配。 */ + ".zusage-exc{position:fixed;max-width:470px;" + + "background:linear-gradient(180deg,rgba(41,18,22,.98),rgba(27,14,16,.98));" + + "background:linear-gradient(180deg,color-mix(in srgb,var(--color-destructive,#ff2d55) 10%,var(--color-background,#1b0e10)),color-mix(in srgb,var(--color-destructive,#ff2d55) 5%,var(--color-background,#1b0e10)));" + + "border:1px solid rgba(255,45,85,.4);border-color:color-mix(in srgb,var(--color-destructive,#ff2d55) 40%,transparent);" + + "border-radius:12px;padding:12px 15px;" + + "font-family:var(--font-mono,Consolas,'Microsoft YaHei UI',monospace);" + + "font-size:calc(var(--ui-font-size,14px) - 1px);line-height:1.7;color:var(--color-foreground,#e9edf4);" + + "box-shadow:0 12px 32px rgba(0,0,0,.5),0 4px 18px rgba(255,45,85,.12);white-space:normal;z-index:2147483647;" + + "user-select:text;display:none}" + + ".zusage-exc .xb-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}" + + ".zusage-exc .xb-title{font-weight:700;color:var(--color-destructive,#ff5c77)}" + + ".zusage-exc .xb-step{color:var(--color-foreground-subtle,#c6cdd9)}" + + ".zusage-exc .xb-close{cursor:pointer;color:var(--color-foreground-subtlest,#8791a5);padding:2px 5px;border-radius:6px;font-size:calc(var(--ui-font-size,14px) + 2px);line-height:1}" + + ".zusage-exc .xb-close:hover{color:var(--color-foreground,#fff);background:var(--color-hover,rgba(255,255,255,.08))}" + + ".zusage-exc .xb-sid{color:var(--color-brand,#57c7ff);cursor:pointer;text-decoration:underline dotted}" + + ".zusage-exc .xb-sid:hover{text-decoration-style:solid}" + + ".zusage-exc .xb-copied{color:#3ecf8e;margin-left:6px;display:none}" + + /* 白色主题程度性覆盖(v58.1):颜色已全量走客户端变量随 .dark 自动翻转, + * 这里只兜两类 —— 程度差(浅色下阴影/内高光减重、红字光晕收掉)与 + * 三档状态色加深版(高对比自调档,见 .ok 注释)。 */ + /* :not(.zu-in) 保特异性:in-card 形态(v80)自身声明 box-shadow:none 且定义在前, + * 同特异性的浅色阴影会反超——透明无框条上浮出一圈浅影(review 抓获) */ + ".zusage-root.zu-light:not(.zu-in){box-shadow:inset 0 1px 0 rgba(255,255,255,.85),0 4px 14px rgba(15,23,42,.12),0 1px 3px rgba(15,23,42,.08)}" + + ".panel.zu-light{box-shadow:inset 0 1px 0 rgba(255,255,255,.9),0 12px 32px rgba(15,23,42,.14),0 2px 8px rgba(15,23,42,.08)}" + + /* v81:反色底后 tip 的 inset 白高光在浅色主题(黑底 tip)上违和,浅色只调淡外阴影 */ + ".tip.zu-light{box-shadow:0 10px 28px rgba(15,23,42,.16),0 2px 6px rgba(15,23,42,.08)}" + + ".zusage-exc.zu-light{box-shadow:0 12px 32px rgba(15,23,42,.14),0 4px 18px rgba(220,38,38,.1)}" + + ".zu-light .exc{text-shadow:none}" + + ".zu-light .ok{color:#0f9d6c}.zu-light .warm{color:#b6791a}.zu-light .hot{color:#d8482f}" + + ".zu-light .sublive{color:#0c8a63;background:rgba(15,140,102,.12)}" + + ".zu-light .xb-copied{color:#0c8a63}" + + /* 减少动态偏好:关闭呼吸/闪烁与悬浮过渡 */ + "@media (prefers-reduced-motion:reduce){.exc,.dot,.zu-compact-dlg,.zu-compact-card{animation:none}" + + ".it,.btn,.ttab,.panel label{transition:none}}" + + /* v105 压缩上下文:弹层内胶囊按钮 + 确认弹框。配色全量引用客户端语义变量 + * (secondary/primary/popover/border/hover,明暗与 zai 主题自动跟随);color-mix + * 处加同值前置声明(不支持时落实色)。按钮复刻原生 hover:bg-secondary/80。 */ + ".zu-compact-btn{display:block;box-sizing:border-box;width:100%;height:36px;padding:0;border:0;border-radius:9999px;" + + "background:var(--color-secondary,#e5e5e5);color:var(--color-foreground,#0a0a0a);" + + "font-family:inherit;font-size:14px;font-weight:500;line-height:1;cursor:pointer;" + + "transition:background .15s}" + + ".zu-compact-btn:hover{background:var(--color-secondary,#e5e5e5);background:color-mix(in srgb,var(--color-secondary,#e5e5e5) 80%,transparent)}" + + ".zu-compact-btn:active{background:var(--color-secondary,#e5e5e5);background:color-mix(in srgb,var(--color-secondary,#e5e5e5) 68%,transparent)}" + + ".zu-compact-dlg{position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;" + + "background:rgba(0,0,0,.6);animation:zuCptFade .1s ease-out}" + + ".zu-compact-card{box-sizing:border-box;width:min(92vw,384px);padding:20px;border-radius:16px;" + + "background:var(--color-popover,#fff);color:var(--color-foreground,#0a0a0a);" + + "border:1px solid var(--color-border,rgba(0,0,0,.1));box-shadow:0 25px 50px -12px rgba(0,0,0,.45);" + + "font-family:inherit;font-size:14px;line-height:1.5;animation:zuCptZoom .1s ease-out}" + + ".zu-compact-title{font-size:16px;font-weight:600}" + + ".zu-compact-desc{padding-top:8px;color:var(--color-foreground-subtle,#8791a3)}" + + ".zu-compact-desc b{color:var(--color-foreground,#0a0a0a);font-weight:500;font-variant-numeric:tabular-nums}" + + ".zu-compact-hint{margin-top:8px;font-size:12.5px;line-height:1.45;color:var(--color-foreground-subtle,#8791a3);opacity:.85}" + + ".zu-compact-err{display:none;margin-top:10px;padding:8px 10px;border-radius:8px;background:rgba(220,38,38,.1);color:#dc2626;font-size:13px}" + + ".zu-compact-err.on{display:block}" + + ".zu-compact-foot{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}" + + ".zu-compact-foot button{height:36px;padding:0 16px;border-radius:8px;font-family:inherit;font-size:14px;cursor:pointer;" + + "transition:background .15s,opacity .15s}" + + ".zu-compact-cancel{background:transparent;border:1px solid var(--color-border,rgba(0,0,0,.1));color:var(--color-foreground,#0a0a0a)}" + + ".zu-compact-cancel:hover{background:var(--color-hover,#e5e5e5)}" + + ".zu-compact-ok{border:0;background:var(--color-primary,#0a0a0a);color:var(--color-primary-foreground,#fff)}" + + ".zu-compact-ok:hover{opacity:.8}" + + ".zu-compact-ok:disabled{opacity:.5;cursor:default}" + + "@keyframes zuCptFade{from{opacity:0}to{opacity:1}}" + + "@keyframes zuCptZoom{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}"; + + /* 结构:条撑满卡片宽后 .zu-main 占满剩余宽、条目+分隔线 space-between 均匀分布, + * ⚙ 落在最右缘(内容超宽时分级收缩、极限右缘渐隐 .zu-clip);面板 fixed 挂 body, + * 由 position*Panel 按条矩形定位在条上方(v80 迁移,见挂载处注) */ + var bar = document.createElement("div"); + bar.className = "zusage-root"; + if (slot === 0) bar.id = "zusage-bar"; + bar.style.display = "none"; // 定位成功前不显示(避免占位) + bar.innerHTML = '…' + + '⚙'; + var panel = document.createElement("div"); + panel.className = "panel zu-settings"; + /* issue #55:这三个 var 必须先于下方 buildPanel() 的调用完成赋值——var 只提升声明不提升 + * 赋值,而 buildPanel → syncUpdate 首轮执行就会读它们(赋值语句曾排在调用点之后, + * v1.5.2 首轮读到 undefined 抛 TypeError 拖垮实例创建)。配套函数是声明有提升,留在原位。 */ + var srcListSig = ""; + var SRC_FIELD_LABELS = { + apiKey: L("API Key", "API Key"), cookie: L("控制台 Cookie", "Console Cookie"), token: L("Token", "Token"), + accessKeyId: L("AccessKeyId", "AccessKeyId"), secretAccessKey: L("SecretAccessKey", "SecretAccessKey"), + planCredits: L("套餐 Credits 总量", "Plan credits"), planStart: L("计费锚日 YYYY-MM-DD", "Billing anchor day"), + }; + var PROGRESS_STAGES = ["download", "verify", "install", "sync"]; + function settingRow(input, title, note) { + return ''; + } + /* ---------- 分区导航(v100,ZCode 设置侧栏同款交互):左栏三组、内容区单分区显隐 ---------- */ + var ZU_NAV_GROUPS = [ + { id: "basics", zh: "基础", en: "Basics" }, + { id: "data", zh: "数据与统计", en: "Data & stats" }, + { id: "maint", zh: "维护", en: "Maintenance" }, + ]; + var ZU_SECTIONS = [ + { id: "display", group: "basics", zh: "状态条显示", en: "Display items", + icon: '' }, + { id: "general", group: "basics", zh: "通用", en: "General", + icon: '' }, + { id: "stats", group: "data", zh: "回答统计", en: "Reply statistics", + icon: '' }, + { id: "quota", group: "data", zh: "套餐额度", en: "Plan quota", + icon: '' }, + { id: "update", group: "maint", zh: "插件更新", en: "Plugin updates", + icon: '' }, + { id: "about", group: "maint", zh: "关于数据", en: "About the data", + icon: '' }, + ]; + function activeSettingsSec() { + for (var i = 0; i < ZU_SECTIONS.length; i++) if (ZU_SECTIONS[i].id === state.settingsSec) return state.settingsSec; + return ZU_SECTIONS[0].id; + } + function secOpen(id) { return activeSettingsSec() === id ? "" : " hidden"; } + function navHtml() { + var active = activeSettingsSec(); + var html = ''; + } + /* 面板文案可随语言重建(事件走 panel 级委托,重建不丢监听) */ + function buildPanel() { + var oldBody = panel.querySelector(".zu-settings-body"); + var scrollTop = oldBody ? oldBody.scrollTop : 0; + panel.innerHTML = + '
' + L("状态条设置", "Status bar settings") + '
' + + '
' + + '
' + navHtml() + + '
' + + '
' + L("状态条显示", "Display items") + '
' + + settingRow('', L("本轮", "Current turn"), L("tokens、请求次数、耗时与首字延迟", "Tokens, requests, duration and first-token latency")) + + settingRow('', L("会话累计", "Session total"), L("当前会话的 tokens、轮数与请求次数", "Tokens, turns and requests in this session")) + + settingRow('', L("工具调用", "Tool calls"), L("悬停查看各工具明细与错误", "Hover for per-tool details and errors")) + + settingRow('', L("今日合计", "Today's total"), L("今天所有会话的消耗", "Usage across all sessions today")) + + settingRow('', L("子代理", "Sub-agents"), L("点击状态条上的子代理查看明细", "Click the bar item for sub-agent details")) + + settingRow('', L("Git 状态", "Git status"), L("「电脑操作」按钮右侧显示当前分支、↑领先 ↓落后与未提交改动(+插入行 -删除行 ?未跟踪);仅本地仓库", "Branch, ↑ahead ↓behind and uncommitted changes (+ins -del ?untracked) beside the Computer Use button; local repositories only")) + + '
' + + '
' + L("通用", "General") + '
' + + settingRow('', L("上下文窗口", "Context window"), L("留空自动识别,优先使用客户端数值", "Leave empty to detect automatically; prefer the client value")) + + /* 万/亿开关只在中文界面出现(英文始终 K/M/B,见 fmt),因此文案不走 L() */ + (state.lang === "en" ? "" : settingRow('', "中文单位(万/亿)", "token 数量以万/亿显示,默认开启")) + + '
' + + '
' + + '
' + L("回答统计", "Reply statistics") + '
' + + settingRow('', L("回答末尾追加统计行", "Append stats to each reply"), L("默认开启,下一次提问生效。包含速率(含思考)、首字延迟、输出量、生成耗时、段数峰值与会话累计。", "On by default; effective on the next prompt. Includes speed (with thinking), first-token latency, output, duration, segments/peak and session total.")) + + '
' + + '
' + L("套餐额度", "Plan quota") + '
' + + settingRow('', L("显示套餐额度", "Show plan quota"), L("在输入框上下文按钮左侧显示当前登录账号编码套餐的已用比例与重置时间(账号级套餐池,不是单个模型独享)。默认关闭。点击 chip 立即刷新。", "Shows the signed-in account's coding-plan usage and reset time left of the context button (account-level pool, not per model). Off by default. Click the chip to refresh.")) + + '
' + L("开启后主进程读取本机 ZCode 登录凭据(~/.zcode/v2/credentials.json)查询官方套餐接口:凭据仅在内存使用、不落盘、不进日志与诊断:内置套餐发往 bigmodel.cn / api.z.ai 官方域;自定义供应商余额用其自己的 key,只发其官方域(api.deepseek.com、openrouter.ai、api.siliconflow.cn、api.minimaxi.com、api.kimi.com、api.commandcode.ai、api.cline.bot、api.anthropic.com、platform.xiaomimimo.com、open.volcengineapi.com、opencode.ai 等)或你配置的中转站本域。套餐支持 BigModel / Z.ai 个人档与多家 Token Plan/余额(见下方数据源列表);团队套餐暂不支持,识别不了余额形状的中转不显示。启用 Anthropic 源时会读取本机 Claude Code 的登录凭据(~/.claude/.credentials.json)查询其官方用量接口,同样仅在内存使用。", + "When on, credentials are read in memory to query plan/balance endpoints and never reach disk, logs or diagnostics: built-in plans go to bigmodel.cn / api.z.ai; custom-provider lookups use that provider's own key and go only to its official domain (api.deepseek.com, openrouter.ai, api.siliconflow.cn, api.minimaxi.com, api.kimi.com, api.commandcode.ai, api.cline.bot, api.anthropic.com, platform.xiaomimimo.com, open.volcengineapi.com, opencode.ai, ...) or the relay base URL you configured — never anywhere else. Multiple Token Plan / balance vendors are supported (see the source list below); team plans are not. Enabling the Anthropic source additionally reads the local Claude Code credential (~/.claude/.credentials.json) for its official usage endpoint, in memory only.") + '
' + + '
' + + '
' + L("每个数据源独立启用、独立刷新;凭据只保存在本机 quota-secrets.json(0600),不进日志与诊断。标 * 的厂商为源码转写适配,未经真实账号实测。", "Each source has its own toggle and refresh; credentials stay in the local quota-secrets.json (0600), never in logs or diagnostics. Vendors marked * are ported from reference projects without live verification.") + '
' + + '
' + L("插件更新", "Plugin updates") + '
' + + settingRow('', L("自动更新", "Automatic updates"), L("每 24 小时检查并安装新版,默认开启", "Check and install every 24 hours; on by default")) + + '
' + + '
' + + '' + + '
' + L("仅更新此插件。GitHub/GitCode 来源会联网,本地目录来源只检查本地文件,不上传用量数据。", "Only this plugin is updated. GitHub/GitCode sources use the network; directory sources check local files. Usage data is never uploaded.") + '
' + + '
' + L("关于数据", "About the data") + '
' + + L("悬停状态条查看明细。数据源 ~/.zcode/cli/db/db.sqlite(只读),请求完成后才落库。", "Hover bar items for details. Data source ~/.zcode/cli/db/db.sqlite (read-only); data is written after requests complete.") + '
' + + '
' + + '
'; + panel.querySelectorAll('input[name=zu-lang]').forEach(function (r) { + r.checked = state.lang === r.value; + }); + /* 重建后回填全部控件状态(语言切换路径不走 syncPanel):checkbox 按 state.show、 + 上下文窗口输入框按 state.ctxOv——否则切语言后勾选态清空、首次点击语义反转 */ + panel.querySelectorAll("input[type=checkbox][data-k]").forEach(function (cb) { + cb.checked = !!state.show[cb.dataset.k]; + }); + var ovInput = panel.querySelector(".zu-ctxov"); + if (ovInput) ovInput.value = state.ctxOv; + var cnBox = panel.querySelector(".zu-cn-num"); + if (cnBox) cnBox.checked = !!state.cnNum; + syncUpdate(); + panel.querySelector(".zu-settings-body").scrollTop = scrollTop; + syncSettingsScroll(); + } + function syncSettingsScroll() { + var body = panel.querySelector(".zu-settings-body"); + var button = panel.querySelector(".zu-settings-scroll"); + if (!body || !button) return; + /* 窄面板(<620px)导航收成 icon rail:ZCode max-lg icon-only 同语义,按面板实测宽切换 */ + panel.classList.toggle("zu-nav-icons", !!panel.offsetWidth && panel.offsetWidth < 620); + var overflow = body.scrollHeight > body.clientHeight + 2; + var more = overflow && body.scrollHeight - body.clientHeight - body.scrollTop > 2; + panel.classList.toggle("zu-settings-more", more); + button.disabled = !more; + var text = more ? L("↓ 下方还有设置 · 滚动或点击查看更多", "↓ More settings below · scroll or click to see more") : + overflow ? L("✓ 已到底部 · 所有设置已展示", "✓ End of settings") : L("所有设置已展示", "All settings are visible"); + if (button.textContent !== text) button.textContent = text; + } + function positionSettingsPanel() { + if (!panel.classList.contains("open")) return; + var br = bar.getBoundingClientRect(); + var height = Math.max(0, Math.floor(br.top - 18)); + /* v101:高度在打开瞬间按内容自然高锁定(scrollHeight 返回未被 max-height 截断的 + * 完整内容高),此后切分区/内容增减都不改面板高,只随可用上限收紧不放大—— + * 左侧导航切分区时面板高度与底边不再抖动;72vh 上限仍由 CSS max-height 兜底。 */ + if (!panel.style.height) { + var natural = panel.scrollHeight || panel.offsetHeight || 0; + panel.style.height = Math.max(0, Math.min(natural, height)) + "px"; + } else if ((parseInt(panel.style.height, 10) || 0) > height) { + panel.style.height = height + "px"; + } + var value = height + "px"; + if (panel.style.getPropertyValue("--zu-settings-height") !== value) panel.style.setProperty("--zu-settings-height", value); + value = br.width + "px"; + if (panel.style.width !== value) panel.style.width = value; + /* v80:面板 fixed 挂 body——视口坐标贴条上沿(原 absolute 语义 bottom:100%+10px), + * 左缘随条、可视区内夹紧;top 用开启后的实测面板高,避免底部悬空/重叠。 */ + var left = Math.max(8, Math.min(Math.round(br.left), innerWidth - Math.round(br.width) - 8)); + if (panel.style.left !== left + "px") panel.style.left = left + "px"; + var top = Math.max(8, Math.round(br.top - panel.offsetHeight - 10)); + if (panel.style.top !== top + "px") panel.style.top = top + "px"; + syncSettingsScroll(); + } + panel.addEventListener("scroll", syncSettingsScroll, true); + panel.addEventListener("keydown", function (e) { + if (e.key === "Escape") panel.classList.remove("open"); + }); + buildPanel(); + /* v80:面板挂 body(fixed 视口坐标),不再挂 bar——条长进输入卡片(overflow-hidden) + * 内部后,absolute 面板会被卡片裁掉。同 .tip 挂 body 的先例;.zusage-panel 类供 + * 全局清理与 isOwnOverlay 命中。 */ + panel.className = "panel zu-settings zusage-panel"; + document.body.appendChild(panel); + /* 子代理明细面板(v49):与设置面板同款 .panel 机制,点击条目开关,互斥打开 */ + var subPanel = document.createElement("div"); + subPanel.className = "panel subp zusage-panel"; + document.body.appendChild(subPanel); + /* 自绘 tooltip:向上弹出(v45 起配空中走廊,移入点击不再被移开即隐掐断);取代原生 title(方向不可控,在窗口底边会朝下被遮挡) */ + /* 自绘 tooltip 挂 body(fixed 视口坐标):挂 bar 内会被消息流的层叠上下文盖住(v31 实证) */ + var tip = document.createElement("div"); + tip.className = "tip zusage-tip-root"; + if (slot === 0) tip.id = "zusage-tip"; + tip.style.display = "none"; + document.body.appendChild(tip); + bar.appendChild(style); // 样式随条走,不进 head(防清理) + /* 内联兜底:即使样式表意外失效,定位行为也不退化。 + * z-index 50:压得过聊天列表/输入框外层容器(Tailwind z-20),又不盖权限菜单等弹层(实测值)。 */ + bar.style.position = "fixed"; + bar.style.zIndex = "50"; + document.body.appendChild(bar); + + /* ---------- 超限告警气泡(v39):当前会话"上下文超限被拒"时自动弹出,随条定位 ---------- */ + var excBubble = document.createElement("div"); + excBubble.className = "zusage-exc"; + /* 文案只进 textContent/静态 innerHTML;会话 ID 是动态数据,一律走 textContent 防注入。 + * 面板语言切换时 rebuildExcBubble() 重建气泡文案。 */ + var excSid, excCopied; + function rebuildExcBubble() { + if (excCopied) clearTimeout(excCopied.timer); + excBubble.innerHTML = + '
' + L("⚠ 上下文超限", "⚠ Context over limit") + '' + + '✕
' + + '
' + L("最近一次请求因超出上下文窗口容量被拒绝,本轮对话暂时无法继续。建议依次尝试:", + "The latest request was rejected for exceeding the context window; this turn cannot continue for now. Try these in order:") + '
' + + '
' + L("① 回滚上一轮对话,去掉超限的那次请求后继续;", + "① Roll back the previous turn, drop the over-limit request and continue;") + '
' + + '
' + L("② 换用上下文窗口更大的模型继续,或压缩 / 精简本会话;", + "② Continue with a larger-window model, or compress / trim this session;") + '
' + + '
' + L("③ 仍无法解决时,新开一个对话,把下面的会话 ID(必要时连同工作区路径)发给它,让新会话读取本会话的记录文件接手修复。", + "③ If that still fails, start a new conversation and send it the session ID below (workspace path if needed) — the new session can pick up this session's rollout files to take over.") + '
' + + '
' + L("会话 ID:", "Session ID: ") + '' + L("已复制", "Copied") + '
'; + excSid = excBubble.querySelector(".xb-sid"); + excCopied = excBubble.querySelector(".xb-copied"); + excSid.addEventListener("click", onExcSidCopy); + excBubble.querySelector(".xb-close").addEventListener("click", onExcClose); + } + function onExcSidCopy() { + if (inactive() || !excSid.textContent) return; + try { + var ta = document.createElement("textarea"); + ta.value = excSid.textContent; + ta.style.cssText = "position:fixed;left:-9999px;top:0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } catch (e) { } + excCopied.style.display = "inline"; + clearTimeout(excCopied.timer); + excCopied.timer = setTimeout(function () { excCopied.style.display = "none"; }, 1500); + } + function onExcClose() { + if (inactive()) return; + excGone = true; + syncExcBubble(); + } + rebuildExcBubble(); + document.body.appendChild(excBubble); + + /* ---------- 主题跟随(v58):深浅由客户端根元素 .dark 类驱动 ---------- + * 客户端主题切换时往 挂/摘 .dark(zai-dark 时挂;theme-zai-* 同步),整套 + * CSS 变量(--color-background/foreground/border/hover/card/destructive/brand/ + * terminal-* …)随级联翻转,上面的配色自动换装 —— 本块只负责观察 .dark 摘挂, + * 同步 .zu-light(仅剩阴影/光晕程度性覆盖)。观察类属性而非 matchMedia: + * 信号源与变量翻转同源,客户端强制浅色+系统深色等错位态不会劈叉。 */ + var rootEl = document.documentElement; + function themeIsDark() { + try { return rootEl.classList.contains("dark"); } catch (e) { return true; } + } + var themeDark = themeIsDark(); + function applyTheme() { + bar.classList.toggle("zu-light", !themeDark); + tip.classList.toggle("zu-light", !themeDark); + excBubble.classList.toggle("zu-light", !themeDark); + /* v80:面板挂 body 后不在 bar 的 zu-light 作用域内,自带类(.panel.zu-light) */ + panel.classList.toggle("zu-light", !themeDark); + subPanel.classList.toggle("zu-light", !themeDark); + } + try { + themeObserver = new MutationObserver(function () { + var d = themeIsDark(); + if (d !== themeDark) { themeDark = d; applyTheme(); } + }); + themeObserver.observe(rootEl, { attributes: true, attributeFilter: ["class"] }); + } catch (e) { } + applyTheme(); + + var $ = function (id) { return bar.querySelector(id); }; + var main = bar.querySelector(".zu-main"), gear = bar.querySelector(".zu-gear"); + + /* ---------- 渲染 ---------- */ + function fmt(n) { + n = n || 0; + if (cnUnits()) { + if (n >= 1e8) return cnUnit(n / 1e8) + "亿"; + if (n >= 1e4) return cnUnit(n / 1e4) + "万"; + return String(n); + } + if (n >= 1e9) return (n / 1e9).toFixed(2) + "B"; + if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; + return String(n); + } + function cnUnit(x) { return x.toFixed(2).replace(/\.?0+$/, ""); } // 至多两位小数并去尾零:58.25万、142万、1亿(数字与单位不留空格:等宽字体下空格占满一格) + function sec(ms) { return ms ? (ms / 1000).toFixed(1) + "s" : "–"; } + + /* 超限判定:最近一次"上下文超限被拒"晚于最近成功请求 = 仍处于超限状态(该请求 + * status=error,不进任何 completed 统计,只能这样单独检测)。html() 与气泡共用。 */ + function excActive(s) { + return !!(s && s.ctx_exc > 0 && s.ctx_exc >= (s.last_at || 0)); + } + + function cachePct(cache, input) { + // 无原生 title:原生提示向下弹且与自绘 tooltip 双显闪烁(v33 移除),定义见各项 tooltip 明细 + return input > 0 ? '' + Math.round(cache / input * 100) + "%" : ""; + } + + /* 上下文分档口径(v93 起唯一消费方是原生按钮增强;原状态条上下文格已删)。 + * cw 优先级:手动覆盖 > 原生按钮 aria-label 读到的总量(nativeCtxVal)> 会话 context_window。 */ + function ctxBandOf(sess) { + var cw = parseInt(state.ctxOv, 10) || nativeCtxVal || (sess && sess.context_window) || 0; + var pct = cw ? (sess && sess.ctx || 0) / cw * 100 : 0; + var exc = excActive(sess); + /* 占比档位分两套:窗口 ≥100 万时同一百分比的绝对 token 量大,40/60 提前预警;其余维持 70/85。 */ + var big = cw >= 1000000; + var cls = exc ? "exc" : pct >= (big ? 60 : 85) ? "hot" : pct >= (big ? 40 : 70) ? "warm" : "ok"; + return { cw: cw, pct: pct, cls: cls, exc: exc, big: big }; + } + + /* 显示顺序:本轮 → 会话 → 工具 → 今日 → 子代理;token 后带缓存命中率。 + * v31:条面只放主数值;悬停改自绘 tooltip(向上弹),随渲染以 tips[] + * 按 .it 顺序挂到元素 __tip,全部为字符串单页({tabs:[...]} 多 tab 机制 v49 起 + * 无条目使用已删除:子代理改点击面板,tooltip 不再承载 tab 切换)。 + * 缓存写入/思考/重试/错误均 >0 才显示,0 时不产生噪音。 */ + function fmtTime(ms) { + var d = ms ? new Date(ms) : null; + return d ? (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes() + ":" + + (d.getSeconds() < 10 ? "0" : "") + d.getSeconds() : "?"; + } + function fmtStart(ms) { // 会话时间范围用:完整日期 YYYY-MM-DD HH:MM(跨数日的会话,秒无意义) + if (!ms) return "?"; + var d = new Date(ms); + function two(v) { return (v < 10 ? "0" : "") + v; } + return d.getFullYear() + "-" + two(d.getMonth() + 1) + "-" + two(d.getDate()) + + " " + two(d.getHours()) + ":" + two(d.getMinutes()); + } + function ioc(inp, out, cache, rea, cw) { // 悬停明细行:输入/输出/缓存命中/缓存写入/思考 + return L("输入 ", "Input ") + fmt(inp) + L(" / 输出 ", " / Output ") + fmt(out) + L(" / 缓存命中 ", " / Cache read ") + fmt(cache) + + (cw > 0 ? L(" / 缓存写入 ", " / Cache write ") + fmt(cw) : "") + + (rea > 0 ? L(" / 思考 ", " / Thinking ") + fmt(rea) : ""); + } + /* 结构化 tooltip(v67.1):\x01 前缀标记 HTML 内容(drawTip 分流),行/脚注排版与 + * 回复末尾统计行 tooltip 同款;动态值一律过 ttEsc(innerHTML 安全,如工具名/错误文案) */ + function ttEsc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } + function ttH(t) { return '
' + ttEsc(t) + "
"; } + function ttRow(k, v) { return '
' + ttEsc(k) + '' + ttEsc(v) + "
"; } + function ttNote(v) { return '
' + ttEsc(v) + "
"; } + /* v97:live chip(生成速度)的 tooltip 内容——子代理速率是渲染端实时状态(不在泵 + * 快照里),html() 整条渲染与 updateLive 原地改字两条路径都要能生成,抽出共用。 + * 运行中的每个子代理一行(…sid4 · 估 X t/s),并行 ≥2 个时先给合计行。 */ + function buildLiveTip(speedView) { + var rows = + ttH(L("生成速度", "Generation speed")) + + ttRow(L("生成中", "While running"), L("按文本增量算出的字符/s,除以「每 token 字符数」估算 tok/s;该比例取自本会话最近一次完成的真实轮次,随每轮刷新(首轮无样本时用粗略默认值)", "estimated from the text-delta chars/s divided by a chars-per-token ratio; the ratio is calibrated from this session's most recently completed turn and refines each turn (a rough default is used before the first sample)")) + + ttRow(L("子代理运行中", "Agent running"), L("子智能体运行期间按各自会话文本增量同款估算,主 chip 显示合计;运行中的请求未入本地库,思考文本不外显时无速率、只显示状态词", "while sub-agents run, each is estimated the same way from its own session text deltas and the bar chip shows their sum; in-flight requests are not in the local DB yet, and when thinking text is not exposed there is no rate, only the status word")); + if (speedView && speedView.phase === "tool") { + var sids = [], now = Date.now(); + for (var sid in subRate.slots) sids.push(sid); + sids.sort(); + var fresh = 0; + for (var i = 0; i < sids.length; i++) { + var sl = subRate.slots[sids[i]]; + if (sl.rate != null && now - sl.at <= 3000) fresh++; + } + if (sids.length) { + if (fresh > 1) rows += ttRow(L("合计", "Sum"), + L("估 ", "est ") + subRate.sum.rate.toFixed(1) + L(" t/s(", " t/s (") + fresh + L(" 个)", " running)")); + for (var j = 0; j < sids.length; j++) { + var s2 = subRate.slots[sids[j]]; + var tail = String(sids[j]).slice(-4); + rows += ttRow(L("子代理 …" + tail, "agent …" + tail), + s2.rate != null && now - s2.at <= 3000 + ? L("估 ", "est ") + s2.rate.toFixed(1) + L(" t/s", " t/s") + : L("估算中…", "estimating…")); + } + } + } + rows += + ttRow(L("完成后", "When done"), L("切换为本轮真实 tok/s:输出 tokens(含思考)÷ 模型生成时长;本轮缺口径时回退数据库最近一段值", "switches to this turn's real tok/s: output tokens (thinking incl.) ÷ model generation time; falls back to the latest DB segment when this turn's basis is missing")) + + ttRow(L("档位", "Bands"), L("≥70 绿 · 40–70 黄 · <40 红(仅完成态)", "≥70 green · 40–70 yellow · <40 red (completed only)")) + + ttNote(L("数据来自 renderer 会话状态(只读桥),实时更新,不经过本地数据库轮询;生成中的数值为字符增量估算", "Data comes from the renderer session state via the read-only bridge, updating live, independent of local database polling; running values are estimated from text deltas")); + return "\x01" + rows; + } + /* 行内 SVG 线性图标(v54):stroke 走 currentColor,颜色由 .ico 控制统一换色 */ + function ico(paths) { + return '"; + } + function html(d) { + var sess = d.session || {}, lt = d.last_turn || {}, today = d.today || {}, last = d.last || {}; + var tls = d.tools || { total: 0, errors: 0, list: [] }; + var items = [], tips = []; + function it(inner, tip, cls) { items.push('' + inner + ""); tips.push(tip || null); } + if (d.remote) { // v9:SSH 远程会话(数据取自远端机),置顶徽标标明数据源 + it(ico('') + + '' + L("远端", "remote") + "", + L("数据源:SSH 远程服务器 ", "Data source: remote server over SSH ") + (d.remote_host || "?") + "\n" + + L("会话数据实时取自远端机的 ZCode 数据库,「今日合计」亦为远端值", "Session data is fetched live from the remote machine's ZCode database; today's total is the remote value") + + (d.remote_error ? "\n⚠ " + L("最近一次远端查询失败:", "Last remote query failed: ") + d.remote_error : "") + + (d.remote_stale ? "\n" + L("当前显示最后成功快照,正在重试", "Showing last successful snapshot; retrying") : "") + + (d.remote_empty ? "\n⚠ " + L("远端库里这个会话还没有已完成的请求(各项为 0)——确认远端 zusage.py 读到的库与远端 ZCode 实际写入的库是同一个", + "The remote database has no completed requests for this session (all items are 0) — check that the remote zusage.py reads the same database the remote ZCode actually writes") : "")); + } + if (d.not_local) { // v62(issue #14):本地库没有这个会话 —— 全 0 不是插件坏了,是数据不在这儿 + it(ico('') + + '' + L("不在本地库", "not in local db") + "", + L("这个会话不在本地数据库里(~/.zcode/cli/db/db.sqlite 查不到它),所以各项显示 0 —— 不是插件坏了。", + "This session is not in the local database (~/.zcode/cli/db/db.sqlite), which is why every item reads 0 — the plugin is not broken.") + "\n" + + (d.remote_enabled + ? L("已配置 remote 段,但远端查询没有返回它:", "A remote section is configured, but the remote query did not return it: ") + + (d.remote_error ? L("查询失败 ", "query failed: ") + d.remote_error + : L("远端库里也没有这个会话(核对远端 zusage.py 读的库/用户与远端 ZCode 实际写入的是否一致)", + "the remote database does not have it either (verify the database/user the remote zusage.py reads matches what the remote ZCode writes)")) + : L("若这是 SSH/WSL 远程会话:客户端连过的远端工作区会自动被发现并查询(零配置);没查到时核对远端已部署 zusage.py(WSL 需发行版内有 python3),或在 config.json 配置 remote 段(README「远程会话」;ssh 也可写成 argv 数组,docker exec / wsl --exec / kubectl exec 等通道都能借道)", + "If this is a remote SSH/WSL session: remote workspaces the client connected to are discovered and queried automatically (zero config); if it's still missing, make sure zusage.py is deployed on the remote machine (WSL needs python3 inside the distro), or add the remote section to config.json (see README; ssh may also be an argv array, so docker exec / wsl --exec / kubectl exec all work)"))); + } + /* v73:生成速度——桥命中(本 pane 会话有 renderer 投影)时用它的估算/真实 tok/s, + * 否则退回原 DB 最近一段值(与 state.rateLine 开关无关:桥不可用或开关关闭时 + * liveStatsCur 恒为空,自动走 db 分支,不影响这个本就独立于逐问速率行的指标)。 + * zuBarSpeedView 对"刚绑定/重绑时捞到的历史存量已完成轮次"返回 null——这种情况 + * 和"桥完全没命中"一视同仁,同样回退 DB/不渲染,不冒充"这个会话速度不可用"。 + * 二者互斥:绝不同时渲染两枚「速度」chip。 */ + var dbTps = last.tps || 0; + var speedView = liveStatsCur ? zuBarSpeedView(liveStatsCur, bridge.handle && bridge.handle.charsPerToken, subRate.sum) : null; + if (speedView) { + liveKindCur = "live"; + livePhaseCur = speedView.phase || ""; + liveIconCur = speedView.phase ? "think" : "bolt"; // v77.1:运行期一律动效脑(思考/正文/工具),完成态才回闪电 + it((speedView.phase ? zuIcoThinking() : ico('')) + + '' + speedView.text + "", + buildLiveTip(speedView)); + } else if (dbTps) { + liveKindCur = "fallback"; + livePhaseCur = ""; + liveIconCur = "bolt"; + var tpsCls = dbTps >= 70 ? "ok" : dbTps >= 40 ? "warm" : "hot"; + it(ico('') + + '' + dbTps + 't/s', + "\x01" + + ttH(L("生成速度 · 最近一段", "Generation speed · latest segment")) + + ttRow(L("口径", "Basis"), L("输出 tokens(含思考)÷ 生成耗时", "output tokens (thinking incl.) ÷ generation time")) + + ttRow(L("档位", "Bands"), L("≥70 绿 · 40–70 黄 · <40 红", "≥70 green · 40–70 yellow · <40 red")) + + ((last.startedAt || last.endedAt) + ? ttRow(L("时间范围", "Time span"), + (last.startedAt ? fmtStart(last.startedAt) : "—") + " - " + + (last.endedAt ? fmtStart(last.endedAt) : "—")) + : "") + + ttNote(L("回答期间每段完成即刷新。回复末尾的统计行是整问加权均值,两者数值不同属正常", + "Refreshes as each segment completes. The reply-end rate line is the whole-turn weighted average, so the two legitimately differ"))); + } else { + liveKindCur = "none"; + livePhaseCur = ""; + liveIconCur = ""; + } + if (state.show.turn) { + it(ico('') + + '' + fmt(lt.total) + "" + + (shrink ? "" : cachePct(lt.cache_read, lt.input)) + // v66 超宽时命中率让位,明细仍在悬停 tooltip + '' + (lt.requests || 0) + L("次", " req") + "" + + ico('') + + '' + sec(last.duration_ms) + "" + + ico('') + + '' + sec(last.ttft_ms) + "", + "\x01" + + ttH(state.lang === "en" + ? "Latest turn · " + (lt.requests || 0) + " requests" + : "本轮 · " + (lt.requests || 0) + " 次请求") + + ttRow(L("输入", "Input"), fmt(lt.input) + + (lt.input > 0 && lt.cache_read > 0 ? L("(命中 " + Math.round(lt.cache_read / lt.input * 100) + "%)", " (" + Math.round(lt.cache_read / lt.input * 100) + "% cached)") : "")) + + ttRow(L("输出", "Output"), fmt(lt.output) + + (lt.reasoning > 0 ? L("(含思考 " + fmt(lt.reasoning) + ")", " (incl. " + fmt(lt.reasoning) + " thinking)") : "")) + + ((typeof lt.tps === "number" && isFinite(lt.tps) && lt.tps > 0) + ? ttRow(L("输出速度", "Output rate"), lt.tps.toFixed(1) + " tok/s" + + L("(Σ有效段输出 ÷ Σ有效段生成时长,段间工具等待不计)", " (Σrated output ÷ Σrated generation time, tool waits excluded)") + + (lt.unrated_output > 0 ? L(";另有 " + fmt(lt.unrated_output) + " token 未参与测速", "; " + fmt(lt.unrated_output) + " tokens excluded from rate") : "")) + : "") + + (lt.cache_write > 0 ? ttRow(L("缓存写入", "Cache write"), fmt(lt.cache_write)) : "") + + ttRow(L("耗时", "Time"), L("单次 ", "request ") + sec(last.duration_ms) + L(" · 首字 ", " · TTFT ") + sec(last.ttft_ms) + L(" · 轮总 ", " · turn ") + sec(lt.duration_ms)) + + ((lt.started || lt.ended) + ? ttRow(L("时间范围", "Time span"), + (lt.started ? fmtStart(lt.started) : "—") + " - " + + (lt.ended ? fmtStart(lt.ended) : "—")) + : "") + + ((lt.tool_calls || lt.retries || lt.tool_errors) + ? ttRow(L("工具", "Tools"), (lt.tool_calls ? lt.tool_calls + L(" 次调用", " calls") : "") + + (lt.retries ? L(" · 重试 ", " · retries ") + lt.retries : "") + + (lt.tool_errors ? L(" · 错误 ", " · errors ") + lt.tool_errors : "")) : "")); + } + if (state.show.win) { + it(ico('') + + '' + fmt(sess.total) + "" + + (shrink ? "" : cachePct(sess.cache_read, sess.input)) + // v66 同上 + '' + (sess.turns || 0) + L("轮 ", " turns · ") + (sess.requests || 0) + L("次", " req") + "", + "\x01" + + ttH(L("会话累计 · 全部请求", "Session total · all requests")) + + ttRow(L("输入", "Input"), fmt(sess.input) + + (sess.input > 0 && sess.cache_read > 0 ? L("(命中 " + Math.round(sess.cache_read / sess.input * 100) + "%)", " (" + Math.round(sess.cache_read / sess.input * 100) + "% cached)") : "")) + + ttRow(L("输出", "Output"), fmt(sess.output) + + (sess.reasoning > 0 ? L("(含思考 " + fmt(sess.reasoning) + ")", " (incl. " + fmt(sess.reasoning) + " thinking)") : "")) + + (sess.cache_write > 0 ? ttRow(L("缓存写入", "Cache write"), fmt(sess.cache_write)) : "") + + ttRow(L("规模", "Scale"), (sess.turns || 0) + L(" 轮 · ", " turns · ") + (sess.requests || 0) + L(" 次请求", " requests") + + (sess.tool_calls ? L(" · 工具 ", " · tools ") + sess.tool_calls : "") + + (sess.retries ? L(" · 重试 ", " · retries ") + sess.retries : "")) + + ttRow(L("时间范围", "Time span"), + (sess.started ? fmtStart(sess.started) : "—") + " - " + + (sess.last_at ? fmtStart(sess.last_at) : "—")) + + (d.code && (d.code.add || d.code.del) + ? ttRow(L("代码变更", "Code changes"), "+" + (d.code.add || 0) + " / -" + (d.code.del || 0) + + (d.code.files ? L("(" + d.code.files + " 文件)", " (" + d.code.files + " files)") : "")) : "")); + } + if (state.show.tools) { + var toolRows = (tls.list || []).map(function (t1) { + return ttRow(t1.name, t1.count + L(" 次 · ", " calls · ") + sec(t1.duration_ms) + + (t1.errors ? L(" · 错误 ", " · errors ") + t1.errors : "")); + }).join(""); + it(ico('') + + '' + (tls.total || 0) + "" + + (tls.errors ? '' + + ico('') + + tls.errors + "" : ""), + "\x01" + + ttH(state.lang === "en" + ? "Tool calls · " + (tls.total || 0) + " total" + (tls.errors ? ", " + tls.errors + " errors" : "") + : "工具调用 · 共 " + (tls.total || 0) + " 次" + (tls.errors ? " · 错误 " + tls.errors : "")) + + (toolRows || ttNote(L("无工具调用记录", "No tool calls recorded")))); + } + if (state.show.today) { + it(ico('') + + '' + fmt(today.total) + "", + "\x01" + + ttH(L("今日合计 · 今天所有会话", "Today · all sessions")) + + ttRow(L("输入", "Input"), fmt(today.input) + + (today.input > 0 && today.cache_read > 0 ? L("(命中 " + Math.round(today.cache_read / today.input * 100) + "%)", " (" + Math.round(today.cache_read / today.input * 100) + "% cached)") : "")) + + ttRow(L("输出", "Output"), fmt(today.output) + + (today.reasoning > 0 ? L("(含思考 " + fmt(today.reasoning) + ")", " (incl. " + fmt(today.reasoning) + " thinking)") : "")) + + ttRow(L("请求", "Requests"), (today.requests || 0) + + (today.retries ? L(" 次请求 · 重试 ", " requests · retries ") + today.retries : L(" 次", " requests"))) + + ((today.started || today.last_at) + ? ttRow(L("时间范围", "Time span"), + (today.started ? fmtStart(today.started) : "—") + " - " + + (today.last_at ? fmtStart(today.last_at) : "—")) + : "")); + } + if (state.show.sub) { + /* v93:随「上下文容量」格移除改为始终展示(零值也显示,口径与其他格统一); + * 无子代理时就是 0。v50:悬停 tooltip 只放汇总 + 打开面板的提示;各子代理明细 + * 在点击弹出的固定面板里用页签切换(面板固定不随鼠标,页签点击没有几何问题)。 */ + var sb = d.sub || { requests: 0, total: 0, input: 0, output: 0, cache_read: 0, reasoning: 0, cache_write: 0, active: false, rate_avg: 0 }; + it(ico('') + + '' + fmt(sb.total) + "" + + (sb.active ? '●' : ""), + L("子代理:当前会话的后台子代理消耗(独立统计,不计入会话累计)", "Sub-agents: background sub-agent usage of the current session (tracked separately, not counted in the session total)") + "\n" + + ioc(sb.input, sb.output, sb.cache_read, sb.reasoning, sb.cache_write) + + L(",共 ", ", ") + (sb.requests || 0) + L(" 次", " requests in total") + + (sb.rate_avg > 0 ? L(" · 均 ", " · avg ") + sb.rate_avg + L(" t/s(已完成段加权平均)", " t/s (weighted avg of completed segments)") : "") + + (sb.active ? L(" · 运行中", " · running") : "") + + "\n" + L("点击条目打开面板,查看每个子代理的具体消耗", "Click the item to open the panel with per-sub-agent details"), "zu-sub"); + } + return { s: items.join('') || '' + L("全部显示项已关闭", "All display items are off") + '', tips: tips }; + } + + /* 当前会话识别(v34 起 mine 优先,此处为兜底启发式):ZCode 在 localStorage 的 + * "zcode-v4-last-session:v1:<工作区路径>" 键里保存每个工作区当前打开的会话 id。 + * 键随会话切换由应用更新(新开空会话时应用还会删键);但 localStorage 全窗口共享、 + * 键对"自动新建的会话"更新滞后,多窗口下根本分不清哪个键属于本窗口 —— + * 所以 v34 起正常路径是泵按窗口注入的 mine(渲染端经 IPC 上报的焦点会话), + * 只有泵无该窗口信息时(辅助窗口/旧泵)才落到这里:取所有键值与 payload.recent + * 的 sid 求交集;多候选取最近活跃(数值 last_at 比较,勿用 HH:MM:SS 字符串,跨午夜会排错)。 + * v32:键值即使不在 recent 池里也作为 want 返回(挂 window.__zusageWantSid), + * 泵把它传给 zusage.py 强制纳入快照——刚开的新会话/超出 6+6 池的会话也能显示自己的数据。 + * 注意:会话标题不在可扫描的 DOM 文本节点里(顶栏标题扫描方案实测永远失败,勿回退)。 */ + var pickedSid = "", wsPrev = null, lastHtml = ""; + function watchQuotaSelection(pane) { + if (pane === quotaPaneRoot) return; + if (quotaSelectionObserver) quotaSelectionObserver.disconnect(); + quotaSelectionObserver = null; + quotaPaneRoot = pane; + if (!pane) return; + quotaSelectionObserver = new MutationObserver(function () { + if (inactive()) return; + if (readQuotaSelection() && state.data) render(state.data); + }); + quotaSelectionObserver.observe(pane, { subtree: true, childList: true, attributes: true, + attributeFilter: ["data-provider", "data-model", "data-session-id"] }); + } + function readQuotaSelection() { + var el = wantedEl || composer, pane = null, selection = null; + var sid = instSid || pickedSid || ""; + if (el && el.isConnected) { + for (var parent = el; parent && parent !== document.body; parent = parent.parentElement) { + var tid = parent.getAttribute && parent.getAttribute("data-testid"); + if (tid && tid.indexOf("v4-session-pane-") === 0) { pane = parent; break; } + } + } + watchQuotaSelection(pane); + if (pane) { + selection = { sid: sid, providerId: "", modelId: "" }; + var paneSid = pane.getAttribute("data-session-id"); + var paneId = pane.getAttribute("data-testid").slice("v4-session-pane-".length); + if (paneSid === sid && (pid === "auto" || paneId === pid)) { + var nodes = pane.querySelectorAll('[data-testid="v4-model-config"]'); + var own = []; + for (var i = 0; i < nodes.length; i++) { + var owner = nodes[i].parentElement; + while (owner && owner !== pane) { + var ot = owner.getAttribute && owner.getAttribute("data-testid"); + if (ot && ot.indexOf("v4-session-pane-") === 0) break; + owner = owner.parentElement; + } + if (owner === pane) own.push(nodes[i]); + } + if (own.length) quotaMarkerSeen = true; + if (!own.length && !quotaMarkerSeen) selection = null; + if (own.length === 1) { + var provider = own[0].getAttribute("data-provider"); + var model = own[0].getAttribute("data-model"); + if (typeof provider === "string" && provider.length <= 160 && + typeof model === "string" && model.length <= 160 && !/[\x00-\x1f\x7f]/.test(provider + model)) { + selection.providerId = provider.trim(); + selection.modelId = model.trim(); + } + } + } + } else if (quotaSelection) { + selection = { sid: sid, providerId: "", modelId: "" }; + } + if (JSON.stringify(selection) === JSON.stringify(quotaSelection)) return false; + quotaSelection = selection; + if (instances.length) reportQuotaSelections(); + return true; + } + function effectiveQuotaProvider() { + return quotaSelection ? quotaSelection.providerId : (quotaProvider || quotaFallback); + } + function pickCurrent(d) { + var recent = d.recent || []; + if (!recent.length) return null; + var bySid = {}, kv = {}; + for (var i = 0; i < recent.length; i++) bySid[recent[i].sid] = recent[i]; + try { + Object.keys(localStorage).forEach(function (k) { + if (/^zcode-v4-last-session:/.test(k)) { + var v = localStorage.getItem(k); + if (v && /^[A-Za-z0-9_-]+$/.test(v)) kv[k] = v; + } + }); + } catch (e) { return null; } + if (!Object.keys(kv).length) return null; + var hit = {}, firstKeyVal = ""; + for (var k in kv) { + if (bySid[kv[k]]) hit[k] = kv[k]; + if (!firstKeyVal) firstKeyVal = kv[k]; + } + var switched = null; + if (wsPrev) { + for (var k2 in kv) { + if (wsPrev[k2] && wsPrev[k2] !== kv[k2]) switched = kv[k2]; // 该工作区刚切换了会话 + } + } + wsPrev = kv; + /* want = 当前真正打开的会话(切换优先,否则任一键值),交由泵强制补拉 */ + var want = switched || firstKeyVal; + if (switched && bySid[switched]) return { sess: bySid[switched], want: want }; + var cands = []; + for (var k3 in hit) cands.push(bySid[hit[k3]]); + if (cands.length === 1) return { sess: cands[0], want: want }; + if (!cands.length) return { sess: null, want: want }; + var best = cands[0]; // 多候选:取最近活跃的 + for (var n = 1; n < cands.length; n++) { + if ((cands[n].last_at || 0) > (best.last_at || 0)) best = cands[n]; + } + return { sess: best, want: want }; + } + + /* 子代理查看视图识别:子代理会话的任务提示词会作为聊天气泡文本出现在消息流里, + * 用 recent 中 is_sub 候选的标题前缀(前 30 字)在 DOM 文本里匹配。 + * (主会话的标题不在 DOM 文本里,但子代理视图的气泡是普通文本,可匹配。) */ + /* 零值会话 stub:当前会话在共享池里还没有数据行(新会话没发过消息/还没拉到)时, + * 显示"当前会话的 0 值"。绝不回退到池里别的会话 —— 显示错会话的数字正是 v34 修的 bug。 */ + function stubFor(sid) { + return { sid: String(sid || ""), title: "", active: false, turns: 0, requests: 0, + input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0, total: 0, + tool_calls: 0, retries: 0, ctx: 0, updated: "", last_at: 0, ctx_exc: 0, started: 0, + last_turn: {requests: 0, retries: 0, tool_calls: 0, tool_errors: 0, input: 0, output: 0, + reasoning: 0, cache_read: 0, cache_write: 0, total: 0, duration_ms: 0, ttft_ms: 0, started: 0, ended: 0}, + last: {duration_ms: 0, ttft_ms: 0, model: "", tps: 0, startedAt: 0, endedAt: 0}, + code: {add: null, del: null, files: null}, + tools: {total: 0, errors: 0, list: []}, + sub: {requests: 0, total: 0, input: 0, output: 0, cache_read: 0, reasoning: 0, + cache_write: 0, active: false, list: []}, + context_window: 0, context_auto: false }; + } + + /* ---------- renderer store 桥客户端(v71,实例级):一个 pane 一个订阅 ---------- + * 低频(2s)能力检测:API 在 + diagnostics 里有本 pane 会话的 topic → 绑定 store; + * 绑定后 tracker.ingest 由订阅推送驱动(实时),store 关闭/换代(getState null / + * 快照 sessionId 变)→ off 释放,下轮 tick 重绑。全链路失败 = 降级 DB 路径。 */ + function bridgeDetach(handle) { + if (!handle || handle.dead) return; + handle.dead = true; + try { if (handle.off) handle.off(); } catch (e) { } // 停订阅 + dispose tracker + try { handle.store.off(); } catch (e) { } + if (bridge.handle === handle) { bridge.handle = null; bridge.turnId = ""; } + subRateDetach(); // 主桥释放(换代/切换/世代失效)时子代理速率桥一并释放 + } + + /* ---------- 子代理运行期速率桥(v97):主会话 rows 的 subagent 行自带 + * childSessionId(turn-stats 的 runningSubagents 集合),据此用同一套 zuTryBindStore + * 为**每个**运行中的子代理绑定会话 store,字符增量 ÷ c2t 估算各自输出 tok/s; + * 主 chip 显示合计(Σ 有效槽位,并行 ≥2 个时动词后缀 ×N),tooltip/明细面板按 + * 槽位逐个展示。运行中请求未入库(DB 完成才写行,150s 实测零 running 行),这是 + * 唯一的实时来源;子代理思考文本若不进 rows(与主会话同款限制),characterRate + * 为 null,该槽位速率缺席。生命周期跟随主桥:主桥释放/换代即全解绑;行结束 + * (runningSubagents 收敛)下一次 updateLive 自然解绑。 + * 上限保护:宿主 schema 允许 64 个并行 subagent,全绑会让订阅失控——只绑 + * rowId 最大(最新派发)的前 8 个,其余不估算(turn-stats 已按 rowId 升序给出)。 */ + var MAX_SUB_SLOTS = 8; + var subRate = { slots: {}, sum: { rate: null, at: 0, count: 0 } }; + /* 释放槽位的绑定资源但保留槽位(want/retryAt):绑定失败/首调 detach 时等待 + * 2s 后重试;对未绑定槽位(handle 为 null)调用也安全。 */ + function subSlotReset(sl) { + if (sl.handle) sl.handle.dead = true; + try { if (sl.off) sl.off(); } catch (e) { } + try { if (sl.store) sl.store.off(); } catch (e) { } + if (sl.tracker) { try { sl.tracker.dispose(); } catch (e) { } } + sl.handle = null; sl.store = null; sl.tracker = null; sl.off = null; + sl.rate = null; sl.calib = null; + sl.retryAt = Date.now() + 2000; + } + function subSlotDetach(sid) { // 彻底移除槽位(行结束/主桥释放) + var sl = subRate.slots[sid]; + if (!sl) return; + subSlotReset(sl); + delete subRate.slots[sid]; + } + function subRateDetach() { // 全解绑(主桥释放/换代/世代失效) + for (var sid in subRate.slots) subSlotDetach(sid); + subRate.sum = { rate: null, at: 0, count: 0 }; + } + /* 合计视图:Σ 3 秒内有效的槽位速率;全部无效/过期时为 {rate:null}(chip 回退状态词)。 */ + function subRateRecompute() { + var now = Date.now(), sum = 0, at = 0, count = 0; + for (var sid in subRate.slots) { + var sl = subRate.slots[sid]; + if (sl.rate != null && now - sl.at <= 3000) { sum += sl.rate; count++; if (sl.at > at) at = sl.at; } + } + subRate.sum = count ? { rate: sum, at: at, count: count } : { rate: null, at: 0, count: 0 }; + } + /* 单槽位展示文案(tooltip/明细面板共用):有效速率 →「估 X t/s」;已绑但无速率 + * (思考期)→「估算中…」;未绑槽位 → 空串。 */ + function subRateSlotText(sid) { + var sl = subRate.slots[sid]; + if (!sl) return ""; + var now = Date.now(); + return sl.rate != null && now - sl.at <= 3000 + ? L("估 ", "est ") + sl.rate.toFixed(1) + L(" t/s", " t/s") + : L("估算中…", "estimating…"); + } + /* 每次主桥数据到达时 reconcile:不在 runningSubagents 里的槽位解绑,新出现的 + * 尝试绑定(槽位级 2s 节流——updateLive 挂在 0.1s 心跳上,不节流会让 diag 全表 + * 枚举跟着心跳空转)。 */ + function subRateTick(runningSubagents) { + var list = []; + if (runningSubagents) { + for (var i = 0; i < runningSubagents.length && list.length < MAX_SUB_SLOTS; i++) { + var cs = runningSubagents[i] && runningSubagents[i].childSessionId; + if (cs && list.indexOf(cs) < 0) list.push(cs); + } + } + for (var sid in subRate.slots) if (list.indexOf(sid) < 0) subSlotDetach(sid); + for (var j = 0; j < list.length; j++) { + if (!subRate.slots[list[j]]) { + subRate.slots[list[j]] = { handle: null, store: null, tracker: null, off: null, + rate: null, at: 0, calib: null, retryAt: 0, want: list[j] }; + } + subTryBindSlot(subRate.slots[list[j]]); + } + subRateRecompute(); + } + function subTryBindSlot(sl) { + if (sl.handle || Date.now() < sl.retryAt) return; + var store = zuTryBindStore(sl.want); + if (!store) { sl.retryAt = Date.now() + 2000; return; } // 注册表里还没有该 topic:2s 后再试 + var h = { dead: false, sid: sl.want }; + var tracker = TURN_STATS.createTracker(); + sl.handle = h; sl.store = store; sl.tracker = tracker; + var offFn = TURN_STATS.subscribeStore(store, tracker, function () { + if (inactive() || h.dead) { subSlotReset(sl); subRateRecompute(); return; } + var st = null; + try { st = store.getState(); } catch (e) { st = null; } + var snap = st && st.snapshot; + if (!snap || snap.sessionId !== sl.want) { subSlotReset(sl); subRateRecompute(); return; } // 关闭/换代:重置待重试 + var tid = zuActiveTurnId(snap); + var stats = tid ? tracker.get(tid) : null; + if (stats && !stats.complete) { + if (stats.characterRate != null) { + sl.rate = stats.characterRate / + (sl.calib || (bridge.handle && bridge.handle.charsPerToken) || DEFAULT_CHARS_PER_TOKEN); + sl.at = Date.now(); + } else { + sl.rate = null; // 思考期无可测字符流:该槽位缺席合计 + } + } else if (stats && stats.complete && stats.outputTokens > 0 && stats.totalCharacters > 0) { + sl.calib = stats.totalCharacters / stats.outputTokens; // c2t 自校准:该子代理完成轮的真实比例 + } + subRateRecompute(); + updateLive(); + }); + sl.off = offFn; + /* subscribeStore 会同步首调一次回调:首调即 reset(换代/sessionId 不匹配)时 + * h.dead 已置位,offFn 是悬挂订阅的解绑函数——直接判 offFn 会把它当成功绑定 + * 写回(10Hz 重绑抖动 + 订阅泄漏,review P1)。按 h.dead 判定:显式释放 offFn + * (幂等,兼带 dispose tracker),subSlotReset 自带 2s 节流防心跳空转。 */ + if (!offFn || h.dead) { + if (offFn) { try { offFn(); } catch (e) { } } + subSlotReset(sl); + } + } + function bridgeTick() { + if (inactive()) return; + if (!state.rateLine || !TURN_STATS) { bridgeDetach(bridge.handle); updateLive(); return; } + var sid = instSid || pickedSid || ""; + if (!sid) { bridgeDetach(bridge.handle); updateLive(); return; } + if (bridge.handle) { + if (bridge.handle.sid === sid) { + var cur = null; + try { cur = bridge.handle.store.getState(); } catch (e) { } + var snap = cur && cur.snapshot; + /* 绑定健康:store 可能在工具执行期间(查阅/编辑等)很久不推新快照—— + * 仍按本轮 2s 心跳刷新一次,让「已观察 Xs」能继续走字,而不是整段 + * 工具执行时间里界面像冻住;updateLive 内部按文本/类名是否真变化 + * 做了去重写入,这里多调用一次不会造成多余的 DOM 抖动。 */ + if (snap && snap.sessionId === bridge.handle.sid) { updateLive(); return; } + } + bridgeDetach(bridge.handle); // pane 切会话 / store 关闭或换代:释放后重绑 + } + var store = zuTryBindStore(sid); + if (!store) { updateLive(); return; } // 无桥 / 无匹配 session:降级(DB 路径不变),下轮再探 + var tracker = TURN_STATS.createTracker(); + var handle = { dead: false, sid: sid, store: store, tracker: tracker, alias: {}, off: null, charsPerToken: null, bornAt: Date.now() }; + var off = TURN_STATS.subscribeStore(store, tracker, function () { + if (inactive() || handle.dead) return; + var st = null; + try { st = store.getState(); } catch (e) { st = null; } + var snap = st && st.snapshot; + if (!snap || snap.sessionId !== sid) { bridgeDetach(handle); updateLive(); return; } // 换代/关闭:下轮 tick 重绑 + var rows = snap.rows && snap.rows.window; + if (Array.isArray(rows)) { + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + if (r && typeof r.turnId === "string") handle.alias[r.turnId] = r.productTurnId || r.turnId; + } + } + bridge.turnId = zuActiveTurnId(snap); + updateLive(); + }); + if (!off) { // store 接口不合约定:退回降级 + try { store.off(); } catch (e) { } + tracker.dispose(); + updateLive(); + return; + } + handle.off = off; + bridge.handle = handle; + bridge.rebinds = (bridge.rebinds || 0) + 1; // 诊断:本 pane 实例绑定/重绑次数 + updateLive(); // subscribeStore 首次同步回调已 ingest 初始快照,这里补挂实时段 + } + function updateLive() { + /* v73:合并「生成速度」chip 后,桥数据持续存在(生成中→完成,同一套 + * ico+''+TEXT 结构)时只原地改 textContent/className, + * 不重建条面(v33 原则);桥「有/无」切换才会改变 chip 结构(图标出现/消失、 + * 退回 DB 的两段式拼接),需要整条重渲染。 */ + var h = bridge.handle; + var stats = (state.rateLine && h && !h.dead && bridge.turnId) ? h.tracker.get(bridge.turnId) : null; + /* v87:绑定成功但 turnHeader 还没到(新会话/TurnStarted 前的空窗口,源码证实 + * draft 期 rows.window 为空)——桥活着就别让 chip 空着:按绑定时长合成"思考中", + * turnHeader 到位后自然切换为真实统计。 */ + if (!stats && h && !h.dead && bridge.handle && h.store) { + try { + var st0 = h.store.getState(); + var snap0 = st0 && st0.snapshot; + if (snap0 && snap0.sessionId === h.sid) { + var rows0 = snap0.rows && snap0.rows.window; + if ((rows0 && rows0.length) || (snap0.control && snap0.control.phase === "running")) { + stats = { complete: false, characterRate: null, runningTool: null, + phase: "thinking", observedMs: Math.max(0, Date.now() - (h.bornAt || Date.now())), + observedLive: true, totalCharacters: 0 }; + bridge.turnId = bridge.turnId || ""; + } + } + } catch (e) { } + } + if (h && stats && stats.complete && stats.outputTokens > 0 && stats.totalCharacters > 0) { + h.charsPerToken = stats.totalCharacters / stats.outputTokens; // c2t 自校准:本轮真实比例供下一轮估算用 + } + subRateTick(stats && !stats.complete ? stats.runningSubagents : null); // 运行中子代理逐个绑/解速率桥 + var wasLive = liveKindCur === "live"; + var view = stats ? zuBarSpeedView(stats, h && h.charsPerToken, subRate.sum) : null; + liveStatsCur = stats; + // 用 view(而非 stats)判定快速路径:stats 非空但 view 为 null(历史存量,见 + // zuBarSpeedView)时结构要切回降级拼法,必须走下面的整条重渲染,不能原地改字。 + // 阶段(思考↔工具↔正文)切换会更换 chip 的图标(动效脑/闪电),同样只能整条 + // 重渲染——原地改字换不了图标。 + var phase = view && view.phase ? view.phase : ""; + if (view && wasLive && phase === livePhaseCur) { + liveTextCur = view.text; + if (!liveEl) { render(state.data || { session: {}, last_turn: {}, today: {}, context_window: 0 }); return; } + if (liveEl.textContent !== view.text) liveEl.textContent = view.text; + var cls = "zu-live " + view.cls; + if (liveEl.className !== cls) liveEl.className = cls; + /* v97:子代理速率是渲染端实时状态(不在泵快照里),tooltip 里的逐子代理 + * 行随原地改字一起刷新——内容签名没变时 drawTip 幂等只调位置,不闪烁。 */ + var tip1 = buildLiveTip(view); + if (liveEl.__tip !== tip1) { + liveEl.__tip = tip1; + if (tipFor === liveEl) drawTip(liveEl, true); + } + return; + } + liveTextCur = view ? view.text : ""; + render(state.data || { session: {}, last_turn: {}, today: {}, context_window: 0 }); + } + /* v90:心跳 0.5s→0.1s——0.5s 秒数走字仍有可感卡顿;render 有 lastHtml 幂等守卫, + * 内容未变时仅字符串构建+比较、不写 DOM,10Hz 空转无性能顾虑。扫描 tick 保持 0.5s。 */ + bridge.timer = setInterval(bridgeTick, 100); + bridgeTick(); + + function render(d) { + state.data = d; + var pc = null, p = null; + if (instSid) { + /* v61.1 分屏多实例:本条显示哪个会话由 composer 祖先链的 data-session-id 决定 + * (客户端每个 pane 自带该标记,比窗口级 mine 更准,绝不串 pane); + * 会话不在池里(还没拉取)就显示本会话 0 值 —— 绝不回退到别的会话(v34 原则)。 */ + pickedSid = instSid; + p = sessionFor(d, instSid, pid); + if (!p) p = stubFor(instSid); + } else { + var mine = d.mine; // 泵按窗口注入:string=本窗口焦点会话(空串=该窗口当前无会话),undefined=泵无该窗口信息 + if (typeof mine === "string") { + pickedSid = mine; + window.__zusageWantSid = mine; + if (mine) { + p = sessionFor(d, mine, pid); + if (!p) p = stubFor(mine); + } else { + p = stubFor(""); + } + } else { + pc = pickCurrent(d); + p = pc ? pc.sess : null; + pickedSid = p ? p.sid : ""; + window.__zusageWantSid = (pc && pc.want) || ""; // 泵读取后让 zusage.py 强制纳入该会话 + if (!p) p = stubFor((pc && pc.want) || ""); + } + } + excOn = excActive(p); // 气泡的显示依据(track 每帧消费) + lastBand = ctxBandOf(p); // v92:原生上下文按钮增强的数据源(与状态条上下文格同口径) + /* 实时 pane 模型优先;没有宿主标记的旧版本才使用最近已完成请求。 */ + readQuotaSelection(); + quotaProvider = quotaSelection ? quotaSelection.providerId : ((p.last && p.last.provider_id) || ""); + quotaModel = quotaSelection ? quotaSelection.modelId : ((p.last && p.last.model) || ""); + quotaFallback = typeof d.provider_fallback === "string" ? d.provider_fallback : ""; + reportQuotaSelections(); + quotaSourceId = ""; + lastQuotaAll = d.quota && typeof d.quota === "object" ? d.quota : null; + if (lastQuotaAll && lastQuotaAll.sources) { + var qprov = effectiveQuotaProvider(); + var qsid = ""; + if (qprov.indexOf("account:") === 0) { + qsid = qprov.indexOf("bigmodel") >= 0 ? "bigmodel" : (qprov.indexOf("zai") >= 0 ? "zai" : ""); + /* 未知 account 档不退 active(可能显示他窗自定义源);team 变体由上方静态提示分支承接 */ + } else if (lastQuotaAll.byProvider && lastQuotaAll.byProvider[qprov]) { + qsid = lastQuotaAll.byProvider[qprov]; // 自定义 provider:只认主进程显式映射 + } + quotaSourceId = qsid; + lastQuota = qsid ? (lastQuotaAll.sources[qsid] || null) : null; + } else if (lastQuotaAll) { + /* 旧主进程平铺协议:数据归属未知。内置 account 档按 family 接受(v96 语义即主进程 + * 只查内置套餐);自定义 provider 无法证明归属 → fail-closed 不显示。 */ + var fp = effectiveQuotaProvider(); + lastQuota = (fp.indexOf("account:") === 0 && fp.indexOf("team") < 0) ? lastQuotaAll : null; + } else lastQuota = null; + /* v12 多服务器:remote_today/remote_errors 按目标键分组,取本会话所属主机的分片; + * 旧格式(单目标,顶层对象即 today)兜底兼容。 */ + var myTarget = p.remote_target || ""; + var remoteToday = null; + if (p.remote && d.remote_today && typeof d.remote_today === "object") { + remoteToday = typeof d.remote_today.requests === "number" ? d.remote_today + : (myTarget && (d.remote_today[myTarget] || remoteTodayCache[myTarget])) || null; + } + var view = { + session: p, last_turn: p.last_turn || {}, last: p.last || {}, + /* v9:远端会话的「今日合计」用远端机数据,本地 today 只在显示本地会话时使用 */ + today: p.remote ? (remoteToday || {}) : d.today, + native_ctx: nativeCtxVal, + context_window: p.context_window, context_auto: p.context_auto, + code: p.code, ctx_exc: p.ctx_exc, tools: p.tools, + remote: !!p.remote, remote_host: p.remote_host || "", remote_error: p.remote_error || (d.remote_errors && myTarget && d.remote_errors[myTarget]) || (!d.remote_errors && d.remote_error) || "", + /* v62(issue #14 / #10):该会话不在本地库且没命中远端 → 条面给一次配置指引, + * 把"各项全 0 = 插件坏了"的误判转成可执行的下一步;远端命中的会话由 ☁ 徽标那套说明。 */ + not_local: (d.not_local || []).indexOf(p.sid) >= 0 && !p.remote, + remote_enabled: !!d.remote_enabled, + remote_stale: !!p.remote_stale, + remote_empty: !!(p.remote && !p.requests && !p.remote_missing), + sub: p.sub || {requests: 0, total: 0, input: 0, output: 0, cache_read: 0, active: false, list: []}, + }; + lastSub = view.sub; // 子代理明细面板的数据源(开关面板/数据推送刷新用) + refreshWants(); + var h = html(view); + /* 内容没变就不重建 DOM:泵每 1.5s 推送一次,无条件 innerHTML 会重建条面+重启呼吸灯 + * 动画+触发 tooltip 重画 = 悬停时周期性闪烁(v33 修复)。变化时才重建。 */ + if (h.s !== lastHtml) { + lastHtml = h.s; + main.innerHTML = h.s; + } + var els = main.querySelectorAll(".it"); + for (var i = 0; i < els.length; i++) els[i].__tip = h.tips[i] || null; + liveEl = main.querySelector(".zu-live"); // v71:条面重建后实时段节点重挂(原地更新用) + /* 数据刷新时正在悬停:接管同位置的新元素继续显示。只调位置不重画内容—— + * 数字每 1.5s 都在变,重画就是闪烁;旧文本保持到鼠标下次移动为止。 */ + if (tipFor) { + var nu = null; + if (tipFor === main) nu = main; + else if (tipFor.__n === els.length) nu = els[tipFor.__idx]; + if (nu && nu.__tip) { tipFor = nu; positionTip(nu, true); } else hideTip(false, "render-orphan"); + /* 接管失败(条目结构变化)也受总闸保护:鼠标仍在条上时保持旧 tooltip, + * 下一次 mousemove 会重新判定该显示哪一项 */ + } + if (subPanel.classList.contains("open")) buildSubPanel(view.sub); // 面板开着:随推送实时刷新 + enhanceCtxButton(); // v92:数据点 reapply(React 数据更新重写 dasharray 后兜回) + enhanceQuotaChip(); // v96:额度 chip 数据点 reapply + enhanceGitChip(); // v96:同点对账 Git chip(开关/会话切换/scope 变化在此收敛) + } + + + /* ---------- 自绘 tooltip:向上弹出(原生 title 方向不可控) ---------- */ + var tipFor = null, lastMoveAt = 0, tipSig = ""; + var mouseInBar = false; // 鼠标是否悬停在条面/tooltip/面板上(最近一次 mousemove 判定) + var tipGraceTimer = 0; // 越顶宽限定时器(v46) + var tipStats = { mv: 0, shows: 0, hides: 0, last: "", lastHide: "", corridor: 0, grace: 0 }; // 诊断:随 mount diag 回写 + /* 隐藏总闸(v33.3):鼠标还在条面上时,任何路径(数据刷新接管失败/输入框重建/ + * 瞬时不可见判定)都不得隐藏 tooltip——只有鼠标真正离开条面(left-bar,force) + * 或条面整体隐藏(force)才允许。 */ + function hideTip(force, cause) { + if (!force && mouseInBar) { tipStats.lastHide = "blocked:" + (cause || "?"); return; } + if (tipGraceTimer) { clearTimeout(tipGraceTimer); tipGraceTimer = 0; } + if (tip.style.display !== "none") tip.style.display = "none"; + tipFor = null; + tipSig = ""; + tipStats.hides++; + tipStats.lastHide = cause || "?"; + } + function positionTip(el, keepTop) { + var tr = tip.getBoundingClientRect(), ar = el.getBoundingClientRect(); + var left = ar.left + ar.width / 2 - tr.width / 2; + left = Math.max(8, Math.min(left, Math.max(8, innerWidth - tr.width - 8))); + /* keepTop=数据刷新等重定位时保持顶边——悬停中只调位置不重建,顶边不动鼠标就 + * 不会因盒形变化被甩出去(外面是 iframe 静默区,甩出去就回不来了)。 + * 只有首次弹出才按条目重新锚定(悬空 8px)。 */ + var top = (keepTop && tip.style.top) ? parseFloat(tip.style.top) : 0; + if (!isFinite(top) || top < 8) top = Math.max(8, ar.top - tr.height - 8); + tip.style.left = Math.round(left) + "px"; + tip.style.top = Math.round(top) + "px"; + } + function drawTip(el, keepTop) { + var t = el && el.__tip; + if (!t) { tip.style.display = "none"; tipFor = null; return; } + /* 幂等:内容没变就只调位置,不重建 innerHTML(重建=悬停中每 1.5s 闪烁) */ + if (t === tipSig && tip.style.display === "block") { + positionTip(el, true); + return; + } + tipSig = t; + tip.innerHTML = '
'; + if (t.charCodeAt(0) === 1) tip.firstChild.innerHTML = t.slice(1); // \x01 前缀 = 结构化 HTML(v67.1) + else tip.firstChild.textContent = t; + tip.style.display = "block"; + positionTip(el, keepTop); + } + function showTipFor(el) { + tipFor = el; + var its = main.querySelectorAll(".it"); + el.__idx = el === main ? -1 : Array.prototype.indexOf.call(its, el); + el.__n = its.length; // render 接管时校验条目数未变,数量变了 __idx 就不可信 + tipStats.shows++; + tipStats.last = el === main ? "main" : String(el.textContent || "").slice(0, 12); + drawTip(el, false); + } + /* 事件挂 document 捕获阶段(v32):React/应用层可能在冒泡路上 stopPropagation, + * 挂 main 的 mousemove 收不到(v31 实证:原生 title 有 hover、自绘 tip 无事件)。 + * stale() 守卫:热更新后旧实例监听器必须罢工,否则新旧两个 tip 同时工作。 */ + listenGlobal(document, "mousemove", function (e) { + if (inactive()) return; + tipStats.mv++; + if (Date.now() - lastMoveAt < 150) return; + lastMoveAt = Date.now(); + var t = e.target; + if (!t || !t.closest) return; + if (tip.contains(t)) { mouseInBar = true; cancelTipGrace(); return; } // 鼠标移入 tooltip:保持 + if (t.closest(".panel")) { + /* 面板上不保留条面 tooltip(v42):开设置的路上触发过条目 tooltip 的话, + * 这分支若不清掉它,鼠标在面板里它就永不消失。 */ + mouseInBar = true; + if (tipFor) hideTip(true, "panel-hover"); + return; + } + if (!bar.contains(t)) { + /* 空中走廊(v45):悬空缝是"条外且 tip 外"的真空带,采样事件落在这里会被误判 + * 成"移开"立即熄灭——鼠标在 tooltip 横向范围、底边下方 ~12px 内时视同仍在条上, + * 穿行去 tooltip 点 tab 的路上不熄灭。 */ + if (tipFor && inTipCorridor(e.clientX, e.clientY)) { + tipStats.corridor++; + mouseInBar = true; + cancelTipGrace(); + return; + } + /* 越顶宽限(v46):mousemove 有 150ms 节流采样,快速上移时两个采样点之间能跨过 + * 整个 tooltip,采样点落在 tip 上方的死区——此前立即隐藏就把"正要进 tip"错杀成 + * "离开"。改挂 300ms 宽限:下一个采样落回 tip/走廊就取消(tip 仍可点 tab), + * 确实继续远离才真正收起。横向/向下移出不享受宽限,依旧立即消失。 */ + if (tipFor && inTipColumn(e.clientX, e.clientY)) { + tipStats.grace++; + mouseInBar = true; // 视同在途:总闸拦住 render-orphan 等路径趁机熄灯 + if (!tipGraceTimer) tipGraceTimer = setTimeout(function () { + tipGraceTimer = 0; + hideTip(true, "grace-expire"); + }, 300); + return; + } + mouseInBar = false; + if (tipFor) hideTip(true, "left-bar"); // v43:移开条面立即消失,不再留 300ms 缓冲 + return; + } + mouseInBar = true; + cancelTipGrace(); + if (panel.classList.contains("open")) return; // 面板开着:条面不弹 tooltip(弹出也会被面板盖住,只露边角) + var el = t.closest(".it"); + if (el) { + if (el !== tipFor) showTipFor(el); + } else if (tipFor) { + /* 条面内非条目区(项间隔/分隔符/⚙):保持当前 tooltip 不动(此处刻意无操作)。 + * 慢速移动时命中目标在条目↔空隙间反复跳,若此处切换内容/隐藏 + * 就是"出现一下立刻消失"的闪烁(v33.2,概览 tooltip 因此废除) */ + } + }, true); + /* 鼠标甩出窗口外/窗口失焦:tooltip 立即收起(v43)。 + * v47 根因修正:ZCode 条面上方的聊天区是独立浏览上下文(iframe/webview,findComposer + * 穿透同源 iframe 即佐证),光标从条面跨入其上时宿主文档会发 documentElement + * mouseleave、且后续 mousemove 全部进子文档——diag 实证(mv 上万但 corridor/grace + * 计数全 0、lastHide=win-leave)这就是"一向上移 tooltip 就消失"的根因:v43 把这种 + * 内部 leave 当成了甩出窗口。故 mouseleave 按退出点分流: + * · 退出点贴窗口边缘 = 真离开窗口 → 立即收; + * · 内部 leave 且退出点在 tip 横向列内(±30px)= 去 tooltip/回条面的穿行 → 400ms + * 宽限(子上下文区域事件停摆,靠定时器兜底收回;tip/条面/走廊事件会取消它); + * · 内部 leave 列外 = 横向走掉 → 立即收。 */ + function tipBoundaryHide() { + if (inactive()) return; + mouseInBar = false; + if (tipFor) hideTip(true, "win-leave"); + } + listenGlobal(window, "blur", tipBoundaryHide, true); + function tipLeaveAt(x, y) { + if (inactive() || !tipFor) return; + if (x <= 1 || x >= innerWidth - 2 || y <= 1 || y >= innerHeight - 2) { + tipBoundaryHide(); + return; + } + /* 伪边界过滤(v99):Chrome 在悬停链收缩/重算时会补发 DOM 内部的 mouseleave + * (Playwright 实测:滑过格内 .v↔.dim 的 flex 空隙时 documentElement 级必发、 + * tooltip 子元素之间连 tip 本体级都发,坐标在视口内、relatedTarget 是文档内存活 + * 元素)——指针并没有离开本文档。此前每次伪 leave 都按 v47 分流挂 400ms 宽限, + * 鼠标随即停在格间空白/tooltip 上(无后续事件取消)→ tooltip「弹出后立即关闭」 + * (用户实测悬停「2.85万 1轮 1次」格内 万↔1 空隙必现;tooltip 本体停留同款熄灭)。 + * 事件坐标与 relatedTarget 都不可信(真进 iframe 时 relatedTarget=宿主 body、坐标 + * 也可能落在门外),改问活体:取触发时刻最深 :hover 元素——仍是本档具体元素即伪 + * leave,忽略交给 mousemove 流接管;截断到 body(空档区/已进子文档,v47 宽限语义)、 + * iframe/webview 或空链(甩出窗口)一律按真边界走原分流。 */ + try { + var hs = document.querySelectorAll(":hover"); + var hovered = hs.length ? hs[hs.length - 1] : null; + if (hovered && hovered !== document.body && hovered !== document.documentElement && + hovered.tagName !== "IFRAME" && hovered.tagName !== "WEBVIEW") return; + } catch (e) { } + var r = tip.getBoundingClientRect(); + if (x >= r.left - 30 && x <= r.right + 30) { + tipStats.grace++; + mouseInBar = true; // 在途:总闸拦住 render-orphan 等路径趁机熄灯 + cancelTipGrace(); + tipGraceTimer = setTimeout(function () { + tipGraceTimer = 0; + hideTip(true, "grace-expire"); + }, 400); + } else { + mouseInBar = false; + hideTip(true, "left-bar"); + } + } + listenGlobal(document.documentElement, "mouseleave", function (e) { + tipLeaveAt(e.clientX, e.clientY); + }, true); + /* 光标离开 tooltip 本体:上行越顶/下行回条面 → 列内宽限;横向 → 立即收(分流同上) */ + tip.addEventListener("mouseleave", function (e) { + tipLeaveAt(e.clientX, e.clientY); + }, true); + tip.addEventListener("mouseenter", function () { + if (inactive()) return; + cancelTipGrace(); + }, true); + /* 空中走廊判定(v45):tooltip 矩形横向 ±4px、纵向 tip 顶边到底边下方 12px + * (覆盖条面顶边到 tooltip 底边的 8px 悬空缝)。只在 tipFor 存在时被调用。 */ + function inTipCorridor(x, y) { + var r = tip.getBoundingClientRect(); + return x >= r.left - 4 && x <= r.right + 4 && y >= r.top && y <= r.bottom + 12; + } + /* 越顶宽限判定(v46):tip 正上方 ±30px、高 80px 的柱形区——快速上移的采样点 + * 落进这里说明鼠标刚越过 tip 顶边,大概率下一拍就停在 tip 里。 */ + function inTipColumn(x, y) { + var r = tip.getBoundingClientRect(); + return x >= r.left - 30 && x <= r.right + 30 && y >= r.top - 80 && y < r.top; + } + function cancelTipGrace() { + if (tipGraceTimer) { clearTimeout(tipGraceTimer); tipGraceTimer = 0; } + } + + /* ---------- 设置面板 ---------- */ + function syncUpdate() { + var u = state.pluginUpdate; + var active = !!(u && u.supported); + var version = panel.querySelector(".zu-settings-version"); + version.textContent = active && u.currentVersion ? u.currentVersion : ""; + version.hidden = !version.textContent; + var cb = panel.querySelector(".zu-auto-update"); + cb.checked = !!(u && u.auto); + cb.disabled = !active; + panel.querySelector('[data-update=check]').disabled = !active || !!u.busy; + panel.querySelector('[data-update=update]').disabled = !active || !!u.busy || u.phase !== "available"; + var messages = { + idle: L("尚未检测", "Not checked yet"), + checking: L("正在检查更新…", "Checking for updates…"), + available: L("发现新版本", "Update available"), + current: L("当前已是最新版本", "Up to date"), + downloading: L("正在下载更新…", "Downloading update…"), + updating: L("正在安装更新…", "Installing update…"), + updated: L("插件已更新;请重开会话以加载新版插件组件", "Plugin updated; start a new session to load the new components"), + error: L("更新失败:", "Update failed: ") + (u && u.error || ""), + }; + var text = !active ? L("仅支持已安装的 zusage-marketplace 用户插件;更新通道未就绪时需先升级插件并重启 ZCode。", "Requires the installed user plugin from zusage-marketplace. Upgrade the plugin and restart ZCode if the update channel is not ready.") : messages[u.phase] || messages.idle; + if (!active && u && u.error) text += "\n" + messages.error; + if (active && u.currentVersion) text += "\n" + L("当前版本:", "Current version: ") + u.currentVersion; + if (active && u.latestVersion) text += (u.channel === "release" ? L(" · Release 版本:", " · Release version: ") : L(" · 市场版本:", " · Marketplace version: ")) + u.latestVersion; + if (active && u.channel) text += "\n" + L("更新来源:", "Update source: ") + (u.channel === "release" ? (u.channelHost === "gitcode" ? L("GitCode 正式 Release(校验 ZIP 与 SHA256)", "stable GitCode Release (validated ZIP and SHA256)") : L("GitHub 正式 Release(校验 ZIP 与 SHA256)", "stable GitHub Release (validated ZIP and SHA256)")) + : u.channel === "market" ? (u.channelHost === "gitcode" ? L("GitCode 仓库源码(由插件管理器直接更新)", "GitCode repository source (updated directly by the plugin manager)") : L("GitHub 仓库源码(由插件管理器直接更新)", "GitHub repository source (updated directly by the plugin manager)")) + : L("本地目录市场", "local directory marketplace")); + if (active && u.releaseUrl) text += "\n" + u.releaseUrl; + if (active && u.checkedAt) text += "\n" + L("上次检测:", "Last checked: ") + new Date(u.checkedAt).toLocaleString(); + if (u && (u.phase === "updating" || u.phase === "downloading")) text += "\n" + L("关闭自动更新仅阻止后续安装;已开始的安装会继续完成", "Turning automatic updates off stops future installs; an install already started will finish"); + if (u && u.restartRequired) text += "\n" + L("主进程或关闭热更新的文件已更新,需重启 ZCode", "Restart ZCode to load main-process updates or files with hot reload disabled"); + if (u && u.skipped && u.skipped.length) text += "\n" + L("保留本地热修(未覆盖):", "Local edits preserved (not overwritten): ") + u.skipped.join(", "); + panel.querySelector(".zu-update-status").textContent = text; + syncProgress(active ? u : null); + /* 逐问速率行开关勾选态:state.rateLine 由主进程推送(或 change 时代乐观更新) */ + var rl = panel.querySelector(".zu-rate-line"); + if (rl) rl.checked = state.rateLine; + var qe = panel.querySelector(".zu-quota-enable"); // v95:勾选态由主进程推送的 quotaEnabled 决定 + if (qe) qe.checked = state.quotaEnabled; + try { syncQuotaSources(); } catch (eSrc) { try { window.__zusageDiag.quotaSrcErr = String((eSrc && eSrc.stack) || eSrc); } catch (_) { } } // 防御:多源列表渲染失败不得拖垮实例创建(实测 SRC_FIELD_LABELS 时序可致整条状态条消失) + } + function srcStatusText(sv) { + var m = { ok: "✓", idle: L("待查询", "pending"), not_configured: L("未配置凭据", "credential missing"), + disabled: L("已关闭", "off"), + auth_failed: L("认证失效", "auth failed"), no_plan: L("无订阅", "no subscription"), + rate_limited: L("限流中", "rate limited"), network_error: L("查询失败", "failed"), + parse_error: L("响应异常", "bad response"), unsupported: L("不支持", "unsupported") }; + return m[sv.status] || sv.status || ""; + } + /* 源列表按推送状态增量重建(JSON 签名没变不重绘,避免打断正在输入的凭据框) */ + function syncQuotaSources() { + var box = panel.querySelector(".zu-src-list"); + if (!box) return; + var list = state.quotaSources || []; + var sig = JSON.stringify(list); + if (sig === srcListSig && box.children && box.children.length) return; + /* 正在输入凭据的源行不重建:状态推送(如别家查询完成)会翻转签名,重建会丢焦点丢内容 */ + try { + var act = document.activeElement; + if (act && act.classList && act.classList.contains("zu-src-field") && box.contains && box.contains(act)) return; + } catch (e) { } + srcListSig = sig; + var html = ""; + for (var i2 = 0; i2 < list.length; i2++) { + var sv = list[i2]; + var credFields = ""; + for (var f = 0; f < (sv.credentialFields || []).length; f++) { + var field = sv.credentialFields[f]; + var isCred = field === "apiKey" || field === "cookie" || field === "token" || field === "secretAccessKey"; + credFields += ''; + } + var pr = sv.presets || [], chips = "", curBase = String(sv.baseUrl || "").replace(/\/+$/, ""); + for (var p2 = 0; p2 < pr.length; p2++) { + chips += ''; + } + html += '
' + + '' + + '' + srcStatusText(sv) + '' + + (sv.keyUrl ? '' : "") + + '' + + (chips ? '' + chips + '' : "") + + '' + credFields + '
'; + } + box.innerHTML = html; + } + /* 更新进度条(v89)。主进程 updater 在 downloading/updating 阶段推送 + * progress = { stage: download|verify|install|sync, loaded?, total? }: + * 下载条在 download 阶段按字节比例,之后一律 100%;安装条没有字节口径(宿主插件管理器 + * 不报进度),按校验→安装→同步三段推进并用扫光表示仍在活动。旧版主进程不带 progress + * 字段(热更新的渲染端先于主进程重启生效),此时整块隐藏,只保留原有状态文案。 */ + function formatBytes(n) { + n = Math.max(0, +n || 0); + if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB"; + if (n >= 1024) return Math.round(n / 1024) + " KB"; + return n + " B"; + } + function setProgressBar(box, key, ratio, active, label) { + var row = box.querySelector('[data-progress="' + key + '"]'); + if (!row) return; + var pct = Math.round(Math.min(1, Math.max(0, ratio)) * 100); + var fill = row.querySelector(".zu-progress-fill"); + fill.style.width = pct + "%"; + fill.classList.toggle("active", !!active); + row.querySelector(".zu-progress-track").setAttribute("aria-valuenow", String(pct)); + row.querySelector(".zu-progress-value").textContent = label; + } + function syncProgress(u) { + var box = panel.querySelector(".zu-update-progress"); + if (!box) return; + var p = u && u.progress; + var show = !!(p && typeof p.stage === "string" && (u.phase === "downloading" || u.phase === "updating")); + box.hidden = !show; + if (!show) return; + var idx = PROGRESS_STAGES.indexOf(p.stage); + var total = +p.total > 0 ? +p.total : 0, loaded = Math.max(0, +p.loaded || 0); + var dlRatio = idx > 0 ? 1 : (total ? loaded / total : 0); + var dlLabel = idx > 0 ? "100%" + : total ? Math.round(Math.min(1, loaded / total) * 100) + "% · " + formatBytes(loaded) + " / " + formatBytes(total) + : formatBytes(loaded); + setProgressBar(box, "download", dlRatio, idx <= 0, dlLabel); + var install = { + verify: [.15, L("校验中", "Verifying")], + install: [.55, L("安装中", "Installing")], + sync: [.9, L("同步中", "Syncing")], + }[p.stage] || [0, L("等待下载完成", "Waiting for download")]; + setProgressBar(box, "install", install[0], idx > 0, install[1]); + } + function syncPanel() { + panel.querySelectorAll("input[type=checkbox][data-k]").forEach(function (cb) { + cb.checked = !!state.show[cb.dataset.k]; + }); + panel.querySelector(".zu-ctxov").value = state.ctxOv; + var cnBox = panel.querySelector(".zu-cn-num"); + if (cnBox) cnBox.checked = !!state.cnNum; + syncUpdate(); + } + panel.addEventListener("click", function (e) { + var navItem = e.target.closest && e.target.closest(".zu-navitem"); + if (navItem && !navItem.hasAttribute("aria-current")) { + state.settingsSec = navItem.dataset.sec || "display"; + persist(); + var cur = activeSettingsSec(); + panel.querySelectorAll(".zu-settings-section[data-sec]").forEach(function (s) { + s.hidden = s.getAttribute("data-sec") !== cur; + }); + panel.querySelectorAll(".zu-navitem").forEach(function (b) { + if (b.getAttribute("data-sec") === cur) b.setAttribute("aria-current", "page"); + else b.removeAttribute("aria-current"); + }); + var navBody = panel.querySelector(".zu-settings-body"); + if (navBody) navBody.scrollTop = 0; + syncSettingsScroll(); + return; + } + if (e.target.closest && e.target.closest(".zu-settings-close")) { + panel.classList.remove("open"); + return; + } + if (e.target.closest && e.target.closest(".zu-settings-scroll")) { + var body = panel.querySelector(".zu-settings-body"); + body.scrollTop += Math.max(1, Math.round(body.clientHeight * .8)); + syncSettingsScroll(); + return; + } + if (e.target.closest && e.target.closest(".zu-settings-feedback")) { + /* 优先走客户端 zcode 桥(IPC → 主进程 shell.openExternal,系统默认浏览器); + window.open 会被客户端内置浏览器接管,仅作无桥环境兜底 */ + var zc = window.zcode, issuesUrl = "https://github.com/ByteRax/zcode-token-usage-statusbar/issues"; + if (zc && typeof zc.openExternal === "function") zc.openExternal(issuesUrl); + else window.open(issuesUrl, "_blank", "noopener"); + return; + } + var keyBtn = e.target.closest && e.target.closest(".zu-src-keyurl"); + if (keyBtn) { + /* 获取 Key:走客户端 openExternal 桥(系统默认浏览器),无桥环境 window.open 兜底 */ + var ku = keyBtn.getAttribute("data-url") || ""; + if (ku) { + var zk = window.zcode; + if (zk && typeof zk.openExternal === "function") zk.openExternal(ku); + else window.open(ku, "_blank", "noopener"); + } + return; + } + var presetBtn = e.target.closest && e.target.closest(".zu-src-preset"); + if (presetBtn) { + /* 预设模板(v102):一键填充官方域变体(走 baseUrl 白名单写通道);写成功后 + * writeQuotaSecrets 会自动解冻重查并回推 sourcesConfig,选中态随重绘高亮 */ + if (presetBtn.dataset.src && presetBtn.dataset.base) pluginRequest("quota-src-key", { id: presetBtn.dataset.src, field: "baseUrl", value: presetBtn.dataset.base }); + return; + } + var srcBtn = e.target.closest && e.target.closest(".zu-src-refresh"); + if (srcBtn) { pluginRequest("quota-src-refresh", { id: srcBtn.dataset.src }); return; } + var button = e.target.closest && e.target.closest("[data-update]"); + if (button && !button.disabled) pluginRequest(button.dataset.update); + }); + gear.addEventListener("click", function (e) { + e.stopPropagation(); + syncPanel(); + var opening = !panel.classList.contains("open"); + panel.classList.toggle("open"); + if (opening) { + panel.style.height = ""; // v101:重开时按当前分区内容重测高度(开着的期间高度锁死) + positionSettingsPanel(); + subPanel.classList.remove("open"); // 两面板同位重叠,互斥打开(v49) + hideTip(true, "gear-open"); // 移向齿轮的路上可能触发过条目 tooltip,开面板时一并收掉 + } + }); + panel.addEventListener("change", function (e) { + var t2 = e.target; + if (t2.classList.contains("zu-auto-update")) { + pluginRequest("auto", t2.checked); + return; + } + if (t2.classList.contains("zu-src-enable")) { + pluginRequest("quota-src", { id: t2.dataset.src, enabled: t2.checked }); + return; + } + if (t2.classList.contains("zu-src-field")) { + if (t2.dataset.src && t2.dataset.field) pluginRequest("quota-src-key", { id: t2.dataset.src, field: t2.dataset.field, value: t2.value }); + return; + } + if (t2.classList.contains("zu-quota-enable")) { + /* v95:乐观更新,真值以主进程写 config.json 的 quota_enabled 后回推为准;关闭时 + chip 在下一次 heavy 对账点摘除,主进程停止解密与联网 */ + state.quotaEnabled = t2.checked; + pluginRequest("quota", t2.checked); + renderAll(false); + return; + } + if (t2.classList.contains("zu-rate-line")) { + /* 乐观更新勾选态,真值以主进程写 config.json 后回推的 rateLine 为准 */ + state.rateLine = t2.checked; + scanRateLines(); + scanDeterministicLines(); // v71:关=撤下确定性历史行,开=立即重扫补行 + pluginRequest("rate-line", t2.checked); + return; + } + if (t2.classList.contains("zu-cn-num")) { + state.cnNum = t2.checked ? 1 : 0; + persist(); + renderAll(false); + return; + } + if (t2.name === "zu-lang") { + state.lang = t2.value; + persist(); + buildPanel(); // 面板自身文案随语言重建(含 radio 勾选态) + rebuildExcBubble(); + renderAll(true); // 条面单位与 tooltip 文案随语言刷新(含各实例面板文案重建) + return; + } + if (t2.type === "checkbox") { + state.show[t2.dataset.k] = t2.checked ? 1 : 0; + persist(); + if (t2.dataset.k === "git") refreshWants(); // v96:开/关即时更新上报路径集(开启时顺带触发首查) + } else if (t2.classList.contains("zu-ctxov")) { + state.ctxOv = t2.value.trim(); + persist(); + } + renderAll(false); + }); + listenGlobal(document, "mousedown", function (e) { + /* v80:面板挂 body 后 bar.contains 不再覆盖面板自身,点面板内部不能关 */ + if (panel.classList.contains("open") && !bar.contains(e.target) && !panel.contains(e.target)) panel.classList.remove("open"); + if (subPanel.classList.contains("open") && !bar.contains(e.target) && !subPanel.contains(e.target)) subPanel.classList.remove("open"); + }); + + /* ---------- 子代理明细面板(v49):点击条目弹出的固定面板,替代悬停 tooltip ---------- */ + var subPanelTab = 0; // 面板当前页签(0=汇总,i=第 i 个子代理);数据刷新时保持 + function buildSubPanel(sub) { + var list = (sub && sub.list) || []; + if (subPanelTab > list.length) subPanelTab = 0; // 列表变短则回汇总 + subPanel.innerHTML = ""; + var head = document.createElement("div"); + head.className = "phead"; + head.textContent = L("子代理明细", "Sub-agent details"); + var ver = document.createElement("span"); + ver.className = "pver"; + ver.textContent = L("独立统计 · 不计入会话累计", "Tracked separately · not counted in session total"); + head.appendChild(ver); + subPanel.appendChild(head); + var runningSids = []; + for (var rsid in subRate.slots) runningSids.push(rsid); // v97:运行中槽位(DB 可能还没有记录) + runningSids.sort(); + if (!sub || (!sub.total && !sub.active && !runningSids.length)) { + var empty = document.createElement("div"); + empty.className = "pnote"; + empty.textContent = L("当前会话没有子代理记录", "No sub-agent records in the current session"); + subPanel.appendChild(empty); + return; + } + /* 页签行:汇总 + 每个子代理(复用 .ttabs/.ttab 样式);动态名走 textContent 防注入 */ + var tabsRow = document.createElement("div"); + tabsRow.className = "ttabs"; + var labels = [L("汇总", "Summary")]; + list.forEach(function (s1) { + /* task=派发时的 description(右侧"子智能体目录"同源);无则退回 title/线路名 */ + var nm = String(s1.task || "").trim() || String(s1.title || "").trim() || + String(s1.agent || "sub").replace(/^zcode-/, "") + "…" + String(s1.sid).slice(-4); + labels.push(nm.length > 12 ? nm.slice(0, 11) + "…" : nm); + }); + labels.forEach(function (lb, i) { + var tb = document.createElement("span"); + tb.className = "ttab" + (i === subPanelTab ? " on" : ""); + tb.setAttribute("data-i", String(i)); + tb.textContent = lb; + tabsRow.appendChild(tb); + }); + subPanel.appendChild(tabsRow); + var body = document.createElement("div"); + body.className = "tbody"; // 复用 tooltip 内容容器的限高滚动 + subPanel.appendChild(body); + function fillRow(row, s1) { + var nm = String(s1.task || "").trim() || String(s1.title || "").trim() || + String(s1.agent || "sub").replace(/^zcode-/, "") + "…" + String(s1.sid).slice(-4); + var n = document.createElement("div"); + n.className = "subname"; + n.textContent = nm + L("(", " (") + String(s1.agent || "subagent").replace(/^zcode-/, "") + + " …" + String(s1.sid).slice(-4) + L(")", ")"); + if (s1.active) { // 运行中徽章:动态名仍走 textContent,徽章是独立 span + var live = document.createElement("span"); + live.className = "sublive"; + live.textContent = L("运行中", "running"); + n.appendChild(live); + var est = document.createElement("span"); // v97:运行期估算速率(渲染端状态,面板 tick 原地更新) + est.className = "subrate dim"; + est.setAttribute("data-sid", String(s1.sid || "")); + est.textContent = subRateSlotText(s1.sid); + n.appendChild(est); + } + var st = document.createElement("div"); + st.className = "substat"; + st.textContent = L("总消耗 ", "Total ") + fmt(s1.total) + L(" · ", " · ") + (s1.requests || 0) + + L(" 次请求", " requests") + + (s1.tps_avg > 0 ? L(" · 均 ", " · avg ") + s1.tps_avg + L(" t/s", " t/s") : "") + + (s1.last ? L(" · 最后活动 ", " · last activity ") + fmtTime(s1.last) : ""); + var br = document.createElement("div"); + br.className = "substat dim"; + br.textContent = ioc(s1.input, s1.output, s1.cache_read, s1.reasoning, s1.cache_write); + row.appendChild(n); + row.appendChild(st); + row.appendChild(br); + } + if (subPanelTab === 0) { + var p1 = document.createElement("div"); + p1.className = "pnote"; + p1.textContent = ioc(sub.input, sub.output, sub.cache_read, sub.reasoning, sub.cache_write) + + L(",共 ", ", ") + (sub.requests || 0) + L(" 次", " requests in total") + + (sub.rate_avg > 0 ? L(" · 均 ", " · avg ") + sub.rate_avg + L(" t/s", " t/s") : "") + + (sub.active ? L(" · 有子代理运行中", " · sub-agent running") : ""); + body.appendChild(p1); + /* v97:运行中子代理逐个实时估算——含 DB 还没有完成记录的新子代理(sub.list + * 只收录有已完成请求的会话);数值 span 由面板 tick 原地刷新。 */ + if (runningSids.length) { + var rh = document.createElement("div"); + rh.className = "pnote"; + rh.textContent = L("运行中估算(字符增量;主 chip 显示合计)", "Running estimates (text-delta; the bar chip shows the sum)"); + body.appendChild(rh); + for (var ri = 0; ri < runningSids.length; ri++) { + var rl = document.createElement("div"); + rl.className = "substat"; + rl.textContent = L("子代理 …", "agent …") + String(runningSids[ri]).slice(-4) + + L(" · 运行中 · ", " · running · "); + var rest = document.createElement("span"); + rest.className = "subrate"; + rest.setAttribute("data-sid", runningSids[ri]); + rest.textContent = subRateSlotText(runningSids[ri]); + rl.appendChild(rest); + body.appendChild(rl); + } + } + if (!list.length && !runningSids.length) { + var p2 = document.createElement("div"); + p2.className = "pnote"; + p2.textContent = L("当前会话还没有子代理记录", "No sub-agent records in the current session yet"); + body.appendChild(p2); + } + } else { + var row = document.createElement("div"); + row.className = "subrow"; + fillRow(row, list[subPanelTab - 1]); + body.appendChild(row); + } + } + function positionSubPanel() { + if (!subPanel.classList.contains("open")) return; + var item = bar.querySelector(".zu-sub"); + if (!item) return; + var ir = item.getBoundingClientRect(); + var width = subPanel.offsetWidth; + var left = ir.left + ir.width / 2 - width / 2; + left = Math.max(8, Math.min(left, window.innerWidth - width - 8)); + subPanel.style.left = Math.round(left) + "px"; + /* v80:fixed 挂 body,top 同设置面板——条上沿留 10px 缝 */ + subPanel.style.top = Math.max(8, Math.round(bar.getBoundingClientRect().top - subPanel.offsetHeight - 10)) + "px"; + } + /* v97:面板打开期间 800ms 原地刷新 .subrate span(估算速率是渲染端状态,泵推送 + * 静默期面板不会自动重建);面板关闭/实例失效即自停,dispose 兜底清理。 */ + var subRateTimer = 0; + function subRateTickPanel() { + if (inactive() || !subPanel.classList.contains("open")) { + if (subRateTimer) { clearInterval(subRateTimer); subRateTimer = 0; } + return; + } + var spans = subPanel.querySelectorAll(".subrate"); + for (var i = 0; i < spans.length; i++) { + var txt = subRateSlotText(spans[i].getAttribute("data-sid") || ""); + if (spans[i].textContent !== txt) spans[i].textContent = txt; + } + } + function toggleSubPanel() { + var opening = !subPanel.classList.contains("open"); + subPanel.classList.toggle("open", opening); + if (opening) { + panel.classList.remove("open"); // 与设置面板互斥 + buildSubPanel(lastSub); + positionSubPanel(); + hideTip(true, "sub-open"); + if (!subRateTimer) subRateTimer = setInterval(subRateTickPanel, 800); + } + } + bar.addEventListener("click", function (e) { + if (inactive()) return; + if (e.target && e.target.closest && e.target.closest(".zu-sub")) { + e.stopPropagation(); + toggleSubPanel(); + } + }); + subPanel.addEventListener("click", function (e) { + if (inactive()) return; + var b = e.target && e.target.closest ? e.target.closest(".ttab") : null; + if (!b) return; + subPanelTab = +b.getAttribute("data-i") || 0; + buildSubPanel(lastSub); // 面板固定,切页签只换内容 + }); + + /* ---------- 输入框查找(穿透 open shadow / 同源 iframe) ---------- */ + var COMPOSER_SEL = 'textarea, [contenteditable="true"], [role="textbox"], .ProseMirror, .ql-editor'; + var deepCache = { el: null, at: 0 }; + function visible(el) { + var r = el.getBoundingClientRect(); + return r.width > 40 && r.height > 8 && r.bottom > 0 && r.top < innerHeight; + } + function deepFind() { + if (deepCache.el && deepCache.el.isConnected && reallyVisible(deepCache.el)) return deepCache.el; + if (Date.now() - deepCache.at < 1000) return null; + deepCache.at = Date.now(); + var found = null; + function scan(doc, depth) { + if (found || !doc || depth > 8) return; + try { + doc.querySelectorAll(COMPOSER_SEL).forEach(function (el) { + if (el.closest && el.closest('[role="dialog"]')) return; // v62:弹窗内的编辑框不是聊天输入框(issue #9) + if (inSettingsView(el)) return; // 设置页(MCP 配置等)的编辑框不是聊天输入框 + if (!found && visible(el) && el.getBoundingClientRect().top > innerHeight * 0.45) found = el; + else if (found && el.getBoundingClientRect().top > found.getBoundingClientRect().top && visible(el)) found = el; + }); + } catch (e) { } + if (found) return; + try { + doc.querySelectorAll("*").forEach(function (el) { + if (!found && el.shadowRoot) scan(el.shadowRoot, depth + 1); + }); + } catch (e) { } + try { + doc.querySelectorAll("iframe").forEach(function (fr) { + if (!found) { try { if (fr.contentDocument) scan(fr.contentDocument, depth + 1); } catch (e) { } } + }); + } catch (e) { } + } + scan(document, 0); + if (found) deepCache.el = found; + return found; + } + /* 输入框是否真的露在屏幕上:幽灵子树检测(设置页等 opacity-0/inert 壳,v61)+ + * 中心点命中测试(真渲染出来的覆盖层,如全屏对话框背景、悬浮菜单)。 */ + /* 自己的浮层(设置面板/超限气泡/悬停 tooltip)展开在条上方、必然覆盖输入区中心: + * 若算进遮挡判定,会进 隐藏→复显→再隐藏 的循环(v40 离线复现的"打开设置闪烁"根因; + * v41 补 tooltip——会话/工具的长 tooltip 同样盖住输入框中心,悬停 0.4s 后闪一下且 tooltip 消失)。 + * 只豁免 track 的可见性路径;findComposer 找输入框时不用(ownOK 缺省 false)。 */ + function isOwnOverlay(el) { + try { return !!(el && el.closest && el.closest(".zusage-tip-root,.panel,.zusage-exc")); } catch (e) { return false; } + } + /* 幽灵子树检测(v61,取代 v58/v59 的 coverLock + 穿透特判): + * 客户端 3.11.x 的设置页 = 设置 tab 激活时给工作区主视图套 opacity-0 + inert + + * pointer-events-none 的壳(DOM 与几何原样保留,纯视觉隐藏)。输入框落在这类 + * "保持挂载但视觉已隐藏"的祖先链里就判不可见 —— 直接读计算样式,与 + * elementFromPoint 无关,天然免疫滚动锁定期的穿透假象。 */ + function inGhostSubtree(el) { + try { + for (var p = el; p && p !== document.body; p = p.parentElement) { + if (p.inert) return true; + var cs = getComputedStyle(p); + if (cs.visibility === "hidden" || parseFloat(cs.opacity) < 0.1) return true; + } + } catch (e) { } + return false; + } + /* 模态弹窗打开判定(v62,issue #9):Radix Dialog 往 body 挂一层「固定全屏 + 带底色 + + * z-index 抬升」的遮罩(下拉菜单/选择器只设 body pointer-events:none、不挂遮罩——v61 + * 实测),且文档里同时存在 [role="dialog"]。此时聊天区整体不可交互,条必须整体收起: + * 模型配置弹窗里的「推理参数映射」编辑框正好落在视口下半部,会被 findComposer 的 + * 启发式当聊天输入框,条就冒在弹窗里(用户 issue #9 截图)。 + * 自己的浮层(.zu-bar/.zu-back/.zu-sheet——移动端抽屉遮罩是同款全屏底色)不算。 */ + function modalOpen() { + try { + if (!document.querySelector('[role="dialog"]')) return false; + var kids = document.body.children; + for (var i = 0; i < kids.length; i++) { + var el = kids[i]; + var cls = String(el.getAttribute && el.getAttribute("class") || ""); + if (el.id === "zusage-bar" || /(^|\s)zu-(bar|back|sheet)(\s|$)/.test(cls)) continue; + var cs = getComputedStyle(el); + if (cs.position !== "fixed" || cs.display === "none" || cs.visibility === "hidden") continue; + if (parseFloat(cs.opacity || "1") < 0.05) continue; + if (!(parseInt(cs.zIndex, 10) >= 10)) continue; // 遮罩必抬层(z-40/50);普通全屏容器不抬 + var r = el.getBoundingClientRect(); + if (r.width < innerWidth * 0.9 || r.height < innerHeight * 0.9) continue; + var bg = cs.backgroundColor; + if (!bg || bg === "transparent" || bg === "rgba(0, 0, 0, 0)") continue; + return true; + } + } catch (e) { } + return false; + } + function reallyVisible(el, ownOK) { + if (!visible(el)) return false; + if (inGhostSubtree(el)) return false; // 设置页等幽灵壳:视觉已隐藏(v61) + var r = el.getBoundingClientRect(); + var hit; + try { hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); } catch (e) { return true; } + if (!hit) return false; + /* 命中根元素 = 穿透(v61 翻转判定):Radix 模态层(下拉/对话框/modal 弹层)打开时给 + * body 设 pointer-events:none,应用树全被跳过、elementFromPoint 一路穿到 。 + * v58/v59 曾把它当"被盖"(修设置页冒条),但正常聊天里开任何下拉也会中招—— + * 条被误藏(用户实测)。现改判"可见":设置页场景由 inGhostSubtree 兜住 + * (3.11.x 设置壳必带 opacity-0+inert,不依赖命中测试),其余穿透场景 + * 都是真·未被盖(弹层在别处,输入框露着)。 */ + if (hit === document.body || hit === document.documentElement) return true; + if (hit === el || hit.contains(el) || el.contains(hit)) return true; // 命中自身/祖先/内部装饰层 + /* 悬浮气泡豁免(v65):非模态浮层(「剩余额度」hover 卡、下拉/选择器弹层——body + * 直挂、无遮罩)会压住输入框中心点,按旧判定悬停一超 400ms 迟滞整条就消失 + * (用户实测)。但条挂在输入框正下方,上方浮层与条并无几何冲突:命中元素只是 + * 部分遮挡(未把输入框矩形整体罩住)时仍判可见。整体罩住才维持"被盖"——模态 + * 全屏遮罩等真接管行为不变,模态弹窗/设置页另有 modalOpen/inGhostSubtree 收口。 */ + var hr = hit.getBoundingClientRect(); + if (hr.left > r.left || hr.top > r.top || hr.right < r.right || hr.bottom < r.bottom) return true; + if (ownOK && isOwnOverlay(hit)) return true; + return false; + } + function findComposer() { + var best = null; + document.querySelectorAll("textarea").forEach(function (ta) { + if (ta.closest && ta.closest('[role="dialog"]')) return; // v62:弹窗内的编辑框不是聊天输入框(模型配置弹窗的「推理参数映射」落在下半屏,issue #9) + if (inSettingsView(ta)) return; // 设置页(MCP 配置等)的编辑框不是聊天输入框 + if (!reallyVisible(ta)) return; + var r = ta.getBoundingClientRect(); + if (r.top < innerHeight * 0.45) return; // 聊天输入框特征:在视口下半部(排除设置页等处元素) + if (!best || r.top > best.getBoundingClientRect().top) best = ta; + }); + return best || deepFind(); + } + function collectDiag(extra) { + if (everMounted) return; /* 已成功挂载的窗口才允许写诊断,防后台空窗口覆盖 */ + if (Date.now() - (collectDiag.at || 0) < 5000) { if (extra) Object.assign(window.__zusageDiag, extra); return; } + collectDiag.at = Date.now(); + /* 只在 window.__zusageDiag 本体上原地更新,绝不整体替换对象: + * v14 曾用 Object.assign(d, ...) 换新对象,FATAL 仍指旧对象, + * 之后写入的 mount/trackErr 全进孤儿对象,diag 文件永远看不到(实测翻车)。 */ + var d = window.__zusageDiag; + d.time = new Date().toISOString(); + d.version = VERSION; + d.href = String(location.href).slice(0, 90); + var shallow = {}; + COMPOSER_SEL.split(",").forEach(function (sel) { + try { shallow[sel.trim()] = document.querySelectorAll(sel).length; } catch (e) { } + }); + d.shallow = shallow; + d.iframes = document.querySelectorAll("iframe").length; + var sr = 0; + (function walk(doc) { + if (!doc || !doc.querySelectorAll) return; + var all; + try { all = doc.querySelectorAll("*"); } catch (e) { return; } + for (var i = 0; i < all.length; i++) { + if (all[i].shadowRoot) { sr++; walk(all[i].shadowRoot); } + } + })(document); + d.shadowRoots = sr; + if (extra) Object.assign(d, extra); + } + + /* 每实例只归还自己占用的卡片,不能清掉其它 pane 的让位边距。 */ + var paddedCard = null, originalMargin = ""; + function releasePads() { + if (!paddedCard) return; + paddedCard.style.marginBottom = originalMargin; + delete paddedCard.dataset.zuCardPad; + paddedCard = null; + } + var CARD_MARGIN = "24px"; + function ensureCardPad() { + if (!cardCache) return; + if (paddedCard !== cardCache) { + releasePads(); + paddedCard = cardCache; + originalMargin = cardCache.style.marginBottom || ""; + cardCache.dataset.zuCardPad = originalMargin; + } + if (cardCache.style.marginBottom !== CARD_MARGIN) cardCache.style.marginBottom = CARD_MARGIN; + } + + /* 从 textarea 向上找视觉卡片容器(有可见边框或背景的最近祖先)——条的锚点 + 读原生总量按钮 */ + function isVisualBox(el) { + try { + var cs = getComputedStyle(el); + return cs.borderTopWidth !== "0px" || cs.backgroundColor !== "rgba(0, 0, 0, 0)"; + } catch (e) { return false; } + } + function cardOf(el) { + var cr = el.getBoundingClientRect(); + var p = el.parentElement, last = el, i = 0; + for (; p && p !== document.body && i < 8; p = p.parentElement, i++) { + var r = p.getBoundingClientRect(); + if (r.height > cr.height * 8 + 80) break; // 会话级大容器(高度异常) + if (isVisualBox(p)) return p; // 第一个有边框/背景的层 = 视觉卡片 + last = p; + } + return last; + } + /* 权限确认面板内的锚点改判:composer 被面板顶掉(display:none)后,面板里的 + * 反馈输入框是该 pane 唯一候选——它比面板卡矮得多,cardOf 的高度熔断会在到达面板卡 + * 之前 break,落到面板内部的选项/反馈包裹层,条就冒进面板里(最初的 bug:确认时条 + * 跑进对话框)。输入框带 data-permission-* 标记(或卡片自带的数据标记)时改判面板 + * 视觉卡:第一个「有边框且有背景」的祖先(选项按钮只有背景无边框会跳过,rounded-2xl + * 面板卡二者俱全)。 + * 普通输入框不走这条(closest 不命中),行为与 v62 完全一致。 */ + function isCardLike(el) { + try { + var cs = getComputedStyle(el); + return cs.borderTopWidth !== "0px" && cs.backgroundColor !== "rgba(0, 0, 0, 0)"; + } catch (e) { return false; } + } + function panelCardOf(el) { + try { + /* 权限面板与批准框(elicitation)同组件,卡片自带该标记(rounded-2xl 视觉卡), + * 直接锚卡片——选项行聚焦/填写时带底色也会被 cardOf 误判成视觉盒,锚了它条会落进卡片里 */ + var card = el.closest("[data-elicitation-dialog-card]"); + if (card) return card; + if (!el.closest('[data-permission-option-kind],[data-permission-feedback-option],[data-permission-rule-scopes]')) return null; + } catch (e) { return null; } + for (var p = el; p && p !== document.body; p = p.parentElement) { + if (isCardLike(p)) return p; + } + return null; + } + function anchorCardOf(el) { + return panelCardOf(el) || cardOf(el); + } + /* 读原生 UI 的上下文总量(输入框工具行按钮的文本/aria-label,如"…总量 1,000,000")。 + * 服务端下发、自动跟随模型,优先级高于 catalog 查表和 config fallback。 */ + var nativeCtx = { val: 0, at: 0 }; + function readNativeCtx(card) { + if (Date.now() - nativeCtx.at < 5000) return nativeCtx.val; + nativeCtx.at = Date.now(); + var els = card.querySelectorAll("button, [aria-label], [title]"); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var t = el.getAttribute("aria-label") || el.getAttribute("title") || el.textContent || ""; + var m = t.match(/总量\s*([\d,,]+)/); + if (m) { + nativeCtx.val = parseInt(m[1].replace(/[,,]/g, ""), 10); + return nativeCtx.val; + } + } + return nativeCtx.val; + } + + /* ---------- v92 原生上下文按钮增强(v93 起档位口径唯一承载处):环填充与右侧百分比 ---------- + * 按钮(chat-context-usage-trigger)与本条同 pane 同会话:数据取本实例 lastBand。 + * 稳定性依据:React 不清理非 props 的手动 data-* 属性,也不动 wrapper 末尾追加的 + * 节点(其 React children 只有 button);dasharray/dashoffset 属 React 管理,宿主 + * 数据更新瞬间会重写,由 render + heavy 双 reapply 兜回(≤600ms)。 + * cw 缺失时还原原生形态(按钮仍承载 Coding Plan/Start Plan 面板,不动其存在性)。 */ + var ZU_CTX_TRIGGER = '[data-testid="chat-context-usage-trigger"]'; + var ZU_CTX_CIRC = 62.83185307179586; // 原生环 r=10 的周长(还原原生 dashoffset 用) + function enhanceCtxButton() { + if (inactive()) return; + var btn = null; + try { btn = cardCache ? cardCache.querySelector(ZU_CTX_TRIGGER) : null; } catch (e) { } + if (!btn || !btn.parentElement || !lastBand || !lastBand.cw) { + if (ctxBtnCache) { restoreCtxButton(ctxBtnCache); ctxBtnCache = null; } + return; + } + ctxBtnCache = btn; + if (btn.getAttribute("data-zusage-armed") !== "1") btn.setAttribute("data-zusage-armed", "1"); + if (btn.getAttribute("data-zusage-band") !== lastBand.cls) btn.setAttribute("data-zusage-band", lastBand.cls); + var lite = bar.classList.contains("zu-light"); + syncCtxLite(btn, lite); + var svg = null; // 排除 .animate-spin:额度重置中 spinner 保留原生转圈,期间环回原生样式 + for (var i = 0; i < btn.children.length; i++) { + var k = btn.children[i]; + if (String(k.tagName || "").toLowerCase() === "svg" && !(k.classList && k.classList.contains("animate-spin"))) { svg = k; break; } + } + if (svg) { + var cs = svg.querySelectorAll("circle"); + var v = cs.length >= 2 ? cs[1] : null; + if (v) { + var da = Math.max(0, Math.min(100, lastBand.pct)).toFixed(1) + " 100"; + if (v.getAttribute("pathLength") !== "100") v.setAttribute("pathLength", "100"); + if (v.getAttribute("stroke-dasharray") !== da) v.setAttribute("stroke-dasharray", da); + if (v.getAttribute("stroke-dashoffset") !== "0") v.setAttribute("stroke-dashoffset", "0"); + if (v.getAttribute("stroke-linecap") !== "round") v.setAttribute("stroke-linecap", "round"); + } + } + var pctEl = null; + for (var j = 0; j < btn.children.length; j++) { + if (btn.children[j].classList && btn.children[j].classList.contains("zu-ctxpct")) { pctEl = btn.children[j]; break; } + } + if (!pctEl) { + pctEl = document.createElement("span"); + pctEl.className = "zu-ctxpct"; + btn.appendChild(pctEl); // 挂在 trigger 按钮内:悬停数值=悬停按钮,原生 hover 效果与「上下文容量」面板自动生效 + } else if (pctEl !== btn.children[btn.children.length - 1]) { + btn.appendChild(pctEl); // React 提交(如额度重置 loading 切换的 appendChild)可能把手动节点挤离末尾,挪回环右侧 + } + var txt = lastBand.pct.toFixed(1) + "%"; + var pcls = "zu-ctxpct pct " + lastBand.cls; + if (pctEl.textContent !== txt) pctEl.textContent = txt; + if (pctEl.className !== pcls) pctEl.className = pcls; + } + function syncCtxLite(el, lite) { + var has = el.getAttribute && el.getAttribute("data-zusage-light"); + if (lite && !has) el.setAttribute("data-zusage-light", "1"); + else if (!lite && has) el.removeAttribute("data-zusage-light"); + } + function restoreCtxButton(btn) { + if (!btn || !btn.isConnected) return; + btn.removeAttribute("data-zusage-armed"); + btn.removeAttribute("data-zusage-band"); + btn.removeAttribute("data-zusage-light"); + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i].classList && btn.children[i].classList.contains("zu-ctxpct")) { btn.children[i].remove(); break; } + } + var cs = btn.querySelectorAll("circle"); + var v = cs.length >= 2 ? cs[1] : null; + if (v) { + v.removeAttribute("pathLength"); + v.removeAttribute("stroke-linecap"); + /* 还原原生 dash 语义:aria-label「上下文已用 X / 总量 Y」推回 offset;拿不到就退 + * 空环(offset=周长),React 数据更新时会写回准确值。 */ + var ratio = 0, m = String(btn.getAttribute("aria-label") || "").match(/已用\s*([\d,,]+)\s*\/\s*总量\s*([\d,,]+)/); + if (m) { + var used = parseFloat(m[1].replace(/[,,]/g, "")), total = parseFloat(m[2].replace(/[,,]/g, "")); + if (used > 0 && total > 0) ratio = Math.min(Math.max(used / total, 0), 1); + } + v.setAttribute("stroke-dasharray", ZU_CTX_CIRC + " " + ZU_CTX_CIRC); + v.setAttribute("stroke-dashoffset", String(ZU_CTX_CIRC * (1 - ratio))); + } + } + + /* ---------- v96 套餐额度 chip:原生上下文按钮左侧 ---------- + * 挂载面与按钮增强同源:wrapper(tooltip-trigger span)的 React children 只有 button, + * 前插的手动节点不受 reconcile 清理;双 reapply(render 数据点 + heavy 对账点)兜回位置 + * 与内容;dispose / 关闭开关 / 无数据一律摘除。显示门控:开关开 + 本 pane 会话已有完成的 + * main_turn(provider_id 已知)+ provider 为内置套餐档;自定义 provider 隐藏,团队档给 + * 不支持提示。数据账号级(快照 quota 字段),同套餐换模型只换 tooltip 高亮不重查。 */ + var ZU_QUOTA_WARM = 80, ZU_QUOTA_HOT = 95; // 已用 % 档位(独立于上下文的 70/85) + function quotaBandOf(pct) { return pct >= 100 ? "exc" : pct >= ZU_QUOTA_HOT ? "hot" : pct >= ZU_QUOTA_WARM ? "warm" : "ok"; } + /* chip 分段:每个窗口一段(5小时/7天),各自按已用 % 上档位色;余额类型一段(阶段 3 数据源接入即用) */ + function quotaSegs(q) { + var w = q && q.windows, out = []; + if (!w) return out; + var defs = [["daily", "今日", "1d"], ["fiveHour", "5小时", "5h"], ["weekly", "7天", "7d"], ["monthly", "本月", "1mo"]]; + for (var i = 0; i < defs.length; i++) { + var e = w[defs[i][0]]; + if (e && typeof e.percent === "number") out.push({ label: L(defs[i][1], defs[i][2]), pct: e.percent }); + } + return out; + } + function quotaChipMode() { + if (!state.quotaEnabled) return null; + /* 会话有请求历史 → 跟随本会话最近完成的请求;无历史(新会话/刚切模型)→ 跟随快照 + * 顶层 provider_fallback(账号最近套餐档),消除切模型到首请求完成的 chip 空窗。 */ + var prov = effectiveQuotaProvider(); + if (!prov) return null; + if (prov.indexOf("account:") === 0 && prov.indexOf("team") >= 0) { + return { kind: "team", html: '⊘', aria: L("团队套餐额度暂不支持", "Team plan quota not supported yet") }; // 静态提示不依赖数据 + } + if (!lastQuota) return null; + /* 按 provider 选源:内置 account:* → family 源;自定义 → byProvider 映射(主进程 + * noteProvider 时记录),未映射的自定义退 custom 源。多窗异 provider 各取各的源。 */ + var q = lastQuota, st = q.status; + if (st === "not_configured" || st === "unsupported" || st === "disabled") return null; + var warn = (st !== "ok" && st !== "idle") ? '⚠' : ""; + var bal = q.balance; + if (bal && typeof bal.amount === "number" && isFinite(bal.amount)) { + var amt = quotaBalanceText(bal); + return { kind: "balance", stale: !!q.stale, + html: '' + (state.lang === "en" ? "" : "余额 ") + amt + "" + warn, + aria: L("余额 ", "Balance ") + amt }; + } + var segs = quotaSegs(q); + if (segs.length) { + var html = segs.map(function (s) { + return '' + s.label + " " + Math.round(s.pct) + "%"; + }).join('|'); + return { kind: "value", stale: !!q.stale, + html: html + warn, + aria: L("套餐已用 ", "Plan used ") + segs.map(function (s) { return s.label + " " + Math.round(s.pct) + "%"; }).join(L(",", ", ")) }; + } + var summary = q.quotaSummary; + if (summary && typeof summary.remaining === "number" && isFinite(summary.remaining)) { + var left = quotaBalanceText({ amount: summary.remaining, currency: summary.currency }); + return { kind: "quota", stale: !!q.stale, html: '' + L("额度 ", "Quota ") + left + "" + warn, + aria: L("Key 剩余额度 ", "Key quota remaining ") + left }; + } + if (st === "ok" || st === "idle") return null; // 还没有可显示的窗口(首查在途/接口回空) + return { kind: "error", html: '⚠', aria: quotaStatusText(st) || st }; + } + function onQuotaClick(e) { + e.preventDefault(); + e.stopPropagation(); + pluginRequest("quota-refresh", quotaSourceId ? { sourceId: quotaSourceId } : undefined); // 绕过 TTL 与软冻结 + var el = this; + el.classList.add("zu-qbusy"); + setTimeout(function () { el.classList.remove("zu-qbusy"); }, 1500); + } + function enhanceQuotaChip() { + if (inactive()) { restoreQuotaChip(); return; } + var mode = quotaChipMode(); + if (!mode) { restoreQuotaChip(); return; } + var btn = null; + try { btn = (ctxBtnCache && ctxBtnCache.isConnected) ? ctxBtnCache : (cardCache ? cardCache.querySelector(ZU_CTX_TRIGGER) : null); } catch (e) { } + if (!btn || !btn.parentElement) { restoreQuotaChip(); return; } + var wrap = btn.parentElement; + var chip = quotaChipCache; + if (!chip || !chip.isConnected || chip.parentElement !== wrap) { + if (chip && chip.isConnected) chip.remove(); + chip = null; + for (var i = 0; i < wrap.children.length; i++) { // 复用热更/重建遗留的同类节点,避免双 chip + if (wrap.children[i].classList && wrap.children[i].classList.contains("zu-quota")) { chip = wrap.children[i]; break; } + } + if (!chip) { + chip = document.createElement("span"); + chip.className = "zu-quota"; + chip.setAttribute("role", "status"); + chip.tabIndex = 0; + chip.addEventListener("mouseenter", onQuotaEnter); + chip.addEventListener("mouseleave", hideQuotaTip); + chip.addEventListener("focus", onQuotaEnter); + chip.addEventListener("blur", hideQuotaTip); + chip.addEventListener("click", onQuotaClick); + } + quotaChipCache = chip; + } + if (chip.nextElementSibling !== btn) wrap.insertBefore(chip, btn); // 位置兜回:始终紧贴按钮左侧 + syncCtxLite(chip, bar.classList.contains("zu-light")); + var srcLabels = {}; + for (var si = 0; si < (state.quotaSources || []).length; si++) srcLabels[state.quotaSources[si].id] = state.quotaSources[si].label; + var all = lastQuotaAll && lastQuotaAll.sources ? Object.assign({}, lastQuotaAll, { active: quotaSourceId }) : lastQuotaAll; + chip.__zuQ = { q: lastQuota, all: all, srcLabels: srcLabels, provider: quotaProvider, model: quotaModel, mode: mode }; + var sig = mode.kind + "|" + (mode.stale ? 1 : 0) + "|" + mode.html; + if (chip.__zuSig !== sig) { // 幂等:内容没变不重建,悬停/焦点态不被打断 + chip.__zuSig = sig; + chip.className = "zu-quota" + (mode.stale ? " zu-qstale" : ""); + chip.setAttribute("aria-label", mode.aria); + chip.innerHTML = mode.html; + } + if (quotaTipFor === chip) showQuotaTip(chip); // 悬停中数据刷新:内容跟随 + } + function restoreQuotaChip() { + if (!quotaChipCache) return; + if (quotaTipFor === quotaChipCache) hideQuotaTip(); + try { quotaChipCache.remove(); } catch (e) { } + quotaChipCache = null; + } + + /* ---------- v96 Git 状态 chip:挂「电脑操作」按钮(v4-composer-cua-entry)右侧 ---------- + * 样式移植 cc-pretty-statusline 的 git 段:⎇ 分支 ↑N ↓N (+插入行 -删除行 ?未跟踪文件)。 + * 数据:模块级 gitViewFor(scope.workspacePath)(主进程旁路查询推送);本 pane 的 scope 为远端 + * (identity 非空)/ 未知 / 非仓库 / 无 git 时不挂载。稳定性同 v92:自有节点插在 React children + * 之间(React 既不复用也不清理外来节点),位置由 render/heavy 双 reapply 兜回;内容签名幂等。 */ + var ZU_CUA_BTN = '[data-testid="v4-composer-cua-entry"]'; + var ZU_GIT_ICON = ''; + /* v96 multi:多仓库聚合态图标 = lucide folder-git 官方 path 数据(描边宽对齐 ZU_GIT_ICON 的 1.5) */ + var ZU_GIT_MULTI_ICON = ''; + function gitChipView() { // null = 本 pane 当前不该显示 chip + if (!state.show.git) return null; + var scope = readPaneScope(wantedEl, pickedSid, pid); + if (!scope || scope.identity || !scope.workspacePath) return null; + var v = gitViewFor(scope.workspacePath); + return v ? { path: scope.workspacePath, st: v.st, stale: v.stale } : null; + } + function gitChipHtml(view) { // view={path, st, stale}:stale 是工作区级过期标志(勿与闭包 stale() 函数混淆) + var st = view.st, stale = view.stale; + if (st.kind === "multi") { // v96 multi:仓库数 + ·分隔语义段(已知/上次前缀、!冲突、失败、过期);文件条目汇总在 tooltip 不上 chip + var agg = gitMultiAgg(st), parts = ['' + agg.total + L("仓", " repos") + ""]; + var prefix = agg.failed > 0 ? L("已知", "") : (agg.staleAny || stale) ? L("上次", "last ") : ""; + if (agg.changed > 0) parts.push('' + prefix + agg.changed + L("改", " changed") + ""); // 有改动仓库数;任一确认冲突时整段红(.zu-conflict) + if (agg.conflict > 0) parts.push('!' + agg.conflict + L("冲突", " conflicted") + ""); // 确认冲突的仓库数 + if (agg.failed > 0) parts.push('' + agg.failed + L("失败", " failed") + ""); + if (stale) parts.push('' + L("过期", "stale") + ""); // 过期用文字提示,不降透明度 + return ZU_GIT_MULTI_ICON + parts.join(' · '); + } + var name = st.detached ? "HEAD" : st.noCommits ? L("初始", "init") : String(st.branch || "HEAD"); + var shown = name.length > 18 ? name.slice(0, 16) + "…" : name; // 全名进 tooltip + var h = ZU_GIT_ICON + '' + gitEsc(shown) + ""; + var ab = (st.ahead > 0 ? "↑" + st.ahead : "") + (st.behind > 0 ? (st.ahead > 0 ? " " : "") + "↓" + st.behind : ""); + if (ab) h += '' + ab + ""; + var nums = []; + if (st.ins > 0) nums.push('+' + st.ins + ""); + if (st.del > 0) nums.push('-' + st.del + ""); + var d = nums.join(" ") + (nums.length && st.untracked > 0 ? ' · ' : "") + (st.untracked > 0 ? '?' + st.untracked + "" : ""); + if (d) h += '(' + d + ")"; // 无任何改动时整段不出现 + if ((st.conflicts | 0) > 0) h += ' !' + st.conflicts + L("冲突", " conflicted") + ""; + if (st.op) h += ' ' + gitEsc(gitOpLabel(st.op)) + ""; + if (stale) h += ' ' + L("过期", "stale") + ""; + return h; + } + function gitChipAria(st, stale) { + if (st.kind === "multi") { + var a = gitMultiAgg(st); + var ms = L("Git " + a.total + " 个仓库", "Git " + a.total + " repos"); + if (a.changed > 0 || a.failed > 0) ms += L("," + (a.failed > 0 ? "已知 " : "") + a.changed + " 个有改动", ", " + a.changed + " with changes"); + if (a.conflict > 0) ms += L("," + a.conflict + " 个仓库冲突", ", " + a.conflict + " repos conflicted"); + if (a.op > 0) ms += L("," + a.op + " 个操作中", ", " + a.op + " mid-operation"); + if (a.failed > 0) ms += L("," + a.failed + " 个查询失败", ", " + a.failed + " failed"); + if (stale) ms += L(",数据已过期", ", data stale"); + return ms; + } + var s = L("Git 分支 ", "Git branch ") + (st.detached ? "HEAD" : st.noCommits ? L("初始(尚无首次提交)", "init (no initial commit yet)") : st.branch); + if (st.ahead > 0) s += L(",领先 ", ", ahead ") + st.ahead; + if (st.behind > 0) s += L(",落后 ", ", behind ") + st.behind; + if (st.ins > 0 || st.del > 0) s += L(",改动 +", ", changes +") + (st.ins | 0) + " -" + (st.del | 0); + if (st.untracked > 0) s += L(",未跟踪 ", ", untracked ") + st.untracked; + if ((st.conflicts | 0) > 0) s += L(",! " + st.conflicts + " 个冲突文件", ", ! " + st.conflicts + " conflicted files"); + if (st.op) s += L(",进行中操作:", ", git operation: ") + gitOpLabel(st.op); + if (stale) s += L(",数据已过期", ", data stale"); + return s; + } + function onGitEnter() { showGitTip(this); } + function onGitClick(e) { + e.preventDefault(); + e.stopPropagation(); + askGitRefresh(true); // 主进程立即重查全部已关注路径(绕过快照节流) + var el = this; + el.classList.add("zu-gbusy"); + setTimeout(function () { el.classList.remove("zu-gbusy"); }, 1500); + } + function enhanceGitChip() { + if (inactive()) { restoreGitChip(); return; } + var view = gitChipView(); + if (!view) { restoreGitChip(); return; } + var btn = null; + try { btn = (cuaBtnCache && cuaBtnCache.isConnected) ? cuaBtnCache : (cardCache ? cardCache.querySelector(ZU_CUA_BTN) : null); } catch (e) { } + if (!btn || !btn.parentElement) { restoreGitChip(); return; } + cuaBtnCache = btn; + var wrap = btn.parentElement; + var chip = gitChipCache; + if (!chip || !chip.isConnected || chip.parentElement !== wrap) { + if (chip && chip.isConnected) chip.remove(); + chip = null; + for (var i = 0; i < wrap.children.length; i++) { // 复用热更/重建遗留的同类节点,避免双 chip + if (wrap.children[i].classList && wrap.children[i].classList.contains("zu-git")) { chip = wrap.children[i]; break; } + } + if (!chip) { + chip = document.createElement("span"); + chip.className = "zu-git"; + chip.setAttribute("role", "status"); + chip.tabIndex = 0; + chip.addEventListener("mouseenter", onGitEnter); + chip.addEventListener("mouseleave", hideGitTipSoon); + chip.addEventListener("focus", onGitEnter); + chip.addEventListener("blur", hideGitTip); + chip.addEventListener("click", onGitClick); + } + gitChipCache = chip; + } + if (btn.nextSibling !== chip) wrap.insertBefore(chip, btn.nextSibling); // 位置兜回:始终紧贴按钮右侧(nextSibling 为 null 即追加末尾) + syncCtxLite(chip, bar.classList.contains("zu-light")); + var compact = btn.getAttribute("data-composer-compact") === "true"; // 宿主窄屏折叠:原生标签隐藏,chip 同步只留图标+计数 + chip.__zuGit = view; + var html = gitChipHtml(view); + var mAgg = view.st.kind === "multi" ? gitMultiAgg(view.st) : null; + var cls = "zu-git" + (compact ? " zu-compact" : "") + (view.st.kind === "multi" ? " zu-git-multi" : "") + (mAgg && mAgg.conflict > 0 ? " zu-conflict" : ""); // v96:形态类(紧凑态收起份数与聚合计数段)+ 确认冲突 → 冲突段转红(类进签名保持幂等);过期走文字徽章不降透明度 + var sig = cls + "|" + html + "|" + state.lang; + if (chip.__zuSig !== sig) { // 幂等:内容没变不重建,悬停/焦点态不被每 1.5s 的推送打断 + chip.__zuSig = sig; + var busy = chip.classList.contains("zu-gbusy"); + chip.className = cls + (busy ? " zu-gbusy" : ""); + chip.setAttribute("aria-label", gitChipAria(view.st, view.stale)); + chip.innerHTML = html; + } + if (gitTipFor === chip) showGitTip(chip); // 悬停中数据刷新:内容跟随 + } + function restoreGitChip() { + if (!gitChipCache) return; + if (gitTipFor === gitChipCache) hideGitTip(); + try { gitChipCache.remove(); } catch (e) { } + gitChipCache = null; + } + + /* ---------- v105 压缩上下文:「上下文容量」弹层内、命中率行下方的胶囊按钮 ---------- + * 弹层(hover-card-content)关即卸载(Radix 无 forceMount),每次打开全新挂载——注入 + * 节点随之销毁,靠 body MutationObserver(即时)+ heavy 对账点(600ms 兜底)双触发重注 + * 入;以 DOM 存在性幂等,多实例并发安全。打开期间 React 只原地 patch,不会删除外来 + * 兄弟节点,最多相对移位(新 border-t 段出现时),同步时位置兜回(同 quota chip 3765 套路)。 + * 面板判别双指纹:!w-80(contextPanelWidthClass,全仓库唯一 important 宽)+ 内含 ui + * Progress;插入锚=ContextContentBody(div.space-y-3)内「剩余额度」border-t 段之前 + * (命中率行在 <78% 时条件隐藏,锚定 border-t 段不受影响),缺则容器尾(仍是命中率行下)。 + * 确认后发送 /compact:优先 fiber 上原生残留的 onSendCompressionCommand(SessionPane + * 压缩入口,最终 dispatchCommand("compact"),与手敲 /compact 同链、不动草稿、自带 toast); + * 缺失回落官方 E2E 输入桥(生产客户端无条件挂载)__zcodeLexicalInputE2E.setText + + * 发送键。Lexical 的 contenteditable 直写不进 EditorState,该路径禁用;兜底通道会清写 + * 输入框,草稿非空一律拒绝。pane 归属:按几何就近的可见触发按钮(分栏各拿各的 fiber)。 */ + var ZU_HC_SEL = '[data-slot="hover-card-content"]'; + var compactDlg = null, compactDlgKey = null, compactAnchor = null, compactFn = null; + function nearestByRect(anchor, sel) { // 可见元素中矩形中心离 anchor 最近者;隐藏 pane 不参与 + var list = [], r0 = null; + try { + list = document.querySelectorAll(sel); + r0 = anchor && anchor.getBoundingClientRect ? anchor.getBoundingClientRect() : null; + } catch (e) { return null; } + var best = null, bd = Infinity; + var cx = r0 ? r0.left + r0.width / 2 : 0, cy = r0 ? r0.top + r0.height / 2 : 0; + for (var i = 0; i < list.length; i++) { + var r = null; + try { r = list[i].getBoundingClientRect(); } catch (e) { } + if (!r || r.width <= 0 || r.height <= 0) continue; + var dx = r.left + r.width / 2 - cx, dy = r.top + r.height / 2 - cy; + var d = dx * dx + dy * dy; + if (d < bd) { bd = d; best = list[i]; } + } + return best; + } + function fiberPropFrom(el, prop) { // 沿 __reactFiber$ 向上找 memoizedProps(限深 60 层) + try { + var ks = Object.keys(el), fk = ""; + for (var i = 0; i < ks.length; i++) { + if (ks[i].indexOf("__reactFiber$") === 0) { fk = ks[i]; break; } + } + if (!fk) return null; + var f = el[fk], n = 0; + while (f && n++ < 60) { + if (f.memoizedProps && typeof f.memoizedProps[prop] === "function") return f.memoizedProps[prop]; + f = f.return; + } + } catch (e) { } + return null; + } + function syncCompactBtn() { + if (inactive()) return; + var hcs = []; + try { hcs = document.querySelectorAll(ZU_HC_SEL); } catch (e) { return; } + for (var i = 0; i < hcs.length; i++) { + var body = null, anchor = null; + try { + if (!hcs[i].classList.contains("!w-80")) continue; + if (!hcs[i].querySelector('[data-slot="progress"]')) continue; + body = hcs[i].querySelector("div.space-y-3"); + } catch (e) { continue; } + if (!body) continue; + try { + for (var j = 0; j < body.children.length; j++) { + /* 「剩余额度」段=border-t+pt-2(CodingPlanContextUsage/StartPlanContextBalance 的 + * separated 分支);命中率行在 breakdown 存在时也带 border-t 但是 pt-3,须双类名 + * 判别,否则按钮会插到命中率行上方。 */ + var cls2 = body.children[j].classList; + if (cls2 && cls2.contains("border-t") && cls2.contains("pt-2")) { anchor = body.children[j]; break; } + } + } catch (e) { } + var exist = null; + for (var k = 0; k < body.children.length; k++) { + if (body.children[k].classList && body.children[k].classList.contains("zu-compact-btn")) { exist = body.children[k]; break; } + } + if (exist) { // 幂等 + 位置兜回:紧贴「剩余额度」段上方(无则容器尾) + var misplaced = anchor ? exist.nextElementSibling !== anchor : body.lastElementChild !== exist; + if (misplaced) body.insertBefore(exist, anchor); + continue; + } + var b = document.createElement("button"); + b.type = "button"; + b.className = "zu-compact-btn"; + b.textContent = L("压缩上下文", "Compact context"); + b.addEventListener("click", onCompactClick); + body.insertBefore(b, anchor); + } + } + function closeCompactDlg() { + if (compactDlgKey) { + try { document.removeEventListener("keydown", compactDlgKey, true); } catch (e) { } + compactDlgKey = null; + } + if (compactDlg) { try { compactDlg.remove(); } catch (e) { } compactDlg = null; } + compactAnchor = null; compactFn = null; + } + function openCompactDlg(anchorBtn) { + closeCompactDlg(); + compactAnchor = anchorBtn || null; + /* fiber 提交函数只在打开瞬间解析(此刻弹层必存活、锚点几何可信);确认时弹层可能 + * 已被 Radix 卸载(指针移入弹框),届时重解析会退化为「离视口左上角最近」, + * 多分栏窗口会把 /compact 发进错误 pane 的会话(v110 修复)。 */ + var trig = null, label = "", fn = null; + try { + trig = nearestByRect(compactAnchor, ZU_CTX_TRIGGER); + label = trig ? String(trig.getAttribute("aria-label") || "") : ""; + fn = trig ? fiberPropFrom(trig, "onSendCompressionCommand") : null; + } catch (e) { } + compactFn = fn || null; + var m = label.match(/([\d,,]+)\s*\/\s*总量\s*([\d,,]+)/) || label.match(/([\d,]+)\s+of\s+([\d,]+)/i); + var dlg = document.createElement("div"); + dlg.className = "zu-compact-dlg"; + var card = document.createElement("div"); + card.className = "zu-compact-card"; + var title = document.createElement("div"); + title.className = "zu-compact-title"; + title.textContent = L("压缩上下文", "Compact context"); + var desc = document.createElement("div"); + desc.className = "zu-compact-desc"; + if (m) { + var b1 = document.createElement("b"); + b1.textContent = L("当前已用 " + m[1] + " / 总量 " + m[2], "Used " + m[1] + " of " + m[2]); + desc.appendChild(b1); + desc.appendChild(document.createTextNode(L("。将把会话历史总结压缩以释放上下文窗口,压缩后上下文占比会回落。", ". The conversation history will be summarized to free context window space; usage drops afterwards."))); + } else { + desc.textContent = L("将把会话历史总结压缩以释放上下文窗口,压缩后上下文占比会回落。", "The conversation history will be summarized to free context window space; usage drops afterwards."); + } + var hint = document.createElement("div"); + hint.className = "zu-compact-hint"; + hint.textContent = L("压缩通常需要 2-4 分钟且没有进度反馈,期间请勿发送新消息或点停止——那会取消压缩。", "Compaction usually takes 2–4 minutes with no progress indicator. Don't send new messages or stop during it — that cancels it."); + var err = document.createElement("div"); + err.className = "zu-compact-err"; + var foot = document.createElement("div"); + foot.className = "zu-compact-foot"; + var cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "zu-compact-cancel"; + cancel.textContent = L("取消", "Cancel"); + var ok = document.createElement("button"); + ok.type = "button"; + ok.className = "zu-compact-ok"; + ok.textContent = L("压缩", "Compact"); + cancel.addEventListener("click", closeCompactDlg); + dlg.addEventListener("mousedown", function (e) { + if (e.target === dlg) closeCompactDlg(); // 点遮罩关闭(卡片内点击不关) + }); + ok.addEventListener("click", function () { + if (ok.disabled) return; + ok.disabled = true; + var fail = compactSend(compactAnchor || ok); + if (fail) { // 失败:弹框保留并显示中性错误文案,不静默不关闭 + ok.disabled = false; + err.textContent = fail; + err.classList.add("on"); + return; + } + closeCompactDlg(); + }); + compactDlgKey = function (e) { + if (!compactDlg) return; + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + closeCompactDlg(); + } else if (e.key === "Enter" && !ok.disabled) { + var f = null; + try { f = document.activeElement; } catch (e2) { } + if (f && dlg.contains && !dlg.contains(f)) return; // 焦点已逸出弹框(无焦点陷阱):不劫持宿主 Enter + e.preventDefault(); + e.stopPropagation(); + ok.click(); + } + }; + document.addEventListener("keydown", compactDlgKey, true); // 捕获先行,拦下宿主对 Esc/Enter 的处理 + foot.appendChild(cancel); + foot.appendChild(ok); + card.appendChild(title); + card.appendChild(desc); + card.appendChild(hint); + card.appendChild(err); + card.appendChild(foot); + dlg.appendChild(card); + document.body.appendChild(dlg); + compactDlg = dlg; + try { ok.focus(); } catch (e) { } + } + function compactSend(anchorEl) { // 返回 "" = 已提交;否则错误文案 + /* 主通道(v109 用户指定「直接调发送会话的接口」;v110 改在打开瞬间解析缓存): + * fiber 上原生残留的 onSendCompressionCommand——SessionPane 自己的程序化压缩入口, + * 直接 dispatchCommand("compact") 命令信封,不碰输入框/发送键,流式中也交给引擎 + * 排队。摘要请求非流式(DB 历史 72s-9.6min、典型 2-4 分钟),hint 据此措辞。 + * 兜底:字面 "/compact" 输入提交(E2E 桥 setText+发送键)——流式/发送键/草稿 + * 三重门控全部前置到 setText 之前(拒绝不留残迹),仅当 fiber 通道缺失时使用。 */ + if (compactFn) { + try { compactFn("/compact"); return ""; } catch (e) { FATAL.cptErr = String((e && e.stack) || e); } + } + var input = nearestByRect(anchorEl, '[data-testid="v4-composer-input"]'); + var api = null; + try { api = input ? input.__zcodeLexicalInputE2E : null; } catch (e) { } + if (input && api && input.getAttribute("data-e2e-lexical-bridge") === "ready") { + try { + if (nearestByRect(anchorEl, '[data-testid="v4-stop"]')) return L("正在生成回复,请等待完成后再压缩", "A reply is being generated; wait for it to finish before compacting"); + var send = nearestByRect(anchorEl, '[data-testid="v4-composer-send"]'); + if (!send || send.disabled) return L("发送键当前不可用(可能正在生成回复)", "Send button is unavailable right now (a reply may be running)"); + var draft = ""; + try { draft = String(api.getText() || ""); } catch (e) { } + if (draft.replace(/\s+/g, "")) return L("输入框有未发送草稿,请先发送或清空后再压缩", "The composer has an unsent draft; send or clear it before compacting"); + api.setText("/compact"); + send.click(); + return ""; + } catch (e) { FATAL.cptErr = String((e && e.stack) || e); return L("发送失败,请重试", "Send failed; please try again"); } + } + return L("未找到可用的提交通道(需 ZCode 桌面端)", "No available submit channel (ZCode desktop required)"); + } + function onCompactClick(e) { + if (inactive()) return; // 停用实例的残留按钮(新代际清扫前的窗口期)无声失效 + e.preventDefault(); + e.stopPropagation(); // 弹层内点击不外泄(Radix/宿主不响应这次 click) + openCompactDlg(e.currentTarget); + } + + /* ---------- v80 in-card 挂载:条挂输入卡片内部、group/toolbar 工具行之后自占一行 ---------- + * 宿主 ChatPromptEditor 的工具行是卡片(flex-col gap-3)的最后一个子块;条作为其兄弟 + * 节点插在后面,卡片纯自适应高度自然长高(12px 间距 + 条高),与对话框融为一体。 + * 工具行 Tailwind 类名含 : / [ ] 特殊字符,querySelector 转义易碎 → 按直接子节点 + * className 子串匹配(同 zuDetAnchor 套路)。找不到工具行 = below-card 兜底形态。 */ + function toolbarRowOf(card) { + try { + var kids = card.children; + for (var i = kids.length - 1; i >= 0; i--) { + if (String(kids[i].className || "").indexOf("group/toolbar") >= 0) return kids[i]; + } + } catch (e) { } + return null; + } + function mountInCard() { + var row = cardCache ? toolbarRowOf(cardCache) : null; + if (!row) return false; + try { + if (bar.parentElement !== cardCache) { + var ref = row.nextSibling; + if (ref && ref.parentElement === cardCache) cardCache.insertBefore(bar, ref); + else cardCache.appendChild(bar); + } + if (!bar.classList.contains("zu-in")) { + bar.classList.add("zu-in"); + /* .zu-in 管不住内联声明:摘掉兜底形态写入的悬浮样式与锁宽 */ + bar.style.position = ""; bar.style.zIndex = ""; + bar.style.left = ""; bar.style.top = ""; bar.style.minWidth = ""; bar.style.maxWidth = ""; + lastPos = [-1, -1]; + } + return true; + } catch (e) { return false; } + } + function mountBelowCard() { // 兜底还原:无工具行(宿主改版/异形布局)时回到旧悬浮形态 + if (!bar.classList.contains("zu-in")) return; + bar.classList.remove("zu-in"); + bar.style.position = "fixed"; bar.style.zIndex = "50"; + document.body.appendChild(bar); + lastPos = [-1, -1]; + } + + /* ---------- 定位:重活低频(找输入框/找卡片),逐帧只做矩形跟随 ---------- */ + var composer = null, cardCache = null, everMounted = false, lastMountDiagAt = 0; + var curDisplay = "none", lastPos = [-1, -1], hideSince = 0; + /* v80:true=条挂在输入卡片内部(group/toolbar 工具行之后);false=旧 below-card 悬浮兜底 */ + var cardMode = false; + + function setComposer(el) { + releasePads(); // 旧卡片的让位边距先还原(in-card 形态不需要让位带;兜底形态由 heavy 补回) + composer = el; + cardCache = null; + cardMode = false; + if (composer) { + var c = anchorCardOf(composer); + if (c && c !== document.body) cardCache = c; + } + if (bar.classList.contains("zu-in")) mountBelowCard(); // 换卡先回落兜底形态,heavy 同 tick 重判挂载 + } + function heavy() { + if (inactive()) return; + if (pid === "auto") { + /* 兜底模式(主文档找不到带 pane 标记的输入框,shadow/iframe 场景):沿用原 + * 单条查找链路(findComposer 含 deepFind 穿透),会话判定走 mine/pickCurrent */ + var el = findComposer(); + if (el && el !== composer) setComposer(el); + else if (!el && composer && !composer.isConnected) setComposer(null); + } else { + /* v61.1 多实例:composer 由协调器按 pane 派发(wantedEl),本层只对账—— + * 元素被 React 重建/pane 关闭时清绑,等下一轮协调器重新派发 */ + if (wantedEl && wantedEl.isConnected) { + if (wantedEl !== composer) setComposer(wantedEl); + } else if (composer) { + setComposer(null); + } + var nextScope = readPaneScope(wantedEl, pendingSid, pid); + var nextScopeStamp = JSON.stringify(nextScope); + if (pendingSid !== instSid || nextScopeStamp !== scopeStamp) { + instSid = pendingSid; + scopeStamp = nextScopeStamp; + if (state.data) render(state.data); + refreshWants(); + } + } + if (!composer && slot === 0) collectDiag(); // 一直找不到输入框的窗口:周期性写环境诊断(everMounted 门防覆盖) + if (composer) { + if (!cardCache || !cardCache.isConnected) { + var c = anchorCardOf(composer); + cardCache = c && c !== document.body ? c : null; + } + if (cardCache) { + /* v80:优先挂输入卡片内部(工具行下);无工具行回落 below-card 兜底 + 让位带 */ + cardMode = mountInCard(); + if (cardMode) releasePads(); + else { mountBelowCard(); ensureCardPad(); } // React 重渲染可能重建卡片/重置内联边距,对比后再补 + nativeCtxVal = readNativeCtx(cardCache); + } else if (cardMode) { + mountBelowCard(); + } + } + enhanceCtxButton(); // v92:DOM 对账点(React 重渲染/按钮重建后兜回,幂等) + enhanceQuotaChip(); // v96:额度 chip DOM 对账点(按钮重建/前插节点被挤走后兜回) + enhanceGitChip(); // v96:同点对账(React 重建工具行后重挂/位置兜回/紧凑态切换) + syncCompactBtn(); // v105:弹层压缩按钮对账点(Observer 漏触发/节点移位后兜回) + if (state.data && pid === "auto") { + var msid = typeof state.data.mine === "string" ? state.data.mine : null; + var pSid; + if (msid !== null) pSid = msid; // mine 模式:泵会在会话切换时推新 payload,这里只需同步 pickedSid + else { + var pc = pickCurrent(state.data); + pSid = pc ? (pc.sess ? pc.sess.sid : pc.want) : ""; + } + if (pSid !== pickedSid) render(state.data); // 切换了会话:立即按缓存 payload 重渲染 + } + } + function hideBar() { + if (curDisplay !== "none") { bar.style.display = "none"; curDisplay = "none"; } + /* v80:面板挂 body 不再随条隐藏,条隐时必须主动收起(否则面板悬空在已消失的条上方) */ + panel.classList.remove("open"); + subPanel.classList.remove("open"); + hideTip(true, "bar-hidden"); // 条面整体隐藏时 tooltip 必须跟着走(force 绕过总闸) + } + /* 超限气泡同步(track 每帧调):位置贴条上沿、左缘对齐;条隐藏/超限解除/已关闭则藏。 + * exc 解除时复位 excGone,下次再超限会重新弹。display 切换与测量在同一同步块内 + * 完成后才绘制,无首帧闪位。 */ + function syncExcBubble() { + var show = excOn && !excGone && curDisplay === "flex"; + if (!show) { + if (!excOn) excGone = false; + if (excBubble.style.display !== "none") excBubble.style.display = "none"; + return; + } + var sidStr = pickedSid || L("(未知)", "(unknown)"); + if (excSid.textContent !== sidStr) excSid.textContent = sidStr; + if (excBubble.style.display !== "block") excBubble.style.display = "block"; + var br = bar.getBoundingClientRect(); + var r = excBubble.getBoundingClientRect(); + var left = Math.max(8, Math.round(Math.min(br.left, innerWidth - r.width - 8))); + var top = Math.max(8, Math.round(br.top - r.height - 8)); + if (excBubble.style.left !== left + "px") excBubble.style.left = left + "px"; + if (excBubble.style.top !== top + "px") excBubble.style.top = top + "px"; + } + /* 可见性判定(带卡片豁免):输入框视觉卡片内部的装饰层(占位符/镜像/焦点层等) + * 瞬时盖到输入框中心不算"被盖住"——逐帧严格判定会造成显示/隐藏来回横跳(闪烁); + * 设置页等真正的覆盖层不在卡片内,仍判为盖住。 + * 条外挂在输入框上方,不参与对输入框中心的遮挡(v12-v14 内嵌形态的自遮挡闪烁根因)。 */ + function coverOK(el) { + var r = el.getBoundingClientRect(); + var hit; + try { hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); } catch (e) { return true; } + if (!hit) return false; + if (hit === el || hit.contains(el) || el.contains(hit)) return true; + if (cardCache && cardCache.contains(el) && cardCache.contains(hit)) return true; + if (isOwnOverlay(hit)) return true; // 自己的面板/气泡盖住输入框中心不算被盖(v40) + return false; + } + /* 隐藏态诊断(v61):条被判定不可见时周期性转储现场 —— body 直接子层的矩形/ + * pointer-events/透明度、elementFromPoint 命中链、幽灵子树判定与 body 的 pe 状态。 + * 定位"正常聊天窗口被误藏"类问题用;节流 4s,只写 __zusageDiag.hidden + * (泵取回写 diag-.json)。 */ + var lastHiddenDiagAt = 0; + function elDesc(el) { + if (!el) return String(el); + var cls = el instanceof Element ? (el.getAttribute("class") || "") : ""; + return el.tagName + (el.id ? "#" + el.id : "") + (cls ? "." + String(cls).slice(0, 60) : ""); + } + function diagHidden(r) { + var now = Date.now(); + if (now - lastHiddenDiagAt < 4000) return; + lastHiddenDiagAt = now; + var cx = r.left + r.width / 2, cy = r.top + r.height / 2; + var hit = null, chain = [], bodyPE = ""; + try { + hit = document.elementFromPoint(cx, cy); + chain = (document.elementsFromPoint(cx, cy) || []).slice(0, 8).map(elDesc); + bodyPE = getComputedStyle(document.body).pointerEvents; + } catch (e) { } + var kids = []; + try { + var cs = document.body.children; + for (var i = 0; i < cs.length; i++) { + var el = cs[i], kr = el.getBoundingClientRect(), st = getComputedStyle(el); + kids.push({ + el: elDesc(el), + rect: [Math.round(kr.left), Math.round(kr.top), Math.round(kr.width), Math.round(kr.height)], + pe: st.pointerEvents, op: st.opacity, vis: st.visibility, disp: st.display, + covers: (kr.width >= 4 && kr.height >= 4 && cx >= kr.left && cx <= kr.right && cy >= kr.top && cy <= kr.bottom) ? 1 : 0, + }); + } + } catch (e) { } + FATAL.hidden = { + time: new Date().toISOString(), + ghost: inGhostSubtree(composer), + modal: modalOpen(), // v62:模态弹窗判定(弹窗打开时条本就该收起,用于区分"误藏"与"应藏") + bodyPE: bodyPE, + hit: elDesc(hit), + chain: chain, + composerCenter: [Math.round(cx), Math.round(cy)], + composerRect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)], + bodyKids: kids, + }; + } + /* 分屏现场诊断(v61):转储全部可见 textarea 的祖先链(含 data-* 属性)、全页 + * iframe 的矩形与 src、pane/layout 相关 localStorage 键——用于确认"pane → 工作区/ + * 会话"能否从 DOM 映射(多实例状态条的数据源设计依据)。随 mount 诊断每 5s 刷新。 */ + function diagPanes() { + try { + var composers = []; + document.querySelectorAll(COMPOSER_SEL + ",textarea").forEach(function (ta) { + if (!visible(ta)) return; + var r = ta.getBoundingClientRect(); + if (r.top < innerHeight * 0.4) return; + var sess = null, pane = null, tag = elDesc(ta); + for (var p = ta; p && p !== document.body; p = p.parentElement) { + if (sess === null && p.hasAttribute && p.hasAttribute("data-session-id")) sess = p.getAttribute("data-session-id"); + if (pane === null && p.hasAttribute && p.hasAttribute("data-pane-id")) pane = p.getAttribute("data-pane-id"); + } + composers.push({ tag: tag, rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)], pane: pane, sess: sess }); + }); + var webviews = []; + document.querySelectorAll("webview,iframe").forEach(function (fr) { + var r = fr.getBoundingClientRect(); + webviews.push({ tag: fr.tagName, rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)], src: String(fr.src || "").slice(0, 130) }); + }); + var attrHits = []; + document.querySelectorAll("[data-session],[data-session-id],[data-workspace],[data-workspace-path],[data-pane],[data-pane-id],[data-task-id]").forEach(function (el) { + var parts = [el.tagName]; + for (var i = 0; i < el.attributes.length; i++) { + var a = el.attributes[i]; + if (/^data-(session|workspace|pane|task)/i.test(a.name)) parts.push(a.name + "=" + String(a.value).slice(0, 60)); + } + attrHits.push(parts.join(" ")); + }); + var lsPane = []; + try { + Object.keys(localStorage).forEach(function (k) { + if (/pane|split|layout|last-session/i.test(k)) lsPane.push(k + " = " + String(localStorage.getItem(k)).slice(0, 160)); + }); + } catch (e) { } + FATAL.panes = { time: new Date().toISOString(), composers: composers, webviews: webviews.slice(0, 10), attrHits: attrHits.slice(0, 30), lsPane: lsPane.slice(0, 20) }; + } catch (e) { FATAL.panes = { err: String((e && e.stack) || e) }; } + } + + function track() { + if (inactive()) return; // 旧实例罢工,把舞台让给新实例 + try { + syncExcBubble(); // 超限气泡:显示/隐藏/贴条定位(内部自判状态,隐藏分支零开销) + if (!composer || !composer.isConnected) { hideSince = 0; hideBar(); return; } + if (cardMode) { + /* v80 in-card 对账:React 整子树重挂(切会话 key 变化)会连外来节点一起丢, + * 帧率级检查补挂;卡片本身被重建时 cardCache 断连,heavy 低频重找后再挂 */ + if (!bar.isConnected || bar.parentElement !== cardCache) { + cardMode = mountInCard(); + if (!cardMode) mountBelowCard(); + } + } else if (cardCache && cardCache.style.marginBottom !== CARD_MARGIN) { + ensureCardPad(); // 流式期间 React 重置内联边距时立刻补回(仅 below-card 兜底形态) + } + var r = composer.getBoundingClientRect(); + var on = r.width > 60 && r.height > 14 && r.bottom > 0 && r.top < innerHeight && + !inGhostSubtree(composer) && // 设置页等幽灵壳(opacity-0/inert):视觉已隐藏(v61) + !inSettingsView(composer) && // v91:设置页排除的第四处收口——已绑到设置页编辑框时不显示(防其它绑定路径漏排除) + !modalOpen() && // v62:模态弹窗(模型配置等)打开时整体收起,弹窗内容不归条管(issue #9) + (reallyVisible(composer, true) || coverOK(composer)); + if (!on) { + if (slot === 0) diagHidden(r); // 隐藏现场周期转储(v61,节流 4s;仅 0 号实例写,槽位共享) + // 迟滞:连续 400ms 判定不可见才隐藏,瞬时失败(流式装饰层等)不闪 + if (!hideSince) hideSince = Date.now(); + if (Date.now() - hideSince > 400) { hideBar(); } + return; + } + hideSince = 0; + if (curDisplay !== "flex") { bar.style.display = "flex"; curDisplay = "flex"; } + /* 悬浮在输入框视觉卡片正下方(v58 定位回归):v55-v57 改贴窗口底,新对话 hero 页 + * 输入框垂直居中时条与输入框彻底脱钩(用户点名回退)。让位带 24px 比条高+顶缝 + * 短 ~7px,条底落进原生底部留白、距窗口底 ~7px —— v54 的"条下 15px 空白"不复发。 + * 水平仍与卡片左缘对齐 */ + var anchor = cardCache || composer; + var ar = anchor.getBoundingClientRect(); + if (!cardMode) { + /* 悬浮在输入框视觉卡片正下方(v58 定位回归):v55-v57 改贴窗口底,新对话 hero 页 + * 输入框垂直居中时条与输入框彻底脱钩(用户点名回退)。让位带 24px 比条高+顶缝 + * 短 ~7px,条底落进原生底部留白、距窗口底 ~7px —— v54 的"条下 15px 空白"不复发。 + * 水平仍与卡片左缘对齐。v80 起仅 below-card 兜底形态走这里。 */ + var left = Math.round(ar.left); + var top = Math.max(8, Math.min(Math.round(ar.bottom + 4), innerHeight - bar.offsetHeight - 2)); + if (left !== lastPos[0] || top !== lastPos[1]) { + bar.style.left = left + "px"; bar.style.top = top + "px"; + lastPos = [left, top]; + } + /* 宽度锁死卡片(v63 对齐,v66 收缩):min=max-width = 卡片实测宽(border-box, + * 外缘含边框正好压在卡片两缘上),任何内容量下条都与会话框两缘对齐。 */ + var minW = Math.max(60, Math.round(ar.width)); + if (bar.style.minWidth !== minW + "px") bar.style.minWidth = minW + "px"; + if (bar.style.maxWidth !== minW + "px") bar.style.maxWidth = minW + "px"; + } + /* in-card 形态(v80):无坐标跟随、无锁宽——宽度由卡片 flex-col 的 stretch 撑满内宽。 + * 两种形态共用的收缩判定:内容超宽不撑破卡片,分级收缩:先隐藏「本轮/会话累计」 + * 的缓存命中率(核心项 tps/上下文/会话累计不动,明细仍在悬停 tooltip), + * 迟滞 +12px/-120px 防数值刷新在阈值附近来回闪;仍装不下由 .zu-main 右缘渐隐 + * 兜底(.zu-clip)。测量基准都是 bar 自身内容宽,形态无关。 */ + var ov = main.scrollWidth - main.clientWidth; + var ns = shrink; + if (!shrink && ov > 12) ns = 1; + else if (shrink && ov < -120) ns = 0; + if (ns !== shrink) { shrink = ns; render(state.data || { session: {}, last_turn: {}, today: {}, context_window: 0 }); } + var clip = main.scrollWidth > main.clientWidth + 1; + if (clip !== fadeOn) { fadeOn = clip; main.classList.toggle("zu-clip", clip); } + positionSettingsPanel(); + positionSubPanel(); + everMounted = true; + if (slot === 0 && Date.now() - lastMountDiagAt > 5000) { + lastMountDiagAt = Date.now(); + var br = bar.getBoundingClientRect(), bh = null; + try { bh = document.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2); } catch (e) { } + FATAL.mount = { + version: VERSION, mode: cardMode ? "in-card" : "below-card", bodyPE: (function () { try { return getComputedStyle(document.body).pointerEvents; } catch (e) { return ""; } })(), + panes: diagPanes(), + theme: themeDark ? "dark" : "light", + /* 字体跟随(v64)实测:条面计算字体 + 客户端两个字体变量的原值(验证 var 命中) */ + font: (function () { + try { + var rs = getComputedStyle(document.documentElement), cs = getComputedStyle(bar); + return { fam: String(cs.fontFamily).slice(0, 90), size: cs.fontSize, + mono: String(rs.getPropertyValue("--font-mono")).slice(0, 80).trim(), + ui: String(rs.getPropertyValue("--ui-font-size")).trim() }; + } catch (e) { return "ERR:" + e; } + })(), + tip: (function () { return { stats: tipStats, disp: tip.style.display, barRect: (function () { var b = bar.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.top), Math.round(b.width), Math.round(b.height)]; })() }; })(), + composerRect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)], + anchorRect: [Math.round(ar.left), Math.round(ar.top), Math.round(ar.width), Math.round(ar.height)], + barRect: [Math.round(br.left), Math.round(br.top), Math.round(br.width), Math.round(br.height)], + /* 条中心命中元素:若被应用层盖住(z 序问题)此处直接暴露 */ + barHit: bh ? bh.tagName + "." + String(bh.className).slice(0, 40) : String(bh), + bottomGap: Math.round(innerHeight - ar.bottom), + nativeCtx: nativeCtxVal, + quota: { providerId: effectiveQuotaProvider(), modelId: quotaModel, sourceId: quotaSourceId, + selected: !!quotaSelection, status: lastQuota ? lastQuota.status : "unavailable", chip: !!quotaChipCache }, + picked: pickedSid, + exc: excOn ? (excGone ? "on-gone" : "on") : 0, + href: String(location.href).slice(0, 120), + lsVals: (function () { + try { + var out = []; + Object.keys(localStorage).forEach(function (k) { + out.push(k + " = " + String(localStorage.getItem(k)).slice(0, 90)); + }); + var wk = []; + Object.keys(window).forEach(function (k) { + if (/zcode|session|store|state|tab/i.test(k)) wk.push(k); + }); + out.push("WINDOW_KEYS=" + wk.join("|").slice(0, 300)); + return out.join("\n").slice(0, 7000) || "(none)"; + } catch (e) { return "ERR:" + e; } + })(), + lastSessionVals: (function () { + try { + var out = []; + Object.keys(localStorage).forEach(function (k) { + if (/last-session/i.test(k)) out.push(k.slice(22) + "=" + String(localStorage.getItem(k)).slice(0, 50)); + }); + return out.join(" | ").slice(0, 1000) || "(none)"; + } catch (e) { return "ERR:" + e; } + })(), + }; + } + } catch (e) { + if (Date.now() - (track.errAt || 0) > 5000) { + track.errAt = Date.now(); + FATAL.trackErr = String((e && e.stack) || e); + } + } finally { + if (!inactive()) frame = requestAnimationFrame(track); // 自调度循环:异常绝不能杀死循环(setInterval 无此问题,rAF 有) + } + } + + heavyTimer = setInterval(heavy, 600); + heavy(); + frame = requestAnimationFrame(track); + /* v105:弹层压缩按钮的即时重注入观察器。弹层每次打开/关闭都是 body 子树变异,rAF 去抖 + * 合并同帧爆发;自身注入也会触发下一轮回查(存在性幂等,无环)。异常只降级为 heavy 兜底。 */ + var hcObserver = null, hcObsQueued = false; + try { + hcObserver = new MutationObserver(function () { + if (hcObsQueued) return; + hcObsQueued = true; + requestAnimationFrame(function () { + hcObsQueued = false; + syncCompactBtn(); + }); + }); + hcObserver.observe(document.body, { childList: true, subtree: true }); + } catch (e) { FATAL.cptErr = String(e); } + + syncPanel(); + if (state.data) render(state.data); // 已有共享数据(后 spawn 的实例)直接渲染,不用零值覆盖 + else render({ session: {}, last_turn: {}, today: {}, context_window: 0 }); + + function dispose() { + if (instanceDisposed) return; + instanceDisposed = true; + clearInterval(heavyTimer); + clearInterval(bridge.timer); // v71:pane 销毁即停能力检测并释放 store 订阅 + clearInterval(subRateTimer); // v97:子代理面板速率刷新定时器 + subRateTimer = 0; + bridgeDetach(bridge.handle); + cancelAnimationFrame(frame); + cancelTipGrace(); + if (excCopied) clearTimeout(excCopied.timer); + if (themeObserver) themeObserver.disconnect(); + if (quotaSelectionObserver) quotaSelectionObserver.disconnect(); + quotaSelectionObserver = null; quotaPaneRoot = null; quotaSelection = null; + if (hcObserver) hcObserver.disconnect(); // v105:停弹层压缩按钮观察器 + closeCompactDlg(); // v105:本实例打开的确认弹框与 Esc/Enter 监听一并摘除 + globalListeners.forEach(function (binding) { + binding[0].removeEventListener(binding[1], binding[2], binding[3]); + }); + globalListeners = []; + releasePads(); + if (ctxBtnCache) { restoreCtxButton(ctxBtnCache); ctxBtnCache = null; } // v92:还原原生按钮形态 + restoreQuotaChip(); // v95:摘除额度 chip(不留孤儿节点) + restoreGitChip(); // v96:摘掉本 pane 的 Git chip(含在显 tooltip) + cuaBtnCache = null; + wantedEl = composer = cardCache = null; + bar.remove(); + panel.remove(); // v80:面板挂 body,dispose 需单独摘除 + subPanel.remove(); + tip.remove(); + excBubble.remove(); + } + var api = { + pid: pid, slot: slot, dispose: dispose, + attach: function (el, sid) { // 协调器派发:pane 最靠下的可见输入框 + 其 data-session-id + if (inactive()) return; + wantedEl = el; + pendingSid = sid || ""; + heavy(); // 立即对账(composer 重建/pane 关闭的最快收敛路径) + }, + render: render, + syncUpdate: syncUpdate, + rebuild: function () { buildPanel(); rebuildExcBubble(); syncPanel(); }, + getSid: function () { return pickedSid || ""; }, + getQuotaSelection: function () { + if (inactive()) return null; + if (quotaSelection) return quotaSelection; + var sid = instSid || pickedSid || ""; + return sid ? { sid: sid, providerId: effectiveQuotaProvider(), modelId: quotaModel } : null; + }, + getScope: function () { return inactive() ? null : readPaneScope(wantedEl, pickedSid, pid); }, + gitTouch: function () { if (!inactive()) enhanceGitChip(); }, // v96:主进程推送 git 状态后即时对账(不等 heavy) + /* v71 确定性历史行数据源:DOM turn id(turnId 或 productTurnId 形态)→ tracker 统计 */ + statsFor: function (tid) { + var h = bridge.handle; + if (!h || h.dead || typeof tid !== "string") return null; + var s = h.tracker.get(tid); + if (!s) { + var key = h.alias[tid]; + if (key && key !== tid) s = h.tracker.get(key); + } + return s; + }, + /* v86 诊断:桥接绑定状态与当前快照窗口(行数/序号/代际)——重启后历史轮可见性取决于它 */ + bridgeInfo: function () { + var h = bridge.handle, info = { bound: !!(h && !h.dead), sid: h ? String(h.sid).slice(-8) : "", rebinds: bridge.rebinds || 0 }; + if (!info.bound) return info; + var snap = null; + try { var st = h.store.getState(); snap = st && st.snapshot; } catch (e) { info.err = String(e); } + if (snap) { + info.win = snap.rows && Array.isArray(snap.rows.window) ? snap.rows.window.length : null; + info.seq = snap.seq; + info.epoch = String(snap.logEpoch).slice(0, 12); + info.heads = 0; + try { snap.rows.window.forEach(function (r) { if (r && r.kind === "turnHeader") info.heads++; }); } catch (e) { } + } + return info; + }, + liveEl: function () { return liveEl; }, // 测试缝:实时段节点引用 + liveIconKind: function () { return liveIconCur; }, // 测试缝:当前 chip 图标(think/bolt/空) + }; + instances.push(api); + refreshWants(); + return api; + } +})(); diff --git a/external_plugins/zcode-token-usage-statusbar/overlay_install.py b/external_plugins/zcode-token-usage-statusbar/overlay_install.py new file mode 100644 index 0000000..0a05038 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/overlay_install.py @@ -0,0 +1,925 @@ +# -*- coding: utf-8 -*- +"""悬浮状态条的插件侧安装器(CLI + SessionStart hook 共用)。 + +CLI 用法(/install-overlay 与 /uninstall-overlay 背后就是它): + python3 overlay_install.py sync # 仅同步运行时文件,stdout 输出 JSON(不读取 asar) + python3 overlay_install.py install # 同步运行时文件 + 写入 asar 注入行(幂等) + python3 overlay_install.py remove # 剥离注入行,并写豁免标记(hook 不再提示/自动安装) + python3 overlay_install.py check # 查看注入状态、运行时目录与开关 + +hook 用法:hooks/session-start.py 调 run_hook(),stdout 只输出 hook 协议 JSON。 + +与仓库根 install.py 的关系:根目录文件是正本,本插件目录里的运行时文件是 +sync_plugin.py 生成的副本;本脚本把副本同步进稳定运行时目录 +~/.zcode/zcode-token-usage-statusbar/(mtime 保护较新手改,升级时以记录 hash 识别托管文件), +再对运行时目录执行 patch_install.set_runtime() + install()——注入行指向稳定目录而非 +插件缓存目录,插件升级换缓存目录不会让浮层失联。 + +测试接缝(环境变量):ZUSAGE_PLUGIN_RUNTIME / ZUSAGE_PLUGIN_ASAR 可把运行时目录和 +目标 asar 指到别处(自测用临时副本,不碰真机)。 + +自动安装开关(默认开,SessionStart hook 自动执行安装/修复): + 运行时目录 config.json 写 "plugin_auto_install_overlay": false 可关闭; + 配置损坏或字段不是布尔值时不自动安装,环境变量 ZUSAGE_PLUGIN_AUTO_INSTALL 优先。 + +提示豁免:运行时目录写 .plugin-overlay-optout 后 hook 永久静默(remove 自动写入); +未安装时每次 SessionStart 都提示;旧 .plugin-overlay-nudge 不再阻止提示,安装成功后清除。 +""" +import contextlib +import hashlib +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).parent.resolve() +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) +from runtime_lock import file_lock, update_config +# 会同步进运行时目录的插件副本(usage_mcp.py 不在其中:MCP 直接从插件目录跑) +RUNTIME_FILES = [ + "inject-main.cjs", + "overlay.js", + "zusage.py", + "patch_install.py", + "install_monitor.py", + "plugin-updater.cjs", + "plugin-release.cjs", + "plugin-storage.cjs", + "quota-client.cjs", + "release_unpack.py", + "runtime_lock.py", "usage_stats.py", "turn-stats.js", + "token_rate.py", "renderer_bridge.py", +] + + +def sudo_user(): + """sudo 运行时的真实用户名(POSIX;非 sudo / Windows 返回 None)。""" + if sys.platform == "win32" or not os.environ.get("SUDO_USER"): + return None + try: + return os.environ["SUDO_USER"] if os.geteuid() == 0 else None + except AttributeError: + return None + + +def real_home(): + """真实用户家目录:sudo 会重置 HOME,Path.home() 指向 /root,而运行时目录、config.json + 与 hook 都以普通用户身份读写,必须落在 SUDO_USER 的家里(与根目录 install.py 同源)。 + Linux 上 /opt 等系统安装位置写入需要 root,这是本函数存在的唯一场景。""" + user = sudo_user() + if user: + try: + import pwd + return Path(pwd.getpwnam(user).pw_dir) + except (KeyError, OSError, ImportError): + pass + return Path.home() + + +def default_runtime(): + return real_home() / ".zcode" / "zcode-token-usage-statusbar" + + +SYSTEM_NODE_LOCATIONS = ("/usr/local/bin/node", "/usr/bin/node", "/usr/bin/nodejs") + + +def _nvm_node_versions(nvm): + """nvm 的版本目录(v24.12.0)按 SemVer 降序;目录名带 v 前缀,_version_key 不认。""" + versions = [] + for path in nvm.glob("v*"): + try: + versions.append((_version_key(path.name.lstrip("v")), path)) + except ValueError: + continue + return [path / "bin" / "node" for _, path in sorted(versions, reverse=True)] + + +def _node_candidates(): + """验收器可用的 Node 解释器(按优先级,去重保序)。 + + sudo 的 secure_path 会重置 PATH,nvm 装的 node 不在 root 的 PATH 里, + `shutil.which("node")` 返回 None(issue #15);因此还要探测 SUDO_USER 的 nvm + 目录与常见系统位置。候选仍会在启动失败时逐个回退,不做身份保证。""" + from_path = shutil.which("node") # which 已验证存在且可执行,不必再 is_file() + candidates = [] + user = sudo_user() + if user: + try: + import pwd + nvm = Path(pwd.getpwnam(user).pw_dir) / ".nvm" / "versions" / "node" + candidates.extend(_nvm_node_versions(nvm)) + except (KeyError, OSError, ImportError, ValueError): + pass + candidates.extend(SYSTEM_NODE_LOCATIONS) + exists = [Path(from_path)] if from_path else [] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + if path.is_file() and path not in exists: + exists.append(path) + return exists + + +def load_patch_install(): + if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + import patch_install + return patch_install + + +def load_runtime_config(runtime): + """缺失配置可创建;损坏或非对象配置不能当空配置覆盖。""" + try: + config = json.loads((runtime / "config.json").read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, ValueError): + return None + return config if isinstance(config, dict) else None + + +def fixed_drives(letters): + """本地固定盘盘符:离线的映射网络盘一被访问就触发重连(可卡数秒),光驱/读卡器可能无介质。""" + try: + import ctypes + drive_type = ctypes.windll.kernel32.GetDriveTypeW + except (ImportError, AttributeError, OSError): + return [] + return [letter for letter in letters if drive_type(f"{letter}:\\") == 3] # DRIVE_FIXED + + +def asar_candidates(): + """仅枚举当前平台常见位置;候选仍须通过包身份校验。""" + if sys.platform == "darwin": + return [Path("/Applications/ZCode.app/Contents/Resources/app.asar"), + Path.home() / "Applications/ZCode.app/Contents/Resources/app.asar"] + if sys.platform.startswith("linux"): + return [Path(root) / "resources/app.asar" for root in + ("/opt/ZCode", "/opt/zcode", "/usr/lib/zcode", "/usr/local/ZCode")] + candidates = [Path(r"D:\ZCode\resources\app.asar"), + Path(r"C:\ZCode\resources\app.asar")] + for variable, suffix in (("LOCALAPPDATA", "Programs/ZCode"), + ("LOCALAPPDATA", "ZCode"), ("ProgramFiles", "ZCode")): + if os.environ.get(variable): + candidates.append(Path(os.environ[variable]) / suffix / "resources/app.asar") + # 自定义盘符安装(如 D:\Program Files\ZCode):逐个本地固定盘枚举两种常见布局; + # %ProgramFiles% 只跟系统盘走。候选仍须通过 is_zcode_asar 包身份校验。 + candidates.extend(Path(f"{letter}:/{folder}/resources/app.asar") + for letter in fixed_drives("DEFGHIJKLMNOPQRSTUVWXYZ") + for folder in ("Program Files/ZCode", "ZCode")) + if os.environ.get("LOCALAPPDATA"): + programs = Path(os.environ["LOCALAPPDATA"]) / "Programs" + candidates.extend(sorted(programs.glob("*/resources/app.asar"))) + return candidates + + +def is_zcode_asar(pi, asar): + """生产目标必须由 asar 内 package.json 的 name 确认为 ZCode。""" + try: + with asar.open("rb") as stream: + header, base = pi.read_header(stream) + node = header["files"]["package.json"] + if node.get("unpacked"): + return False + stream.seek(base + int(node["offset"])) + package = json.loads(stream.read(int(node["size"])).decode("utf-8")) + name = package.get("name") + return isinstance(name, str) and "zcode" in name.lower() + except Exception: + return False + + +def resolve_paths(runtime=None): + """返回 (runtime_dir, patch_install 模块);无有效目标时 ASAR 为 None。""" + pi = load_patch_install() + runtime = Path(runtime or os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or default_runtime()).expanduser().resolve() + pi.set_runtime(runtime) + test_asar = os.environ.get("ZUSAGE_PLUGIN_ASAR") + if test_asar: + pi.set_target(Path(test_asar).expanduser().resolve()) + return runtime, pi + config = load_runtime_config(runtime) + candidates = [os.environ.get("ZCODE_ASAR"), (config or {}).get("asar_path")] + candidates.extend(asar_candidates()) + for candidate in candidates: + if not isinstance(candidate, (str, Path)) or not candidate: + continue + asar = Path(os.path.expandvars(str(candidate))).expanduser().resolve() + if is_zcode_asar(pi, asar): + pi.set_target(asar) + return runtime, pi + # patch_install 是缓存模块;上次会话的目标绝不能作为本次探测兜底。 + pi.ASAR = pi.BAK = pi.TMP = pi.ZCODE_EXE = None + return runtime, pi + + +def optout_marker(runtime): + return runtime / ".plugin-overlay-optout" + + +def nudge_marker(runtime): + return runtime / ".plugin-overlay-nudge" + + +def clear_markers(runtime): + for m in (optout_marker(runtime), nudge_marker(runtime)): + try: + m.unlink() + except FileNotFoundError: + pass + + +def plugin_version(): + """优先读 ZCode manifest,兼容只有 Claude manifest 的插件副本。""" + for folder in (".zcode-plugin", ".claude-plugin"): + manifest = PLUGIN_ROOT / folder / "plugin.json" + if manifest.is_file(): + return json.loads(manifest.read_text(encoding="utf-8"))["version"] + raise FileNotFoundError("插件 manifest 不存在") + + +def _version_key(version): + """SemVer 排序;忽略 build metadata,预发布版本低于正式版。""" + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?" + r"(?:\+[0-9A-Za-z.-]+)?", version) + if not match: + raise ValueError("无效插件版本:{}".format(version)) + prerelease = match.group(4) + parts = tuple((0, int(part)) if part.isdigit() else (1, part) + for part in prerelease.split(".")) if prerelease else () + return tuple(int(match.group(i)) for i in (1, 2, 3)), prerelease is None, parts + + +def _atomic_write(dst, data, src=None): + """同目录原子替换;已有运行时内容改变必须产生不同 mtime 供 loader 检测。""" + previous_stat = dst.stat() if src is not None and dst.exists() else None + fd, temporary = tempfile.mkstemp(prefix="." + dst.name + ".", dir=str(dst.parent)) + temporary = Path(temporary) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(data) + if src is not None: + if previous_stat is None: + shutil.copystat(src, temporary) + else: + shutil.copymode(src, temporary) + stamp = max(time.time_ns(), previous_stat.st_mtime_ns + 1000000) + os.utime(temporary, ns=(temporary.stat().st_atime_ns, stamp)) + os.replace(temporary, dst) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +VERIFY_TIMEOUT = 10 # 单次验收器运行上限(秒) + + +def _run_verifier(command, env): + """运行一次验收器,输出按 UTF-8 解码(Node 恒写 UTF-8,系统区域编码会把中文路径解错)。 + 超时先结束整棵进程树再上报 TimeoutExpired:subprocess.run 只杀直接子进程,且在 + Windows 上之后还会不限时地读完输出管道,子孙若继承了管道就永远等不到 EOF。 + 收尾总耗时有界(taskkill 3 秒、回收 2 秒),叠加后仍在更新器 30 秒时限内。""" + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, + start_new_session=os.name != "nt") + try: + stdout, stderr = process.communicate(timeout=VERIFY_TIMEOUT) + except subprocess.TimeoutExpired: + if os.name == "nt": + try: + subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3) + except (OSError, subprocess.SubprocessError): + pass + process.kill() + try: + process.communicate(timeout=2) # 读线程收尾;整棵树没结束干净时放弃,不能无限等 + except subprocess.TimeoutExpired: + pass + else: + try: + os.killpg(process.pid, signal.SIGKILL) # 新会话即新进程组:连同子孙一起结束 + except OSError: + process.kill() + process.stdout.close() # POSIX 的 communicate 不起读线程,直接关管道即可 + process.stderr.close() + process.wait() + raise + return subprocess.CompletedProcess(command, process.returncode, stdout.decode("utf-8", "replace"), + stderr.decode("utf-8", "replace")) + + +@contextlib.contextmanager +def _sync_verifier(runtime, target_asar=None): + """target_asar 是本轮已解析的安装目标(resolve_paths 已做过包身份校验)。sudo 下 + ZCODE_ASAR 与 /root 的 config 都为空,只有它能让 asar 旁的 ZCode 可执行文件参与验收。""" + pending_path = runtime / "plugin-update.json" + try: + pending = json.loads(pending_path.read_text(encoding="utf-8")) + if not isinstance(pending, dict): + raise ValueError("plugin-update.json 必须是对象") + except FileNotFoundError: + pending = {} + active = (pending.get("pendingSync") is True or pending.get("pendingRelease") is not None + or pending.get("pendingPid")) + base = runtime if (runtime / "plugin-updater.cjs").is_file() else PLUGIN_ROOT + if active and base != runtime: + raise ValueError("待恢复安装缺少可信运行时验收器,停止同步") + nodes = _node_candidates() + executable = None + config = load_runtime_config(runtime) or {} + asar = target_asar or os.environ.get("ZCODE_ASAR") or config.get("asar_path") + if asar: + target = Path(asar) + candidates = ([target.parent.parent / "MacOS" / "ZCode"] if sys.platform == "darwin" + else [target.parent.parent / ("ZCode.exe" if sys.platform == "win32" else "zcode")]) + executable = next((str(item) for item in candidates if item.is_file()), None) + # asar 旁的同名可执行文件可能是外部应用/损坏文件(文件名不是身份依据): + # 启动失败(OSError/WinError 216)或启动后挂起超时,都回退 Node 再试。 + launch_candidates = list(dict.fromkeys(str(item) for item in + ([executable] if executable else []) + nodes)) + if not launch_candidates: + raise ValueError( + "未找到运行时验收所需的 Node 可执行文件。sudo 会重置 PATH:nvm 等用户级 node " + "不在 root 的 PATH 里。请改用保留环境的方式重跑,例如:" + " sudo env \"HOME=$HOME\" \"PATH=$PATH\" python3 overlay_install.py install" + "(或把 node 加入 root 的 PATH / 设 ZCODE_ASAR 指向 /opt/ZCode/resources/app.asar)") + names = ("plugin-updater.cjs", "plugin-release.cjs", "plugin-storage.cjs") + with tempfile.TemporaryDirectory(prefix="zusage-sync-verifier-") as directory: + snapshot = Path(directory) + original = {} + for name in names: + original[name] = (base / name).read_bytes() + (snapshot / name).write_bytes(original[name]) + if any((base / name).read_bytes() != data for name, data in original.items()): + raise ValueError("可信验收器在读取期间变化,停止同步") + # The migration contract belongs to this installer, never to the new + # cache helper. Require only the frozen runtime modules; newer runtimes + # retain their own verifier, while HEAD runtimes use this fixed adapter. + verifier_script = r''' +const fs = require("fs"), path = require("path"), os = require("os"); +const { isDeepStrictEqual } = require("util"); +const [snapshot, runtime, pluginRoot] = process.argv.slice(1); +const updater = require(path.join(snapshot, "plugin-updater.cjs")); +function migrate() { + const release = require(path.join(snapshot, "plugin-release.cjs")); + const { resolvePluginStorage } = require(path.join(snapshot, "plugin-storage.cjs")); + const readObject = (file, missing) => { + let value; + try { value = JSON.parse(fs.readFileSync(file, "utf8")); } + catch (error) { if (error.code === "ENOENT") return missing; throw error; } + if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("同步验收记录不是 JSON 对象: " + file); + return value; + }; + const root = fs.realpathSync(pluginRoot), id = "zcode-token-usage-statusbar@zusage-marketplace"; + const manifest = readObject(path.join(root, ".zcode-plugin", "plugin.json"), null); + if (!manifest || manifest.name !== id.split("@")[0]) throw Error("同步插件清单身份无效"); + updater.compareVersions(manifest.version, manifest.version); + const pending = readObject(path.join(runtime, "plugin-update.json"), null); + const hasPending = pending && (pending.pendingSync === true || pending.pendingRelease != null || pending.pendingPid); + const live = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return error.code !== "ESRCH"; } + }; + const legacy = path.join(runtime, "plugin-update.lock"); + try { + let owner; + try { owner = JSON.parse(fs.readFileSync(legacy, "utf8")); } catch (error) { owner = {}; } + if ((owner && Number.isSafeInteger(owner.pid) && owner.pid > 0) + ? live(owner.pid) : Date.now() - fs.statSync(legacy).mtimeMs < 600000) throw Error("旧更新进程仍在运行,停止运行时同步"); + } catch (error) { if (error.code !== "ENOENT") throw error; } + if (pending && pending.pendingPid) { + if (!Number.isSafeInteger(pending.pendingPid) || pending.pendingPid <= 0) throw Error("安装进程记录无效"); + if (live(pending.pendingPid)) throw Error("插件安装进程仍在运行,停止运行时同步"); + } + const storage = resolvePluginStorage(os.homedir(), process.env); + if (fs.existsSync(path.join(storage, "marketplaces", ".zusage-marketplace.transaction.json"))) throw Error("官方市场事务尚未完成,停止运行时同步"); + const inventory = readObject(path.join(storage, "installed_plugins.json"), null); + if (inventory && !Array.isArray(inventory.plugins)) throw Error("官方安装记录形状无效"); + const records = inventory ? inventory.plugins.filter(row => row && row.id === id && row.scope === "user") : []; + if (!records.length) { + if (hasPending || inventory) throw Error("同步所需用户插件安装记录不存在"); + return { ok: true, pluginRoot: root, identity: { record: null, channel: null, pending, manifest } }; + } + if (records.length !== 1) throw Error("同步所需用户插件安装记录不唯一"); + const record = records[0]; + if (record.enabled === false || typeof record.installPath !== "string" || fs.realpathSync(record.installPath) !== root || record.version !== manifest.version) throw Error("同步插件与官方安装记录不一致或已禁用"); + const selected = release.readChannel(os.homedir(), storage); + const channel = { kind: selected.kind, repository: selected.repository || "", source: selected.marketplace ? selected.marketplace.source : null }; + if (hasPending) { + if (pending.pendingSync !== true) throw Error("待同步意图不完整,停止运行时同步"); + if (pending.pendingVersion && updater.compareVersions(record.version, pending.pendingVersion) <= 0) throw Error("插件安装尚未提交新版,停止运行时同步"); + if (pending.pendingRelease != null) { + const expected = pending.pendingRelease, r = expected.release, m = expected.manifest; + if (!r || !m || !expected.channel || !isDeepStrictEqual(channel, expected.channel)) throw Error("Release 更新意图无效或市场来源已变化,停止运行时同步"); + const validated = release.validateRelease({ tag_name: r.tag, draft: false, prerelease: false, assets: [r.archive, r.manifest] }, r.repository); + if (!validated || validated.version !== r.version || channel.kind !== "release" || channel.repository !== r.repository) throw Error("Release 更新意图无效,停止运行时同步"); + release.validateManifest(m, validated); + const source = record.source; + if (record.version !== r.version || !source || source.url !== r.archive.browser_download_url || source.sha256 !== m.asset.sha256 || source.source !== "url" || source.type !== "zip" || source.path !== "plugins/zcode-token-usage-statusbar" || (source.stripRoot !== undefined && source.stripRoot !== false) || source.headers !== undefined) throw Error("官方安装结果与选定 Release 不一致,停止运行时同步"); + } else if (channel.kind === "release") throw Error("历史更新意图缺少 Release 校验信息,停止运行时同步"); + } + return { ok: true, pluginRoot: root, identity: { record, channel, pending, manifest } }; +} +try { + const result = typeof updater.verifyRuntimeSync === "function" + ? updater.verifyRuntimeSync({ runtime, pluginRoot }) : migrate(); + process.stdout.write(JSON.stringify(result) + "\n"); +} catch (error) { console.error("[zusage-plugin] 同步验收失败:", error.message); process.exitCode = 1; } +''' + def verify(): + result = None + for candidate in list(launch_candidates): + try: + result = _run_verifier([candidate, "-e", verifier_script, str(snapshot), + str(runtime), str(PLUGIN_ROOT)], + env=dict(os.environ, ELECTRON_RUN_AS_NODE="1")) + break + except (OSError, subprocess.TimeoutExpired): + # 无法启动(非 PE/格式错误)或挂起不退出:本次同步不再试它, + # 否则每次复核都要再等满一个超时,叠加起来超过调用方的时限。 + launch_candidates.remove(candidate) + if result is None: + raise ValueError("运行时验收所需可执行文件均无法启动或超时") + if result.returncode: + raise ValueError("运行时同步验收失败: " + result.stderr.strip()[:1000]) + data = json.loads(result.stdout) + if data.get("ok") is not True or data.get("pluginRoot") != str(PLUGIN_ROOT.resolve()): + raise ValueError("运行时同步验收结果无效") + return data["identity"] + # 惰性校验:首次 verify() 才拉起 node 验收进程并锚定身份,其后每次调用复核比对。 + # 稳态同步(无任何写入计划)由调用方直接跳过 verify —— 每次会话启动省 1-3 次 + # node 子进程(实测 ~400ms),写入路径的安全语义不变。 + anchor = {} + def recheck(): + current = verify() + if "identity" not in anchor: + anchor["identity"] = current + elif current != anchor["identity"]: + raise ValueError("运行时同步期间安装身份发生变化") + yield recheck + + +def sync_runtime(runtime, target_asar=None): + """All entry points verify installed identity before the shared transaction. + + target_asar 是本轮已解析的安装目标(可为 None):随 sync 一起进入验收器, + 让 sudo 下也能用 asar 旁的 ZCode 可执行文件验收。""" + runtime = Path(runtime) + runtime.mkdir(parents=True, exist_ok=True) + with file_lock(runtime / "plugin-runtime.lock"): + with _sync_verifier(runtime, target_asar) as verify: + result = _sync_runtime_locked(runtime, verify) + chown_runtime_to_user(runtime) # root 写的运行时文件须归还给 sudo 的真实用户。 + return result + + +def _sync_runtime_locked(runtime, verify=lambda: None): + """可信 hash 持续保护手改;无基线迁移用 mtime,旧插件不能回滚新版。""" + version = plugin_version() + current_version = _version_key(version) + plugin_root = str(PLUGIN_ROOT.resolve()) + record_path = runtime / "plugin-runtime.json" + try: + previous = json.loads(record_path.read_text(encoding="utf-8")) + except FileNotFoundError: + previous = {} + if not isinstance(previous, dict): + raise ValueError("plugin-runtime.json 必须是对象") + previous_version = _version_key(previous["version"]) if previous else None + journal_path = runtime / "plugin-runtime-pending.json" + try: + journal = json.loads(journal_path.read_text(encoding="utf-8")) + except FileNotFoundError: + journal = None + if journal is not None: + if not isinstance(journal, dict) or not isinstance(journal.get("targets"), dict) or not isinstance(journal.get("base"), dict): + raise ValueError("运行时待同步记录损坏") + if previous == journal["base"]: + recovered = dict(previous) + recovered_hashes = dict(previous.get("hashes", {})) + for name, digest in journal["targets"].items(): + if name not in RUNTIME_FILES or not isinstance(digest, str) or not re.fullmatch("[0-9a-f]{64}", digest): + raise ValueError("运行时待同步 hash 无效") + target = runtime / name + if target.is_file() and hashlib.sha256(target.read_bytes()).hexdigest() == digest: + recovered_hashes[name] = digest + recovered["hashes"] = recovered_hashes + if not previous and recovered_hashes: + recovered.update(version="0.0.0", plugin_root=str(PLUGIN_ROOT.resolve())) + if recovered != previous: + verify() + _atomic_write(record_path, (json.dumps(recovered) + "\n").encode("utf-8")) + previous = recovered + journal_path.unlink() + downgrade = previous_version is not None and current_version < previous_version + recorded_hashes = previous.get("hashes", {}) + if not isinstance(recorded_hashes, dict): + raise ValueError("plugin-runtime.json hashes 必须是对象") + hashes = {} + for name, digest in recorded_hashes.items(): + if name not in RUNTIME_FILES: + continue + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest): + raise ValueError("plugin-runtime.json 的 {} hash 损坏".format(name)) + hashes[name] = digest.lower() + changed, skipped, preserved = [], [], [] + writes = [] + planned_targets = {} + for name in RUNTIME_FILES: + src, dst = PLUGIN_ROOT / name, runtime / name + if not src.is_file() or downgrade: + skipped.append(name) + continue + data = src.read_bytes() + src_hash = hashlib.sha256(data).hexdigest() + planned_targets[name] = hashlib.sha256(dst.read_bytes()).hexdigest() if dst.is_file() else None + if dst.is_file(): + dst_hash = hashlib.sha256(dst.read_bytes()).hexdigest() + if dst_hash == src_hash: + hashes[name] = src_hash + skipped.append(name) + continue + if name in hashes: + protect = dst_hash != hashes[name] + reason = "运行时 hash 与记录不同(可能含手改热修)" + else: + protect = src.stat().st_mtime_ns <= dst.stat().st_mtime_ns + reason = "运行时副本更新(可能含手改热修)" + if protect: + print(f"[zusage-plugin] 跳过 {name}:{reason}," + f"如需覆盖请手动替换 {dst}", file=sys.stderr) + skipped.append(name) + preserved.append(name) + continue + hashes[name] = src_hash + changed.append(name) + writes.append((dst, data, src)) + result = {"changed": changed, "skipped": skipped, "preserved": preserved, + "version": version, "plugin_root": plugin_root, "hashes": hashes, + "restartRecorded": True} + cold_files = {"inject-main.cjs", "plugin-updater.cjs", "plugin-release.cjs", "plugin-storage.cjs", "quota-client.cjs"} + if (load_runtime_config(runtime) or {}).get("hot_reload") is False: + cold_files.update(("overlay.js", "zusage.py")) + restart_hashes = {name: hashes[name] for name in changed if name in cold_files} + record = dict(result) + if downgrade: + record["version"] = previous["version"] + record["plugin_root"] = previous["plugin_root"] + # 稳态(无写入计划、无待恢复 journal、记录内容不变):跳过 node 验收与重复落盘。 + # 无写入即无"覆盖手改"风险,身份校验保护的恰是写入路径;此路径是每次会话启动的 + # 常态,省 1-3 次 node 子进程(实测 ~400ms)。record != previous 的字段差异 + # (旧版本写入的历史记录缺 restartRecorded 等)仍走完整校验后落盘。 + steady = not writes and not restart_hashes and journal is None and record == previous + if not steady: + verify() + if writes: + journal_out = {"base": previous, "targets": {dst.name: hashes[dst.name] for dst, _, _ in writes}} + _atomic_write(journal_path, (json.dumps(journal_out) + "\n").encode("utf-8")) + if restart_hashes: + from restart_notice import record_restart + record_restart(runtime, {"hashes": restart_hashes, "changed": changed, + "version": version, "state": "pending-sync"}) + for dst, data, src in writes: + verify() + current_hash = hashlib.sha256(dst.read_bytes()).hexdigest() if dst.is_file() else None + if current_hash != planned_targets[dst.name]: + raise ValueError("运行时文件在同步规划后发生变化,保留手改并停止同步: " + dst.name) + _atomic_write(dst, data, src) + if not steady: + verify() + _atomic_write(record_path, (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")) + if journal_path.exists(): + journal_path.unlink() + return result + + +def injected_status(pi): + """True=当前运行时的唯一注入行 False=需修复 None=asar 不可读。""" + try: + entry = pi.entry_bytes_of(pi.ASAR) + line = pi.INJECT_LINE + if isinstance(line, str): + line = line.encode("utf-8") + return bool(line and entry.count(line) == 1 and + all(match.group() == line for match in pi.ZUSAGE_LINE_RE.finditer(entry))) + except Exception: + return None + + +def save_runtime_config(runtime, pi): + """调用方已确认目标有效/安装成功;仅补 Python 路径与实际 asar 位置。""" + if pi.ASAR is None: + return False + try: + update_config(runtime, {"asar_path": str(pi.ASAR)}, {"python_path": sys.executable}) + except (OSError, ValueError, TimeoutError): + print("[zusage-plugin] config.json 损坏、不可读或锁忙,保留原文件。", file=sys.stderr) + return False + return True + + +def auto_enabled(runtime): + v = os.environ.get("ZUSAGE_PLUGIN_AUTO_INSTALL") + if v is not None: + return v.strip().lower() in ("1", "true", "yes", "on") + config = load_runtime_config(runtime) + return config is not None and config.get("plugin_auto_install_overlay", True) is True + + +def write_marker(marker): + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("1", encoding="utf-8") + return True + except OSError: + return False + + +def chown_runtime_to_user(runtime): + """sudo 安装后把运行时目录归还给 SUDO_USER:root 写的文件属主是 root,之后普通用户 + 身份运行的 hook / 自动更新无法再写(升级、豁免标记都会失败)。非 sudo 是 no-op。""" + user = sudo_user() + if not user: + return + try: + import pwd + record = pwd.getpwnam(user) + except (KeyError, OSError, ImportError): + return + targets = [runtime] + try: + targets.extend(path for path in runtime.iterdir() if not path.is_dir()) + except OSError: + pass + for path in targets: + try: + os.chown(path, record.pw_uid, record.pw_gid) + except OSError: + pass + + +def _nudge(output, message): + from restart_notice import visible_context + output["systemMessage"] = message + output["hookSpecificOutput"] = { + "hookEventName": "SessionStart", + "additionalContext": visible_context("安装提醒", "状态条安装尚未完成。", message)} + return output + + +def _install_overlay(runtime, pi): + from restart_notice import record_restart + return pi.install(before_publish=lambda installation: record_restart( + runtime, {"state": "pending-install", "installation": installation})) + + +def ensure_renderer_bridge(pi, quiet_monitor=False): + """安装/更新后补桥:客户端官方升级会整体重写 asar,把只读桥抹掉。这里按 + renderer_bridge 的版本+哈希指纹表门控就地重打;未知版本 fail-closed 跳过, + 任何失败都不影响安装主流程。就地替换被系统拒绝(Windows 客户端运行中持有 + asar、或目标目录只读)时不再静默:把已构建的完整包转存为待替换文件 + (patch_install 的 TMP+monitor 链,客户端退出后自动收尾),状态交给 + run_hook/_bridge_notice 给用户可见提示。用户 remove 待收尾时让位——自动补桥 + 绝不覆盖显式卸载意图。""" + try: + from renderer_bridge import ensure_deployed + result = ensure_deployed(pi.ASAR) + except Exception as e: + print(f"[zusage-plugin] renderer bridge ensure failed(已忽略):{e}", file=sys.stderr) + return {"status": "skipped-error", "detail": str(e)} + if result.get("status") == "replace-failed": + stage = Path(result["stage"]) + + def discard_stage(): + # 杀毒扫描短暂锁文件/ACL 有建无删时清理自身不能炸——桥绝不影响主流程 + try: + stage.unlink(missing_ok=True) + except OSError: + pass + + try: + existing = pi.pending_operation() + except Exception: + existing = None + if existing == "remove": + discard_stage() + result = {"status": "skipped-error", "version": result.get("version"), + "detail": "已有待收尾的卸载操作,桥补装让位"} + else: + # hook 路径(quiet_monitor)stdout 只能是 hook JSON:publish/syntax 检查行 + # 全部重定向 stderr(与 auto-install 分支包 _install_overlay 同款) + redirect = contextlib.redirect_stdout(sys.stderr) if quiet_monitor else contextlib.nullcontext() + try: + with redirect: + pi.publish_external_pending(stage, source=result.get("source"), operation="install") + except PermissionError as e: + discard_stage() + print(f"[zusage-plugin] renderer bridge pending publish failed(已忽略):{e}", file=sys.stderr) + result = {"status": "skipped-error", "version": result.get("version"), + "detail": f"pending publish failed: {e}", "permission": True} + except Exception as e: + discard_stage() + print(f"[zusage-plugin] renderer bridge pending publish failed(已忽略):{e}", file=sys.stderr) + result = {"status": "skipped-error", "version": result.get("version"), + "detail": f"pending publish failed: {e}"} + else: + monitor_env = {name: os.environ.get(name) for name in + ("ZUSAGE_NO_MONITOR", "ZUSAGE_MONITOR_QUIET")} + try: + if quiet_monitor: + # SessionStart 场景不弹监控黑窗,systemMessage 里已说明去向 + os.environ["ZUSAGE_NO_MONITOR"] = "1" + os.environ["ZUSAGE_MONITOR_QUIET"] = "1" + with redirect: + pi.launch_monitor(time.time()) + except Exception as e: + print(f"[zusage-plugin] renderer bridge monitor 启动失败(已忽略):{e}", file=sys.stderr) + finally: + for name, value in monitor_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + result = {"status": "pending", "version": result.get("version"), + "detail": result.get("detail")} + if result.get("status") not in ("present",): + print(f"[zusage-plugin] renderer bridge: {result.get('status')} " + f"(v{result.get('version')}) {result.get('detail')}", file=sys.stderr) + return result + + +def _bridge_notice(out, result, pi): + """桥补装的用户可见提示(issue #67:受保护目录/客户端运行中不再静默失效)。 + pending 提示收敛(客户端退出收尾后转 present);权限不足在目标持续不可写期间 + 每次会话提示,与未安装时的 INSTALL_MESSAGE 同一强度。已有安装/重启提示时 + 不覆盖——桥只是增强,优先级低于主链。""" + if "systemMessage" in out: + return out + status = (result or {}).get("status") + if status == "pending": + from restart_notice import PREFIX, RESTART_ACTION + return _nudge(out, PREFIX + "逐问速率行所需的一次性客户端补丁已排定:" + "完全退出 ZCode(所有窗口)后会自动完成,之后重新打开即可;" + "其余功能不受影响。" + RESTART_ACTION) + if status == "skipped-error" and result.get("permission"): + # Windows 上 os.access 看不见 ACL(目录恒报可写),权限判定按真实 + # PermissionError 而不是 target_writable(),否则 Program Files 场景静默 + from restart_notice import PREFIX + script = PLUGIN_ROOT / "overlay_install.py" + if os.name == "nt": + action = (f'以管理员身份打开终端,执行 python "{script}" install') + else: + action = (f'执行 sudo env HOME="$HOME" PATH="$PATH" python3 "{script}" install') + return _nudge(out, PREFIX + f"状态条已安装,但逐问速率行所需的客户端补丁" + f"无法写入受保护目录:{pi.ASAR}。如需该功能请{action}," + f"完成后完全退出并重开 ZCode;不需要该功能可忽略本提示。") + return out + + +def remove_renderer_bridge(pi): + """卸载对称清理:只剥本项目的桥(指纹表逐版本识别),失败不影响卸载结果。""" + try: + from renderer_bridge import remove_deployed + result = remove_deployed(pi.ASAR) + if result.get("status") not in ("absent",): + print(f"[zusage-plugin] renderer bridge remove: {result.get('status')} " + f"(v{result.get('version')}) {result.get('detail')}", file=sys.stderr) + return result + except Exception as e: + print(f"[zusage-plugin] renderer bridge remove failed(已忽略):{e}", file=sys.stderr) + return {"status": "skipped-error", "detail": str(e)} + + +def run_hook(): + """SessionStart hook 主体:返回 hook 协议 JSON dict。任何异常都转成合法输出, + 绝不影响会话;patch_install 的人类可读输出重定向到 stderr,保证 stdout 纯 JSON。""" + out = {"suppressOutput": True} + runtime = Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or default_runtime()) + try: + runtime, pi = resolve_paths() + changed = sync_runtime(runtime, pi.ASAR)["changed"] + try: + from plugin_auto_update import start_auto_update + start_auto_update(runtime, pi) + except Exception as e: + print(f"[zusage-plugin] 后台更新启动失败(已忽略):{e}", file=sys.stderr) + if optout_marker(runtime).exists(): + return out + st = injected_status(pi) + if st is None: + return out # 本机没有可定位的桌面客户端(远程会话等),静默 + save_runtime_config(runtime, pi) + if st: + bridge = ensure_renderer_bridge(pi, quiet_monitor=True) # 已注入也可能缺桥(客户端升级重写 asar):幂等补一次 + from restart_notice import session_notice + cold_files = {"inject-main.cjs", "plugin-updater.cjs", "plugin-release.cjs", "plugin-storage.cjs", "quota-client.cjs"} + if (load_runtime_config(runtime) or {}).get("hot_reload") is False: + cold_files.update(("overlay.js", "zusage.py")) + restart_files = [name for name in changed if name in cold_files] + out = session_notice(runtime, out, injected=st, restart_files=restart_files) + return _bridge_notice(out, bridge, pi) + if auto_enabled(runtime): + monitor_env = {name: os.environ.get(name) for name in + ("ZUSAGE_NO_MONITOR", "ZUSAGE_MONITOR_QUIET")} + try: + os.environ["ZUSAGE_NO_MONITOR"] = "1" + os.environ["ZUSAGE_MONITOR_QUIET"] = "1" # 无窗口后台收尾 + with contextlib.redirect_stdout(sys.stderr): + ok = _install_overlay(runtime, pi) + finally: + for name, value in monitor_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + if ok: + bridge = ensure_renderer_bridge(pi, quiet_monitor=True) + clear_markers(runtime) + from restart_notice import session_notice + out = session_notice(runtime, out, injected=injected_status(pi)) + return _bridge_notice(out, bridge, pi) + else: + from restart_notice import PREFIX + _nudge(out, (PREFIX + "用量状态条自动安装未完成。" + "请运行 /install-overlay 重试;" + "不需要请运行 /uninstall-overlay,之后不再提示。" + "如果仍然失败,请查看客户端插件日志。")) + return out + from restart_notice import INSTALL_MESSAGE + return _nudge(out, INSTALL_MESSAGE) + except Exception as e: # noqa: BLE001 — hook 绝不能抛 + message = f"zusage 插件自检失败(已忽略):{e}" + if optout_marker(runtime).exists(): + print("[zusage-plugin] " + message, file=sys.stderr) + return {"suppressOutput": True} + out["systemMessage"] = message + out["hookSpecificOutput"] = {"hookEventName": "SessionStart", "additionalContext": message} + return out + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else "check" + if cmd == "sync": + runtime = Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or default_runtime()) + try: + result = sync_runtime(runtime) + except Exception as e: + print(f"[zusage-plugin] 同步失败:{e}", file=sys.stderr) + sys.exit(1) + print(json.dumps(result, ensure_ascii=False)) + return + runtime, pi = resolve_paths() + if cmd == "install": + sync_runtime(runtime, pi.ASAR) + if pi.ASAR is None: + print("[zusage-plugin] 未找到有效 ZCode app.asar;请设置 ZCODE_ASAR。", file=sys.stderr) + if cmd in ("install", "remove"): + sys.exit(1) + return + if cmd == "install": + clear_markers(runtime) # 主动安装 = 重新参与提示逻辑 + ok = _install_overlay(runtime, pi) + if ok: + bridge = ensure_renderer_bridge(pi) + if bridge.get("status") == "pending": + print("客户端运行中,逐问速率行补丁已排定:完全退出 ZCode 后自动完成替换。") + from restart_notice import reminder + notice = reminder(runtime, injected=injected_status(pi)) + if notice: + print(notice["message"]) + if ok or injected_status(pi) is not None: + save_runtime_config(runtime, pi) + chown_runtime_to_user(runtime) + elif cmd == "remove": + ok = pi.remove() + if ok: + remove_renderer_bridge(pi) + if ok and not write_marker(optout_marker(runtime)): + print("(豁免标记写入失败,hook 可能继续提示)") + chown_runtime_to_user(runtime) + else: + st = injected_status(pi) + print(f"runtime: {runtime}") + print(f"asar: {pi.ASAR}") + print("injected:", {True: "是", False: "否", None: "asar 不可读"}[st]) + print(f"auto install: {auto_enabled(runtime)}" + f"(config.json 的 plugin_auto_install_overlay 或环境变量 ZUSAGE_PLUGIN_AUTO_INSTALL)") + print(f"optout: {optout_marker(runtime).exists()} nudge: {nudge_marker(runtime).exists()}") + with contextlib.redirect_stdout(sys.stdout): + pi.check() + return + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/external_plugins/zcode-token-usage-statusbar/patch_install.py b/external_plugins/zcode-token-usage-statusbar/patch_install.py new file mode 100644 index 0000000..3577ade --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/patch_install.py @@ -0,0 +1,930 @@ +# -*- coding: utf-8 -*- +"""ZCode app.asar 注入/卸载工具(token 用量悬浮条)。 + +用法: + python patch_install.py install # 安装/重定向(幂等;自动替换旧注入行,含指向旧目录的历史行; + # 客户端运行中则生成 .tmp 待退出后替换) + python patch_install.py install --finalize # 客户端退出后完成替换 + python patch_install.py remove # 卸载(从 asar 剥离本工具注入行,不依赖备份) + python patch_install.py check # 检查当前注入状态与入口语法 + python patch_install.py install --finalize --asar --runtime <运行时目录> + # standalone 运行须显式指定非默认安装位置的目标, + # 否则按平台默认位置猜测,自定义路径下会猜错/找不到文件; + # 也可各设一次环境变量 ZCODE_ASAR / ZCODE_RUNTIME 代替每次都传 + # (命令行参数优先于环境变量)。--runtime 只有 install --finalize + # 需要——pending TMP 的 sidecar 记录了生成时的运行时目录, + # standalone 默认 RUNTIME 是脚本自身所在目录,插件安装的运行时 + # 数据目录通常是另一个目录,不传会被来源授权校验拒绝; + # remove/check 不受影响。install.py / overlay_install.py + # 拉起时会调用 set_target()/set_runtime() 代为处理,不受此限制。 + +原理:asar 主入口 out/main/index.js 尾部追加一行 dynamic import(loader)(ESM 入口,实测见下)。 +Electron fuses: EmbeddedAsarIntegrityValidation=0(已实测),改动 asar 可正常加载。 + +安装/卸载均只增删本工具自己的注入行(ZUSAGE_LINE_RE 识别,不限目录的历史行一并匹配), +不制作也不依赖 asar 备份:卸载不会连带移除其它工具后装的注入行;ZCode 官方升级覆盖 asar +之后再卸载,也不会把客户端降级回安装时的旧版本。历史版本遗留的 app.asar.zusage.bak +会在成功安装/卸载后自动清理。 + +安装成功后自动弹出常驻监控窗口(install_monitor.py,每 10 秒检测一次):提醒重启 +ZCode、确认注入加载后自动退出;运行中替换失败(.tmp 待替换)时还会在 ZCode 退出后 +自动完成替换 —— 该收尾职责已由监控窗口承担,旧的 schtasks 计划任务方案废弃。 +""" +import hashlib +import json +import os +import re +import stat +import struct +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from contextlib import nullcontext +from runtime_lock import file_lock, publish_install + +IS_WIN = sys.platform == "win32" +IS_MAC = sys.platform == "darwin" + +if IS_WIN: + ASAR_DEFAULT = Path(r"D:\ZCode\resources\app.asar") + ZCODE_EXE_DEFAULT = Path(r"D:\ZCode\ZCode.exe") +elif IS_MAC: + ASAR_DEFAULT = Path("/Applications/ZCode.app/Contents/Resources/app.asar") + ZCODE_EXE_DEFAULT = Path("/Applications/ZCode.app/Contents/MacOS/ZCode") +else: + ASAR_DEFAULT = Path("app.asar") # 其它平台无默认位置,用 --asar / install.py 指定 + ZCODE_EXE_DEFAULT = None +ASAR = ASAR_DEFAULT # 可被 set_target() 改指其它安装位置(install.py 探测后调用) +BAK = ASAR.with_name("app.asar.zusage.bak") +TMP = ASAR.with_name("app.asar.zusage.tmp") +TMP_META = TMP.with_name(TMP.name + ".json") +HERE = Path(__file__).parent.resolve() +# 运行时目录:install.py 标准安装时指向数据目录(~/.zcode/zcode-token-usage-statusbar, +# 经 set_runtime() 切换);独立运行本脚本或 --dev 安装时保持仓库目录(向后兼容)。 +RUNTIME = HERE +LOADER = RUNTIME / "inject-main.cjs" +ENTRY = "out/main/index.js" +# 入口是 ESM(package.json type:module,实测 2026-09-04):必须用 dynamic import(CJS/ESM 通用), +# 指向 .cjs loader(在 type:module 包里仍按 CJS 加载,require 可用);asar 内相对路径不可用,用绝对 file URL。 +INJECT_LINE_TMPL = ( + '\n;import("{url}").then(() => null, (e) => console.error("[zusage] load failed", e));' +) +INJECT_LINE = INJECT_LINE_TMPL.format(url=(RUNTIME / "inject-main.cjs").as_uri()) +# 匹配任何 zusage 注入行(不限当前目录)——迁移/重装/标准↔dev 互切时剥离旧行用 +ZUSAGE_LINE_RE = re.compile( + rb'\n;import\("[^"]*inject-main\.cjs"\)\.then\(\(\) => null, ' + rb'\(e\) => console\.error\("\[zusage\] load failed", e\)\);' +) +ZCODE_EXE = ZCODE_EXE_DEFAULT +ALIGN = 4 +BLOCK = 4194304 + +# 输出语言:惰性读 config.json 的 "lang"(运行时目录优先,其次数据目录;缺省 zh)。 +# install.py 标准安装流程里 set_runtime() 先于 install(),此时序下读到的就是本次安装写入的语言。 +_LANG = None + + +def L(zh, en): + global _LANG + if _LANG is None: + _LANG = "zh" + # sudo 运行时(Linux 系统级安装)config 在真实用户的家目录,Path.home() 会指到 root + sudo_home = None + if sys.platform != "win32" and os.environ.get("SUDO_USER"): + try: + if os.geteuid() == 0: + import pwd + sudo_home = Path(pwd.getpwnam(os.environ["SUDO_USER"]).pw_dir) + except (KeyError, OSError, AttributeError): + pass + bases = [RUNTIME, + Path.home() / ".zcode" / "zcode-token-usage-statusbar"] + if sudo_home: + bases.append(sudo_home / ".zcode" / "zcode-token-usage-statusbar") + for base in bases: + try: + lang = json.loads((base / "config.json").read_text(encoding="utf-8")).get("lang") + if lang in ("zh", "en"): + _LANG = lang + break + except (OSError, ValueError): + pass + return en if _LANG == "en" else zh + + +def set_runtime(path): + """改运行时目录(LOADER/INJECT_LINE 随动)。ZCode 端泵的全套路径都基于 __dirname, + 运行时副本目录即自洽运行,无需改运行时代码。""" + global RUNTIME, LOADER, INJECT_LINE + RUNTIME = Path(path) + LOADER = RUNTIME / "inject-main.cjs" + INJECT_LINE = INJECT_LINE_TMPL.format(url=LOADER.as_uri()) + + +def zcode_exe_for(asar_path): + """从 asar 位置推 ZCode 可执行文件(语法自检用):Windows 布局 /ZCode.exe, + macOS bundle 布局 /MacOS/ZCode,Linux 布局 /zcode(deb/rpm 官方包,/opt/ZCode); + 非 ZCode 布局(如 %LOCALAPPDATA%\\Programs 下其它 Electron 应用)返回 None—— + 客户端进程名随之失去语义(客户端是别的应用),语法检查自动跳过。""" + root = asar_path.parent.parent + if IS_WIN: + exe = root / "ZCode.exe" + return exe if exe.is_file() else None + if IS_MAC: + return root / "MacOS" / "ZCode" + if sys.platform.startswith("linux"): + return root / "zcode" + return None + + +def set_target(asar_path): + """改指目标安装位置(BAK/TMP/ZCODE_EXE 随动)。ZCODE_EXE 只用于语法自检,缺了自动跳过。""" + global ASAR, BAK, TMP, TMP_META, ZCODE_EXE + asar_path = Path(asar_path).resolve() + ASAR = asar_path + BAK = asar_path.with_name("app.asar.zusage.bak") + TMP = asar_path.with_name("app.asar.zusage.tmp") + TMP_META = TMP.with_name(TMP.name + ".json") + ZCODE_EXE = zcode_exe_for(asar_path) + + +def client_running(): + try: + if IS_WIN: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq ZCode.exe"], + capture_output=True, text=True, encoding="gbk", errors="replace", + ).stdout + return "ZCode.exe" in out + # macOS/Linux:pgrep 精确匹配主进程名;误判"未运行"在 POSIX 上也无害 + # (运行中替换本就允许:rename 语义,旧进程继续读旧 inode) + return subprocess.run(["pgrep", "-x", "ZCode"], capture_output=True).returncode == 0 + except Exception: + return True # 查不到时按在跑处理,走安全路径 + + +def _spawn_detached(cmd, flags, **kwargs): + """Windows:优先带 CREATE_BREAKAWAY_FROM_JOB 拉起,让监控脱离父进程的 Job 对象—— + 从 agent/工具会话里跑安装时,会话清理会整树杀掉子进程,监控等不到客户端退出就被杀, + 收尾只能寄望下次 SessionStart 重试。Job 不允许 breakaway 时降级为普通创建。""" + if not IS_WIN: + return None + breakaway = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0) + try: + return subprocess.Popen(cmd, creationflags=flags | breakaway, **kwargs) + except OSError: + return subprocess.Popen(cmd, creationflags=flags, **kwargs) + + +def launch_monitor(epoch): + """Windows 开新控制台窗口跑安装监控(提醒重启 + 确认生效 + .tmp 收尾); + macOS/Linux 无控制台概念,后台静默跑(收尾与自动退出照常)。设 ZUSAGE_NO_MONITOR=1 可禁用。""" + if os.environ.get("ZUSAGE_NO_MONITOR") and not os.environ.get("ZUSAGE_MONITOR_QUIET"): + return + monitor = HERE / "install_monitor.py" + if not monitor.exists(): + return + cmd = [sys.executable, str(monitor), "%.3f" % epoch, str(ASAR), str(RUNTIME)] + try: + if os.environ.get("ZUSAGE_MONITOR_QUIET"): + if IS_WIN: + return _spawn_detached(cmd, subprocess.CREATE_NO_WINDOW, cwd=str(HERE), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return subprocess.Popen(cmd, creationflags=0, cwd=str(HERE), + start_new_session=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + if IS_WIN: + return _spawn_detached(cmd, subprocess.CREATE_NEW_CONSOLE, cwd=str(HERE)) + return subprocess.Popen(cmd, cwd=str(HERE), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as e: + print(L(f"(监控窗口启动失败,不影响安装:{e})", + f"(monitor window failed to launch; installation is unaffected: {e})")) + return None + + +# ---------- asar 读写 ---------- + +def read_header(f): + f.seek(0) + a, b, c, d = struct.unpack("<4I", f.read(16)) + assert a == 4, f"unexpected pickle prefix {a}" + header = json.loads(f.read(d)) + return header, 8 + b + + +def iter_files(node, path=""): + """yield (path_in_asar, node_dict)。node_dict 为文件(含 size)。""" + for name, ch in node.get("files", {}).items(): + p = f"{path}/{name}" + if "files" in ch: + yield from iter_files(ch, p) + else: + yield p, ch + + +def compute_integrity(data: bytes): + return { + "algorithm": "SHA256", + "hash": hashlib.sha256(data).hexdigest(), + "blockSize": BLOCK, + "blocks": [hashlib.sha256(data[i:i + BLOCK]).hexdigest() for i in range(0, len(data), BLOCK)], + } + + +def repack(src_path: Path, modify: dict, dst_path: Path): + """重建 asar。modify: {asar内路径: 新内容bytes}。流式拷贝未改动文件。""" + with open(src_path, "rb") as src: + header, base = read_header(src) + header = json.loads(json.dumps(header)) # 深拷贝 + nodes = dict(iter_files(header)) + missing = set(modify) - set(nodes) + assert not missing, f"paths not in asar: {missing}" + + data_parts = [] + offset = 0 + for p, node in nodes.items(): + if node.get("unpacked"): + node["offset"] = "0" + continue + if p in modify: + data = modify[p] + node["size"] = len(data) + node["integrity"] = compute_integrity(data) + else: + src.seek(base + int(node["offset"])) + data = src.read(node["size"]) + node["offset"] = str(offset) + data_parts.append(data) + offset += len(data) + + payload = b"".join(data_parts) + header_bytes = json.dumps(header, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + d = len(header_bytes) + c = (d + 4 + ALIGN - 1) // ALIGN * ALIGN + b_total = c + 4 + with open(dst_path, "wb") as out: + out.write(struct.pack("<4I", 4, b_total, c, d)) + out.write(header_bytes) + out.write(b"\0" * (c - d - 4)) # pickle 对齐:数据区从 8+b = 12+c 处开始 + assert out.tell() == 8 + b_total, f"header pad mismatch: {out.tell()} vs {8 + b_total}" + out.write(payload) + return dst_path + + +def entry_bytes_of(asar_path: Path) -> bytes: + with open(asar_path, "rb") as f: + header, base = read_header(f) + nodes = dict(iter_files(header)) + node = nodes["/" + ENTRY] + f.seek(base + int(node["offset"])) + return f.read(node["size"]) + + +def self_check(asar_path: Path, expect=True): + """结构自检:header 可解析、文件数、入口注入行符合预期(install 期望有,remove 期望无)。""" + with open(asar_path, "rb") as f: + header, base = read_header(f) + n = sum(1 for _ in iter_files(header)) + entry = entry_bytes_of(asar_path) + has = INJECT_LINE.encode() in entry if expect else bool(ZUSAGE_LINE_RE.search(entry)) + ok = has == expect + print(L(f" [check] header ok, 文件数={n}, 注入行{'符合' if ok else '不符合'}预期(期望{'有' if expect else '无'})", + f" [check] header ok, files={n}, injection line {'as expected' if ok else 'UNEXPECTED'} (expected {'present' if expect else 'absent'})")) + return ok + + +def syntax_check(asar_path: Path): + """用 ZCode 自身当 node(ELECTRON_RUN_AS_NODE)对入口做语法检查; + 找不到/无法运行 ZCode 可执行文件时跳过(自检是锦上添花,不应挡安装)。""" + exe = ZCODE_EXE + if not exe or not exe.is_file(): + print(L(" [check] 未找到 ZCode 可执行文件,跳过语法检查", + " [check] ZCode executable not found; syntax check skipped")) + return True + src = entry_bytes_of(asar_path) + env = dict(os.environ, ELECTRON_RUN_AS_NODE="1") + fd, name = tempfile.mkstemp(prefix=".entry-check.", suffix=".js", dir=HERE) + tmp_js = Path(name) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(src) + # Node 恒写 UTF-8:按区域编码解码,回显里的非 ASCII(中文路径/源码行)会让 Windows + # 读线程崩溃(stderr 变 None)或解成乱码。 + r = subprocess.run([str(exe), "--check", str(tmp_js)], capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env) + except OSError as e: + r = None + run_err = str(e) + finally: + tmp_js.unlink(missing_ok=True) + if r is None: + print(L(f" [check] 语法检查无法执行({run_err}),跳过", + f" [check] syntax check failed to run ({run_err}); skipped")) + return True + # 当前输出编码打印不了的字符(cp1252/ASCII 管道上的中文路径等)转义,其余原样显示; + # 重定向到 StringIO 时 encoding 为 None,按 UTF-8 处理。 + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + detail = r.stderr.strip()[:200].encode(enc, "backslashreplace").decode(enc) + print(L(" [check] 语法检查 exit=", " [check] node --check exit=") + str(r.returncode) + " " + detail) + return r.returncode == 0 + + +def cleanup_legacy_bak(): + """备份机制已退役(安装/卸载均改为剥离注入行):清掉历史版本遗留的 .bak,省约 300MB 磁盘。""" + if not BAK.exists(): + return + try: + BAK.unlink() + print(L(f"已清理历史遗留备份 {BAK}", f"Removed legacy backup {BAK}")) + except OSError as e: + print(L(f"(遗留备份清理失败,可手动删除 {BAK}:{e})", + f"(failed to remove legacy backup {BAK}; delete it manually: {e})")) + + +# ---------- 待替换包的来源与身份 ---------- + +class PendingTmpError(ValueError): + """来源/身份不可信:立即取消收尾,保留文件供诊断,不按文件占用重试。""" + + +# Windows 的 st_ctime 是创建时间:内容修改不会改变它(没有防篡改价值),且杀软过滤 +# 驱动下同一路径 lstat 与打开句柄 fstat 读到的 ctime_ns 可能恒不一致(NTFS 实测, +# size/mtime/dev/ino 均相同),纳入比对会让每次 source_fingerprint 都误报 +# "file changed"。POSIX 的 ctime 是 inode 变更时间,写入会更新,保留作额外信号。 +if sys.platform == "win32": + _STAMP_FIELDS = ("size", "mtime_ns", "dev", "ino") +else: + _STAMP_FIELDS = ("size", "mtime_ns", "ctime_ns", "dev", "ino") + + +def _stat_stamp(st): + if not stat.S_ISREG(st.st_mode): + raise PendingTmpError("not a regular file") + values = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, + "ctime_ns": st.st_ctime_ns, "dev": st.st_dev, "ino": st.st_ino} + return {key: values[key] for key in _STAMP_FIELDS} + + +def file_stamp(path): + """廉价身份快照(不跟随 symlink);每轮监控只读 stat 和小 sidecar。""" + return _stat_stamp(Path(path).lstat()) + + +def source_fingerprint(path): + """流式 SHA256;hash 前后及打开的 inode 都须一致,避免读取升级中的混合包。""" + before = file_stamp(path) + digest = hashlib.sha256() + with open(path, "rb") as stream: + if _stat_stamp(os.fstat(stream.fileno())) != before: + raise PendingTmpError("file changed before hashing") + for block in iter(lambda: stream.read(BLOCK), b""): + digest.update(block) + if _stat_stamp(os.fstat(stream.fileno())) != before: + raise PendingTmpError("file changed while hashing") + if file_stamp(path) != before: + raise PendingTmpError("file replaced while hashing") + return dict(before, sha256=digest.hexdigest()) + + +def _same_stamp(path, fingerprint): + try: + current = file_stamp(path) + except FileNotFoundError as e: + raise PendingTmpError(f"pending source/TMP disappeared: {path}") from e + if current != {key: fingerprint[key] for key in _STAMP_FIELDS}: + raise PendingTmpError(f"file identity changed: {path}") + + +def _same_fingerprint(current, recorded): + """按当前平台字段集比较:升级前写的 sidecar 可能多带 Windows 已不比对的 ctime_ns; + 缺字段或非对象(未经校验的 previous_tmp)按不匹配处理。""" + return isinstance(recorded, dict) and all( + recorded.get(key) == value for key, value in current.items()) + + +def _load_tmp_metadata(): + try: + if TMP_META.is_symlink(): + raise PendingTmpError("sidecar is a symlink") + meta = json.loads(TMP_META.read_text(encoding="utf-8")) + if (meta["version"] != 1 or meta["asar"] != str(ASAR.resolve()) or + meta["tmp_path"] != str(TMP.absolute()) or + meta["operation"] not in ("install", "remove", "cancelled")): + raise PendingTmpError("sidecar target or operation mismatch") + for field in ("source", "tmp"): + fp = meta[field] + if (not all(type(fp[key]) is int for key in _STAMP_FIELDS) or + not isinstance(fp["sha256"], str) or + not re.fullmatch("[0-9a-f]{64}", fp["sha256"])): + raise PendingTmpError("invalid fingerprint") + if not isinstance(meta["runtime"], str): + raise PendingTmpError("invalid runtime") + return meta + except (FileNotFoundError, ValueError, KeyError, TypeError) as e: + raise PendingTmpError(f"untrusted pending TMP; re-run install: {e}") from e + + +def _atomic_tmp_metadata(meta): + fd, name = tempfile.mkstemp(prefix=TMP_META.name + ".", dir=TMP_META.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(meta, stream, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, TMP_META) + finally: + Path(name).unlink(missing_ok=True) + + +def _target_lock(): + # Independent of runtime/update locks: never call runtime synchronization here. + return file_lock(ASAR.with_name(ASAR.name + ".zusage.lock")) + + +def _checked_tmp_metadata(path, source, operation): + if operation not in ("install", "remove"): + raise PendingTmpError("invalid pending operation") + _same_stamp(ASAR, source) + before = source_fingerprint(path) + try: + ok = self_check(path, expect=operation == "install") and syntax_check(path) + except (ValueError, AssertionError, KeyError, struct.error) as e: + raise PendingTmpError(f"pending validation failed: {e}") from e + if not ok: + raise PendingTmpError("pending structure/syntax validation failed") + _same_stamp(path, before) + _same_stamp(ASAR, source) + return {"version": 1, "asar": str(ASAR.resolve()), "tmp_path": str(TMP.absolute()), + "runtime": str(RUNTIME.resolve()), "operation": operation, + "validated": True, "source": source, "tmp": before} + + +def write_tmp_metadata(source, operation="install"): + """Existing TMP helper: authorize only after structure and syntax validation.""" + with _target_lock(): + meta = _checked_tmp_metadata(TMP, source, operation) + _atomic_tmp_metadata(meta) + return meta + + +def _owned_tmp_metadata(): + """Only reclaim proven files, including our interrupted publication journal.""" + meta = _load_tmp_metadata() + if TMP.exists() or TMP.is_symlink(): + current = source_fingerprint(TMP) + matches = _same_fingerprint(current, meta["tmp"]) + if meta["operation"] == "cancelled" and meta.get("publishing") is True: + # rename can change ctime. Same inode, content and other stamps prove + # ownership; the prior package may still be present before rename. + fields = ("size", "mtime_ns", "dev", "ino", "sha256") + matches = matches or all(current[key] == meta["tmp"][key] for key in fields) + matches = matches or _same_fingerprint(current, meta.get("previous_tmp")) + if not matches: + raise PendingTmpError("pending TMP no longer belongs to its sidecar") + return meta + + +def _cancel_pending_install(): + if not TMP_META.exists() and not TMP_META.is_symlink(): + return + meta = _load_tmp_metadata() + if meta["operation"] == "install": + meta["operation"] = "cancelled" + _atomic_tmp_metadata(meta) + + +def cancel_pending_install(): + """remove/optout revokes prior install finalization under the target lock.""" + with _target_lock(): + _cancel_pending_install() + + +def _publish_pending(stage, source, operation, previous): + """校验 stage 后按 journal→原子发布→sidecar 的次序落成 TMP(prepare 与桥链共用)。""" + meta = _checked_tmp_metadata(stage, source, operation) + # Two files cannot be atomically published together. Persist a cancelled + # ownership journal first, so any interruption is safe and recoverable. + journal = dict(meta, operation="cancelled", publishing=True) + if previous is not None and TMP.exists(): + journal["previous_tmp"] = source_fingerprint(TMP) + _atomic_tmp_metadata(journal) + _same_stamp(ASAR, source) + _same_stamp(stage, meta["tmp"]) + os.replace(stage, TMP) + meta["tmp"] = source_fingerprint(TMP) + _same_stamp(ASAR, source) + _atomic_tmp_metadata(meta) + return meta + + +def _prepare_tmp(modify, operation="install", source=None): + source = source if source is not None else source_fingerprint(ASAR) + _same_stamp(ASAR, source) + previous = None + if TMP.exists() or TMP.is_symlink() or TMP_META.exists() or TMP_META.is_symlink(): + previous = _owned_tmp_metadata() + fd, name = tempfile.mkstemp(prefix=TMP.name + ".", suffix=".stage", dir=TMP.parent) + os.close(fd) + stage = Path(name) + try: + repack(ASAR, modify, stage) + original_stat = ASAR.stat() + if os.name != "nt": + stage_stat = stage.stat() + if (stage_stat.st_uid, stage_stat.st_gid) != (original_stat.st_uid, original_stat.st_gid): + os.chown(stage, original_stat.st_uid, original_stat.st_gid) + os.chmod(stage, stat.S_IMODE(original_stat.st_mode)) + return _publish_pending(stage, source, operation, previous) + finally: + # Only this invocation's exclusive name, never glob historical TMPs. + stage.unlink(missing_ok=True) + + +def prepare_tmp(modify, operation="install", source=None): + """Generate privately, validate, then publish; unknown historical TMPs stay.""" + with _target_lock(): + return _prepare_tmp(modify, operation, source) + + +def publish_external_pending(stage, source=None, operation="install"): + """把外部已构建好的完整 asar 镜像发布为待替换包(renderer 桥链:ensure_deployed + 就地替换被拒后转存)。与 prepare_tmp 同一套校验与 journal 语义;stage 一经移交, + 成功转存或失败清理都由本函数负责,调用方不再管理该文件。已有 pending 的 + operation 为 remove 时拒绝:自动补桥绝不覆盖用户的显式卸载意图——调用方的 + 锁外快照(pending_operation)存在 TOCTOU,以锁内判定为准(issue #67 二审)。""" + stage = Path(stage) + source = source if source is not None else source_fingerprint(ASAR) + with _target_lock(): + previous = None + if TMP.exists() or TMP.is_symlink() or TMP_META.exists() or TMP_META.is_symlink(): + previous = _owned_tmp_metadata() + try: + if operation == "install" and previous is not None and previous["operation"] == "remove": + raise PendingTmpError("pending remove in progress; install pending refused") + return _publish_pending(stage, source, operation, previous) + finally: + stage.unlink(missing_ok=True) + + +def pending_operation(): + """当前待替换 sidecar 的 operation(install/remove/cancelled);无合法 sidecar + 时返回 None。桥补装链用它让位:用户 remove 待收尾期间,自动补桥不得以 + install pending 覆盖卸载意图。""" + try: + return _load_tmp_metadata()["operation"] + except PendingTmpError: + return None + + +def pending_tmp(operation="install", verify_hash=False, verified=None): + """校验来源及 TMP;verified 是单次监控的缓存,stat/sidecar 不变才复用 hash。""" + meta = _load_tmp_metadata() + if (meta["operation"] == "cancelled" or meta.get("validated") is not True or + (operation is not None and meta["operation"] != operation) or + (meta["operation"] == "install" and meta["runtime"] != str(RUNTIME.resolve()))): + raise PendingTmpError("pending operation/runtime is no longer authorized") + _same_stamp(ASAR, meta["source"]) + _same_stamp(TMP, meta["tmp"]) + if verify_hash and (verified is None or verified.get("metadata") != meta): + if not (_same_fingerprint(source_fingerprint(ASAR), meta["source"]) and + _same_fingerprint(source_fingerprint(TMP), meta["tmp"])): + raise PendingTmpError("pending source/TMP hash mismatch") + try: + ok = self_check(TMP, expect=meta["operation"] == "install") + except (ValueError, AssertionError, KeyError, struct.error) as e: + raise PendingTmpError(f"pending self-check failed: {e}") from e + if not ok: + raise PendingTmpError("pending self-check failed") + _same_stamp(ASAR, meta["source"]) + _same_stamp(TMP, meta["tmp"]) + if verified is not None: + verified["metadata"] = meta + return meta + + +def _replace_pending(operation, verified=None, client_closed=None): + meta = pending_tmp(operation, verify_hash=True, verified=verified) + if client_closed is not None and not client_closed(): + return False + # 紧贴 rename 再核对。外部升级不共享 OS 锁,仍有无法彻底消除的极短 TOCTOU 窗口。 + if pending_tmp(operation) != meta: + raise PendingTmpError("sidecar changed before replacement") + publication = (publish_install(RUNTIME, {"asar": str(ASAR.resolve()), + "entryHash": hashlib.sha256(entry_bytes_of(TMP)).hexdigest()}) + if meta["operation"] == "install" and (RUNTIME / ".plugin-restart-required").is_file() + else nullcontext()) + with publication: + if pending_tmp(operation) != meta: + raise PendingTmpError("sidecar changed before replacement") + os.replace(TMP, ASAR) + try: + if _load_tmp_metadata() == meta: + TMP_META.unlink() + except (OSError, PendingTmpError): + pass # 替换已经成功;不能因 sidecar 清理失败重试并误报失败。 + return True + + +def finalize_tmp(operation="install", client_closed=None, verified=None): + """只收尾可信包且客户端已退出;OSError 可重试,PendingTmpError 须取消。""" + closed = client_closed if client_closed is not None else lambda: not client_running() + if not closed(): + return False + with _target_lock(): + if not closed(): + return False + return _replace_pending(operation, verified=verified, client_closed=closed) + + +# ---------- 安装 / 卸载 ---------- + +def target_writable(): + """asar 与其所在目录都可写(/opt 等系统位置需要 root:只读目标不能再重打包)。""" + return os.access(ASAR, os.W_OK) and os.access(ASAR.parent, os.W_OK) + + +def sudo_hint(operation="install"): + """系统位置不可写时可操作的提示:sudo 默认重置 HOME/PATH,必须显式保留, + 否则 nvm 装的 node 与 ~/.zcode 下的运行时目录都会找错(issue #15)。 + 插件安装走 overlay_install.py,仓库根一键安装走 install.py;用当前目录实际在的那个。 + operation 必须和调用方实际要做的事一致:install 场景提示装,remove 场景提示卸载, + 否则会把想卸载的用户引导去重新安装/注入(issue #15 的 review 发现)。 + Windows 没有 sudo:受保护目录(Program Files 等)的提示是以管理员身份重跑(issue #67)。""" + here = Path(__file__).resolve().parent + args = {"install": ("overlay_install.py", "install"), "remove": ("overlay_install.py", "remove")}[operation] + fallback = ("install.py", "" if operation == "install" else "--remove") + script, cmd = next(((here / name, cmd) for name, cmd in (args, fallback) + if (here / name).is_file()), (here / fallback[0], fallback[1])) + if IS_WIN: + print(L(f"[权限] app.asar 或其所在目录不可写(Program Files 等受保护位置需要管理员):{ASAR}", + f"[permission] app.asar or its directory is not writable (protected locations like Program Files need an administrator): {ASAR}")) + print(L("请以管理员身份打开终端(右键终端图标,选「以管理员身份运行」),然后执行:", + "Open a terminal as administrator (right-click the terminal icon, choose 'Run as administrator'), then run:")) + print(L(' python "{}" {}'.format(script, cmd).rstrip(), + ' python "{}" {}'.format(script, cmd).rstrip())) + return + print(L(f"[权限] app.asar 或其所在目录不可写(Linux 上 /opt 等系统位置需要 root):{ASAR}", + f"[permission] app.asar or its directory is not writable (Linux system locations like /opt need root): {ASAR}")) + print(L("请用 sudo 重跑,并保留当前 HOME 与 PATH(sudo 默认重置它们,会让 nvm 的 node " + "和 ~/.zcode 下的运行时目录定位失败):", + "Re-run with sudo while keeping the current HOME and PATH (sudo resets both by default, " + "which hides an nvm node and mislocates the runtime dir under ~/.zcode):")) + print(L(' sudo env HOME="$HOME" PATH="$PATH" python3 "{}" {}'.format(script, cmd).rstrip(), + ' sudo env HOME="$HOME" PATH="$PATH" python3 "{}" {}'.format(script, cmd).rstrip())) + + +def install(finalize=False, before_publish=None): + """Serialize install/remove/finalize; optional callback precedes a new install's publication.""" + if finalize: + return _install(finalize=True) + try: + with _target_lock(): + return _install(before_publish=before_publish) + except OSError as e: + print(L(f"安装锁获取失败:{e}", f"Cannot acquire install lock: {e}")) + return False + + +def _install(finalize=False, before_publish=None): + """返回 True=已安装/已指向当前目录,False=失败或待收尾。""" + if finalize: + try: + ok = finalize_tmp(operation="install") + except (OSError, PendingTmpError) as e: + print(L(f"收尾未完成,保留 TMP,请确认退出客户端并重新安装:{e}", + f"Finalization not completed; TMP kept, quit the client and re-run install: {e}")) + return False + if ok: + cleanup_legacy_bak() + return ok + assert LOADER.exists(), L(f"loader 缺失:{LOADER}", f"loader missing: {LOADER}") + if not target_writable(): + sudo_hint() + return False + try: + source = source_fingerprint(ASAR) # 必须在读取待修改入口和 repack 前取得。 + entry = entry_bytes_of(ASAR) + _same_stamp(ASAR, source) + except (OSError, PendingTmpError) as e: + print(L(f"来源校验失败:{e}", f"Source validation failed: {e}")) + return False + stripped = ZUSAGE_LINE_RE.sub(b"", entry) # 剥离任何旧注入行(含指向旧目录的) + if stripped + INJECT_LINE.encode() == entry: + print(L("已安装,注入行已指向当前目录。如需重装先 remove。", + "Already installed; the injection line points at the current directory. Run remove first to reinstall.")) + cleanup_legacy_bak() + return True + + new_entry = stripped + INJECT_LINE.encode() + if stripped != entry: + print(L("检测到旧注入行(可能指向旧目录),将替换为新路径。", + "Old injection line detected (possibly pointing at an old directory); it will be replaced with the new path.")) + print(L("重打包(307MB,约需十几秒)...", "repacking (307MB, takes ~10-20 seconds)...")) + try: + if before_publish is not None: + before_publish({"asar": str(ASAR.resolve()), + "entryHash": hashlib.sha256(new_entry).hexdigest()}) + _prepare_tmp({"/" + ENTRY: new_entry}, source=source) + except (OSError, PendingTmpError) as e: + print(L(f"待替换包生成失败,未替换:{e}", f"Pending package preparation failed; not replaced: {e}")) + return False + if client_running(): + try: + _replace_pending("install") + print(L("\n客户端运行中,但 asar 原子替换成功(运行中进程仍读旧数据)。", + "\nZCode is running, but the asar was replaced atomically (running processes keep reading the old data).")) + print(L("重启 ZCode 后悬浮条生效;已弹出监控窗口,确认生效后自动关闭。", + "The floating bar activates after restarting ZCode; a monitor window has opened and will close itself once the load is confirmed.")) + cleanup_legacy_bak() + launch_monitor(time.time()) + return True + except PendingTmpError as e: + print(L(f"待替换包已失效,未替换:{e}", f"Pending package invalidated; not replaced: {e}")) + return False + except OSError as e: + print(L(f"\n运行中替换失败({e})。已弹出监控窗口:完全退出 ZCode 后会自动完成替换,", + f"\nReplacement while running failed ({e}). A monitor window has opened: it finishes the replacement once ZCode quits completely,")) + print(L(f'或之后手动执行:python patch_install.py install --finalize --asar "{ASAR}" --runtime "{RUNTIME}"', + f'or run manually afterwards: python patch_install.py install --finalize --asar "{ASAR}" --runtime "{RUNTIME}"')) + launch_monitor(time.time()) + return False + try: + if not _replace_pending("install", client_closed=lambda: not client_running()): + launch_monitor(time.time()) + return False + except PendingTmpError as e: + print(L(f"待替换包已失效,未替换:{e}", f"Pending package invalidated; not replaced: {e}")) + return False + except OSError as e: + print(L(f"替换失败,监控将等待退出并重试:{e}", f"Replacement failed; monitor will wait for exit and retry: {e}")) + launch_monitor(time.time()) + return False + print(L("完成。启动 ZCode 即可在窗口底部看到悬浮条。", "Done. Start ZCode and the floating bar appears at the bottom of the window.")) + cleanup_legacy_bak() + launch_monitor(time.time()) + return True + + +def injected_state(): + """True=已注入 False=未注入 None=asar 读不到;只读即可判定,不需要目标锁。""" + try: + return bool(ZUSAGE_LINE_RE.search(entry_bytes_of(ASAR))) + except (OSError, ValueError, AssertionError, KeyError, struct.error): + return None + + +def remove(): + # 系统位置(/opt 等)下普通用户拿不到 asar 锁:此时只有"确无注入"才该免 root 放行。 + # 安装从未成功过(issue #15 的常见状态)不需要剥离任何东西,豁免标记必须能写, + # 否则「关闭自动安装与提醒」这件事本身也被权限卡住。 + if not target_writable(): + state = injected_state() + if state is False: + try: + _cancel_pending_install() + except (OSError, PendingTmpError) as e: + print(L(f"(未注入,但旧安装意图清理失败:{e})", + f"(not injected, but cleaning the stale install intent failed: {e})")) + print(L("当前 asar 未注入,无需 root 权限。", "The current asar is not injected; root is not needed.")) + cleanup_legacy_bak() + return True + if state is None: + # 读都读不到:把"关闭提醒"交回给调用方,别把 optout 也一起卡住。 + print(L("无法读取 app.asar(不存在或权限不足),无法确认注入状态。", + "Cannot read app.asar (missing or permission denied); the injection state is unknown.")) + return False + sudo_hint("remove") + return False + try: + with _target_lock(): + return _remove() + except OSError as e: + print(L(f"卸载锁获取失败:{e}", f"Cannot acquire remove lock: {e}")) + return False + + +def _remove(): + """卸载:从当前 asar 剥离本工具注入行(不依赖备份,其它工具的注入行不受影响)。 + 返回 True=已卸载/本就未注入,False=需人工处理(替换失败待收尾)。""" + try: + _cancel_pending_install() # 即使目标未注入,也不能让旧 install TMP 在 optout 后重注入。 + source = source_fingerprint(ASAR) + old = entry_bytes_of(ASAR) + _same_stamp(ASAR, source) + except (OSError, PendingTmpError) as e: + print(L(f"卸载安全校验失败,未替换:{e}", f"Remove safety validation failed; not replaced: {e}")) + return False + stripped = ZUSAGE_LINE_RE.sub(b"", old) + if stripped == old: + print(L("当前 asar 未注入。", "The current asar is not injected.")) + cleanup_legacy_bak() + return True + print(L("重打包(剥离注入行,约需十几秒)...", "repacking (stripping the injection line, takes ~10-20 seconds)...")) + try: + _prepare_tmp({"/" + ENTRY: stripped}, operation="remove", source=source) + except (OSError, PendingTmpError) as e: + print(L(f"卸载待替换包生成失败:{e}", f"Remove pending package preparation failed: {e}")) + return False + running = client_running() + try: + _replace_pending("remove") + except PendingTmpError as e: + print(L(f"卸载待替换包已失效,未替换:{e}", f"Remove pending package invalidated; not replaced: {e}")) + return False + except OSError as e: + print(L(f'\n替换失败({e})。请完全退出 ZCode 后重跑 python patch_install.py remove --asar "{ASAR}",', + f'\nReplacement failed ({e}). Quit ZCode completely and re-run python patch_install.py remove --asar "{ASAR}", ')) + print(L("待替换文件保留;不要手动 move/mv 覆盖可能已升级的 app.asar。", + "Pending files kept; do not manually move/mv over a possibly upgraded app.asar.")) + return False + print(L("已剥离注入行" + ("(客户端运行中,已原子替换,运行中进程仍读旧数据)" if running else "") + "。重启 ZCode 后生效。", + "Injection line stripped" + (" (ZCode was running; replaced atomically, the running process keeps the old data)" if running else "") + ". Takes effect after restarting ZCode.")) + cleanup_legacy_bak() + return True + + +def check(): + injected = bool(ZUSAGE_LINE_RE.search(entry_bytes_of(ASAR))) # 宽松匹配任意目录的历史注入行(独立运行时 INJECT_LINE 指向仓库路径,精确匹配会误报未注入) + print("asar:", ASAR, ASAR.stat().st_size, "bytes") + print(L("注入状态:", "Injection:"), L("已注入", "injected") if injected else L("未注入", "not injected")) + print(L("遗留备份(机制已退役,成功安装/卸载后自动清理):", "Legacy backup (retired; auto-removed on successful install/remove):"), BAK.exists()) + if injected: + syntax_check(ASAR) + if TMP.exists(): + # 待替换 TMP 可能是 install 也可能是 remove(sidecar 的 operation 字段),命令不能 + # 写死 install --finalize——remove 的 TMP 照抄这条命令会被 pending_tmp 的操作类型 + # 校验拒绝;读不到/不可信时保留原本的 install --finalize 兜底(成功率更高的默认值)。 + try: + operation = _load_tmp_metadata().get("operation") + except PendingTmpError: + operation = None + if operation == "remove": + hint = L(f'(退出 ZCode 后跑 remove --asar "{ASAR}")', + f'(quit ZCode, then run remove --asar "{ASAR}")') + else: + hint = L(f'(退出 ZCode 后跑 install --finalize --asar "{ASAR}" --runtime "{RUNTIME}")', + f'(quit ZCode, then run install --finalize --asar "{ASAR}" --runtime "{RUNTIME}")') + print(L("存在待替换 TMP:", "Pending replacement TMP exists:"), TMP, hint) + + +def _consume_flag_arg(argv, name): + """取出并剥离 -- / --=(不论在 argv 哪个位置),返回 + (value_or_None, 剩余 argv);剩余 argv 保持原有顺序,cmd/--finalize 解析不受影响。 + 出现多次时取最后一次(与常见 CLI 行为一致);紧跟的下一个 token 本身像另一个 flag + (以 -- 开头)时按缺值处理,原样保留在剩余 argv 里,不会被误吞成这个 flag 的值。""" + flag = f"--{name}" + value, remaining, i = None, [], 0 + while i < len(argv): + a = argv[i] + if a == flag and i + 1 < len(argv) and not argv[i + 1].startswith("--"): + value = argv[i + 1] + i += 2 + continue + if a.startswith(flag + "="): + value = a[len(flag) + 1:] + i += 1 + continue + remaining.append(a) + i += 1 + return value, remaining + + +def main(argv=None): + args = sys.argv[1:] if argv is None else argv + # standalone 运行(install.py/overlay_install.py 拉起时已 set_target()/set_runtime(), + # 不会带这两个参数):命令行优先于对应环境变量;都没有时沿用模块级默认值。 + # --asar 不猜自定义安装位置(按平台默认猜测,猜错即 FileNotFoundError); + # --runtime 不对就算 --asar 对了也无济于事——install --finalize 要求 sidecar 记录的 + # runtime 与当前 RUNTIME 一致(pending_tmp 的来源授权校验,二者各自 .resolve() 后比较, + # 不受符号链接影响),standalone 默认 RUNTIME=HERE(脚本所在目录),插件安装时 sidecar + # 记的却是运行时数据目录,两者不一致会被拒绝 "pending operation/runtime is no longer + # authorized"。两者均见文件顶部用法说明。 + asar_arg, args = _consume_flag_arg(args, "asar") + asar_arg = asar_arg or os.environ.get("ZCODE_ASAR") + if asar_arg: + set_target(os.path.expandvars(os.path.expanduser(asar_arg))) + runtime_arg, args = _consume_flag_arg(args, "runtime") + runtime_arg = runtime_arg or os.environ.get("ZCODE_RUNTIME") + if runtime_arg: + # 不用 .resolve():各打印点原样回显当前 RUNTIME(见上方 install/check 的提示), + # 用户复制粘贴后必须字节级还原成同一个值——self_check() 用 RUNTIME 现算的 + # INJECT_LINE 与 TMP 里已固化的内容做字面比较,不再重新 resolve;若这里 resolve, + # 符号链接路径上(如 macOS /var 对 /private/var)会生成不同字符串,白白通过授权 + # 校验却自检失败。但 LOADER.as_uri() 要求绝对路径,手动输入相对路径会直接崩溃 + # (提示本身回显的永远是绝对路径,不受影响);os.path.abspath 只补全成绝对路径、 + # 不跟随符号链接解析,对已是绝对路径的输入是纯粹的 no-op,两头都不破。 + set_runtime(os.path.abspath(os.path.expandvars(os.path.expanduser(runtime_arg)))) + cmd = args[0] if args else "check" + if cmd == "install": + return install(finalize="--finalize" in args[1:]) + return {"remove": remove, "check": check}.get(cmd, check)() + + +if __name__ == "__main__": + main() diff --git a/external_plugins/zcode-token-usage-statusbar/plugin-release.cjs b/external_plugins/zcode-token-usage-statusbar/plugin-release.cjs new file mode 100644 index 0000000..2aa94e2 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/plugin-release.cjs @@ -0,0 +1,288 @@ +"use strict"; +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); +const { execFile } = require("child_process"); + +const PLUGIN_ID = "zcode-token-usage-statusbar@zusage-marketplace"; +const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const MAX_ASSET = 16 * 1024 * 1024; +const MAX_JSON = 256 * 1024; +// GitCode 形状于 2026-10-20 实测:browser_download_url 为 GitHub 风格路径, +// 下载重定向到 file-cdn.gitcode.com;两台主机的形状都必须留在各自白名单里。 +const RELEASE_HOSTS = { + github: { + assetUrl: /^https:\/\/github\.com\/([^/]+\/[^/]+)(\/releases\/download\/[^?#]+)$/, + downloadHosts: ["api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"], + }, + gitcode: { + assetUrl: /^https:\/\/gitcode\.com\/([^/]+\/[^/]+)(\/releases\/download\/[^?#]+)$/, + downloadHosts: ["gitcode.com", "file-cdn.gitcode.com"], + }, +}; +const DOWNLOAD_HOSTS = Object.values(RELEASE_HOSTS).flatMap((host) => host.downloadHosts); + +function githubRepository(value) { + if (typeof value !== "string") return null; + const match = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)?([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/.exec(value); + return match ? match[1] : null; +} +function gitcodeRepository(value) { + if (typeof value !== "string") return null; + const match = /^(?:https:\/\/gitcode\.com\/|ssh:\/\/git@gitcode\.com\/|git@gitcode\.com:)?([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/.exec(value); + return match ? match[1] : null; +} +function sameRepository(a, b) { + return typeof a === "string" && typeof b === "string" && githubRepository(a) === a && githubRepository(b) === b && a.toLowerCase() === b.toLowerCase(); +} +function parseAssetUrl(value) { + if (typeof value !== "string") return null; + for (const [host, profile] of Object.entries(RELEASE_HOSTS)) { + const match = profile.assetUrl.exec(value); + if (match) return { host, repository: match[1], path: match[2] }; + } + return null; +} +function sameAssetUrl(actual, expected) { + const a = parseAssetUrl(actual), b = parseAssetUrl(expected); + return !!a && !!b && a.host === b.host && sameRepository(a.repository, b.repository) && a.path === b.path; +} +function readCatalog(root) { + // 市场缓存清单只用来识别“源码清单”这一形态;事务进行中、缺失或损坏时按未知处理, + // 由后续 Release 流程报出准确原因。 + try { + if (fs.existsSync(path.join(root, "marketplaces", ".zusage-marketplace.transaction.json"))) return null; + return JSON.parse(fs.readFileSync(path.join(root, "marketplaces", "zusage-marketplace", "marketplace.json"), "utf8")); + } catch (e) { return null; } +} +function readChannel(home, root = require("./plugin-storage.cjs").resolvePluginStorage(home)) { + const file = path.join(root, "known_marketplaces.json"); + const data = JSON.parse(fs.readFileSync(file, "utf8")); + const entries = data && data.marketplaces; + if (!Array.isArray(entries)) throw new Error("插件市场来源记录无效"); + const matches = entries.filter((m) => m && (m.id === "zusage-marketplace" || m.name === "zusage-marketplace")); + const market = matches.length === 1 ? matches[0] : null; + if (!market || !market.source || typeof market.source !== "object") throw new Error("未找到 zusage-marketplace 的注册来源"); + const source = market.source; + if (source.source === "directory") return { kind: "local", marketplace: market }; + // 仓库来源但清单指向源码(默认分支的 ./plugins/... 路径源)时没有可校验的 Release: + // 按 market 渠道交给宿主插件管理器直接更新,与本地目录来源同一条链。 + const kind = catalogChannel(readCatalog(root)) === "source" ? "market" : "release"; + if (source.source === "git") { + const gitcode = gitcodeRepository(source.url); + if (gitcode) return { kind, flavor: "gitcode", repository: gitcode, marketplace: market }; + } + const repository = source.source === "github" ? githubRepository(source.repo || source.repository) + : source.source === "git" ? githubRepository(source.url) : null; + if (!repository) throw new Error("远端自动更新仅支持已注册的 GitHub/GitCode 仓库来源"); + return { kind, flavor: "github", repository, marketplace: market }; +} +async function fetchBytes(url, limit, onProgress) { + let activeRequest, activeResponse, timer, settled = false; + return new Promise((resolve, reject) => { + const finish = (error, bytes) => { + if (settled) return; + settled = true; clearTimeout(timer); + if (error) { + if (activeResponse) activeResponse.destroy(); + if (activeRequest) activeRequest.destroy(error); + reject(error); + } else resolve(bytes); + }; + const visit = (target, redirects) => { + try { + const parsed = new URL(target); + const allowed = parsed.protocol === "https:" && !parsed.username && !parsed.password && !parsed.port && + DOWNLOAD_HOSTS.includes(parsed.hostname); + if (!allowed) throw new Error("拒绝非 GitHub/GitCode HTTPS 更新地址"); + const request = https.get(parsed, { headers: { "User-Agent": "zcode-token-usage-statusbar-release-updater", + "Accept": parsed.hostname === "api.github.com" ? "application/vnd.github+json" : "application/octet-stream", + "X-GitHub-Api-Version": "2022-11-28" } }, (response) => { + if (settled) { response.destroy(); return; } + activeResponse = response; + response.on("error", (error) => finish(error)); + response.on("aborted", () => finish(new Error("发布资源下载中断"))); + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + response.resume(); + if (!response.headers.location || redirects >= 3) { finish(new Error("下载重定向无效")); return; } + try { visit(new URL(response.headers.location, parsed).href, redirects + 1); } + catch (error) { finish(error); } + return; + } + if (response.statusCode !== 200) { finish(new Error(`Release 请求失败 (${response.statusCode})`)); return; } + const declared = Number(response.headers["content-length"] || 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > limit) { finish(new Error("发布资源超过大小上限")); return; } + const chunks = []; let length = 0; + response.on("data", (chunk) => { + if (settled) return; + length += chunk.length; + if (length > limit) finish(new Error("发布资源超过大小上限")); + else { + chunks.push(chunk); + if (typeof onProgress === "function") onProgress(length, declared); + } + }); + response.on("end", () => { + if (!response.complete || (declared && declared !== length)) finish(new Error("发布资源下载不完整")); + else finish(null, Buffer.concat(chunks)); + }); + }); + activeRequest = request; + request.setTimeout(30000, () => finish(new Error("Release 请求超时"))); + request.on("error", (error) => finish(error)); + } catch (error) { finish(error); } + }; + timer = setTimeout(() => finish(new Error("Release 请求超过总时限")), 60000); + visit(url, 0); + }); +} +function releaseAssetUrl(repository, tag, name) { + return `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; +} +function validateRelease(release, repository) { + if (!release || release.draft !== false || release.prerelease !== false || typeof release.tag_name !== "string") return null; + const version = release.tag_name.startsWith("v") ? release.tag_name.slice(1) : ""; + if (!STABLE.test(version)) return null; + const names = [`zcode-token-usage-statusbar-v${version}.zip`, "release-manifest.json"]; + const assets = Array.isArray(release.assets) ? release.assets : []; + const picked = names.map((name) => assets.filter((a) => a && a.name === name)); + if (picked.some((matches) => matches.length !== 1)) return null; + const [archive, manifest] = picked.map((matches) => matches[0]); + for (const asset of [archive, manifest]) { + const limit = asset === archive ? MAX_ASSET : MAX_JSON; + if (asset.state !== "uploaded" || !Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > limit || + !sameAssetUrl(asset.browser_download_url, releaseAssetUrl(repository, release.tag_name, asset.name))) throw new Error("GitHub Release 资产来源或大小无效"); + } + return { flavor: "github", version, tag: release.tag_name, repository, archive, manifest, + url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(release.tag_name)}` }; +} +async function checkRelease(repository, compareVersions, fetch = fetchBytes) { + if (!githubRepository(repository) || repository !== githubRepository(repository)) throw new Error("GitHub 仓库标识无效"); + let best = null; + for (let page = 1; page <= 10; page++) { + const body = await fetch(`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`, 4 * 1024 * 1024); + const rows = JSON.parse(body.toString("utf8")); + if (!Array.isArray(rows)) throw new Error("GitHub Release 列表格式无效"); + for (const row of rows) { + const candidate = validateRelease(row, repository); + if (candidate && (!best || compareVersions(candidate.version, best.version) > 0)) best = candidate; + } + if (rows.length < 100) { + if (!best) throw new Error("此 GitHub 仓库尚无带完整发布包的正式 Release,不能安装开发分支"); + return best; + } + } + throw new Error("GitHub Release 列表超过安全分页上限,无法确定最新正式版本"); +} +function validateManifest(manifest, release) { + const asset = manifest && manifest.asset; + // GitCode 渠道的合成 release 行在下载 manifest 前拿不到资产大小(archive.size 为 null), + // 大小以下载到的 manifest 为准;GitHub 渠道的 API 行始终带大小,逐项强校验。 + // GitCode 镜像的 manifest.repository 是打包时的上游仓库(如 ByteRax/...),与注册的 + // 镜像仓库(tanranran/...)刻意不同;镜像渠道的身份绑定改由清单 URL、pluginId 与 + // asset.sha256 承担,repository 只要求仍是合法 owner/repo(解包器按 manifest 值复核)。 + const repositoryOk = sameRepository(manifest.repository, release.repository) || + (release.flavor === "gitcode" && typeof manifest.repository === "string" && + githubRepository(manifest.repository) === manifest.repository); + if (!manifest || manifest.schemaVersion !== 1 || !repositoryOk || + manifest.version !== release.version || manifest.tag !== release.tag || manifest.pluginId !== PLUGIN_ID || !asset || + asset.name !== release.archive.name || (release.archive.size !== null && asset.size !== release.archive.size) || + !Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ASSET || + typeof asset.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("发布 manifest 与正式版本、来源或资源不一致"); + return manifest; +} +function unpack(command, args) { + return new Promise((resolve, reject) => execFile(command, args, + { windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { reject(new Error(String(stderr || error.message).trim().slice(0, 1000))); return; } + try { resolve(JSON.parse(stdout)); } catch (e) { reject(new Error("发布包校验器未返回有效 JSON")); } + })); +} +async function downloadRelease(release, python, fetch = fetchBytes, runUnpack = unpack, onProgress) { + const report = typeof onProgress === "function" ? onProgress : () => { }; + const manifestBody = await fetch(release.manifest.browser_download_url, MAX_JSON); + const manifest = validateManifest(JSON.parse(manifestBody.toString("utf8")), release); + report({ stage: "download", loaded: 0, total: manifest.asset.size }); + const bytes = await fetch(release.archive.browser_download_url, MAX_ASSET, + (loaded, total) => report({ stage: "download", loaded, total: total || manifest.asset.size })); + if (bytes.length !== manifest.asset.size || crypto.createHash("sha256").update(bytes).digest("hex") !== manifest.asset.sha256) throw new Error("发布包下载大小或 SHA256 校验失败"); + report({ stage: "verify" }); + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "zusage-release-"))); + const cleanup = () => fs.rmSync(directory, { recursive: true, force: true }); + try { + const archive = path.join(directory, release.archive.name), info = path.join(directory, "release-manifest.json"); + const output = path.join(directory, "marketplace"); + fs.writeFileSync(archive, bytes, { flag: "wx" }); fs.writeFileSync(info, manifestBody, { flag: "wx" }); + const result = await runUnpack(python, [path.join(__dirname, "release_unpack.py"), "--archive", archive, + "--manifest", info, "--repository", release.flavor === "gitcode" ? manifest.repository : release.repository, + "--version", release.version, "--output", output]); + if (!result || result.ok !== true || result.version !== release.version || result.marketplaceRoot !== output || + result.pluginRoot !== path.join(output, "plugins", "zcode-token-usage-statusbar")) throw new Error("发布包校验或解压结果无效"); + return { directory, marketplaceRoot: output, pluginRoot: result.pluginRoot, release, manifest, cleanup }; + } catch (error) { cleanup(); throw error; } +} +function gitcodeReleaseFromCatalog(catalog, repository) { + // GitCode 渠道的版本与下载源只信任宿主刚从注册 ref 拉取的渠道清单;不调用 GitCode API。 + // 下载 URL 与安装后复核(verifyCommittedRelease)共用同一套主机模式,形状以 2026-10-20 实测为准。 + if (!githubRepository(repository) || repository !== githubRepository(repository)) throw new Error("GitCode 仓库标识无效"); + const entries = catalog && catalog.plugins; + const matches = Array.isArray(entries) ? entries.filter((p) => p && p.name === "zcode-token-usage-statusbar") : []; + const entry = matches.length === 1 ? matches[0] : null; + const source = entry && entry.source; + if (!catalog || catalog.name !== "zusage-marketplace" || !entry || typeof entry.version !== "string" || + !STABLE.test(entry.version) || !source || typeof source !== "object" || source.source !== "url" || + source.type !== "zip" || typeof source.url !== "string" || source.headers !== undefined || + (source.stripRoot !== undefined && source.stripRoot !== false) || + source.path !== "plugins/zcode-token-usage-statusbar" || + typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256)) throw new Error("GitCode 渠道清单格式无效"); + const tag = "v" + entry.version; + const archiveName = `zcode-token-usage-statusbar-v${entry.version}.zip`; + const expectedPath = `/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archiveName)}`; + const parsed = parseAssetUrl(source.url); + if (!parsed || parsed.host !== "gitcode" || !sameRepository(parsed.repository, repository) || + parsed.path !== expectedPath) throw new Error("GitCode 渠道清单下载源与注册仓库或版本不一致"); + return { + flavor: "gitcode", version: entry.version, tag, repository, + archive: { name: archiveName, size: null, browser_download_url: `https://gitcode.com/${repository}${expectedPath}` }, + manifest: { name: "release-manifest.json", + browser_download_url: `https://gitcode.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent("release-manifest.json")}` }, + url: `https://gitcode.com/${repository}/releases/tag/${encodeURIComponent(tag)}` }; +} +function validateGitcodeRelease(release) { + // 安装提交后复核持久化的 GitCode 更新意图:与 gitcodeReleaseFromCatalog 同一形状门。 + if (!release || release.flavor !== "gitcode" || !githubRepository(release.repository) || + release.repository !== githubRepository(release.repository) || typeof release.version !== "string" || + !STABLE.test(release.version) || release.tag !== "v" + release.version || !release.archive || + release.archive.name !== `zcode-token-usage-statusbar-v${release.version}.zip` || + release.archive.size !== null) return null; + const archive = parseAssetUrl(release.archive.browser_download_url); + const manifest = parseAssetUrl(release.manifest && release.manifest.browser_download_url); + const downloadPath = `/releases/download/${encodeURIComponent(release.tag)}/`; + let page = null; + try { page = new URL(release.url); } catch (e) { } + if (!archive || archive.host !== "gitcode" || !sameRepository(archive.repository, release.repository) || + archive.path !== downloadPath + encodeURIComponent(release.archive.name) || + !manifest || manifest.host !== "gitcode" || !sameRepository(manifest.repository, release.repository) || + manifest.path !== downloadPath + encodeURIComponent("release-manifest.json") || + !page || page.protocol !== "https:" || page.hostname !== "gitcode.com" || + page.pathname !== `/${release.repository}/releases/tag/${encodeURIComponent(release.tag)}`) return null; + return { ...release, version: release.version }; +} +/* 已注册市场的清单指向什么:Release 下载源,还是仓库源码(本地路径/开发分支)。 + readChannel 据此把源码清单的仓库来源判为 market 渠道,交给宿主插件管理器直接更新。 */ +function catalogChannel(catalog, pluginName = "zcode-token-usage-statusbar") { + const entries = catalog && catalog.plugins; + const matches = Array.isArray(entries) ? entries.filter((p) => p && p.name === pluginName) : []; + const entry = matches.length === 1 ? matches[0] : null; + if (!entry) return "unknown"; + const source = entry.source; + /* 源码清单里 source 常是相对路径字符串(如 "./plugins/zcode-token-usage-statusbar") */ + if (typeof source === "string") return "source"; + if (!source || typeof source !== "object") return "unknown"; + return source.source === "url" && source.type === "zip" ? "release" : "source"; +} +module.exports = { sameRepository, sameAssetUrl, githubRepository, gitcodeRepository, readChannel, checkRelease, gitcodeReleaseFromCatalog, validateGitcodeRelease, + downloadRelease, validateRelease, validateManifest, catalogChannel, + fetchBytes, MAX_ASSET, MAX_JSON, PLUGIN_ID }; diff --git a/external_plugins/zcode-token-usage-statusbar/plugin-storage.cjs b/external_plugins/zcode-token-usage-statusbar/plugin-storage.cjs new file mode 100644 index 0000000..7b1070a --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/plugin-storage.cjs @@ -0,0 +1,60 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); + +function storageDir(value, source) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${source} 必须是非空字符串`); + return value; +} +function readStorageDir(file) { + let text; + try { text = fs.readFileSync(file, "utf8"); } + catch (error) { + if (error.code === "ENOENT") return undefined; + throw new Error(`无法读取插件存储配置 ${file}: ${error.message}`); + } + let config; + try { config = JSON.parse(text); } + catch (error) { throw new Error(`插件存储配置 JSON 损坏 ${file}: ${error.message}`); } + if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error(`插件存储配置必须是对象: ${file}`); + if (!Object.prototype.hasOwnProperty.call(config, "storage")) return undefined; + if (!config.storage || typeof config.storage !== "object" || Array.isArray(config.storage)) throw new Error(`storage 必须是对象: ${file}`); + if (!Object.prototype.hasOwnProperty.call(config.storage, "dir")) return undefined; + return storageDir(config.storage.dir, `${file} 的 storage.dir`); +} +function projectDirectories(home) { + const directories = []; + let directory = home; + for (;;) { + directories.push(directory); + let git; + try { git = fs.statSync(path.join(directory, ".git")); } + catch (error) { + if (error.code !== "ENOENT") throw new Error(`无法识别插件存储配置的 Git 边界 ${directory}: ${error.message}`); + } + if (git && (git.isDirectory() || git.isFile())) return directories.reverse(); + const parent = path.dirname(directory); + if (parent === directory) return [home]; + directory = parent; + } +} +function resolvePluginStorage(home, env = process.env) { + storageDir(home, "home"); + if (!env || typeof env !== "object" || Array.isArray(env)) throw new Error("env 必须是对象"); + home = path.resolve(home); + let directory = "~/.zcode"; + const files = [path.join(home, ".zcode", "cli", "config.json")]; + for (const project of projectDirectories(home)) { + files.push(path.join(project, "zcode.json"), path.join(project, ".zcode", "config.json")); + } + // 每次读取全部依赖;即使被高优先级设置覆盖,损坏配置也不得回退。 + for (const file of files) { + const value = readStorageDir(file); + if (value !== undefined) directory = value; + } + if (env.ZCODE_STORAGE_DIR !== undefined) directory = storageDir(env.ZCODE_STORAGE_DIR, "ZCODE_STORAGE_DIR"); + const root = directory.startsWith("~/") ? path.join(home, directory.slice(2)) : path.resolve(home, directory); + return path.join(root, ...(path.basename(root) === "cli" ? [] : ["cli"]), "plugins"); +} + +module.exports = { resolvePluginStorage }; diff --git a/external_plugins/zcode-token-usage-statusbar/plugin-updater.cjs b/external_plugins/zcode-token-usage-statusbar/plugin-updater.cjs new file mode 100644 index 0000000..091a428 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/plugin-updater.cjs @@ -0,0 +1,563 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execFile, spawn } = require("child_process"); +const crypto = require("crypto"); +const { isDeepStrictEqual } = require("util"); +const releaseChannel = require("./plugin-release.cjs"); +const { resolvePluginStorage } = require("./plugin-storage.cjs"); + +const PLUGIN_ID = "zcode-token-usage-statusbar@zusage-marketplace"; +const INTERVAL_MS = 24 * 60 * 60 * 1000; + +function compareVersions(a, b) { + const parse = (v) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v || ""); + const aa = parse(a), bb = parse(b); + if (!aa || !bb) throw new Error("插件版本号缺失或不是语义版本,无法确认更新状态"); + for (let i = 1; i <= 3; i++) { + if (Number(aa[i]) !== Number(bb[i])) return Number(aa[i]) > Number(bb[i]) ? 1 : -1; + } + if (!aa[4] && !bb[4]) return 0; + if (!aa[4]) return 1; + if (!bb[4]) return -1; + const ap = aa[4].split("."), bp = bb[4].split("."); + for (let i = 0; i < Math.max(ap.length, bp.length); i++) { + if (ap[i] === bp[i]) continue; + if (ap[i] === undefined) return -1; + if (bp[i] === undefined) return 1; + const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]); + if (an && bn) return Number(ap[i]) > Number(bp[i]) ? 1 : -1; + if (an !== bn) return an ? -1 : 1; + return ap[i] > bp[i] ? 1 : -1; + } + return 0; +} + +function readJson(file, fallback) { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { return fallback; } +} +function readConfig(file) { + try { + const value = JSON.parse(fs.readFileSync(file, "utf8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch (e) { return e.code === "ENOENT" ? {} : null; } +} +function writeJson(file, value) { + const tmp = file + "." + process.pid + ".tmp"; + try { + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n"); + fs.renameSync(tmp, file); + } finally { + try { fs.unlinkSync(tmp); } catch (e) { } + } +} +function runFile(command, args, options, input, onSpawn) { + return new Promise((resolve, reject) => { + const child = execFile(command, args, options, (error, stdout, stderr) => { + let data; + try { data = JSON.parse(stdout); } catch (e) { } + if (error) { + const detail = data && data.diagnostics && data.diagnostics.map((d) => d.message).join("; "); + reject(new Error(detail || String(stderr || error.message).trim().slice(0, 1000))); + } else if (!data) reject(new Error("插件管理器没有返回有效 JSON")); + else resolve(data); + }); + if (onSpawn) { + try { if (child.pid) onSpawn(child.pid); } + catch (e) { child.kill(); reject(e); } + } + if (input !== undefined) { child.stdin.on("error", () => {}); child.stdin.end(input); } + }); +} + +async function updateConfig(runtime, changes, python, defaults) { + const result = await runFile(python, [path.join(__dirname, "runtime_lock.py"), "--update-config", runtime], { + windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024, + }, JSON.stringify({ changes, defaults })); + if (result.ok !== true || typeof result.changed !== "boolean") throw new Error("配置更新没有返回有效确认"); + return result; +} + +function readState(file, currentTime) { + const value = readJson(file, {}); + const saved = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + const timestamp = (v) => Number.isSafeInteger(v) && v >= 0 && v <= currentTime ? v : 0; + let pendingVersion = ""; + try { if (typeof saved.pendingVersion === "string") { compareVersions(saved.pendingVersion, saved.pendingVersion); pendingVersion = saved.pendingVersion; } } catch (e) { } + return { checkedAt: timestamp(saved.checkedAt), lastAttempt: timestamp(saved.lastAttempt), + pendingSync: saved.pendingSync === true, pendingVersion, + pendingPid: Number.isSafeInteger(saved.pendingPid) && saved.pendingPid > 0 ? saved.pendingPid : 0, + pendingRelease: saved.pendingRelease === undefined || saved.pendingRelease === null ? null : saved.pendingRelease }; +} +function legacyLockBusy(runtime) { + // An already-running wx updater cannot honor v2 locks. Respect its live owner, + // never unlink its file, and retire old updater processes by restarting ZCode. + const file = path.join(runtime, "plugin-update.lock"); + try { + const owner = readJson(file, {}); + if (owner && Number.isSafeInteger(owner.pid) && owner.pid > 0) { + try { process.kill(owner.pid, 0); return true; } + catch (e) { return e.code !== "ESRCH"; } + } + return Date.now() - fs.statSync(file).mtimeMs < 600000; + } catch (e) { if (e.code === "ENOENT") return false; throw e; } +} +function acquireUpdateLock(runtime, python, helper) { + return new Promise((resolve, reject) => { + const child = spawn(python, [helper, "--hold", path.join(runtime, "plugin-update-v2.lock")], { + windowsHide: true, stdio: ["pipe", "pipe", "pipe"], + }); + let settled = false, output = "", detail = ""; + const timer = setTimeout(() => fail(new Error("插件更新锁未返回就绪状态")), 10000); + const fail = (error) => { + if (settled) return; + settled = true; clearTimeout(timer); child.stdin.destroy(); child.kill(); reject(error); + }; + child.stdin.on("error", () => {}); + child.once("error", fail); + child.stderr.on("data", (chunk) => { detail = (detail + chunk).slice(0, 1000); }); + child.once("close", (code) => { + if (!settled) fail(new Error(detail.trim() || `插件更新锁进程提前退出 (${code})`)); + }); + child.stdout.on("data", (chunk) => { + if (settled) return; + output += chunk; + if (output.length > 1000) { fail(new Error("插件更新锁返回无效 JSON")); return; } + if (!output.includes("\n")) return; + let data; + try { data = JSON.parse(output.trim()); } catch (e) { fail(new Error("插件更新锁返回无效 JSON")); return; } + if (!data || typeof data.locked !== "boolean" || Object.keys(data).length !== 1) { + fail(new Error("插件更新锁返回无效 JSON")); return; + } + settled = true; clearTimeout(timer); + if (!data.locked) { child.stdin.end(); resolve(null); return; } + let releasing; + resolve(() => { + if (!releasing) releasing = new Promise((done) => { + if (child.exitCode !== null || child.signalCode !== null) { done(); return; } + child.once("close", done); child.stdin.end(); + }); + return releasing; + }); + }); + }); +} + +// 渠道身份只比 kind/repository/source;flavor 由 source 可推导,不参与比较, +// 这样旧版本持久化的更新意图(无 flavor 字段)在新代码下仍然等价,不会误判来源变化。 +function sameChannel(a, b) { + return !!a && !!b && a.kind === b.kind && (a.repository || "") === (b.repository || "") && + isDeepStrictEqual(a.source === undefined ? null : a.source, b.source === undefined ? null : b.source); +} +function channelFlavor(channel) { + // 持久化意图的渠道形态:注册为 GitCode git 来源即镜像渠道,其余远端一律按 GitHub 校验。 + return !!(channel && channel.source && channel.source.source === "git" && + releaseChannel.gitcodeRepository(channel.source.url)) ? "gitcode" : "github"; +} + +function verifyCommittedRelease(record, expected, currentChannel) { + const release = expected && expected.release, manifest = expected && expected.manifest; + if (!release || !manifest || !expected.channel || !sameChannel(currentChannel, expected.channel)) throw new Error("Release 更新意图无效或市场来源已变化,停止运行时同步"); + const flavor = expected.channel.flavor || channelFlavor(expected.channel); + const validated = flavor === "gitcode" + ? releaseChannel.validateGitcodeRelease(release) + : releaseChannel.validateRelease({ tag_name: release.tag, draft: false, prerelease: false, + assets: [release.archive, release.manifest] }, release.repository); + if (!validated || validated.version !== release.version || expected.channel.kind !== "release" || + flavor !== validated.flavor || + !releaseChannel.sameRepository(expected.channel.repository, release.repository)) throw new Error("Release 更新意图无效,停止运行时同步"); + releaseChannel.validateManifest(manifest, validated); + if (record.version !== release.version || !record.source || !releaseChannel.sameAssetUrl(record.source.url, release.archive.browser_download_url) || + record.source.sha256 !== manifest.asset.sha256 || record.source.source !== "url" || record.source.type !== "zip" || + record.source.path !== "plugins/zcode-token-usage-statusbar" || + (record.source.stripRoot !== undefined && record.source.stripRoot !== false) || record.source.headers !== undefined) throw new Error("官方安装结果与选定 Release 不一致,停止运行时同步"); +} + +function verifyRuntimeSync(options) { + const { runtime, pluginRoot, home = os.homedir(), env = process.env } = options; + const readObject = (file, missing) => { + let value; + try { value = JSON.parse(fs.readFileSync(file, "utf8")); } + catch (error) { if (error.code === "ENOENT") return missing; throw error; } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("同步验收记录不是 JSON 对象: " + file); + return value; + }; + const root = fs.realpathSync(pluginRoot); + const manifest = readObject(path.join(root, ".zcode-plugin", "plugin.json"), null); + if (!manifest || manifest.name !== PLUGIN_ID.split("@")[0]) throw new Error("同步插件清单身份无效"); + compareVersions(manifest.version, manifest.version); + const pending = readObject(path.join(runtime, "plugin-update.json"), null); + const hasPending = pending && (pending.pendingSync === true || (pending.pendingRelease !== undefined && pending.pendingRelease !== null) || pending.pendingPid); + if (legacyLockBusy(runtime)) throw new Error("旧更新进程仍在运行,停止运行时同步"); + if (pending && pending.pendingPid) { + if (!Number.isSafeInteger(pending.pendingPid) || pending.pendingPid <= 0) throw new Error("安装进程记录无效"); + try { process.kill(pending.pendingPid, 0); throw new Error("插件安装进程仍在运行,停止运行时同步"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + const storage = resolvePluginStorage(home, env); + if (fs.existsSync(path.join(storage, "marketplaces", ".zusage-marketplace.transaction.json"))) throw new Error("官方市场事务尚未完成,停止运行时同步"); + const inventory = readObject(path.join(storage, "installed_plugins.json"), null); + if (inventory && !Array.isArray(inventory.plugins)) throw new Error("官方安装记录形状无效"); + const records = inventory ? inventory.plugins.filter((row) => row && row.id === PLUGIN_ID && row.scope === "user") : []; + if (!records.length) { + if (hasPending || inventory) throw new Error("同步所需用户插件安装记录不存在"); + return { ok: true, pluginRoot: root, version: manifest.version, identity: { record: null, channel: null, pending, manifest } }; + } + if (records.length !== 1) throw new Error("同步所需用户插件安装记录不唯一"); + const record = records[0]; + if (record.enabled === false || typeof record.installPath !== "string" || fs.realpathSync(record.installPath) !== root || record.version !== manifest.version) throw new Error("同步插件与官方安装记录不一致或已禁用"); + const selected = releaseChannel.readChannel(home, storage); + const currentChannel = { kind: selected.kind, repository: selected.repository || "", source: selected.marketplace ? selected.marketplace.source : null }; + if (hasPending) { + if (pending.pendingSync !== true) throw new Error("待同步意图不完整,停止运行时同步"); + if (pending.pendingVersion && compareVersions(record.version, pending.pendingVersion) <= 0) throw new Error("插件安装尚未提交新版,停止运行时同步"); + if (pending.pendingRelease !== undefined && pending.pendingRelease !== null) verifyCommittedRelease(record, pending.pendingRelease, currentChannel); + else if (currentChannel.kind === "release") throw new Error("历史更新意图缺少 Release 校验信息,停止运行时同步"); + } + return { ok: true, pluginRoot: root, version: manifest.version, identity: { record, channel: currentChannel, pending, manifest } }; +} + +function listOverview(data) { + // The official CLI answers different shapes by flag: plain `plugins list --json` + // returns a top-level array of installed entries (no scope field, per-entry + // diagnostics), while `--available` returns { installed, available, diagnostics }. + // Accept both so an absent plugin is a real absence, never a shape mismatch. + if (Array.isArray(data)) { + const diagnostics = []; + for (const entry of data) { + if (entry && entry.id === PLUGIN_ID && Array.isArray(entry.diagnostics)) diagnostics.push(...entry.diagnostics); + } + return { installed: data, available: [], diagnostics }; + } + if (data && typeof data === "object") { + const installed = Array.isArray(data.installed) ? data.installed : null; + const available = Array.isArray(data.available) ? data.available : null; + // An envelope without either list is a shape we do not know; an empty list + // is a real absence. Never let shape drift masquerade as "not installed". + if (!installed && !available) throw new Error("插件管理器返回了无法识别的插件列表"); + return { installed: installed || [], available: available || [], + diagnostics: Array.isArray(data.diagnostics) ? data.diagnostics : [] }; + } + throw new Error("插件管理器返回了无法识别的插件列表"); +} + +function createUpdater(options) { + const { runtime, home, resourcesPath, executable, notify = () => {}, now = Date.now } = options; + const configFile = path.join(runtime, "config.json"); + const stateFile = path.join(runtime, "plugin-update.json"); + const environment = options.env || process.env; + let storageRoot = null, localError = ""; + const storage = () => { + const root = resolvePluginStorage(home, environment); + if (storageRoot && storageRoot !== root) throw new Error("更新期间插件存储目录已变化,请重新启动更新器"); + storageRoot = root; + return root; + }; + const bundle = path.join(resourcesPath, "glm", "zcode.cjs"); + const markerFile = path.join(runtime, ".plugin-restart-required"); + const saved = readState(stateFile, now()); + let state = { phase: "idle", supported: false, auto: false, currentVersion: "", latestVersion: "", + ...saved, restartRequired: fs.existsSync(markerFile), skipped: [], error: "", channel: "", channelHost: "", releaseUrl: "", progress: null }; + let busy = false; + let selectedRelease = null, selectedChannel = null; + const channel = () => { + const source = options.channel ? options.channel() : releaseChannel.readChannel(home, storage()); + return { kind: source.kind, flavor: source.flavor || "", repository: source.repository || "", source: source.marketplace ? source.marketplace.source : null }; + }; + const fetchRelease = options.fetchRelease || releaseChannel.fetchBytes; + const prepareRelease = options.prepareRelease || ((release, onProgress) => releaseChannel.downloadRelease(release, python(), fetchRelease, undefined, onProgress)); + const readReleaseCatalog = options.readReleaseCatalog || (() => { + const root = path.join(storage(), "marketplaces"); + if (fs.existsSync(path.join(root, ".zusage-marketplace.transaction.json"))) throw new Error("官方插件市场事务尚未完成,请稍后重试"); + return readJson(path.join(root, "zusage-marketplace", "marketplace.json"), null); + }); + const python = () => process.env.ZUSAGE_PLUGIN_PYTHON || (readConfig(configFile) || {}).python_path || (process.platform === "win32" ? "python" : "python3"); + const helper = path.join(__dirname, "runtime_lock.py"); + const lock = options.acquireLock || (() => acquireUpdateLock(runtime, python(), helper)); + const cli = options.cli || ((args, onSpawn) => runFile(executable, [bundle, "plugins", ...args, "--json"], { + cwd: home, env: { ...environment, HOME: home, USERPROFILE: home, ELECTRON_RUN_AS_NODE: "1", ZCODE_STORAGE_DIR: path.dirname(storage()) }, windowsHide: true, + timeout: 120000, maxBuffer: 4 * 1024 * 1024, + }, undefined, onSpawn)); + const sync = options.sync || ((root) => runFile(python(), [ + path.join(root, "overlay_install.py"), "sync", + ], { cwd: home, env: { ...process.env, ZUSAGE_PLUGIN_RUNTIME: runtime }, + windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024 })); + + function installedRecord() { + const inventory = readJson(path.join(storage(), "installed_plugins.json"), {}); + const records = inventory && inventory.plugins; + return Array.isArray(records) ? records.find((p) => p && p.id === PLUGIN_ID && p.scope === "user" + && typeof p.installPath === "string" && p.installPath && typeof p.version === "string") : null; + } + function refreshLocal() { + const config = readConfig(configFile); + state.auto = !!config && (!Object.prototype.hasOwnProperty.call(config, "plugin_auto_update") || config.plugin_auto_update === true); + let record = null; + localError = ""; + try { record = installedRecord(); } catch (error) { localError = error.message; } + state.supported = !!(record && record.installPath && (options.cli || fs.existsSync(bundle))); + state.currentVersion = record && record.version || ""; + state.restartRequired = fs.existsSync(markerFile); + return record; + } + function publish() { state.restartRequired = fs.existsSync(markerFile); notify({ ...state, busy }); } + function load() { Object.assign(state, readState(stateFile, now())); } + function save() { + writeJson(stateFile, { checkedAt: state.checkedAt, lastAttempt: state.lastAttempt, + pendingSync: state.pendingSync, pendingVersion: state.pendingVersion, pendingPid: state.pendingPid, pendingRelease: state.pendingRelease }); + } + function diagnostics(data) { + const errors = (data.diagnostics || []).filter((d) => d.severity === "error" && (!d.pluginId || d.pluginId === PLUGIN_ID)); + if (errors.length) throw new Error(errors.map((d) => d.message).join("; ")); + } + async function check(auto = false, install = false) { + if (busy) { if (!auto) publish(); return; } + refreshLocal(); + if (!state.supported) { state.phase = localError ? "error" : "unavailable"; state.error = localError; publish(); return; } + busy = true; + // 手动检查要立刻有反馈:取锁、恢复待处理意图等前置步骤可能耗时数秒,不能让面板毫无动静。 + if (!auto) { state.phase = "checking"; state.error = ""; publish(); } + let release; + try { + if (legacyLockBusy(runtime)) { if (!auto) throw new Error("另一个更新进程仍在运行,请稍后重试"); return; } + release = await lock(); + if (!release || legacyLockBusy(runtime)) { if (!auto) throw new Error("另一个更新进程仍在运行,请稍后重试"); return; } + load(); + if (state.pendingSync && await recoverPending()) return; + refreshLocal(); + if (auto && (!state.auto || (state.lastAttempt && now() - state.lastAttempt < INTERVAL_MS))) return; + state.phase = "checking"; state.error = ""; publish(); + state.lastAttempt = now(); + save(); + const source = channel(); + state.channel = source.kind; state.channelHost = source.flavor === "gitcode" ? "gitcode" : source.flavor === "github" ? "github" : ""; + state.releaseUrl = ""; selectedRelease = null; selectedChannel = source; + if (source.kind === "release" && source.flavor === "gitcode") { + // GitCode 渠道:宿主从注册 ref 拉取的渠道清单是唯一版本源,不调 GitCode API。 + const market = await cli(["marketplace", "update", "zusage-marketplace"]); + diagnostics(market); + if (!isDeepStrictEqual(channel(), source)) throw new Error("更新期间市场来源已变化"); + selectedRelease = releaseChannel.gitcodeReleaseFromCatalog(readReleaseCatalog(), source.repository); + } else if (source.kind === "release") { + selectedRelease = await releaseChannel.checkRelease(source.repository, compareVersions, fetchRelease); + } + if (selectedRelease) { + state.releaseUrl = selectedRelease.url; + state.latestVersion = selectedRelease.version; + const record = installedRecord(); + if (!record) throw new Error("用户级插件安装记录不存在"); + state.currentVersion = record.version; + const newer = compareVersions(state.latestVersion, record.version) > 0; + state.phase = newer ? "available" : "current"; state.checkedAt = now(); save(); publish(); + if ((auto || install) && newer) { + const overview = listOverview(await cli(["list"])); diagnostics(overview); + const installed = (overview.installed || []).find((p) => p && p.id === PLUGIN_ID && (!p.scope || p.scope === "user")); + if (!installed) throw new Error("用户插件未注册,不能安装 Release"); + if (installed.enabled !== false) await installUpdate(auto); + } + } else { + const market = await cli(["marketplace", "update", "zusage-marketplace"]); + diagnostics(market); + const overview = listOverview(await cli(["list", "--available"])); + diagnostics(overview); + if (!isDeepStrictEqual(channel(), source)) throw new Error("更新期间市场来源已变化"); + const installed = (overview.installed || []).find((p) => p && p.id === PLUGIN_ID && (!p.scope || p.scope === "user")); + const available = (overview.available || []).find((p) => p && p.id === PLUGIN_ID); + if (!installed || !available) throw new Error("未找到已安装插件或市场中的插件,请检查市场来源"); + state.currentVersion = installed.version || ""; + state.latestVersion = available.version || ""; + const newer = compareVersions(state.latestVersion, state.currentVersion) > 0; + state.phase = newer ? "available" : "current"; + state.checkedAt = now(); save(); publish(); + if ((auto || install) && newer && installed.enabled !== false) await installUpdate(auto); + } + } catch (e) { + state.phase = "error"; state.error = e.message; state.progress = null; + } finally { + if (release) await release(); + busy = false; publish(); + } + } + function clearPending() { + state.pendingSync = false; state.pendingVersion = ""; state.pendingPid = 0; state.pendingRelease = null; + save(); + } + async function recoverPending() { + // Legacy pendingSync meant an acknowledged CLI commit. New intents are saved + // before launch and must prove a version change in official user inventory. + // A CLI orphaned by parent shutdown can still be writing inventory/runtime. + // Wait even after its version commit; normal completion clears its PID. + if (state.pendingPid) { + try { process.kill(state.pendingPid, 0); state.phase = "updating"; state.progress = { stage: "install" }; return true; } + catch (e) { if (e.code !== "ESRCH") { state.phase = "updating"; state.progress = { stage: "install" }; return true; } } + state.pendingPid = 0; + } + const record = installedRecord(); + if (!record) throw new Error("更新恢复所需的安装记录不存在"); + if (!state.pendingVersion || compareVersions(record.version, state.pendingVersion) > 0) { + if (!state.pendingRelease && channel().kind === "release") throw new Error("历史更新意图缺少 Release 校验信息,停止运行时同步"); + await syncInstalled(); return true; + } + state.lastAttempt = 0; + clearPending(); + state.phase = "idle"; + return false; + } + async function installUpdate(auto) { + let record = refreshLocal(); + if (!record) throw new Error("插件已卸载,更新已取消"); + if (auto && !state.auto) return; + if (!isDeepStrictEqual(channel(), selectedChannel)) throw new Error("更新期间市场来源已变化"); + compareVersions(record.version, record.version); + let downloaded = null; + // 中途放弃(插件被卸载/禁用、自动更新被关闭、版本已不再更新)时退回「发现新版本」, + // 不能让 downloading 阶段与进度条停在面板上。 + const abandon = () => { state.phase = "available"; state.progress = null; }; + try { + if (selectedRelease) { + const currentChannel = channel(); + if (!isDeepStrictEqual(currentChannel, selectedChannel) || currentChannel.kind !== "release" || !releaseChannel.sameRepository(currentChannel.repository, selectedRelease.repository)) throw new Error("更新期间市场来源已变化"); + state.phase = "downloading"; state.progress = { stage: "download", loaded: 0, total: selectedRelease.archive.size || 0 }; publish(); + let lastPublished = 0; + downloaded = await prepareRelease(selectedRelease, (progress) => { + state.progress = progress; + // 下载字节进度按 ~150ms 节流推送;阶段切换与下载完成一律立即推送。 + const complete = progress.stage !== "download" || (progress.total && progress.loaded >= progress.total); + if (complete || now() - lastPublished >= 150) { lastPublished = now(); publish(); } + }); + releaseChannel.validateManifest(downloaded.manifest, selectedRelease); + state.progress = { stage: "verify" }; publish(); + const market = await cli(["marketplace", "update", "zusage-marketplace"]); diagnostics(market); + if (!isDeepStrictEqual(channel(), currentChannel)) throw new Error("更新期间市场来源已变化"); + record = refreshLocal(); + if (!record) throw new Error("插件已卸载,更新已取消"); + if (auto && !state.auto) { abandon(); return; } + if (compareVersions(selectedRelease.version, record.version) <= 0) { abandon(); return; } + const overview = listOverview(await cli(["list"])); diagnostics(overview); + const installed = (overview.installed || []).find((p) => p && p.id === PLUGIN_ID && (!p.scope || p.scope === "user")); + if (!installed) throw new Error("用户插件已卸载,Release 安装已取消"); + if (installed.enabled === false) { abandon(); return; } + if (!isDeepStrictEqual(channel(), currentChannel)) throw new Error("更新期间市场来源已变化"); + record = refreshLocal(); + if (!record) throw new Error("插件已卸载,更新已取消"); + if ((auto && !state.auto) || compareVersions(selectedRelease.version, record.version) <= 0) { abandon(); return; } + } + state.phase = "updating"; state.progress = { stage: "install" }; + state.pendingSync = true; state.pendingVersion = record.version; state.pendingPid = 0; + state.pendingRelease = selectedRelease ? { release: selectedRelease, manifest: downloaded.manifest, channel: channel() } : null; + save(); publish(); + const command = selectedRelease ? ["install", PLUGIN_ID, "--scope", "user"] : ["update", PLUGIN_ID]; + try { + let result; + try { result = await cli(command, (pid) => { state.pendingPid = pid; save(); }); } + finally { state.pendingPid = 0; save(); } + diagnostics(result); + if (result.ok !== true) throw new Error("插件管理器未确认更新成功"); + } catch (e) { + const committed = installedRecord(); + if (committed && compareVersions(committed.version, state.pendingVersion) > 0) { + if (selectedRelease) verifyReleaseCommit(committed); + await syncInstalled(); return; + } + state.lastAttempt = 0; clearPending(); throw e; + } + const committed = installedRecord(); + if (!committed || compareVersions(committed.version, state.pendingVersion) <= 0) { + state.lastAttempt = 0; clearPending(); throw new Error("插件管理器未提交新版安装记录"); + } + if (selectedRelease) verifyReleaseCommit(committed); + await syncInstalled(); + } finally { if (downloaded && downloaded.cleanup) downloaded.cleanup(); } + } + function verifyReleaseCommit(record) { + verifyCommittedRelease(record, state.pendingRelease, channel()); + } + async function recordRestart(report) { + if (report.restartRecorded === true) return; + const changed = Array.isArray(report.changed) ? report.changed : []; + const cold = changed.filter((f) => f === "inject-main.cjs" || f === "plugin-updater.cjs" || f === "plugin-release.cjs" || f === "plugin-storage.cjs" || f === "quota-client.cjs" + || ((readConfig(configFile) || {}).hot_reload === false && (f === "overlay.js" || f === "zusage.py"))); + if (!cold.length) return; + const hashes = {}; + for (const name of cold) { + try { hashes[name] = crypto.createHash("sha256").update(fs.readFileSync(path.join(runtime, name))).digest("hex"); } + catch (e) { if (e.code !== "ENOENT") throw e; } + } + await runFile(python(), [helper, "--record-restart", runtime], { + windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024, + }, JSON.stringify({ hashes, changed, version: state.currentVersion })); + } + async function syncInstalled() { + // 更新器只信任宿主安装记录,不接收页面提供的脚本路径。 + const record = installedRecord(); + if (!record || !record.installPath) throw new Error("更新后的安装记录不存在"); + if (state.pendingRelease !== null) verifyReleaseCommit(record); + state.phase = "updating"; state.progress = { stage: "sync" }; publish(); + const report = await sync(record.installPath); + if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("运行时同步没有返回有效报告"); + state.currentVersion = record.version; + // Current packages persist the marker themselves. This also covers old + // packages' changed reports, before clearing durable pending coordination. + await recordRestart(report); + clearPending(); + state.skipped = Array.isArray(report.preserved) ? report.preserved : []; + state.restartRequired = fs.existsSync(markerFile); + state.phase = "updated"; state.error = ""; state.progress = null; + } + async function request(req) { + if (!req) return; + if (req.action === "auto" && typeof req.enabled === "boolean") { + try { + await updateConfig(runtime, { plugin_auto_update: req.enabled }, python()); + refreshLocal(); state.error = ""; + publish(); + if (req.enabled) await check(true); + } catch (e) { state.phase = "error"; state.error = e.message; publish(); } + } else if (req.action === "check") await check(); + else if (req.action === "update") { + // 手动更新也先刷新版本,避免过期的“有新版”状态触发重装或降级。 + await check(false, true); + } + } + async function tick() { + refreshLocal(); + if (localError) { state.phase = "error"; state.error = localError; } + publish(); + if (busy) return; + load(); + if (state.supported && (state.pendingSync || (state.auto && (!state.lastAttempt || now() - state.lastAttempt >= INTERVAL_MS)))) await check(true); + } + refreshLocal(); + return { request, tick, status: () => ({ ...state, busy }) }; +} + +async function runAutoCommand(argv) { + if (argv[0] !== "--auto") throw new Error("仅支持 --auto 后台更新模式"); + const values = {}; + for (let i = 1; i < argv.length; i += 2) { + if (!["--resources", "--runtime"].includes(argv[i]) || !argv[i + 1]) throw new Error("无效自动更新参数"); + values[argv[i]] = argv[i + 1]; + } + if (!values["--resources"] || !values["--runtime"]) throw new Error("缺少客户端或运行时目录"); + const updater = createUpdater({ runtime: path.resolve(values["--runtime"]), home: os.homedir(), + resourcesPath: path.resolve(values["--resources"]), executable: process.execPath }); + await updater.tick(); + const state = updater.status(); + process.stdout.write(JSON.stringify(state) + "\n"); + if (state.phase === "error") process.exitCode = 1; +} + +module.exports = { createUpdater, compareVersions, INTERVAL_MS, PLUGIN_ID, runAutoCommand, updateConfig, verifyRuntimeSync, listOverview }; +if (require.main === module) { + const argv = process.argv.slice(2); + if (argv[0] === "--verify-runtime-sync") { + try { + if (argv.length !== 3) throw new Error("同步验收需要运行时与插件路径"); + process.stdout.write(JSON.stringify(verifyRuntimeSync({ runtime: path.resolve(argv[1]), pluginRoot: path.resolve(argv[2]) })) + "\n"); + } catch (error) { console.error("[zusage-plugin] 同步验收失败:", error.message); process.exitCode = 1; } + } else runAutoCommand(argv).catch((e) => { + console.error("[zusage-plugin] 自动更新失败:", e.message); + process.exitCode = 1; + }); +} diff --git a/external_plugins/zcode-token-usage-statusbar/plugin_auto_update.py b/external_plugins/zcode-token-usage-statusbar/plugin_auto_update.py new file mode 100644 index 0000000..0619e44 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/plugin_auto_update.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""SessionStart 的独立自动更新 bootstrap;不安装或恢复 asar 注入。""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time + + +INTERVAL_MS = 24 * 60 * 60 * 1000 + + +def start_auto_update(runtime, pi): + """默认后台检查更新,保留显式关闭;最终限频与跨进程锁由 Node 负责。""" + try: + runtime = Path(runtime) + cfg = json.loads((runtime / "config.json").read_text(encoding="utf-8")) + except FileNotFoundError: + cfg = {} + except (OSError, ValueError, TypeError): + return False + if not isinstance(cfg, dict) or cfg.get("plugin_auto_update", True) is not True: + return False + + # 远程会话或本机 asar 不可读时保持静默,不产生安装/更新提示。 + try: + asar = Path(pi.ASAR) + with asar.open("rb") as stream: + stream.read(1) + except (OSError, ValueError, TypeError, AttributeError): + return False + + try: + try: + state = json.loads((runtime / "plugin-update.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + state = {} + if isinstance(state, dict) and state.get("pendingSync") is not True: + last_attempt = state.get("lastAttempt") + # Node 的 Date.now() 以毫秒记录;损坏字段不阻止新的尝试。 + elapsed = time.time() * 1000 - last_attempt if type(last_attempt) in (int, float) else None + if (elapsed is not None and 0 < last_attempt < float("inf") + and 0 <= elapsed < INTERVAL_MS): + return False + + executable = getattr(pi, "ZCODE_EXE", None) + if not executable or not Path(executable).is_file(): + executable = pi.zcode_exe_for(asar) + if (not executable or not Path(executable).is_file() + or (sys.platform != "win32" and not os.access(executable, os.X_OK))): + print("[zusage-plugin] 自动更新未启动:找不到有效的 ZCode 可执行文件。", file=sys.stderr) + return False + bundle = asar.parent / "glm" / "zcode.cjs" + if not bundle.is_file(): + print(f"[zusage-plugin] 自动更新未启动:ZCode CLI bundle 不存在:{bundle}", file=sys.stderr) + return False + loader = runtime / "plugin-updater.cjs" + if not loader.is_file(): + print(f"[zusage-plugin] 自动更新未启动:更新器不存在:{loader}", file=sys.stderr) + return False + + env = os.environ.copy() + env["ELECTRON_RUN_AS_NODE"] = "1" + env["ZUSAGE_PLUGIN_PYTHON"] = sys.executable + detach = ({"creationflags": subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW} + if sys.platform == "win32" else {"start_new_session": True}) + subprocess.Popen( + [str(executable), str(loader), "--auto", "--resources", str(asar.parent), + "--runtime", str(runtime)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=str(Path.home()), env=env, shell=False, **detach) + return True + except Exception as e: # noqa: BLE001 — 自动更新不能影响 SessionStart hook + print(f"[zusage-plugin] 自动更新启动失败(已忽略):{e}", file=sys.stderr) + return False diff --git a/external_plugins/zcode-token-usage-statusbar/quota-client.cjs b/external_plugins/zcode-token-usage-statusbar/quota-client.cjs new file mode 100644 index 0000000..9c5dec3 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/quota-client.cjs @@ -0,0 +1,1725 @@ +/* 额度客户端(主进程,冷文件)v3:多厂商适配器 + 多源调度器。 + * 阶段 3(浮层 v96 线):BigModel/Z.ai 内置套餐(解密宿主 OAuth 凭据直查 monitor 端点) + * 之外,新增零配置官方域适配器(MiniMax/Kimi/CommandCode/DeepSeek/OpenRouter/SiliconFlow/ + * Anthropic OAuth)与显式配置适配器(Cline/MiMo Cookie/火山 AK-SK/SCNet 账本)。 + * + * 调度语义:enabled 源全量低频刷新(供 tooltip 分家列全),chip 主显跟随本 pane 会话的 + * 模型归属(primary 源)。每源独立 nextAt/epoch/退避/凭据指纹;凭据只在内存,不落盘、 + * 不进日志与诊断;诊断只记 status/HTTP code/错误摘要;key 只发各适配器声明的域。 + * + * 凭据发现优先级(契约):环境变量 → quota-secrets.json(运行目录 0600)→ 自动发现 + * (宿主 provider_config.json 的自定义供应商 key;Anthropic 另读 ~/.claude/.credentials.json)。 + * 源码转写声明:MiniMax/Kimi/CommandCode/Cline/MiMo/火山/SCNet 的端点与字段转写自 + * dsh-cost-meter 与 cc-switch(MIT),其中无真实账号实测的家在适配器上标注 unverified。 + * + * 输出协议:view() = { active, sources: {id: entry} };为兼容本版浮层,active 源的 + * 字段同时镜像到顶层(下版移除)。entry 形状与 v96 单源一致(status/windows/tools/ + * models/level/plan/balance/stale/...),balance 扩展 parts[](赠送/充值拆分)。 */ +"use strict"; +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ENC_PREFIX = "enc:v1:"; +const QUOTA_PATH = "/api/monitor/usage/quota/limit"; +const TTL_MS = 10 * 60 * 1000; // 套餐窗默认(对齐宿主 usageEntitlementCache) +const TTL_BALANCE_MS = 15 * 60 * 1000; // 余额默认(dsh 同款) +const FETCH_TIMEOUT_MS = 10 * 1000; +const BODY_LIMIT = 256 * 1024; +const BACKOFF_MS = [30 * 1000, 2 * 60 * 1000, 10 * 60 * 1000, 10 * 60 * 1000]; +const PARSE_BACKOFF_MS = 30 * 60 * 1000; +const NOT_CONFIGURED_RETRY_MS = 60 * 1000; +const EXP_SOON_MS = 2 * 60 * 1000; +const EXP_RETRY_MS = 5 * 60 * 1000; + +/* ---------- 基础工具 ---------- */ +function sha16(v) { return crypto.createHash("sha256").update(String(v)).digest("hex").slice(0, 16); } +function qErr(code, message, extra) { + return Object.assign(new Error(message), Object.assign({ code }, extra || {})); +} +function clampPct(x) { return Math.max(0, Math.min(100, x)); } +function parseRetryAfterMs(v) { + if (v == null) return null; + const sec = Number(v); + if (isFinite(sec) && sec >= 0) return Math.min(Math.max(sec * 1000, 30 * 1000), 10 * 60 * 1000); + return 60 * 1000; +} +function jwtExpMs(token) { + try { + const payload = String(token).split(".")[1]; + if (!payload) return null; + const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return typeof json.exp === "number" && json.exp > 0 ? json.exp * 1000 : null; + } catch (e) { return null; } +} + +/* ---------- 宿主凭据解密(enc:v1 AES-256-GCM,按宿主语义重写) ---------- */ +function deriveSecret(env) { + const conf = env.ZCODE_CREDENTIAL_SECRET; + if (conf) return conf; + let username = "unknown"; + try { username = os.userInfo().username; } catch (e) { /* 拿不到用户名按占位 */ } + return `zcode-credential-fallback:${os.platform()}:${os.homedir()}:${username}`; +} +function deriveCipherKey(env) { return crypto.createHash("sha256").update(deriveSecret(env)).digest(); } +function decryptValue(value, key) { + if (typeof value !== "string" || !value.startsWith(ENC_PREFIX)) return value; + const parts = value.slice(ENC_PREFIX.length).split("."); + if (parts.length !== 3 || !parts[0] || !parts[1] || parts[2] === undefined) return null; + let iv, tag, ct; + try { + iv = Buffer.from(parts[0], "base64url"); + tag = Buffer.from(parts[1], "base64url"); + ct = Buffer.from(parts[2], "base64url"); + } catch (e) { return null; } + if (iv.length !== 12 || tag.length !== 16) return null; + try { + const d = crypto.createDecipheriv("aes-256-gcm", key, iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]).toString("utf8"); + } catch (e) { return null; } +} + +/* ---------- BigModel/Z.ai monitor 归一化(v96 口径不变) ---------- */ +function isNoPlanMessage(msg) { + const m = String(msg || "").toLowerCase(); + const compact = m.replace(/[\s_-]/g, ""); + return compact.indexOf("codingplan") >= 0 && + ["不存在", "没有", "未开通", "not exist", "not found", "not have", "doesn't have", "no coding plan", "not subscribed"] + .some((kw) => m.indexOf(kw) >= 0); +} +function pctOf(limit) { + if (typeof limit.percentage === "number" && limit.percentage >= 0) { + if (typeof limit.currentValue === "number" && typeof limit.usage === "number" && + limit.usage > 0 && limit.currentValue >= 0) { + return Math.round(clampPct((limit.currentValue / limit.usage) * 100) * 10) / 10; + } + return Math.round(clampPct(limit.percentage) * 10) / 10; + } + if (typeof limit.currentValue === "number" && typeof limit.usage === "number" && + limit.usage > 0 && limit.currentValue >= 0) { + return Math.round(clampPct((limit.currentValue / limit.usage) * 100) * 10) / 10; + } + return null; +} +function resetOf(v) { + if (typeof v === "number" && isFinite(v)) { + if (v <= 0) return null; + return v < 1e12 ? v * 1000 : v; + } + if (typeof v === "string" && v) return v; + return null; +} +function usageDetailsOf(limit) { + const out = []; + if (Array.isArray(limit && limit.usageDetails)) { + for (const u of limit.usageDetails) { + if (u && typeof u === "object" && typeof u.modelCode === "string" && u.modelCode) { + out.push({ modelCode: u.modelCode, displayName: typeof u.displayName === "string" ? u.displayName : "", usage: typeof u.usage === "number" ? u.usage : null }); + } + } + } + return out; +} +function normalizeQuotaBody(body) { + if (!body || typeof body !== "object") throw qErr("parse_error", "non-object body"); + if (body.success === false) { + const msg = String(body.msg || ""); + if (isNoPlanMessage(msg)) return { noPlan: true }; + throw qErr("network_error", "business error: " + msg.slice(0, 80)); + } + const data = body.data; + if (!data || typeof data !== "object" || !Array.isArray(data.limits)) { + throw qErr("parse_error", "missing data.limits"); + } + const windows = { fiveHour: null, weekly: null }; + let tools = null; + const unknownUnits = []; + const unclassified = []; + const models = []; + const seenModel = {}; + for (const limit of data.limits) { + if (!limit || typeof limit !== "object") continue; + const type = String(limit.type || "").toUpperCase(); + if (type === "TIME_LIMIT") { + if (!tools) { + tools = { + used: typeof limit.currentValue === "number" ? limit.currentValue : null, + total: typeof limit.usage === "number" ? limit.usage : null, + resetsAt: resetOf(limit.nextResetTime), + }; + } + continue; + } + if (type !== "TOKENS_LIMIT" && type !== "CREDIT_LIMIT") continue; + const entry = { percent: pctOf(limit), resetsAt: resetOf(limit.nextResetTime), details: usageDetailsOf(limit) }; + for (const m of entry.details) { + const key = m.displayName || m.modelCode; + if (!seenModel[key]) { seenModel[key] = 1; models.push(key); } + } + const u = typeof limit.unit === "number" ? limit.unit : null; + if (u === 3) { if (!windows.fiveHour) windows.fiveHour = entry; } + else if (u === 6) { if (!windows.weekly) windows.weekly = entry; } + else if (u === null) unclassified.push(entry); + else unknownUnits.push(u); + } + unclassified.sort((a, b) => { + const ra = a.resetsAt == null ? -1 : (typeof a.resetsAt === "string" ? 0 : a.resetsAt); + const rb = b.resetsAt == null ? -1 : (typeof b.resetsAt === "string" ? 0 : b.resetsAt); + return ra - rb; + }); + for (const entry of unclassified) { + if (!windows.fiveHour) windows.fiveHour = entry; + else if (!windows.weekly) windows.weekly = entry; + } + return { level: typeof data.level === "string" ? data.level : "", windows, tools, models, unknownUnits }; +} +/* 订阅周期字段语义(与宿主 bigmodelUsageQuotaMapper.pickCurrentSubscriptionExpireTime 语义对齐且更严): + * - nextRenewTime:非自动续费=当前权益结束日;自动续费=下次扣费日(不是最终到期)。 + * 接受纯日期与宿主同款日期时间(空格/T 分隔、可选秒、Z 或 ±hh:mm 偏移;无偏移按 + * 服务端 UTC+8 解释)。 + * - valid 区间可能超前一个计费周期(issue:年度套餐显示 2028-03-14),只有包含 + * "当前时刻"的区间才能作为权益边界回退;端点带时刻按精确时刻比较(续费日当天 + * 10:00 前未来区间不算生效),仅日期端点按服务端日历日左闭右开;未来/过期/反转/ + * 非法日历或非法时刻(25:99 等)一律整段不采信。 + * - 仅日期的续费边界覆盖整个服务端日:续费日当天上午不清除当前权益显示; + * 带时刻的续费边界在时刻过后按陈旧数据处理(隐藏日期,不显示下一年)。 + * - currentRenewTime 是当前周期起点;缺失或非法时不暴露,不用未来 valid 起点冒充。 */ +const SUB_TZ_OFFSET_MS = 8 * 3600 * 1000; // BigModel 服务端日历 Asia/Shanghai +function serviceDayMs(day, endExclusive) { + const y = Number(day.slice(0, 4)), mo = Number(day.slice(5, 7)); + const d = Number(day.slice(8, 10)) + (endExclusive ? 1 : 0); + return Date.UTC(y, mo - 1, d) - SUB_TZ_OFFSET_MS; // 服务端日的 00:00(endExclusive=次日 00:00) +} +/* 解析单个订阅时间值 → { day, at };at=精确时刻 ms(值带时刻时),纯日期为 null。 + * 形状/日历/时刻任一非法返回 null(整值拒绝)。 */ +function subscriptionTimestamp(value) { + if (typeof value !== "string") return null; + const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/); + if (!m) return null; + const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]); + if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; + const dt = new Date(Date.UTC(y, mo - 1, d)); + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null; + const day = m[1] + "-" + m[2] + "-" + m[3]; + if (m[4] === undefined) return { day: day, at: null }; + const hh = Number(m[4]), mi = Number(m[5]); + const ss = m[6] === undefined ? 0 : Number(m[6]); + if (hh > 23 || mi > 59 || ss > 59) return null; + let at = Date.UTC(y, mo - 1, d, hh, mi, ss); + if (m[7] === undefined) at -= SUB_TZ_OFFSET_MS; // 无偏移:服务端时区 + else if (m[7] !== "Z") { + const offsetHour = Number(m[7].slice(1, 3)), offsetMinute = Number(m[7].slice(-2)); + // ISO 8601:分钟 00-59,小时上限 ±14:00;超界偏移视同非法值 + if (offsetMinute > 59 || offsetHour > 14 || (offsetHour === 14 && offsetMinute > 0)) return null; + at += (m[7][0] === "+" ? -1 : 1) * (offsetHour * 60 + offsetMinute) * 60000; + } + return { day: day, at: at }; +} +function parseValidPeriod(value, now) { + if (typeof value !== "string") return null; + /* 时刻 token 吸收时偏移必须带冒号(±hh:mm):免冒号变体会把紧随的下一个日期 + * ("16:28:31-2027-02-28")误吞成偏移。 */ + const tokens = value.match(/\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?)?/g); + if (!tokens || tokens.length < 2) return null; + const start = subscriptionTimestamp(tokens[0]); + const end = subscriptionTimestamp(tokens[tokens.length - 1]); + if (!start || !end || start.day > end.day) return null; + const startMs = start.at != null ? start.at : serviceDayMs(start.day, false); + const endMs = end.at != null ? end.at : serviceDayMs(end.day, false); // 仅日期末端:日粒度右开 + return { start: start.day, end: end.day, current: startMs <= now && now < endMs }; +} +function normalizeSubscriptionPlan(body, nowMs) { + const data = body && typeof body === "object" && Array.isArray(body.data) ? body.data : null; + if (!data) return null; + const active = data.filter((e) => e && typeof e === "object" && e.status === "VALID" && e.inCurrentPeriod === true); + if (active.length !== 1) return null; + const s = active[0]; + const now = typeof nowMs === "number" && isFinite(nowMs) ? nowMs : Date.now(); + const autoRenew = s.autoRenew === true || s.autoRenew === 1; + const nextRenew = subscriptionTimestamp(s.nextRenewTime); + const currentRenew = subscriptionTimestamp(s.currentRenewTime); + const valid = parseValidPeriod(s.valid, now); + const out = { + version: typeof s.version === "string" ? s.version.slice(0, 8) : "", + productName: typeof s.productName === "string" ? s.productName.slice(0, 40) : "", + validStart: "", + validEnd: "", + }; + /* 续费边界时刻:带时刻按精确时刻;纯日期覆盖整个服务端日(当天上午仍属当前权益, + * 服务端 10:00 扣费惯例下不到时刻不清除;时刻已过=陈旧行,不采信)。 */ + const renewBoundary = nextRenew && (nextRenew.at != null ? nextRenew.at : serviceDayMs(nextRenew.day, true)) > now + ? nextRenew : null; + if (renewBoundary && currentRenew && currentRenew.day <= renewBoundary.day) out.validStart = currentRenew.day; + else if (renewBoundary && valid && valid.current) out.validStart = valid.start; + if (renewBoundary) { + if (autoRenew) { + out.renewAt = renewBoundary.day; // 自动续费:下次扣费日,不声称最终到期 + } else { + out.validEnd = renewBoundary.day; // 非自动续费:nextRenewTime 即当前权益结束日 + } + return out; + } + if (valid && valid.current) { + out.validStart = valid.start; + if (!autoRenew) out.validEnd = valid.end; + else out.periodEnd = valid.end; // 自动续费且无有效扣费日:仅记录当前周期末端 + } + return out; +} + +/* ---------- 余额工具 ---------- */ +/* "CNY 110.00" / "¥110.00" / "$12.5" / 110.5 → {amount, currency};解析不了返回 null */ +function parseCurrencyAmount(v, currencyHint) { + if (typeof v === "number" && isFinite(v)) return { amount: v, currency: currencyHint || "" }; + if (typeof v !== "string") return null; + const m = v.trim().match(/^([A-Z]{3}|¥|\$)?\s*([\d,]+(?:\.\d+)?)$/); + if (!m) return null; + const cur = m[1] === "¥" ? "CNY" : m[1] === "$" ? "USD" : (m[1] || currencyHint || ""); + return { amount: parseFloat(m[2].replace(/,/g, "")), currency: cur }; +} +function num(v) { + if (typeof v === "number" && isFinite(v)) return v; + if (typeof v === "string" && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(v.trim())) { + const value = Number(v); + return Number.isFinite(value) ? value : null; + } + return null; +} +/* 通用窗口行收集:[{period|type, percentUsed|percent|utilization|usage_percentage, resetsAt|reset_at|resets_at|resetTime}] */ +function collectWindows(list, defs) { + const out = {}; + if (!Array.isArray(list)) return out; + const names = new Map(defs.map((d) => (Array.isArray(d) ? [d[0], d[1]] : [d.match, d.name]))); + for (const e of list) { + if (!e || typeof e !== "object") continue; + const key = String(e.type || e.period || e.window || "").toLowerCase().replace(/[\s_-]/g, ""); + const name = names.get(key); + if (!name || out[name]) continue; + let pct = num(e.percentUsed); + if (pct == null) pct = num(e.percent); + if (pct == null) pct = num(e.utilization); + if (pct != null && e.usage_percentage != null) { /* ZenMux 0-1 已用小数 */ } + if (pct == null && e.usage_percentage != null) pct = e.usage_percentage <= 1 ? e.usage_percentage * 100 : e.usage_percentage; + if (pct == null || !isFinite(pct) || pct < 0) continue; + out[name] = { percent: Math.round(clampPct(pct) * 10) / 10, resetsAt: resetOf(e.resetsAt != null ? e.resetsAt : (e.resets_at != null ? e.resets_at : e.resetTime)) }; + } + return out; +} + +/* ---------- 宿主路径与供应商配置(builtin → personal) ---------- */ +function resolveQuotaPaths(opts) { + const o = opts || {}, env = o.env || process.env; + const home = o.defaultHome || os.homedir(); + // 可独立注入 win32;非 Windows 测试/诊断也不能把 D:\\... 当 POSIX 相对路径。 + const pathOf = (p) => o.pathImpl || (/^(?:[a-z]:[\\/]|\\\\)/i.test(p) ? path.win32 : path); + const absolute = (p) => { + if (p === "~") p = home; + else if (/^~[\\/]/.test(p)) p = pathOf(home).join(home, p.slice(2)); + return pathOf(p).resolve(p); + }; + const base = absolute(o.dataBaseDir || env.ZCODE_DATA_BASE_DIR || o.desktopHome || env.ZCODE_DESKTOP_HOME_DIR || home); + const root = pathOf(base).join(base, ".zcode", "v2"); + const credsPath = absolute(o.credsPath || pathOf(root).join(root, "credentials.json")); + const customCfgPath = absolute(o.customCfgPath || env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE || pathOf(root).join(root, "provider_config.json")); + // Runtime builtin 文件由宿主按版本/endpoint 选择,不能扫描或猜测其它缓存副本。 + const builtin = o.builtinCfgPath || env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE; + const builtinCfgPath = builtin ? absolute(builtin) : ""; + return { credsPath, customCfgPath, builtinCfgPath, + credsDir: pathOf(credsPath).dirname(credsPath), + customCfgDir: pathOf(customCfgPath).dirname(customCfgPath), + builtinCfgDir: builtinCfgPath ? pathOf(builtinCfgPath).dirname(builtinCfgPath) : "" }; +} +let providerCfgCache = null; +function loadProviderCfg(readFile2, cfgPath, builtinCfgPath) { + const paths = [builtinCfgPath, cfgPath].filter(Boolean); + let stamp = null; + try { stamp = JSON.stringify(paths.map((p) => { const s = fs.statSync(p); return [p, s.mtimeMs, s.size]; })); } + catch (e) { providerCfgCache = null; /* stat 失败仍现读,并撤销旧缓存,避免同时间戳重建后复活旧 key */ } + if (stamp !== null && providerCfgCache && providerCfgCache.stamp === stamp && providerCfgCache.readFile === readFile2) { + return providerCfgCache.byId; + } + const rules = new Map(); + for (const p of paths) { + try { + const d = readFile2(p); + for (const r of ((d.config || {}).providerConfigRules || {}).providerRules || []) { + if (!r || !r.providerId) continue; + const old = rules.get(String(r.providerId)) || {}, cfg = r.config || {}; + rules.set(String(r.providerId), { + name: r.providerName == null ? old.name : String(r.providerName), + access: Object.assign({}, old.access, cfg.access), + api: Object.assign({}, old.api, cfg.api), + }); + } + } catch (e) { /* 损坏/缺失按无配置处理 */ } + } + const byId = new Map(); + for (const [id, cfg] of rules) { + byId.set(id, { baseUrl: String(cfg.api.baseUrl || "").replace(/\/+$/, ""), apiKey: String(cfg.access.apiKey || ""), name: cfg.name || "", + accessType: String(cfg.access.type || "") }); + } + if (stamp !== null) providerCfgCache = { stamp, readFile: readFile2, byId }; + return byId; +} +function resolveCustomProvider(providerId, readFile2, cfgPath, builtinCfgPath) { + const e = loadProviderCfg(readFile2, cfgPath, builtinCfgPath).get(String(providerId)); + return e ? { baseUrl: e.baseUrl, apiKey: e.apiKey, name: e.name } : null; +} + +/* apiKey 模式的 BigModel/Z.ai 连接(provider bigmodel-api[-N] / zai-api[-N]):额度端点与 OAuth 同属账号池, + * 凭据取 provider_config 里同家族的 key,编码套餐 key(access.type 以 coding-plan-api-key 结尾)优先, + * 其次当前 provider 自身,再其次同家族首个非空 key。baseUrl 若显式配置必须落在官方域, + * 否则视为指向第三方中转的同名 provider,绝不把它的 key 发往官方域。 */ +const API_KEY_PROVIDER_RE = /^(bigmodel|zai)-api(?:-\d+)?$/; +const FAMILY_HOST_RE = { bigmodel: /(^|\.)bigmodel\.cn$/i, zai: /(^|\.)z\.ai$/i }; +function apiKeyFamilyOf(providerId) { + const m = API_KEY_PROVIDER_RE.exec(String(providerId || "")); + return m ? m[1] : ""; +} +function resolveFamilyApiKey(family, providerId, readFile2, cfgPath, builtinCfgPath) { + const usable = []; + for (const [id, e] of loadProviderCfg(readFile2, cfgPath, builtinCfgPath)) { + if (apiKeyFamilyOf(id) !== family || !e.apiKey) continue; + if (e.baseUrl) { + let host = ""; + try { host = new URL(e.baseUrl).hostname; } catch (err) { continue; } + if (!FAMILY_HOST_RE[family].test(host)) continue; + } + usable.push({ id, key: e.apiKey, plan: /coding-plan-api-key$/.test(e.accessType) }); + } + const pick = usable.find((u) => u.plan && u.id === providerId) || usable.find((u) => u.plan) || + usable.find((u) => u.id === providerId) || usable[0]; + return pick ? pick.key : ""; +} + +/* ---------- 适配器注册表 ---------- + * 契约:{id, label, kind, envKeys[], defaultOn, unverified?, + * detect(j, providerId) → ctx|null (providerId 驱动的零配置发现;返回 null=不命中) + * resolveCred(j, ctx) → {token|cookie|ak|sk|none, fingerprint, source} | null | {decryptFailed} + * fetchOne(j, ctx, cred, ctl) → value | {noPlan:true} (抛 qErr 分类错误)} + * j = {env, now, fetchImpl, readFile, secrets, customCfgPath, customProviderOf, log, http} */ +const ADAPTERS = []; + +/* --- BigModel / Z.ai 内置套餐(主链,v96 同款) --- */ +for (const fam of ["bigmodel", "zai"]) { + const conf = fam === "bigmodel" + ? { credKey: "oauth:bigmodel:access_token", origin: "https://bigmodel.cn", bearer: false, + envKeys: ["ZCODE_BIGMODEL_USAGE_API_KEY", "BIGMODEL_USAGE_API_KEY"], envUrl: "ZCODE_BIGMODEL_USAGE_QUOTA_URL", subPath: "/api/biz/subscription/list" } + : { credKey: "oauth:zai:access_token", origin: "https://api.z.ai", bearer: true, + envKeys: [], envUrl: "", subPath: null }; + ADAPTERS.push({ + id: fam, label: fam === "bigmodel" ? "BigModel" : "Z.ai", kind: "plan", defaultOn: false, envKeys: conf.envKeys, + detect(j, providerId) { + if (providerId.indexOf("account:") !== 0) return null; + if (providerId.indexOf("team") >= 0) return { team: true }; + if (providerId.indexOf("account:" + fam) === 0) return { family: fam }; + return null; + }, + resolveCred(j, ctx) { + for (const k of conf.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const configured = ((j.secrets.sources || {})[fam] || {}).apiKey; + if (typeof configured === "string" && configured) return { token: configured, fingerprint: "sec:" + sha16(configured), source: "secrets" }; + if (ctx.apiKeyProvider) { + const key = j.familyApiKeyOf(fam, ctx.apiKeyProvider); + if (key) return { token: key, fingerprint: "cfg:" + sha16(key), source: "provider_config" }; + } + let raw = null; + try { raw = j.readFile(j.credsPath); } catch (e) { return null; } + const enc = raw && typeof raw === "object" ? raw[conf.credKey] : null; + if (typeof enc !== "string" || !enc) return null; + const token = decryptValue(enc, deriveCipherKey(j.env)); + if (!token) return { decryptFailed: true, fingerprint: "host:" + sha16(enc) }; + return { token, fingerprint: "host:" + sha16(enc), source: "host" }; + }, + async fetchOne(j, ctx, cred, ctl) { + const url = (conf.envUrl && j.env[conf.envUrl]) || conf.origin + QUOTA_PATH; + /* 订阅元数据与额度并行(仅 bigmodel);失败静默降级不拖累额度 */ + const subFetch = (conf.subPath && ctx.family === "bigmodel") + ? j.http.maybeGetJson(conf.origin + conf.subPath, { authorization: cred.token }, ctl) : Promise.resolve(null); + subFetch.catch(() => { }); + const body = await j.http.getJson(url, { authorization: (conf.bearer ? "Bearer " : "") + cred.token }, ctl); + const value = normalizeQuotaBody(body); + if (value && value.noPlan) return { noPlan: true }; + const plan = await subFetch; + const sub = plan ? normalizeSubscriptionPlan(plan, j.now()) : null; + return sub ? Object.assign({}, value, { plan: sub }) : value; + }, + }); +} + +/* --- MiniMax Token Plan(零配置:api.minimaxi.com/.cn/.io 自定义供应商;可信域可配) --- */ +ADAPTERS.push({ + id: "minimax", label: "MiniMax", kind: "plan", defaultOn: true, unverified: true, + envKeys: ["MINIMAX_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + if (!cp.baseUrl && providerId === "minimax") return { domain: "https://api.minimaxi.com", key: cp.apiKey, origin: "cfg" }; + let host = ""; + try { host = new URL(cp.baseUrl).hostname; } catch (e) { return null; } + if (host === "api.minimaxi.com" || host === "api.minimax.cn") return { domain: "https://api.minimaxi.com", key: cp.apiKey, origin: "cfg" }; + if (host === "api.minimax.io") return { domain: "https://api.minimax.io", key: cp.apiKey, origin: "cfg" }; + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).minimax || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const rawDomain = ((j.secrets.sources || {}).minimax || {}).baseUrl || ""; + let host = ""; + try { host = rawDomain ? new URL(rawDomain).hostname : ""; } catch (e) { host = ""; } + const trusted = host === "api.minimaxi.com" || host === "api.minimax.cn" || host === "api.minimax.io"; // 可信查询域白名单:key 不发任意域 + const domain = trusted ? rawDomain.replace(/\/+$/, "") : (ctx.domain || "https://api.minimaxi.com"); + let body; + try { + body = await j.http.getJson(domain + "/v1/api/openplatform/coding_plan/remains", { authorization: "Bearer " + cred.token }, ctl); + } catch (e) { + if (e && e.code !== "parse_error") throw e; + body = await j.http.getJson("https://www." + (domain.indexOf(".io") >= 0 ? "minimax.io" : "minimaxi.com") + "/v1/token_plan/remains", { authorization: "Bearer " + cred.token }, ctl); // dsh 旧端点兜底 + } + const br = body && body.base_resp; + if (br && num(br.status_code) !== 0) throw qErr(br.status_code === 401 || br.status_code === 403 ? "auth_failed" : "parse_error", "minimax base_resp " + br.status_code); + const rows = Array.isArray(body && body.model_remains) ? body.model_remains : (Array.isArray(body && body.data && body.data.model_remains) ? body.data.model_remains : []); + const row = rows.find((r) => r && String(r.model_name || "").toLowerCase() === "general") || + rows.find((r) => /^minimax-m/i.test(String((r || {}).model_name || ""))) || + rows.find((r) => r && (num(r.current_interval_remaining_percent) != null || num(r.current_weekly_remaining_percent) != null)); + if (!row) throw qErr("parse_error", "no general row"); + const windows = {}; + const five = num(row.current_interval_remaining_percent); + if (five != null) windows.fiveHour = { percent: Math.round(clampPct(100 - five) * 10) / 10, resetsAt: resetOf(row.end_time) }; + if (num(row.current_weekly_status) === 1) { // ==3 = 套餐无周限额(remaining 恒 100),必须跳过 + const wk = num(row.current_weekly_remaining_percent); + if (wk != null) windows.weekly = { percent: Math.round(clampPct(100 - wk) * 10) / 10, resetsAt: resetOf(row.weekly_end_time) }; + } + if (!Object.keys(windows).length) throw qErr("parse_error", "no minimax windows"); + return { windows }; + }, +}); + +/* --- Kimi Code(订阅 usages,401/403 回落 moonshot PAYG 余额) --- */ +ADAPTERS.push({ + id: "kimi", label: "Kimi", kind: "plan", defaultOn: true, unverified: true, + envKeys: ["KIMI_CODING_API_KEY", "MOONSHOT_API_KEY", "KIMI_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let u = ""; + try { u = new URL(cp.baseUrl); } catch (e) { return null; } + if (u.hostname === "api.kimi.com" || u.hostname === "api.moonshot.cn") { + return { key: cp.apiKey, origin: "cfg" }; + } + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).kimi || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const headers = { authorization: "Bearer " + cred.token, "user-agent": "KimiCLI/1.6" }; + let body = null; + let authish = false; + for (const p of ["/coding/v1/usages", "/coding/v1/usage"]) { + try { + body = await j.http.getJson("https://api.kimi.com" + p, headers, ctl); + break; + } catch (e) { + if (e && (e.code === "auth_failed")) { authish = true; break; } // 该 Key 非订阅 Key/无订阅 → 回落 PAYG + if (e && e.code !== "network_error" && e.code !== "parse_error") throw e; + } + } + if (body && !authish) { + const windows = {}; + const limits = Array.isArray(body.limits) ? body.limits : []; + for (const e of limits) { // 兼容 cc-switch(detail 直挂)与 dsh(window+detail)两形状 + if (!e || typeof e !== "object") continue; + const d = e.detail || e; + const limit = num(d.limit), remaining = num(d.remaining); + if (limit == null || remaining == null || limit <= 0) continue; + const pct = clampPct(((limit - remaining) / limit) * 100); + if (!windows.fiveHour) { // cc-switch 语义:limits 各行均属 5 小时窗,取首条有效行 + windows.fiveHour = { percent: Math.round(pct * 10) / 10, resetsAt: resetOf(d.resetTime) }; + } + } + const u = body.usage && typeof body.usage === "object" ? body.usage : null; + if (u) { + const limit = num(u.limit), remaining = num(u.remaining); + if (limit != null && limit > 0 && remaining != null) { + windows.weekly = { percent: Math.round(clampPct(((limit - remaining) / limit) * 100) * 10) / 10, resetsAt: resetOf(u.resetTime) }; + } + } + if (Object.keys(windows).length) return { windows }; + } + /* PAYG 回落:moonshot 余额(人民币分) */ + const bal = await j.http.getJson("https://api.moonshot.cn/v1/users/me/balance", { authorization: "Bearer " + cred.token }, ctl); + const raw = (bal && (bal.available_balance != null ? bal.available_balance : (bal.balance != null ? bal.balance : (bal.data && bal.data.available_balance)))) || + (bal && bal.data && (bal.data.available_balance != null ? bal.data.available_balance : bal.data.balance)); + const v = num(raw); + if (v == null) throw qErr("parse_error", "kimi payg shape"); + const yuan = v >= 100 ? v / 100 : v; // 分 → 元(≥100 视为分) + return { balance: { amount: Math.round(yuan * 100) / 100, currency: "CNY" }, note: "PAYG" }; + }, +}); + +/* --- CommandCode(控制面四连;credits 三分) --- */ +ADAPTERS.push({ + id: "commandcode", label: "CommandCode", kind: "plan", defaultOn: true, unverified: true, + envKeys: ["COMMANDCODE_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let u = ""; + try { u = new URL(cp.baseUrl); } catch (e) { return null; } + if (u.hostname === "api.commandcode.ai") return { key: cp.apiKey, origin: "cfg" }; + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).commandcode || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const H = { authorization: "Bearer " + cred.token }; + const who = await j.http.getJson("https://api.commandcode.ai/alpha/whoami?limits=1", H, ctl); + const orgId = who && who.org && who.org.id; + if (!orgId) throw qErr("parse_error", "no org id"); + const cred8 = await j.http.getJson("https://api.commandcode.ai/alpha/billing/credits?orgId=" + encodeURIComponent(String(orgId)), H, ctl); + const c = (cred8 && (cred8.credits || cred8)) || {}; + const wl = (cred8 && (cred8.windowLimits || (cred8.credits && cred8.credits.windowLimits))) || null; + const windows = {}; + if (wl && wl.limited) { + for (const [k, name] of [["fiveHour", "fiveHour"], ["weekly", "weekly"]]) { + const w = wl[k]; + const used = num(w && w.used), cap = num(w && w.cap); + if (w && cap != null && cap > 0 && used != null) { + windows[name] = { percent: Math.round(clampPct((used / cap) * 100) * 10) / 10, resetsAt: resetOf(w.resetAt) }; + } + } + } + const subs = await j.http.getJson("https://api.commandcode.ai/alpha/billing/subscriptions?orgId=" + encodeURIComponent(String(orgId)), H, ctl); + const sd = (subs && (subs.data || subs)) || {}; + const periodStart = sd.currentPeriodStart, periodEnd = sd.currentPeriodEnd; + let monthly = null; + const sum = await j.http.getJson("https://api.commandcode.ai/alpha/usage/summary?since=" + encodeURIComponent(String(periodStart || "")) + "&orgId=" + encodeURIComponent(String(orgId)), H, ctl); + const totalCost = num(sum && sum.totalCost); + const parts = [ + ["monthly", num(c.monthlyCredits)], ["purchased", num(c.purchasedCredits)], ["free", num(c.freeCredits)], + ].filter(([, v]) => v != null); + const remainTotal = parts.reduce((a, [, v]) => a + v, 0); + if (totalCost != null && (totalCost > 0 || remainTotal > 0)) { + monthly = { percent: Math.round(clampPct((totalCost / Math.max(totalCost + remainTotal, 1e-9)) * 100) * 10) / 10, resetsAt: resetOf(periodEnd) }; + } + if (monthly) windows.monthly = monthly; + const out = {}; + if (Object.keys(windows).length) out.windows = windows; + if (parts.length) { + out.balance = { amount: Math.round(remainTotal * 100) / 100, currency: "CR", + parts: parts.map(([label, v]) => ({ label: { monthly: "月度", purchased: "购买", free: "免费" }[label] || label, amount: v })) }; + } + if (!out.windows && !out.balance) throw qErr("parse_error", "commandcode empty"); + if (sd.planId) out.plan = { version: "", productName: String(sd.planId).slice(0, 40), validStart: "", validEnd: "" }; + return out; + }, +}); + +/* --- Cline Pass(配置型:独立 Key;三窗口已用%) --- */ +ADAPTERS.push({ + id: "cline", label: "Cline Pass", kind: "plan", defaultOn: false, unverified: true, + envKeys: ["CLINE_API_KEY"], + detect() { return null; }, + resolveCred(j) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).cline || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const body = await j.http.getJson("https://api.cline.bot/api/v1/users/me/plan/usage-limits", { authorization: "Bearer " + cred.token }, ctl); + const windows = collectWindows(body && body.data && body.data.limits, + [["five_hour", "fiveHour"], ["fivehour", "fiveHour"], ["weekly", "weekly"], ["monthly", "monthly"]]); + if (!Object.keys(windows).length) throw qErr("parse_error", "cline empty"); + return { windows }; + }, +}); + +/* --- Anthropic Claude Pro/Max(OAuth 自动发现 ~/.claude/.credentials.json) --- */ +ADAPTERS.push({ + /* 显式启用(非零配置默认):凭据自动发现读的是第三方应用 Claude Code 的凭据文件, + * 属跨应用读取,必须用户在 ⚙ 单独打开(披露句随面板/README)。 */ + id: "anthropic", label: "Anthropic", kind: "plan", defaultOn: false, unverified: true, + envKeys: ["ANTHROPIC_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], + detect() { return null; }, + resolveCred(j) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).anthropic || {}; + if (sec.token) return { token: sec.token, fingerprint: "sec:" + sha16(sec.token), source: "secrets" }; + try { // 自动发现:Claude Code 凭据文件(披露已列明) + const p = path.join(os.homedir(), ".claude", ".credentials.json"); + const d = j.readFile(p); + const tok = d && d.claudeAiOauth && d.claudeAiOauth.accessToken; + if (typeof tok === "string" && tok) return { token: tok, fingerprint: "claude:" + sha16(tok), source: "claude-file" }; + } catch (e) { /* 未装 Claude Code / 文件不存在:按未配置 */ } + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const body = await j.http.getJson("https://api.anthropic.com/api/oauth/usage", { authorization: "Bearer " + cred.token }, ctl); + const windows = {}; + for (const [src, name] of [["five_hour", "fiveHour"], ["seven_day", "weekly"]]) { + const w = body && body[src]; + if (!w || typeof w !== "object") continue; + let pct = num(w.utilization); + if (pct == null) pct = num(w.used_percentage); + if (pct == null || !isFinite(pct) || pct < 0) continue; + windows[name] = { percent: Math.round(clampPct(pct) * 10) / 10, resetsAt: resetOf(w.resets_at != null ? w.resets_at : w.reset_at) }; + } + if (!Object.keys(windows).length) throw qErr("parse_error", "anthropic empty"); + return { windows }; // 子配额窗(seven_day_sonnet 等)整体丢弃,不覆盖主窗 + }, +}); + +/* --- DeepSeek(官方域零配置;total/granted/topped_up 三分拆分) --- */ +ADAPTERS.push({ + id: "deepseek", label: "DeepSeek", kind: "balance", defaultOn: true, + envKeys: ["DEEPSEEK_API_KEY", "DEEPSEEK_BALANCE_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let host = ""; + try { host = new URL(cp.baseUrl).hostname; } catch (e) { return null; } + if (host === "api.deepseek.com") return { key: cp.apiKey, origin: "cfg" }; + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).deepseek || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const body = await j.http.getJson("https://api.deepseek.com/user/balance", { authorization: "Bearer " + cred.token }, ctl); + const infos = Array.isArray(body && body.balance_infos) ? body.balance_infos : []; + if (!infos.length) throw qErr("parse_error", "no balance_infos"); + const parsed = infos + .map((x) => { + const total = parseCurrencyAmount(x.total_balance, x.currency); + const granted = parseCurrencyAmount(x.granted_balance, x.currency); + const topped = parseCurrencyAmount(x.topped_up_balance, x.currency); + if (!total) return null; + return { currency: x.currency || total.currency, total, granted, topped }; + }) + .filter(Boolean); + if (!parsed.length) throw qErr("parse_error", "balance_infos unparsable"); + const best = parsed.sort((a, b) => b.total.amount - a.total.amount)[0]; + const out = { balance: { amount: Math.round(best.total.amount * 100) / 100, currency: best.currency || "CNY" } }; + if (best.granted && best.granted.amount > 0) { // 仅赠送≠0 才拆分展示 + out.balance.parts = [ + { label: "赠送", amount: best.granted.amount }, + { label: "充值", amount: best.topped ? best.topped.amount : Math.max(0, best.total.amount - best.granted.amount) }, + ]; + } + return out; + }, +}); + +/* --- OpenRouter credits --- */ +ADAPTERS.push({ + id: "openrouter", label: "OpenRouter", kind: "balance", defaultOn: true, + envKeys: ["OPENROUTER_MANAGEMENT_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let host = ""; + try { host = new URL(cp.baseUrl).hostname; } catch (e) { return null; } + if (host === "openrouter.ai") return { key: cp.apiKey, origin: "cfg" }; + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).openrouter || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + const body = await j.http.getJson("https://openrouter.ai/api/v1/credits", { authorization: "Bearer " + cred.token }, ctl); + const d = body && body.data; + const total = num(d && d.total_credits), used = num(d && d.total_usage); + if (total == null || used == null) throw qErr("parse_error", "openrouter shape"); + return { balance: { amount: Math.round((total - used) * 100) / 100, currency: "USD" } }; + }, +}); + +/* --- SiliconFlow(端点存在 410 废弃风险,失败中性引导) --- */ +ADAPTERS.push({ + id: "siliconflow", label: "SiliconFlow", kind: "balance", defaultOn: true, + envKeys: ["SILICONFLOW_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let host = ""; + try { host = new URL(cp.baseUrl).hostname; } catch (e) { return null; } + if (host === "api.siliconflow.cn" || host === "api.siliconflow.com") return { + key: cp.apiKey, origin: "cfg", apiOrigin: "https://" + host, currency: host.endsWith(".com") ? "USD" : "CNY" }; + + return null; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).siliconflow || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + let body; + /* v102:secrets.baseUrl(⚙ 预设模板写入)仅在可信域白名单内生效(minimax 同款, + * key 不发任意域);.com 币种随域。端点自带 currency 时仍以响应为准。 */ + const rawBase = ((j.secrets.sources || {}).siliconflow || {}).baseUrl || ""; + let baseHost = ""; + try { baseHost = rawBase ? new URL(rawBase).hostname : ""; } catch (e) { baseHost = ""; } + const trustedBase = baseHost === "api.siliconflow.cn" || baseHost === "api.siliconflow.com"; + const baseOrigin = trustedBase ? rawBase.replace(/\/+$/, "") : (ctx.apiOrigin || "https://api.siliconflow.cn"); + const baseCurrency = baseHost === "api.siliconflow.com" ? "USD" : baseHost === "api.siliconflow.cn" ? "CNY" : ""; + try { body = await j.http.getJson(baseOrigin + "/v1/user/info", { authorization: "Bearer " + cred.token }, ctl); } + catch (e) { if (e && e.httpStatus === 410) throw qErr("unsupported", "siliconflow quota endpoint retired", { reason: "endpoint_retired" }); throw e; } + const d = (body && body.data) || {}; + const v = parseCurrencyAmount(d.totalBalance != null ? d.totalBalance : d.balance, d.currency || baseCurrency || ctx.currency || "CNY"); + if (!v) throw qErr("parse_error", "siliconflow shape"); + return { balance: { amount: Math.round(v.amount * 100) / 100, currency: v.currency || "CNY" } }; + }, +}); + +/* --- 小米 MiMo Token Plan(配置型:控制台 Cookie) --- */ +function normalizeMimoCookie(raw) { + let s = String(raw || "").trim().replace(/^Cookie:\s*/i, "").replace(/^["']|["']$/g, "").replace(/\s*\n\s*/g, "; ").replace(/;\s*;+/g, ";"); + if (!/userId=/.test(s)) return null; + if (!/(api-platform_)?serviceToken=/.test(s)) return null; + return s; +} +ADAPTERS.push({ + id: "mimo", label: "小米 MiMo", kind: "plan", defaultOn: false, unverified: true, + envKeys: ["MIMO_COOKIE", "XIAOMI_MIMO_COOKIE"], + detect() { return null; }, + resolveCred(j) { + let raw = null; + for (const k of this.envKeys) { if (j.env[k]) { raw = j.env[k]; break; } } + if (!raw) raw = ((j.secrets.sources || {}).mimo || {}).cookie; + if (!raw) return null; + const cookie = normalizeMimoCookie(raw); + if (!cookie) return { decryptFailed: true, fingerprint: "sec:" + sha16(raw), missing: "mimoCookieFields" }; + return { cookie, fingerprint: "sec:" + sha16(cookie), source: "secrets" }; + }, + async fetchOne(j, ctx, cred, ctl) { + const H = { cookie: cred.cookie }; + const base = "https://platform.xiaomimimo.com/api/v1"; + const usage = await j.http.getJson(base + "/tokenPlan/usage", H, ctl); + const ud = (usage && usage.data) || {}; + const items = Array.isArray(ud.monthUsage && ud.monthUsage.items) ? ud.monthUsage.items : []; + const windows = {}; + const pick = (names, name) => { + for (const it of items) { + if (!it) continue; + const key = String(it.name || ""); + if (!names.some((n) => key === n)) continue; + const used = num(it.used), limit = num(it.limit); + let pct = num(it.percent); + if ((used != null && limit != null && limit > 0)) pct = (used / limit) * 100; + if (pct == null || !isFinite(pct) || pct < 0) continue; + if (!windows[name]) windows[name] = { percent: Math.round(clampPct(pct) * 10) / 10, resetsAt: null }; + } + }; + pick(["month_total_token", "plan_total_token"], "monthly"); + pick(["compensation_total_token"], "compensation"); + let out = {}; + if (Object.keys(windows).length) out.windows = windows; + /* 周期截止与余额并行(均无依赖);失败静默降级 */ + const detP = j.http.maybeGetJson(base + "/tokenPlan/detail", H, ctl).catch(() => null); + const balP = j.http.maybeGetJson(base + "/balance", H, ctl).catch(() => null); + const det = await detP; + const dd = (det && det.data) || {}; + const end = typeof dd.currentPeriodEnd === "string" ? dd.currentPeriodEnd : ""; + if (end) { + const m = end.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/); + if (m && out.windows && out.windows.monthly) out.windows.monthly.resetsAt = m[1] + " " + m[2] + " +08:00"; + } + const bal = await balP; + const bd = (bal && bal.data) || {}; + const b = parseCurrencyAmount(bd.balance != null ? bd.balance : bd.cashBalance, bd.currency || "CNY"); + if (b) { + out.balance = { amount: Math.round(b.amount * 100) / 100, currency: b.currency || "CNY" }; + const cash = parseCurrencyAmount(bd.cashBalance, bd.currency), gift = parseCurrencyAmount(bd.giftBalance, bd.currency); + if (cash && gift && (gift.amount > 0 || cash.amount > 0)) { + out.balance.parts = [{ label: "充值", amount: cash.amount || 0 }, { label: "赠送", amount: gift.amount || 0 }]; + } + } + if (!out.windows && !out.balance) throw qErr("parse_error", "mimo empty"); + return out; + }, +}); + +/* --- 火山方舟(配置型 AK/SK;SigV4 火山变体) --- */ +function hmac(key, data) { return crypto.createHmac("sha256", key).update(data).digest(); } +async function volcCall(http, host, action, region, ak, sk) { + const now = new Date(); + const xDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); // YYYYMMDDTHHMMSSZ + const query = "Action=" + action + "&Region=" + region + "&Version=2024-01-01"; // 字母序 Action 0 && used != null) { + windows[name] = { percent: Math.round(clampPct((used / q) * 100) * 10) / 10, resetsAt: resetOf(afp[k].ResetTime) }; + } + } + } + if (Object.keys(windows).length) return { windows, planName: tag }; + const cp = await volcCall(j.http, host, "GetCodingPlanUsage", region, cred.ak, cred.sk); + const arr = (cp && cp.Result && (cp.Result.QuotaUsage || cp.Result.Usages || cp.Result.Details)) || []; + for (const e of arr) { + if (!e || typeof e !== "object") continue; + const lvl = String(e.Level || e.Type || e.Period || e.Label || e.Window || "").toLowerCase().replace(/[\s_-]/g, ""); + const name = /session|^5h|fivehour|rolling5h/.test(lvl) ? "fiveHour" : /weekly|week|7d/.test(lvl) ? "weekly" : /monthly|month/.test(lvl) ? "monthly" : null; + if (!name || windows[name]) continue; + let pct = num(e.Percent); + if (pct == null) pct = num(e.UsedPercent); + if (pct == null) pct = num(e.UsagePercent); + if (pct == null || pct < 0) continue; + windows[name] = { percent: Math.round(clampPct(pct) * 10) / 10, resetsAt: resetOf(e.ResetTime != null ? e.ResetTime : e.ResetTimestamp) }; + } + if (!Object.keys(windows).length) throw qErr("parse_error", "volcano empty"); + return { windows }; + }, +}); + +/* --- SCNet(账本估算,无网络;数据由 zusage 快照侧聚合注入) --- */ +ADAPTERS.push({ + id: "scnet", label: "SCNet", kind: "ledger", defaultOn: false, unverified: true, + envKeys: [], + detect() { return null; }, + resolveCred(j) { + const sec = (j.secrets.sources || {}).scnet || {}; + if (!sec.enabled || !(num(sec.planCredits) > 0)) return null; + return { none: true, fingerprint: "sec:scnet:" + sha16(JSON.stringify([sec.planCredits, sec.planStart || "", sec.modelPatterns || {}])), source: "secrets" }; + }, + async fetchOne(j) { + const ex = j.extras && j.extras.scnet; + if (!ex || ex.source === "none" || ex.credits_used == null) return { noPlan: true }; // 快照未带估算(zusage 旧版/未配置):软隐藏不报错 + return { + windows: { monthly: { percent: Math.round(clampPct((ex.credits_used / ex.plan_credits) * 100) * 10) / 10, resetsAt: ex.resets_at || null } }, + ledger: { used: ex.credits_used, total: ex.plan_credits, source: ex.source }, + }; + }, +}); + +/* sub2api 的 key 用量接口(与推理地址同源,非管理接口)。remaining 可能是最紧 + * 订阅窗口的剩余,不一定是钱包余额:存在 subscription 时仅输出套餐窗口。 */ +function normalizeSub2api(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + if (body.isValid === false) throw qErr("auth_failed", "sub2api key unavailable"); + if (!("isValid" in body) && !("mode" in body)) return null; + if (body.isValid !== true || !["balance", "subscription", "unrestricted", "quota", "quota_limited"].includes(body.mode)) { + throw qErr("parse_error", "sub2api mode invalid"); + } + const unit = body.unit == null && body.mode === "quota_limited" ? "USD" : body.unit; + if (!["USD", "CNY", "credits"].includes(unit)) throw qErr("parse_error", "sub2api unit invalid"); + if (body.subscription != null && (typeof body.subscription !== "object" || Array.isArray(body.subscription))) { + throw qErr("parse_error", "sub2api subscription invalid"); + } + const currency = unit === "credits" ? "CR" : unit; + if (body.mode === "quota" || body.mode === "quota_limited") { + const value = { label: "sub2api", kind: "plan" }; + if (body.quota != null) { + const quota = body.quota; + const limit = num(quota.limit), used = num(quota.used), remaining = num(quota.remaining); + if (typeof quota !== "object" || Array.isArray(quota) || limit == null || limit <= 0 || + used == null || used < 0 || remaining == null || (quota.unit != null && quota.unit !== "USD")) { + throw qErr("parse_error", "sub2api quota invalid"); + } + value.quotaSummary = { limit, used, remaining, currency: "USD" }; + } + if (body.rate_limits != null) { + if (!Array.isArray(body.rate_limits)) throw qErr("parse_error", "sub2api rate limits invalid"); + const windows = {}; + for (const entry of body.rate_limits) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw qErr("parse_error", "sub2api rate limit invalid"); + const key = entry.window === "5h" ? "fiveHour" : entry.window === "1d" ? "daily" : entry.window === "7d" ? "weekly" : null; + if (!key) continue; + const total = num(entry.limit), used = num(entry.used), remaining = num(entry.remaining); + if (total == null || total <= 0 || used == null || used < 0 || remaining == null || + (entry.unit != null && entry.unit !== "USD")) throw qErr("parse_error", "sub2api rate limit invalid"); + windows[key] = { percent: Math.round(clampPct(used / total * 100) * 10) / 10, + used, total, remaining, currency: "USD", resetsAt: resetOf(entry.reset_at) }; + } + if (Object.keys(windows).length) value.windows = windows; + } + if (!value.quotaSummary && !value.windows) throw qErr("parse_error", "sub2api quota missing"); + return value; + } + const sub = body.subscription; + if (sub && typeof sub === "object" && !Array.isArray(sub)) { + const windows = {}; + for (const [period, key] of [["daily", "daily"], ["weekly", "weekly"], ["monthly", "monthly"]]) { + const total = num(sub[period + "_limit_usd"]); + const used = num(sub[period + "_usage_usd"]); + if (total == null || total <= 0 || used == null || used < 0) continue; + const reset = sub[period + "_reset_at"] || sub[period + "_resets_at"]; + windows[key] = { percent: Math.round(clampPct(used / total * 100) * 10) / 10, + used: used, total: total, remaining: total - used, currency: "USD", resetsAt: resetOf(reset) }; + } + if (!Object.keys(windows).length) throw qErr("parse_error", "sub2api subscription windows missing"); + const expires = typeof sub.expires_at === "string" && Number.isFinite(Date.parse(sub.expires_at)) ? sub.expires_at : ""; + return { label: "sub2api", kind: "plan", windows: windows, + subscription: { name: typeof body.planName === "string" ? body.planName.slice(0, 100) : "", expiresAt: expires } }; + } + if (body.mode !== "balance" && body.mode !== "unrestricted") throw qErr("parse_error", "sub2api balance scope unknown"); + const remaining = num(body.mode === "unrestricted" ? body.balance : body.remaining); + if (remaining == null) throw qErr("parse_error", "sub2api balance missing"); + return { label: "sub2api", kind: "balance", balance: { amount: remaining, currency: currency } }; +} + +function relayQuotaUrls(baseUrl) { + let url; + try { url = new URL(baseUrl); } catch (e) { throw qErr("parse_error", "bad baseUrl"); } + if (url.protocol !== "https:" && !(url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw qErr("parse_error", "untrusted query scheme"); + if (url.username || url.password) throw qErr("parse_error", "baseUrl userinfo rejected"); + const prefix = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, ""); + return { sub2api: url.origin + prefix + "/v1/usage", legacy: url.origin + url.pathname.replace(/\/+$/, "") }; +} + +/* --- OpenCode Go($10/月订阅;转写自 cc-switch coding_plan.rs,MIT,未真实账号实测) --- + * 用量端点第一方未文档化:GET https://opencode.ai/zen/go/v1/usage,只认 + * Authorization: Bearer(与推理侧只认 x-api-key 相反);响应 + * {usage:{rolling|weekly|monthly:{status,percent(已用0-100),resetsAt(ISO)}}}, + * 端点不回金额。detect 较 cc-switch 的裸子串收严:host 精确 opencode.ai + + * 路径 /zen/go 或其子路径(两档预设),防外域伪装;Zen 按量版 /zen/v1 无 + * 用量 API(cc-switch 实测 404)刻意不命中。 */ +function normalizeOpencodeGo(body) { + const usage = body && typeof body === "object" && !Array.isArray(body) && + body.usage && typeof body.usage === "object" && !Array.isArray(body.usage) ? body.usage : null; + if (!usage) throw qErr("parse_error", "opencode usage shape"); + const windows = {}; + for (const pair of [["rolling", "fiveHour"], ["weekly", "weekly"], ["monthly", "monthly"]]) { + const w = usage[pair[0]] && typeof usage[pair[0]] === "object" && !Array.isArray(usage[pair[0]]) ? usage[pair[0]] : null; + const pct = w ? num(w.percent) : null; + if (pct == null || pct < 0) continue; // 坏窗跳过不整体失败(端点上线当天改过一次形态) + windows[pair[1]] = { + percent: Math.round(clampPct(pct) * 10) / 10, // 已用%直取(Go 语义即已用,非剩余) + resetsAt: pct > 0 ? resetOf(w.resetsAt) : null, // percent=0 时 resetsAt 是 now+窗长占位值,丢弃 + }; + } + if (!Object.keys(windows).length) throw qErr("parse_error", "opencode windows missing"); + return { label: "OpenCode Go", kind: "plan", windows: windows }; +} + +ADAPTERS.push({ + id: "opencode", label: "OpenCode Go", kind: "plan", defaultOn: true, unverified: true, + envKeys: ["OPENCODE_API_KEY"], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + if (!cp) return null; + let u = null; + try { u = new URL(cp.baseUrl); } catch (e) { return null; } + if (u.protocol !== "https:" || u.hostname.toLowerCase() !== "opencode.ai") return null; + const p = u.pathname.toLowerCase().replace(/\/+$/, ""); + if (p !== "/zen/go" && !p.startsWith("/zen/go/")) return null; // 仅两档预设;/zen/v1 按量版与 /zen/gopher 类前缀不命中 + return { key: cp.apiKey, origin: "cfg" }; + }, + resolveCred(j, ctx) { + for (const k of this.envKeys) { + const v = j.env[k]; + if (v) return { token: v, fingerprint: "env:" + sha16(v), source: "env" }; + } + const sec = (j.secrets.sources || {}).opencode || {}; + if (sec.apiKey) return { token: sec.apiKey, fingerprint: "sec:" + sha16(sec.apiKey), source: "secrets" }; + if (ctx.key) return { token: ctx.key, fingerprint: "cfg:" + sha16(ctx.key), source: "provider_config" }; + return null; + }, + async fetchOne(j, ctx, cred, ctl) { + let body; + try { + body = await j.http.getJson("https://opencode.ai/zen/go/v1/usage", + { authorization: "Bearer " + cred.token }, ctl); + } catch (e) { + /* 403 EntitlementError:key 本身有效(Zen/Go 共用 workspace key)但无 Go 订阅—— + * 与 401 认证失败分开(classifyStatus 把 401/403 都归 auth_failed,此处按消息分流)。 */ + if (e && e.httpStatus === 403) return { noPlan: true }; + throw e; + } + return normalizeOpencodeGo(body); + }, +}); + +/* --- 自定义中转(sub2api / OpenAI legacy;仅查询配置地址的同源) --- */ +ADAPTERS.push({ + id: "custom", label: "中转站", kind: "balance", defaultOn: false, + envKeys: [], + detect(j, providerId) { + if (providerId.indexOf("account:") === 0) return null; + const cp = j.customProviderOf(providerId); + return cp ? { cp } : null; + }, + resolveCred(j, ctx) { return { token: ctx.cp.apiKey, fingerprint: "cfg:" + sha16(ctx.cp.apiKey + "|" + ctx.cp.baseUrl), source: "provider_config" }; }, + async fetchOne(j, ctx, cred, ctl) { + const cp = ctx.cp; + const urls = relayQuotaUrls(cp.baseUrl); + const headers = { authorization: "Bearer " + cred.token }; + let usage = null; + try { usage = await j.http.getJson(urls.sub2api, headers, ctl); } + catch (e) { + if (!e || (e.code !== "parse_error" && e.httpStatus !== 404 && e.httpStatus !== 405)) throw e; + } + const detected = normalizeSub2api(usage); + if (detected) return detected; + const sub = await j.http.maybeGetJson(urls.legacy + "/dashboard/billing/subscription", headers, ctl); + if (!sub) return null; // 识别不了的网关:无可展示余额(unsupported 由调度器标注) + const hard = num(sub.hard_limit_usd); + if (hard == null || hard >= 1e8) return null; // 按量后付无固定池 + const billed = await j.http.getJson(urls.legacy + "/dashboard/billing/usage", headers, ctl); + const cents = billed && num(billed.total_usage); + if (cents == null || cents < 0) throw qErr("parse_error", "legacy usage missing"); + return { balance: { amount: Math.round((hard - cents / 100) * 100) / 100, currency: "USD" } }; + }, +}); +const ADAPTER_BY_ID = new Map(ADAPTERS.map((a) => [a.id, a])); + +/* ---------- 预设模板(v102,⚙ 面板「预设模板」行) ---------- + * CC Switch / magpie 同款「选模板只填 Key」体验:presets 为官方域变体,选中值写入 + * secrets.sources..baseUrl(minimax 的可信域白名单机制,同款推广到 siliconflow); + * keyUrl 是控制台取 Key 页。纯静态白名单数据,key 绝不进预设;未列出的源无模板行。 */ +const SOURCE_PRESETS = { + minimax: { + keyUrl: "https://platform.minimaxi.com", + presets: [ + { label: "国内站", labelEn: "China", value: "https://api.minimaxi.com" }, + { label: "国际站", labelEn: "Global", value: "https://api.minimax.io" }, + ], + }, + siliconflow: { + keyUrl: "https://cloud.siliconflow.cn/account/ak", + presets: [ + { label: "中国站 ¥", labelEn: "China ¥", value: "https://api.siliconflow.cn" }, + { label: "国际站 $", labelEn: "Global $", value: "https://api.siliconflow.com" }, + ], + }, + deepseek: { keyUrl: "https://platform.deepseek.com/api_keys" }, + openrouter: { keyUrl: "https://openrouter.ai/settings/keys" }, + kimi: { keyUrl: "https://platform.moonshot.cn/console/api-keys" }, + anthropic: { keyUrl: "https://console.anthropic.com/settings/keys" }, + mimo: { keyUrl: "https://platform.xiaomimimo.com" }, + volcano: { keyUrl: "https://console.volcengine.com/ark" }, +}; + +/* ---------- 多源调度器 ---------- */ +function createQuotaClient(opts) { + const o = opts || {}; + const env = o.env || process.env; + const now = o.now || (() => Date.now()); + const fetchImpl = o.fetchImpl || (typeof fetch === "function" ? fetch : null); + const { credsPath, customCfgPath, builtinCfgPath } = resolveQuotaPaths(o); + const secretsPath = o.secretsPath || path.join(path.dirname(credsPath), "quota-secrets.json"); + const readFile = o.readFile || ((p) => JSON.parse(fs.readFileSync(p, "utf8"))); + const log = o.log || (() => {}); + const onUpdate = o.onUpdate || null; + + /* quota-secrets.json:运行目录 0600(首读强制收紧权限);损坏/缺失按空配置 */ + let secretsMtime = -2, secrets = { sources: {} }; + function readSecrets() { + let m = -1; + try { m = fs.statSync(secretsPath).mtimeMs; } + catch (e) { m = -1; } // stat 失败不短路:仍尝试读取(测试虚拟路径/权限受限);-1 不缓存 + if (m !== secretsMtime) { + try { + const d = readFile(secretsPath); + secrets = d && typeof d === "object" && d.sources && typeof d.sources === "object" ? d : { sources: {} }; + } catch (e) { secrets = { sources: {} }; } + if (m >= 0) secretsMtime = m; else return secrets; + } + return secrets; + } + const extras = {}; // 快照侧注入的账本数据(scnet 估算等):noteSnapshotExtras 写入 + + /* HTTP 助手:分类错误(401/403 auth_failed、429 rate_limited、5xx/其它 network_error、 + * JSON 解析 parse_error);maybeGetJson 任何失败返回 null(探测/可选端点用)。 */ + function classifyStatus(status, headers) { + if (status === 401 || status === 403) return qErr("auth_failed", "HTTP " + status, { httpStatus: status }); + if (status === 429) { + const ra = headers && typeof headers.get === "function" ? headers.get("retry-after") : null; + return qErr("rate_limited", "HTTP 429", { retryAfterMs: parseRetryAfterMs(ra) }); + } + return qErr("network_error", "HTTP " + status, { httpStatus: status }); + } + async function rawFetch(url, headers, ctl, method) { + const res = await fetchImpl(url, { + method: method || "GET", + headers: Object.assign({ accept: "application/json", "accept-encoding": "identity" }, headers), + signal: ctl ? ctl.signal : undefined, redirect: "manual", + }); + let text = ""; + if (res.body && typeof res.body.getReader === "function") { + const reader = res.body.getReader(); + const chunks = []; let size = 0; + for (;;) { + const step = await reader.read(); + if (step.done) break; + chunks.push(step.value); size += step.value.length; + if (size > BODY_LIMIT) { try { ctl.abort(); } catch (e) { } throw qErr("parse_error", "response too large"); } + } + const buf = Buffer.alloc(size); let off = 0; + for (const c of chunks) { buf.set(c, off); off += c.length; } + text = buf.toString("utf8"); + } else { + text = await res.text(); + if (text.length > BODY_LIMIT) throw qErr("parse_error", "response too large"); + } + return { status: res.status, headers: res.headers, text }; + } + const http = { + async getJson(url, headers, ctl) { + const r = await rawFetch(url, headers, ctl); + if (r.status < 200 || r.status >= 300) throw classifyStatus(r.status, r.headers); + try { return JSON.parse(r.text); } catch (e) { throw qErr("parse_error", "invalid json"); } + }, + async maybeGetJson(url, headers, ctl) { + try { return await http.getJson(url, headers, ctl); } catch (e) { return null; } + }, + async post(url, headers, ctl) { + const r = await rawFetch(url, headers, ctl, "POST"); + let json = null; + try { json = JSON.parse(r.text); } catch (e) { throw qErr("parse_error", "invalid json"); } + const meta = json && json.ResponseMetadata && json.ResponseMetadata.Error; // 火山信封:HTTP 200 也可能带错 + if (meta && meta.Code) { + const c = String(meta.Code); + if (/auth|signature|accessdenied|denied|unauthorized|forbidden|credential|token/i.test(c)) throw qErr("auth_failed", c.slice(0, 60)); + throw qErr("network_error", c.slice(0, 60)); + } + if (json && json.Error && json.Error.Code) { + const c = String(json.Error.Code); + if (/auth|signature|denied|unauthorized|forbidden/i.test(c)) throw qErr("auth_failed", c.slice(0, 60)); + throw qErr("network_error", c.slice(0, 60)); + } + if (r.status < 200 || r.status >= 300) throw classifyStatus(r.status, r.headers); // 签名错常 400 非 401 + return json; + }, + }; + + const j = { + env, now, fetchImpl, readFile, secrets: { sources: {} }, customCfgPath, log, http, extras, + credsPath, + customProviderOf: (pid) => resolveCustomProvider(pid, readFile, customCfgPath, builtinCfgPath), + familyApiKeyOf: (family, pid) => resolveFamilyApiKey(family, pid, readFile, customCfgPath, builtinCfgPath), + }; + + /* 源状态:id → st(v96 单源状态机的每源实例) */ + const states = new Map(); + const byProvider = {}; // providerId → 源 id(同厂商不同 provider 也隔离) + let wantedProviders = null; // null 为旧渲染端单源模式;数组来自全部存活 pane 的实时选择 + + function adapterOf(id) { return ADAPTER_BY_ID.get(String(id).split(":")[0]); } + let primaryId = ""; // 本 pane 会话模型归属的源(chip 主显) + let primaryProvider = ""; // 最近一次 noteProvider 的原始 provider 串 + let seq = 0; + + function stateOf(id) { + let st = states.get(id); + if (!st) { + st = { id, status: "idle", reason: "", value: null, fetchedAt: 0, nextAt: 0, backoffIdx: 0, + fingerprint: "", epoch: 0, inFlight: null, ctl: null, lastError: "", ctx: null }; + states.set(id, st); + } + return st; + } + function setState(st, fields) { + Object.assign(st, fields); + if (onUpdate) onUpdate(); + } + + /* provider → 源 id 解析(primary 专用;custom 匹配官方域适配器优先于 generic relay) */ + function resolvePrimary(providerId) { + const p = String(providerId || ""); + if (!p) return { id: "" }; + if (p.indexOf("account:") === 0) { + if (p.indexOf("team") >= 0) return { id: "team" }; + if (p.indexOf("account:bigmodel") === 0) return { id: "bigmodel", ctx: { family: "bigmodel" } }; + if (p.indexOf("account:zai") === 0) return { id: "zai", ctx: { family: "zai" } }; + return { id: "" }; + } + const keyFam = apiKeyFamilyOf(p); + if (keyFam) return { id: keyFam, ctx: { family: keyFam, apiKeyProvider: p }, provider: p }; + j.secrets = readSecrets(); // provider→adapter 探测前刷新配置 + for (const a of ADAPTERS) { + if (a.id === "custom" || a.id === "bigmodel" || a.id === "zai") continue; + try { + const ctx = a.detect(j, p); + if (ctx) return { id: a.id, ctx, provider: p }; + } catch (e) { /* detect 抛错按不命中 */ } + } + const ctx = ADAPTER_BY_ID.get("custom").detect(j, p); + /* custom 源按 provider 隔离(custom:):两个不同中转的窗口各持独立状态/key, + * 互不覆盖、互不串显;未命中同样建独立空源(unsupported/provider)。 */ + return { id: "custom:" + p, ctx, provider: p }; + } + + function settleError(st, ep, err) { + if (ep !== st.epoch) return; // 代际守卫:源凭据/身份已切换,旧响应丢弃 + const code = err && err.code || "network_error"; + st.lastError = String(err && err.message || code).slice(0, 120); + st.status = code; + if (code === "auth_failed" || code === "no_plan") st.nextAt = Infinity; + else if (code === "parse_error") st.nextAt = now() + PARSE_BACKOFF_MS; + else if (code === "rate_limited") st.nextAt = now() + (err.retryAfterMs || 60 * 1000); + else st.nextAt = now() + BACKOFF_MS[Math.min(st.backoffIdx++, BACKOFF_MS.length - 1)]; + log("quota settle:", st.id, code, st.lastError); + if (onUpdate) onUpdate(); + } + function settleOk(st, ep, value) { + if (ep !== st.epoch) return; + st.value = value; + st.fetchedAt = now(); + st.status = "ok"; + st.lastError = ""; + st.backoffIdx = 0; + const a = adapterOf(st.id); + st.nextAt = now() + (a && a.kind === "balance" ? TTL_BALANCE_MS : TTL_MS); + if (onUpdate) onUpdate(); + } + + function startFetch(adapter, st) { + st.epoch += 1; + const ep = st.epoch; + const ctl = new AbortController(); + st.ctl = ctl; + const kill = setTimeout(() => { try { ctl.abort(); } catch (e) { } }, FETCH_TIMEOUT_MS); + const p = (async () => { + const value = await adapter.fetchOne(j, st.ctx, st.cred, ctl); + if (value && value.noPlan) { const e = qErr("no_plan", "no plan"); e.st = st; throw e; } + if (value == null) { const e = qErr("unsupported", "gateway"); e.st = st; throw e; } // custom:识别不了的网关 + settleOk(st, ep, value); + })() + .catch((e) => { + const target = e && e.st || st; + if (e && e.code === "no_plan") { if (ep === target.epoch) { target.status = "no_plan"; target.nextAt = Infinity; target.lastError = "no plan"; if (onUpdate) onUpdate(); } return; } + if (e && e.code === "unsupported") { if (ep === target.epoch) { target.status = "unsupported"; target.reason = e.reason || "gateway"; target.value = null; target.lastError = ""; target.nextAt = now() + PARSE_BACKOFF_MS; if (onUpdate) onUpdate(); } return; } + settleError(target, ep, e); + }) + .finally(() => { clearTimeout(kill); }); + st.inFlight = p; + const q = p; + p.then(() => { if (st.inFlight === q) st.inFlight = null; }); // fetchOne 包装自身不 reject + } + + function tickSource(adapter, st, force) { + j.secrets = readSecrets(); + if (((j.secrets.sources || {})[adapter.id] || {}).enabled === false) return; + const t = now(); + if (st.inFlight) return; + if (!force && t < st.nextAt) return; + const ctx = st.ctx; + if (!ctx) { + if (adapter.id === "custom") { // 无配置的自定义供应商:unsupported/provider(v96 兼容语义) + if (st.status !== "unsupported" || st.reason !== "provider") setState(st, { status: "unsupported", reason: "provider", value: null }); + } else if (st.status !== "not_configured") setState(st, { status: "not_configured", reason: "", lastError: "" }); + st.nextAt = t + NOT_CONFIGURED_RETRY_MS; + return; + } + const cred = adapter.resolveCred(j, ctx); + if (!cred) { + if (st.status !== "not_configured") setState(st, { status: "not_configured", reason: "", lastError: "" }); + st.nextAt = t + NOT_CONFIGURED_RETRY_MS; + return; + } + if (cred.decryptFailed) { + if (st.status !== "not_configured") setState(st, { status: "not_configured", reason: "decrypt", lastError: "credential decrypt failed" }); + st.nextAt = t + NOT_CONFIGURED_RETRY_MS; + return; + } + if (cred.fingerprint !== st.fingerprint) { + st.fingerprint = cred.fingerprint; + st.backoffIdx = 0; + if (st.status === "auth_failed" || st.status === "no_plan" || (st.status === "unsupported" && st.reason === "gateway")) st.status = "idle"; + st.nextAt = 0; + } + if (!force && (st.status === "auth_failed" || st.status === "no_plan")) return; + if (cred.source === "host" || cred.source === "env") { + const exp = jwtExpMs(cred.token || ""); + if (exp && exp - t < EXP_SOON_MS) { st.nextAt = t + EXP_RETRY_MS; return; } + } + st.cred = cred; + startFetch(adapter, st); + } + + /* 配置撤销清展示值并换代;不通知 onUpdate,避免 view/settings 重评递归推送。 */ + function deactivateSource(st, status) { + if (st.status === status && !st.value && !st.inFlight && !st.fingerprint) return; + st.epoch += 1; + if (st.ctl) { try { st.ctl.abort(); } catch (e) { } } + Object.assign(st, { status, reason: "", value: null, fetchedAt: 0, nextAt: 0, + backoffIdx: 0, fingerprint: "", cred: null, ctl: null, inFlight: null, lastError: "" }); + } + + /* 活跃源集合:primary + 全部可解析的零配置适配器 + secrets 显式启用的配置适配器 */ + function activeSet() { + j.secrets = readSecrets(); + const set = new Map(); + const wanted = wantedProviders === null ? [primaryProvider] : wantedProviders; + for (const providerId of wanted) { + const id = byProvider[providerId] || ""; + if (id === "team") { + const st = stateOf(id); + if (st.status !== "unsupported") setState(st, { status: "unsupported", reason: "team" }); + continue; + } + const a = adapterOf(id); + if (!a) continue; + const st = stateOf(id); + const cfg = (j.secrets.sources || {})[a.id] || {}; + if (cfg.enabled === false) continue; + if (a.id === "anthropic" && cfg.enabled !== true) continue; // anthropic 仅显式启用(终版契约) + set.set(id, { a, st }); + } + for (const a of ADAPTERS) { + if (a.id === "custom" || set.has(a.id)) continue; // custom 系只在 primary 分支进入 + /* 账户复合源(vendor:providerId)已入集时跳过裸厂商源:同一凭据两条状态 + * 会重复请求同一端点(secrets 键被两者共用),账户源已覆盖该厂商。 */ + let vendorCovered = false; + for (const k of set.keys()) if (k.indexOf(a.id + ":") === 0) { vendorCovered = true; break; } + if (vendorCovered) continue; + const secCfg = (j.secrets.sources || {})[a.id] || {}; + const enabledCfg = secCfg.enabled === true; + const hasEnv = a.envKeys.some((key) => !!j.env[key]); + const hasConfigured = !!(secCfg.apiKey || secCfg.cookie || secCfg.token || secCfg.accessKeyId); + if (secCfg.enabled !== false && (enabledCfg || (a.defaultOn && (hasEnv || hasConfigured)))) { + const st = stateOf(a.id); + if (!st.ctx) { + const auto = hasEnv || secCfg.apiKey || secCfg.cookie || secCfg.token || (a.id === "scnet" && num(secCfg.planCredits) > 0) || + (a.id === "anthropic") || (a.id === "volcano" && (j.env.VOLCENGINE_ACCESS_KEY_ID || secCfg.accessKeyId)); + if (auto || a.id === "scnet") st.ctx = { config: true, family: a.id }; // anthropic 已改显式启用:enabled+凭据可得才激活 + } + if (st.ctx) set.set(a.id, { a, st }); + else if (a.defaultOn) { + /* 零配置适配器但探测不到 provider:仅当 primary 是它时才标 not_configured,否则静默跳过 */ + } + } + } + for (const [id, st] of states) { + const a = adapterOf(id); // 复合源 id(vendor:providerId)也须命中:禁用/撤销/解密检查不得跳过账户源 + if (!a) continue; + const cfg = (j.secrets.sources || {})[a.id] || {}; + if (cfg.enabled === false) { + deactivateSource(st, "disabled"); + } else if (a.id !== "custom" && !set.has(id)) { + /* 复合账户源(id 含 ":")随 pane 离开只视为不在集合:保留 TTL 内缓存供切回即用; + * 禁用(enabled=false)与凭据失效(resolveCred 空)的撤销在各自分支执行。 */ + if (id.indexOf(":") < 0) deactivateSource(st, "not_configured"); + } else if (set.has(id) && st.ctx) { + const cred = a.resolveCred(j, st.ctx); + if (!cred || cred.decryptFailed) { + deactivateSource(st, "not_configured"); + if (cred && cred.decryptFailed) { + st.reason = "decrypt"; + st.lastError = "credential decrypt failed"; + } + } else if (st.status === "disabled") st.status = "idle"; + } + } + return set; + } + + let primaryCtx = null; + + function tick(force) { + for (const { a, st } of activeSet().values()) tickSource(a, st, force); + } + + function rememberProvider(providerId) { + const r = resolvePrimary(providerId); + if (r.id && providerId.indexOf("account:") !== 0 && r.id.indexOf(":") < 0) { + r.id += ":" + providerId; + } + byProvider[providerId] = r.id || ""; + if (!r.id || r.id === "team") return r; + const st = stateOf(r.id); + const contextStamp = sha16(JSON.stringify(r.ctx || null)); + if (st.contextStamp !== contextStamp) { + st.epoch += 1; + if (st.ctl) st.ctl.abort(); + st.inFlight = null; + st.ctx = r.ctx || null; + st.contextStamp = contextStamp; + st.value = null; + st.fetchedAt = 0; + st.status = "idle"; + st.reason = ""; + st.lastError = ""; + st.fingerprint = ""; + st.backoffIdx = 0; + st.nextAt = 0; + } + return r; + } + + function entryView(st) { + const t = now(); + const a = adapterOf(st.id); + const ttl = a && a.kind === "balance" ? TTL_BALANCE_MS : TTL_MS; + const cfg = a ? ((j.secrets.sources || {})[a.id] || {}) : {}; + const v = { status: cfg.enabled === false ? "disabled" : st.status, reason: st.reason, fetchedAt: st.fetchedAt, + stale: !!(st.fetchedAt && t - st.fetchedAt > ttl), error: st.lastError }; + if (cfg.enabled === false) return v; + if (st.value) { + v.level = st.value.level; + v.plan = st.value.plan || null; + v.balance = st.value.balance || null; + v.windows = st.value.windows; + v.tools = st.value.tools; + v.models = st.value.models; + v.ledger = st.value.ledger || null; + if (st.value.label) v.label = st.value.label; + if (st.value.kind) v.kind = st.value.kind; + if (st.value.subscription) v.subscription = st.value.subscription; + if (st.value.quotaSummary) v.quotaSummary = st.value.quotaSummary; + } + return v; + } + + return { + tick, + refresh() { tick(true); }, + refreshSource(id) { // 厂商按钮刷新该厂商各账户;chip 的完整源 id 只刷新本账户 + const a = adapterOf(id); + if (!a) return; + if (String(id).indexOf(":") >= 0) { + const st = states.get(id); + if (st) tickSource(a, st, true); + } else { + for (const st of states.values()) if (adapterOf(st.id) === a) tickSource(a, st, true); + } + }, + noteProvider(providerId) { + const p = String(providerId || ""); + if (p === primaryProvider && wantedProviders === null) return; + wantedProviders = null; + primaryProvider = p; + if (!p) { primaryId = ""; primaryCtx = null; return; } + const r = rememberProvider(p); + primaryId = r.id; + primaryCtx = r.ctx || null; + }, + setProviders(providerIds) { + const next = Array.from(new Set((Array.isArray(providerIds) ? providerIds : []) + .filter((p) => typeof p === "string" && p.length > 0 && p.length <= 160 && !/[\x00-\x1f\x7f]/.test(p)))); + const ids = new Set(); + for (const p of next) ids.add(rememberProvider(p).id); + for (const p of Object.keys(byProvider)) if (!next.includes(p)) delete byProvider[p]; + if (!next.includes(primaryProvider)) primaryProvider = next[0] || ""; + primaryId = byProvider[primaryProvider] || ""; + primaryCtx = primaryId ? stateOf(primaryId).ctx : null; + wantedProviders = next; + for (const [id, st] of states) { + if (id.indexOf(":") < 0 || ids.has(id) || !st.inFlight) continue; + st.epoch += 1; + if (st.ctl) st.ctl.abort(); + st.inFlight = null; + } + }, + credentialsChanged() { + /* 同一 provider 的 key/baseURL 也可能被修改;重读实际配置并重判适配器, + * 不能沿用旧 ctx 或让一次 unsupported 永久锁住后来补全的 MiniMax 规则。 */ + const registered = new Set(Object.keys(byProvider)); + if (primaryProvider) registered.add(primaryProvider); + /* 经 rememberProvider 重判:复合源后缀(vendor:providerId)与代际失效/状态复位 + * 由它统一处理——直接用 resolvePrimary 裸写 byProvider 会把厂商内复合源坍缩成 + * 裸家族源,丢账户隔离(多账户中转串显,实测)。 */ + for (const provider of registered) rememberProvider(provider); + if (primaryProvider) { + primaryId = byProvider[primaryProvider] || ""; + const st = primaryId ? states.get(primaryId) : null; + primaryCtx = st ? st.ctx : null; + } + activeSet(); // 总开关关闭时也撤销旧额度;只重评本地配置,不发请求/回调 + /* 唤醒"值得重评"的源:冻结态(Infinity)重评后仍由 status 短路(指纹未变不复 fetch); + * not_configured 唤醒以承接 secrets/供应商配置刚启用;宿主凭据源即时重拉(token 刷新)。 + * ok 态保持 TTL——指纹在其自然到期重查时校验,避免一次 secrets 写入全源冗余刷新。 */ + for (const st of states.values()) { + if (st.nextAt === Infinity || st.status === "not_configured" || (st.cred && st.cred.source === "host")) st.nextAt = 0; + } + }, + noteSnapshotExtras(data) { // 快照侧注入(scnet 估算等);ledger 源数据到位即解冻 + const prevScnet = extras.scnet; + Object.assign(extras, data || {}); + if (data && data.scnet !== undefined && JSON.stringify(data.scnet) !== JSON.stringify(prevScnet)) { + const st = states.get("scnet"); + if (st && (st.status === "no_plan" || st.status === "network_error")) { + st.status = "idle"; + st.nextAt = 0; // 估算就绪:立即重查(首次启用早于首个快照的自我冻结修复) + } + } + }, + view() { + activeSet(); // 配置撤销立即可见,不等待下一轮联网 tick + const sources = {}; + for (const [id, st] of states) sources[id] = entryView(st); + const out = { active: primaryId || "", byProvider: Object.assign({}, byProvider), sources }; + const prim = primaryId ? sources[primaryId] : null; // 兼容镜像(本版保留,浮层迁移完成后移除) + if (prim) Object.assign(out, { + status: prim.status, reason: prim.reason, providerId: primaryProvider, + fetchedAt: prim.fetchedAt, stale: prim.stale, error: prim.error, + level: prim.level, plan: prim.plan, balance: prim.balance, + windows: prim.windows, tools: prim.tools, models: prim.models, ledger: prim.ledger, + label: prim.label, kind: prim.kind, subscription: prim.subscription, quotaSummary: prim.quotaSummary, + }); + return out; + }, + sourcesConfig() { // ⚙ 面板数据源列表(不回显凭据值) + activeSet(); + const list = []; + for (const a of ADAPTERS) { + if (a.id === "custom") continue; + const cfg = (j.secrets.sources || {})[a.id] || {}; + const accounts = Array.from(states.values()).filter((st) => adapterOf(st.id) === a && st.ctx); + const st = accounts.find((entry) => entry.status === "ok") || accounts[0] || states.get(a.id); + list.push({ + id: a.id, label: a.label, kind: a.kind, defaultOn: a.defaultOn, unverified: !!a.unverified, + enabled: cfg.enabled !== false && (a.defaultOn || cfg.enabled === true), + credentialFields: a.id === "mimo" ? ["cookie"] : a.id === "volcano" ? ["accessKeyId", "secretAccessKey"] : a.id === "scnet" ? ["planCredits", "planStart"] : a.id === "anthropic" ? ["token"] : ["apiKey"], + keyConfigured: !!(cfg.apiKey || cfg.cookie || cfg.token || cfg.accessKeyId || + accounts.some((entry) => !!entry.cred) || (a.id === "scnet" && num(cfg.planCredits) > 0)), + keySource: st && st.cred ? st.cred.source : "", + /* v104:显式关闭的源统一报 disabled(与 entryView 同口径)——原样透传 st.status 时 + * 从未激活过的关闭源会冒充 idle,面板出现「开关已关却显示待查询」的矛盾 */ + status: cfg.enabled === false ? "disabled" : (st ? st.status : "idle"), + /* v102:预设模板(静态白名单)+ 当前 baseUrl(域名非凭据,仅用于面板高亮选中模板; + * apiKey/cookie/token/AK/SK 照旧不回显) */ + presets: (SOURCE_PRESETS[a.id] || {}).presets, + keyUrl: (SOURCE_PRESETS[a.id] || {}).keyUrl, + baseUrl: cfg.baseUrl || "", + }); + } + return list; + }, + secretsPath, + dispose() { + for (const st of states.values()) { + if (st.ctl) { try { st.ctl.abort(); } catch (e) { } } + st.inFlight = null; + } + }, + get _state() { return states.get(primaryId) || states.get("custom") || {}; }, // 测试接缝兼容(v96 单源形状) + _states: states, + _adapters: ADAPTERS, + }; +} + +module.exports = { + createQuotaClient, + resolveQuotaPaths, + resolveCustomProvider, + decryptValue, + deriveCipherKey, + jwtExpMs, + normalizeQuotaBody, + normalizeSubscriptionPlan, + normalizeSub2api, + relayQuotaUrls, + isNoPlanMessage, + normalizeMimoCookie, + parseCurrencyAmount, + collectWindows, + resolveWanted(providerId) { // v96 兼容导出(单测在用):内置 family 判定 + const p = String(providerId || ""); + if (apiKeyFamilyOf(p)) return { family: apiKeyFamilyOf(p) }; + if (p.indexOf("account:") !== 0) return null; + if (p.indexOf("team") >= 0) return { unsupported: "team" }; + if (p.indexOf("account:bigmodel") === 0) return { family: "bigmodel" }; + if (p.indexOf("account:zai") === 0) return { family: "zai" }; + return null; + }, + TTL_MS, +}; diff --git a/external_plugins/zcode-token-usage-statusbar/release_unpack.py b/external_plugins/zcode-token-usage-statusbar/release_unpack.py new file mode 100644 index 0000000..948a60c --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/release_unpack.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Validate a release asset completely before unpacking it into a new directory.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import shlex +import stat +import struct +import sys +import unicodedata +import zipfile +import zlib + +PLUGIN = "zcode-token-usage-statusbar" +PREFIX = "plugins/" + PLUGIN + "/" +PLUGIN_ID = PLUGIN + "@zusage-marketplace" +MAX_JSON = 256 * 1024 +MAX_ARCHIVE = 16 * 1024 * 1024 +MAX_TOTAL = 64 * 1024 * 1024 +MAX_FILE = 8 * 1024 * 1024 +MAX_ENTRIES = 1000 +MAX_RATIO = 1000 +SEMVER = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" +PLUGIN_FILES = ( + "usage_mcp.py", "zusage.py", "patch_install.py", "install_monitor.py", + "inject-main.cjs", "plugin-updater.cjs", "plugin-storage.cjs", "quota-client.cjs", "runtime_lock.py", "usage_stats.py", "turn-stats.js", "overlay.js", + "plugin-release.cjs", "release_unpack.py", "overlay_install.py", "token_rate.py", "renderer_bridge.py", + "plugin_auto_update.py", "renderer_bridge.py", "restart_notice.py", ".mcp.json", "README.md", + ".zcode-plugin/plugin.json", ".claude-plugin/plugin.json", "hooks/hooks.json", + "hooks/session-start.py", "hooks/prompt-submit.py", "commands/usage.md", + "commands/install-overlay.md", "commands/uninstall-overlay.md", + "skills/zcode-token-usage/SKILL.md", +) +ALLOWED_FILES = frozenset(("marketplace.json", "LICENSE") + + tuple(PREFIX + name for name in PLUGIN_FILES)) +ALLOWED_DIRS = frozenset(parent for name in ALLOWED_FILES + for parent in ("/".join(name.split("/")[:i]) + for i in range(1, len(name.split("/"))))) +# Official releases are complete products, not reduced test fixtures: omitted runtime +# dependencies can pass identity checks but fail when installed. +REQUIRED_FILES = ALLOWED_FILES +BOOTSTRAP_FILES = frozenset(("marketplace.json", "LICENSE") + tuple(PREFIX + name for name in ( + ".zcode-plugin/plugin.json", ".claude-plugin/plugin.json", "overlay_install.py", + "inject-main.cjs", "overlay.js", "zusage.py", "patch_install.py", "install_monitor.py", + "plugin-updater.cjs", "plugin-release.cjs", "plugin-storage.cjs", "runtime_lock.py", "usage_stats.py", "turn-stats.js", + "release_unpack.py", "plugin_auto_update.py", "restart_notice.py"))) + + +def layout_path(name, is_dir=False): + if name in (ALLOWED_DIRS if is_dir else ALLOWED_FILES): + return True + if not name.startswith(PREFIX): + return False + relative = name[len(PREFIX):] + if is_dir: + return relative in ("hooks", "commands", "skills", "skills/zcode-token-usage") + if "/" not in relative: + return bool(re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*\.(?:py|cjs|js)", relative) + and relative not in ("release_tool.py", "install.py", "sync_plugin.py")) + return bool(re.fullmatch(r"hooks/[A-Za-z][A-Za-z0-9_-]*\.py|commands/[A-Za-z][A-Za-z0-9_-]*\.md", relative)) + + +def release_files(data): + manifest = json_object(data[PREFIX + ".zcode-plugin/plugin.json"], "plugin manifest") + layout = manifest.get("packageLayout") + if layout is None: + require(set(data) == REQUIRED_FILES, "ZIP is missing required release files") + return + require(isinstance(layout, dict) and type(layout.get("schemaVersion")) is int and layout["schemaVersion"] == 1, + "unsupported package layout schema") + names = layout.get("files") + require(isinstance(names, list) and all(isinstance(name, str) for name in names) + and len(names) == len(set(names)), "invalid package layout files") + files = set(names) + require(files == set(data) and BOOTSTRAP_FILES <= files and all(layout_path(name) for name in files), + "package layout is incomplete or outside the bootstrap contract") + + +class UnpackError(ValueError): + pass + + +def require(condition, message): + if not condition: + raise UnpackError(message) + + +def absolute_path(value): + path = Path(value) + require(path.is_absolute(), "archive, manifest and output paths must be absolute") + return path + + +def read_bounded(path, limit): + require(not path.is_symlink(), "input must not be a symlink: " + str(path)) + with path.open("rb") as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_size <= limit, + "input is not a regular bounded file: " + str(path)) + data = stream.read(limit + 1) + require(len(data) <= limit, "input exceeds size limit: " + str(path)) + return data + + +def json_object(data, name): + require(len(data) <= MAX_JSON, "JSON exceeds 256KB: " + name) + + def pairs(items): + result = {} + for key, value in items: + require(key not in result, "duplicate JSON key in " + name) + result[key] = value + return result + + def invalid_constant(value): + raise UnpackError("invalid JSON constant in " + name + ": " + value) + + value = json.loads(data.decode("utf-8"), object_pairs_hook=pairs, + parse_constant=invalid_constant) + require(isinstance(value, dict), "JSON must be an object: " + name) + return value + + +def stable_version(value): + require(isinstance(value, str) and len(value) <= 64 and re.fullmatch(SEMVER, value), + "version must be a numeric three-part stable version") + return value + + +def repository(value): + require(isinstance(value, str) and len(value) <= 140 and re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}", value), + "repository must be explicit owner/repo") + return value + + +def release_manifest(data, archive_name, expected_repository, expected_version): + manifest = json_object(data, "release manifest") + require(type(manifest.get("schemaVersion")) is int and manifest["schemaVersion"] == 1, + "unsupported release manifest schema") + require(repository(manifest.get("repository")).lower() == expected_repository.lower(), + "release repository disagrees with expected source") + require(stable_version(manifest.get("version")) == expected_version, + "release version disagrees with expected version") + require(manifest.get("tag") == "v" + expected_version and + manifest.get("pluginId") == PLUGIN_ID, "release tag/plugin identity disagrees") + if "sourceCommit" in manifest: + commit = manifest["sourceCommit"] + require(isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}", commit), + "invalid sourceCommit") + asset = manifest.get("asset") + require(isinstance(asset, dict), "release asset must be an object") + require(asset.get("name") == archive_name == PLUGIN + "-v" + expected_version + ".zip", + "release asset basename disagrees") + require(type(asset.get("size")) is int and 0 < asset["size"] <= MAX_ARCHIVE, + "release asset size exceeds bound or is invalid") + digest = asset.get("sha256") + require(isinstance(digest, str) and re.fullmatch(r"[0-9a-f]{64}", digest), + "invalid release asset sha256") + return asset + + +def member_path(member): + name = member.orig_filename + require(name == member.filename and name and name.isascii() and + "\\" not in name and ":" not in name and "\x00" not in name, + "unsafe ZIP path: " + repr(name)) + path = name[:-1] if name.endswith("/") else name + parts = path.split("/") + for part in parts: + require(part not in ("", ".", "..") and not part.endswith((".", " ")) and + not any(ord(char) < 32 or char in '<>"|?*' for char in part), + "unsafe ZIP component: " + repr(name)) + require(not re.fullmatch(r"(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])", + part.split(".", 1)[0], re.I), + "Windows device path: " + repr(name)) + return path + + +def declared_resources(manifest, data): + for component in ("commands", "skills"): + values = manifest.get(component) + values = [values] if isinstance(values, str) else values + require(isinstance(values, list) and values, "missing declared " + component) + for value in values: + require(isinstance(value, str), "invalid declared " + component) + relative = value[2:] if value.startswith("./") else value + require(relative == component or relative.startswith(component + "/"), + "unsafe declared " + component + " path") + name = PREFIX + relative + require(name in data or any(key.startswith(name + "/") for key in data), + "non-whitelisted declared resource: " + value) + if name in data: + require(name in data and (name.endswith(".md") if component == "commands" + else name.endswith("/SKILL.md")), + "missing declared resource: " + value) + else: + suffix = ".md" if component == "commands" else "/SKILL.md" + require(any(key.startswith(name + "/") and key.endswith(suffix) for key in data), + "empty declared resource directory: " + value) + + +def referenced_resources(value, data, literal=False): + if isinstance(value, dict): + structured = isinstance(value.get("args"), list) + for key, item in value.items(): + referenced_resources(item, data, key == "args" or (key == "command" and structured)) + elif isinstance(value, list): + for item in value: + referenced_resources(item, data, True) + elif isinstance(value, str) and "${ZCODE_PLUGIN_ROOT}" in value: + tokens = [value] if literal else shlex.split(value) + for token in tokens: + if "${ZCODE_PLUGIN_ROOT}" not in token: + continue + require(token.startswith("${ZCODE_PLUGIN_ROOT}/") and + PREFIX + token[len("${ZCODE_PLUGIN_ROOT}/"):] in data, + "missing or unsafe declared plugin resource: " + token) + + +def identities(data, version): + market = json_object(data["marketplace.json"], "marketplace.json") + plugin = json_object(data[PREFIX + ".zcode-plugin/plugin.json"], "plugin manifest") + compatibility = json_object(data[PREFIX + ".claude-plugin/plugin.json"], "compatibility manifest") + require(json.dumps(plugin, sort_keys=True) == json.dumps(compatibility, sort_keys=True), + "plugin manifests disagree") + require(plugin.get("name") == PLUGIN and stable_version(plugin.get("version")) == version, + "plugin name/version disagrees") + require(market.get("name") == "zusage-marketplace", "marketplace identity disagrees") + entries = market.get("plugins") + require(isinstance(entries, list) and len(entries) == 1 and isinstance(entries[0], dict), + "marketplace must contain exactly one plugin") + entry = entries[0] + require(entry.get("name") == PLUGIN and entry.get("version") == version and + entry.get("source") in ("./plugins/" + PLUGIN, "plugins/" + PLUGIN), + "marketplace plugin name/version/source disagrees") + declared_resources(plugin, data) + for name, content in data.items(): + if name.endswith(".json"): + referenced_resources(json_object(content, name), data) + + +def local_member(payload, member): + offset = member.header_offset + require(0 <= offset <= len(payload) - 30, "invalid ZIP local header offset") + require(payload[offset:offset + 4] == b"PK\x03\x04", "invalid ZIP local header") + flags, method = struct.unpack_from("> 16) + require(mode in (0, stat.S_IFDIR if is_dir else stat.S_IFREG) and + (is_dir or not member.external_attr & 0x10), + "ZIP contains a symlink/device or invalid directory") + require(not member.flag_bits & 0x41 and member.compress_type in + (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED), + "encrypted or unsupported ZIP compression") + require(0 <= member.file_size <= MAX_FILE and + 0 <= member.compress_size <= len(payload), "ZIP file size exceeds limit") + require(member.compress_type != zipfile.ZIP_STORED or + member.compress_size == member.file_size, "stored ZIP size disagrees") + require(member.file_size <= max(member.compress_size, 1) * MAX_RATIO, + "ZIP compression ratio exceeds limit") + require(not is_dir or member.file_size == 0, "nonempty ZIP directory") + require(not path.endswith(".json") or member.file_size <= MAX_JSON, + "ZIP JSON exceeds 256KB") + total += member.file_size + require(total <= MAX_TOTAL, "ZIP extracted total exceeds limit") + files = {member_path(member) for member in members if not member.is_dir()} + require(BOOTSTRAP_FILES <= files, "ZIP is missing required release files") + # Read every member (including directories) to validate CRC and advertised size. + # No directory or package code is touched until every member and identity is valid. + for member in members: + local_member(payload, member) + with archive.open(member) as stream: + content = stream.read(MAX_FILE + 1) + require(len(content) == member.file_size and not stream.read(1), + "ZIP actual size disagrees: " + member.filename) + if not member.is_dir(): + data[member.filename] = content + release_files(data) + identities(data, version) + return data + + +def unpack(archive, manifest, expected_repository, expected_version, output): + archive, manifest, output = map(absolute_path, (archive, manifest, output)) + expected_repository = repository(expected_repository) + expected_version = stable_version(expected_version) + require(not os.path.lexists(str(output)), "output directory already exists") + record = release_manifest(read_bounded(manifest, MAX_JSON), archive.name, + expected_repository, expected_version) + payload = read_bounded(archive, MAX_ARCHIVE) + require(len(payload) == record["size"] and hashlib.sha256(payload).hexdigest() == record["sha256"], + "archive size/sha256 disagrees") + data = validate_zip(payload, expected_version) + # mkdir(exist_ok=False) also protects against another writer creating output during validation. + output.mkdir() + owned = output.stat() + try: + for name, content in data.items(): + destination = output / name + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as stream: + stream.write(content) + except BaseException: + try: + current = output.lstat() + if (current.st_dev, current.st_ino) == (owned.st_dev, owned.st_ino): + shutil.rmtree(output) + except OSError: + pass + raise + return {"ok": True, "marketplaceRoot": str(output.resolve()), + "pluginRoot": str((output / "plugins" / PLUGIN).resolve()), + "version": expected_version} + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise UnpackError(message) + + +def main(argv=None): + try: + parser = Parser(description=__doc__) + parser.add_argument("--archive", required=True) + parser.add_argument("--manifest", required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + result = unpack(args.archive, args.manifest, args.repository, args.version, args.output) + print(json.dumps(result, sort_keys=True)) + return 0 + except (ValueError, OSError, TypeError, RuntimeError, EOFError, RecursionError, + zipfile.BadZipFile, zipfile.LargeZipFile, zlib.error) as error: + print("release_unpack: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/external_plugins/zcode-token-usage-statusbar/renderer_bridge.py b/external_plugins/zcode-token-usage-statusbar/renderer_bridge.py new file mode 100644 index 0000000..f2a7112 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/renderer_bridge.py @@ -0,0 +1,783 @@ +"""Offline renderer bridge: pinned fingerprints, strict auto-derivation, marker strip.""" +import argparse +import copy +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import struct +import tempfile +import time + +import patch_install as asar + +WORKTREE = Path("/Users/smzdm/Projects/AI/zcode-token-usage-statusbar") +MARKER = b"/* zusage-existing-conversation-store:v1" +BEGIN = b"/* zusage-existing-conversation-store:v1 begin */" +END = b"/* zusage-existing-conversation-store:v1 end */" +# 多版本指纹表(新→旧):客户端官方升级会整体换渲染器资产与混淆名,这里按 +# (版本, 资产路径, 原始 sha256, 混淆名三元组) 逐版本钉死,优先于自动推导。 +# 表内版本的资产哈希漂移仍拒绝(fail-closed);表外版本走 derive_target 的 +# 严格结构推导——任何一项不唯一/不符即拒绝,绝不对没验证过的 bundle 做字节手术。 +HOST_TARGETS = [ + {"version": "3.16.3", "asset": "/out/renderer/assets/styles-CQuzX8si.js", + "sha256": "f7eaccd11e350ff6f5dbbeecae15107f519e5b644f359bd1142b26d7f4f44115", + "names": {"key": "kde", "topic": "So", "registry": "hf"}, + "pane": {"anchor": b'"data-testid":Sb(WRe,e),"data-session-id":t??`draft`,', + "workspacePath": "c", "workspaceIdentity": "l", "remoteSessionId": "u", "paneId": "e"}}, + {"version": "3.16.2", "asset": "/out/renderer/assets/styles-n-UNIiNg.js", + "sha256": "10ea7c92c2b52a1c4006ced89a1ec2da83996605ffcd9e22190c9dc6b80f8509", + "names": {"key": "Ede", "topic": "gee", "registry": "hf"}, + "pane": {"anchor": b'"data-testid":hb(WRe,e),"data-session-id":t??`draft`,', + "workspacePath": "c", "workspaceIdentity": "l", "remoteSessionId": "u", "paneId": "e"}}, + {"version": "3.16.1", "asset": "/out/renderer/assets/styles-CqgV6nRf.js", + "sha256": "6054041f771950896c1d80bd8c32b241dde9ab414b3eb66d038ca2c578332783", + "names": {"key": "Ode", "topic": "hee", "registry": "pf"}, + "pane": {"anchor": b'"data-testid":vb(VRe,e),"data-session-id":t??`draft`,', + "workspacePath": "c", "workspaceIdentity": "l", "remoteSessionId": "u", "paneId": "e"}}, + {"version": "3.15.3", "asset": "/out/renderer/assets/styles-CSpM3qgc.js", + "sha256": "e3e0496d9ba2432ca995b8fce908333c861e3ad1c3641b5751dbe06441c53ecf", + "names": {"key": "BYe", "topic": "Nse", "registry": "oE"}, + "pane": {"anchor": b'"data-testid":Oa(Mr,e),"data-session-id":t??`draft`,', + "workspacePath": "c", "workspaceIdentity": "l", "remoteSessionId": "u", "paneId": "e"}}, +] +NEWEST = HOST_TARGETS[0] +ASSET, HOST_VERSION, ASSET_SHA256 = NEWEST["asset"], NEWEST["version"], NEWEST["sha256"] +# 自动推导(derive / 运行时补丁)只认与已登记版本一致的稳定代码形状: +# 会话数据层注册表与 key 函数、acquire 里的 topic 绑定、SessionPane 的 +# testid/session-id 锚点、组件参数与 store 生命周期方法。任何一项缺失或 +# 命中多于一次即拒绝,宁可降级为 DB 轮询,也不对可疑 bundle 做字节手术。 +AUTO_ASSET_RE = re.compile(r"/out/renderer/assets/styles-[^/]+\.js") +AUTO_NAMES = re.compile( + rb"([A-Za-z_$][\w$]*)=new Map,[A-Za-z_$][\w$]*=`__base__`,[A-Za-z_$][\w$]*=\d+(?:\.\d+)?(?:e\d+)?;" + rb"function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=\3\.workspaceIdentity\?\.trim\(\)\|\|\3\.workspacePath;" + rb"return`\$\{\3\.remoteSessionId\?\?`__base__`\} \$\{\4\}`\}") +AUTO_TOPIC = re.compile( + rb"acquire\(e\)\{if\(this\.disposed\)throw Error\(`[^`]*`\);let t=([A-Za-z_$][\w$]*)\(e\)," + rb"n=[A-Za-z_$][\w$]*\(\),r=this\.entries\.get\(t\)") +AUTO_SESSION = re.compile(rb'"data-session-id":([A-Za-z_$][\w$]*)\?\?`draft`') +AUTO_TESTID = re.compile(rb'("data-testid":[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,([A-Za-z_$][\w$]*)\),)$') +AUTO_PANE_PARAM = re.compile(rb"(?:^|,)(paneId|workspacePath|workspaceIdentity|remoteSessionId):([A-Za-z_$][\w$]*)") +AUTO_HEADER = re.compile(rb"function [A-Za-z_$][\w$]*\(\{([^{}]{0,4000}?)\}\)\{") +AUTO_IMPORT = re.compile(rb'import\{([^}]*)\}from"([^"]+)"') +AUTO_PANE_ATTRS = re.compile( + rb'"data-zusage-scope-version":"1","data-zusage-workspace-path":[A-Za-z_$][\w$]*\?\?``,' + rb'"data-zusage-workspace-identity":[A-Za-z_$][\w$]*\?\?``,' + rb'"data-zusage-remote-session-id":[A-Za-z_$][\w$]*\?\?``,"data-zusage-pane-id":[A-Za-z_$][\w$]*,') +# 桥代码模板:标识符占位符按目标版本的混淆名实例化(__ZU_*__ 不与 JS 语法冲突)。 +BRIDGE_TEMPLATE = rb''' +/* zusage-existing-conversation-store:v1 begin */ +export function getExistingConversationStore(scope) { + const fields = ["sessionId", "workspacePath", "workspaceIdentity", "remoteSessionId"]; + if (!scope || typeof scope !== "object" || Array.isArray(scope)) + throw new TypeError("Invalid conversation scope"); + const descriptors = Object.getOwnPropertyDescriptors(scope); + if (Reflect.ownKeys(descriptors).some(key => !fields.includes(key))) + throw new TypeError("Unknown conversation scope field"); + const checked = Object.create(null); + for (const key of fields) { + const d = descriptors[key]; + if (!d) continue; + if (!("value" in d) || typeof d.value !== "string" || + !d.value.trim() || /[\x00-\x1f\x7f]/.test(d.value)) + throw new TypeError("Invalid conversation scope field"); + if ((key === "sessionId" || key === "remoteSessionId") && /\s/.test(d.value)) + throw new TypeError("Invalid conversation identifier"); + if (key === "remoteSessionId" && d.value === "__base__") + throw new TypeError("Reserved remote identifier"); + checked[key] = d.value; + } + if (!checked.sessionId || !checked.workspacePath) + throw new TypeError("Missing conversation scope field"); + const key = __ZU_KEY_FN__(checked), topic = __ZU_TOPIC_FN__(checked.sessionId); + const entry = __ZU_REGISTRY__.get(key), layer = entry?.layer; + const session = layer?.entries.get(topic), store = session?.store; + const current = () => !!store && __ZU_REGISTRY__.get(key) === entry && !entry.stale && + !layer.disposed && layer.entries.get(topic) === session && + session.store === store && !store.closed; + if (!current()) return null; + // Only consumer reads allocate copies. Never freeze a host-owned object. + const snapshot = (value, seen = new WeakMap()) => { + if (value === null || typeof value !== "object") { + if (typeof value === "function" || typeof value === "symbol") + throw new TypeError("Unsupported snapshot value"); + return value; + } + if (seen.has(value)) return seen.get(value); + const proto = Object.getPrototypeOf(value); + if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) + throw new TypeError("Unsupported snapshot object"); + const result = Array.isArray(value) ? [] : Object.create(null); + seen.set(value, result); + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === "length") continue; + const d = Object.getOwnPropertyDescriptor(value, key); + if (!("value" in d)) throw new TypeError("Unsupported snapshot accessor"); + Object.defineProperty(result, key, { + value: snapshot(d.value, seen), enumerable: d.enumerable, + writable: false, configurable: false + }); + } + if (Array.isArray(value)) result.length = value.length; + return Object.freeze(result); + }; + let stopped = false; + const cancellations = new Set(); + const off = () => { + if (stopped) return; + stopped = true; + for (const cancel of [...cancellations]) cancel(); + }; + const active = () => { + if (stopped) return false; + if (current()) return true; + off(); + return false; + }; + return Object.freeze({ + getState: () => active() ? snapshot(store.getState()) : null, + subscribe: callback => { + if (typeof callback !== "function") throw new TypeError("Invalid callback"); + if (!active()) return () => {}; + let cancelled = false; + const cancel = () => { + if (cancelled) return; + cancelled = true; + cancellations.delete(cancel); + try { unsubscribe(); } catch (_) {} + }; + const unsubscribe = store.subscribe(() => { + if (cancelled || !active()) return; + try { + const result = callback(); + if (result && typeof result.then === "function") + Promise.resolve(result).catch(() => {}); + } catch (_) {} + }); + cancellations.add(cancel); + return cancel; + }, + off + }); +} +if (typeof globalThis === "object" && globalThis !== null) { + globalThis.__zusageGetExistingConversationStore = getExistingConversationStore; + // Read-only acceptance diagnostics: enumerate live registry keys/topics for verification. + globalThis.__zusageConversationDiagnostics = function () { + const keys = [], sessions = []; + for (const [key, entry] of __ZU_REGISTRY__) { + keys.push(key); + const layer = entry && entry.layer; + if (!layer || layer.disposed || entry.stale) continue; + for (const [topic, session] of layer.entries) { + const store = session && session.store; + const state = store && !store.closed && store.getState(); + sessions.push({ key, topic, status: state ? (state.status || "unknown") : "unavailable", + seq: state && state.snapshot ? state.snapshot.seq : null }); + } + } + return { keys, sessions }; + }; +} +/* zusage-existing-conversation-store:v1 end */ +''' + +def bridge_code(target): + names = target["names"] + return (BRIDGE_TEMPLATE + .replace(b"__ZU_KEY_FN__", names["key"].encode()) + .replace(b"__ZU_TOPIC_FN__", names["topic"].encode()) + .replace(b"__ZU_REGISTRY__", names["registry"].encode())) + +# 最新目标的桥正文(CLI 默认 / 测试兼容别名) +BRIDGE = bridge_code(NEWEST) + + +def pane_attributes(target): + pane = target["pane"] + return (f'"data-zusage-scope-version":"1",' + f'"data-zusage-workspace-path":{pane["workspacePath"]}??``,' + f'"data-zusage-workspace-identity":{pane["workspaceIdentity"]}??``,' + f'"data-zusage-remote-session-id":{pane["remoteSessionId"]}??``,' + f'"data-zusage-pane-id":{pane["paneId"]},').encode() + + +def _original_module(data, target): + """仅识别精确旧桥/新桥;第三方尾缀只供 remove 保留。""" + prefix, suffix = data, b"" + has_bridge = MARKER in data + if has_bridge: + bridge = bridge_code(target) + if data.count(MARKER) != 2 or data.count(bridge) != 1: + raise BridgeError("Bridge marker/content tampered") + prefix, suffix = data.split(bridge) + elif b"getExistingConversationStore" in data: + raise BridgeError("Export name collision") + anchor = target["pane"]["anchor"] + patched_anchor = anchor + pane_attributes(target) + if prefix.count(anchor) != 1: + raise BridgeError("Missing or duplicate SessionPane anchor") + if has_bridge and prefix.count(patched_anchor) == 1: + prefix = prefix.replace(patched_anchor, anchor, 1) + if _digest(prefix) != target["sha256"]: + raise BridgeError("Unknown or tampered original module") + return prefix, suffix + + +def target_for_module(data): + """新/旧补丁均须还原到已登记的原始 SHA。""" + for target in HOST_TARGETS: + try: + _original_module(data, target) + except BridgeError: + continue + return target + return None + + +class BridgeError(ValueError): + pass + + +def _digest(data): + return hashlib.sha256(data).hexdigest() + + +def _only(pattern, data, what): + found = pattern.findall(data) + if len(found) != 1: + raise BridgeError(f"Ambiguous {what}: {len(found)} matches") + return found[0] + + +def derive_target(version, module, asset, asset_paths): + """从未登记的原始模块推导指纹(derive CLI 与运行时自动补丁共用)。 + + 返回与人工登记条目同构的 dict(sha256 取当前模块,另带 derived 标记); + 任何一项校验不过都抛 BridgeError,调用方按 fail-closed 处理。 + """ + if MARKER in module or b"getExistingConversationStore" in module: + raise BridgeError("Bridge already present") + registry, key, _param, _local = _only(AUTO_NAMES, module, "registry/key function") + topic = _only(AUTO_TOPIC, module, "topic binding") + session = _only(AUTO_SESSION, module, "SessionPane session attribute") + if len({registry, key, topic, session}) != 4: + raise BridgeError("Minified name collision") + sid = module.index(b'"data-session-id":' + session + b"??`draft`") + testid = AUTO_TESTID.search(module[:sid]) + if testid is None: + raise BridgeError("Missing SessionPane testid anchor") + anchor = testid.group(1) + b'"data-session-id":' + session + b"??`draft`," + if module.count(anchor) != 1: + raise BridgeError("Missing or duplicate SessionPane anchor") + candidates = [] + for header in AUTO_HEADER.finditer(module[:sid]): + params = {} + for name, var in AUTO_PANE_PARAM.findall(header.group(1)): + if name.decode() in params: + params = None + break + params[name.decode()] = var.decode() + if params and len(params) == 4: + candidates.append(params) + if not candidates: + raise BridgeError("Missing SessionPane component parameters") + if len(candidates) != 1: + raise BridgeError("Ambiguous SessionPane component parameters") + pane = candidates[0] + if len(set(pane.values())) != 4: + raise BridgeError("Ambiguous SessionPane parameter aliases") + if testid.group(2).decode() != pane["paneId"]: + raise BridgeError("SessionPane testid pane argument mismatch") + props = ("workspacePath:{workspacePath},workspaceIdentity:{workspaceIdentity}," + "remoteSessionId:{remoteSessionId}").format(**pane).encode() + if props not in module[max(0, sid - 4000):sid]: + raise BridgeError("SessionPane scope props not passed from parameters") + for method in (b"getState(){return this.state}", b"async connect(e={})"): + if module.count(method) != 1: + raise BridgeError("Unexpected session store shape") + if b"this.closed=!0" not in module: + raise BridgeError("Missing session store closed flag") + reg = registry + b"." + if module.count(reg + b"get(") < 1 or module.count(reg + b"delete(") < 1: + raise BridgeError("Registry map usage missing") + entry = (b"layer:new ", b"stale:!1", b"refCount:1") + if not any(all(token in module[max(0, at - 600):at] for token in entry) + for at in (m.start() for m in re.finditer(re.escape(reg + b"set("), module))): + raise BridgeError("Unexpected registry entry shape") + imports = [] + for segment, source in AUTO_IMPORT.findall(module): + if re.search(rb"(?:^|,)[A-Za-z_$][\w$]* as " + re.escape(topic) + rb"(?:,|$)", segment): + imports.append(source) + if len(imports) != 1: + raise BridgeError("Topic function is not a unique module import") + try: + source = imports[0].decode("ascii") + except UnicodeDecodeError: + raise BridgeError("Non-ASCII topic import source") + if not source.startswith("./") or "/out/renderer/assets/" + source[2:] not in asset_paths: + raise BridgeError("Topic import target missing from archive") + if b"sourceMappingURL" in module: + raise BridgeError("Unverified source map layout") + return {"version": version, "asset": asset, "sha256": _digest(module), + "names": {"key": key.decode(), "topic": topic.decode(), "registry": registry.decode()}, + "pane": dict(pane, anchor=anchor), "derived": True} + + +def _verify_auto_present(data): + """表外版本的幂等判定:完整且唯一的桥块与属性串,否则视为不明补丁。""" + if data.count(BEGIN) != 1 or data.count(END) != 1 or data.count(MARKER) != 2: + raise BridgeError("Bridge markers tampered") + start, end = data.index(BEGIN), data.index(END) + len(END) + # 桥块总以 "\n" + BEGIN 开头、以 END + "\n" 结尾;不满足即不是本模板的补丁 + if start < 1 or data[start - 1:start] != b"\n" or data[end:end + 1] != b"\n": + raise BridgeError("Bridge markers out of order or misaligned") + if data.count(b"export function getExistingConversationStore(scope)") != 1: + raise BridgeError("Bridge content tampered") + if len(AUTO_PANE_ATTRS.findall(data)) != 1: + raise BridgeError("Scope attributes tampered") + + +def _strip_auto_bridge(data): + """表外版本的回滚路径:按模板剥离桥块与属性串,不依赖版本指纹。""" + _verify_auto_present(data) + start, end = data.index(BEGIN), data.index(END) + len(END) + block = data[start:end] + if b"__zusageGetExistingConversationStore" not in block: + raise BridgeError("Bridge content tampered") + # 两侧换行由 _verify_auto_present 保证;连换行一起剥离可精确复原原字节 + rest = data[:start - 1] + data[end + 1:] + rest, removed = AUTO_PANE_ATTRS.subn(b"", rest) + if removed != 1: + raise BridgeError("Scope attributes tampered") + if MARKER in rest or b"getExistingConversationStore" in rest: + raise BridgeError("Bridge content tampered") + return rest + + +def _read(stream, nodes, base, path): + node = nodes.get(path) + if not node or "offset" not in node or node.get("unpacked") or "link" in node: + raise BridgeError(f"Missing packed file: {path}") + stream.seek(base + int(node["offset"])) + data = stream.read(node["size"]) + if len(data) != node["size"]: + raise BridgeError(f"Truncated file: {path}") + if "integrity" in node: + integrity = node["integrity"] + block = integrity.get("blockSize") + if (integrity.get("algorithm") != "SHA256" or type(block) is not int or block <= 0 or + integrity.get("hash") != _digest(data) or + integrity.get("blocks") != [_digest(data[i:i + block]) + for i in range(0, len(data), block)]): + raise BridgeError(f"Invalid integrity: {path}") + return data + + +def transform_module(data, operation, target=None): + """精确升级/移除桥和 DOM 属性;第三方尾缀仅在移除时保留。 + + target 只在调用方已完成指纹校验(自动推导或已解析命中)时显式传入。 + """ + if operation not in ("patch", "remove"): + raise BridgeError("Unknown operation") + if target is None: + target = target_for_module(data) + if target is None: + raise BridgeError("Unknown renderer asset hash") + original, suffix = _original_module(data, target) + if operation == "remove": + return original + suffix + if suffix: + raise BridgeError("Unknown patched module suffix") + anchor = target["pane"]["anchor"] + return original.replace(anchor, anchor + pane_attributes(target), 1) + bridge_code(target) + + +def _resolve_target_from(package): + """按 package.json dict 选目标;版本未登记即拒绝(fail-closed)。""" + version = package.get("version") + for target in HOST_TARGETS: + if version == target["version"]: + return target + raise BridgeError(f"Unknown host version: {version}") + + +def _resolve_target(stream, nodes, base): + """读 asar 的 package.json,按已登记版本选目标;版本未登记即拒绝。""" + package = json.loads(_read(stream, nodes, base, "/package.json")) + version = package.get("version") + for target in HOST_TARGETS: + if version == target["version"]: + return target + raise BridgeError(f"Unknown host version: {version}") + + +def _output_path(source, output): + if Path(output).is_symlink(): + raise BridgeError("Output is a symlink") + source, output = Path(source).resolve(), Path(output).resolve() + roots = (WORKTREE.resolve(), Path(tempfile.gettempdir()).resolve()) + if source == output or not any(output.is_relative_to(root) for root in roots): + raise BridgeError("Output must be a new worktree/temporary file, never an installation") + # Even a copied application in the authorized roots is not an output target. + if any(part.endswith(".app") for part in output.parts) or output.parent.name.lower() == "resources": + raise BridgeError("Refusing application installation directory") + if output.exists() or Path(output).is_symlink(): + raise BridgeError("Output already exists") + if not output.parent.is_dir(): + raise BridgeError("Output directory does not exist") + return source, output + + +def rewrite_asar(source, output, operation="patch"): + """Copy the payload with one splice; preserve links, unpacked metadata and holes.""" + source, output = _output_path(source, output) + before = asar.source_fingerprint(source) + stage = None + try: + with source.open("rb") as stream: + header, base = asar.read_header(stream) + nodes = dict(asar.iter_files(header)) + package = json.loads(_read(stream, nodes, base, "/package.json")) + if (package.get("name"), package.get("type")) != ("@zcode/desktop", "module"): + raise BridgeError("Unknown host package") + target = _resolve_target_from(package) + asset_path = target["asset"] + original = _read(stream, nodes, base, asset_path) + if (b"sourceMappingURL" in original or any( + path in nodes for path in (asset_path + ".map", asset_path[:-3] + ".map"))): + raise BridgeError("Unverified source map layout") + modified = transform_module(original, operation) + new_header = copy.deepcopy(header) + new_nodes = dict(asar.iter_files(new_header)) + asset_node = nodes[asset_path] + start, end = int(asset_node["offset"]), int(asset_node["offset"]) + asset_node["size"] + payload_size = before["size"] - base + delta = len(modified) - len(original) + for path, node in nodes.items(): + if node.get("unpacked") or "link" in node or "files" in node: + continue + offset, size = int(node["offset"]), node["size"] + if offset < 0 or size < 0 or offset + size > payload_size: + raise BridgeError("Invalid packed file bounds") + if path != asset_path and size and offset < end and offset + size > start: + raise BridgeError("Overlapping packed files") + if offset >= end and path != asset_path: + new_nodes[path]["offset"] = str(offset + delta) + new_nodes[asset_path]["size"] = len(modified) + new_nodes[asset_path]["integrity"] = asar.compute_integrity(modified) + encoded = json.dumps(new_header, ensure_ascii=False, separators=(",", ":")).encode() + padded = (len(encoded) + 7) // 4 * 4 + fd, name = tempfile.mkstemp(prefix=".renderer-bridge-", dir=output.parent) + stage = Path(name) + with os.fdopen(fd, "wb") as dest: + dest.write(struct.pack("<4I", 4, padded + 4, padded, len(encoded))) + dest.write(encoded) + dest.write(b"\0" * (padded - len(encoded) - 4)) + stream.seek(base) + remaining = start + while remaining: + chunk = stream.read(min(asar.BLOCK, remaining)) + if not chunk: + raise BridgeError("Truncated payload") + dest.write(chunk) + remaining -= len(chunk) + dest.write(modified) + stream.seek(base + end) + shutil.copyfileobj(stream, dest, asar.BLOCK) + dest.flush() + os.fsync(dest.fileno()) + if asar.source_fingerprint(source) != before: + raise BridgeError("Source archive changed") + # Exclusive publication: never overwrite an existing path, including races. + os.link(stage, output) + return output + finally: + if stage is not None: + stage.unlink(missing_ok=True) + + +STALE_STAGE_SECONDS = 600 + + +def _sweep_stale_stages(directory): + """清掉早于 STALE_STAGE_SECONDS 的 .zusage-bridge-* 残留(调用方拿到 + replace-failed 后、publish_external_pending 前崩溃的孤儿)。刚创建的 + stage 处在移交窗口内,绝不能删。""" + now = time.time() + for stale in Path(directory).glob(".zusage-bridge-*"): + try: + if now - stale.stat().st_mtime > STALE_STAGE_SECONDS: + stale.unlink() + except OSError: + pass + + +def ensure_deployed(asar_path): + """插件安装/更新链专用:确认目标 asar 带桥,没有则就地补打。 + + 与 rewrite_asar(CLI/测试,只输出到工作区/临时目录)不同,本函数就是为写 + 安装目录设计的:借用 patch_install 同一把 app.asar.zusage.lock 跨进程锁, + 输出临时文件在 asar 同目录后 os.replace 原子换。任何失败都不抛出——桥只是 + 实时统计的增强,绝不影响安装/更新主流程。表内版本严格按指纹;表外版本 + 先走 derive_target 的严格结构推导,校验不过按 fail-closed 跳过。返回状态: + {"status": "patched"|"present"|"skipped-unknown"|"skipped-error", + "version": <客户端版本>, "detail": <说明>} + 例外是发布被系统拒绝(Windows 客户端运行中持有 asar 不带 FILE_SHARE_DELETE、 + 或目标目录 ACL 只读):此时返回 replace-failed,stage 是已构建完整的带桥 + 包、source 是发布前 asar 的指纹;调用方应转存待替换链 + (patch_install.publish_external_pending)或删除 stage,不能留在原地。 + """ + try: + from runtime_lock import file_lock + asar_path = Path(asar_path) + with file_lock(asar_path.with_name(asar_path.name + ".zusage.lock")): + _sweep_stale_stages(asar_path.parent) + before = asar.source_fingerprint(asar_path) + with asar_path.open("rb") as stream: + header, base = asar.read_header(stream) + nodes = dict(asar.iter_files(header)) + package = json.loads(_read(stream, nodes, base, "/package.json")) + if (package.get("name"), package.get("type")) != ("@zcode/desktop", "module"): + raise BridgeError("Unknown host package") + version = package.get("version") + target = next((item for item in HOST_TARGETS if item["version"] == version), None) + auto = target is None + if not auto: + asset_path = target["asset"] + original = _read(stream, nodes, base, asset_path) + if MARKER not in original and _digest(original) != target["sha256"]: + return {"status": "skipped-unknown", "version": version, + "detail": "asset hash mismatch for pinned version (host build drift)"} + else: + modules = {path: _read(stream, nodes, base, path) for path in + sorted(path for path in nodes if AUTO_ASSET_RE.fullmatch(path))} + marked = [path for path, data in modules.items() if MARKER in data] + if marked: + if len(marked) != 1: + return {"status": "skipped-unknown", "version": version, + "detail": "bridged renderer asset is not unique"} + _verify_auto_present(modules[marked[0]]) + # 其余资产只有在“确实可推导”时才是漏补的 live chunk; + # 不可推导的无关 styles-* 不能把幂等 present 变成跳过。 + for path in modules: + if path in marked: + continue + try: + derive_target(version, modules[path], path, set(nodes)) + except BridgeError: + continue + return {"status": "skipped-unknown", "version": version, + "detail": "unpatched derivable renderer asset present"} + return {"status": "present", "version": version, + "detail": "auto-derived bridge already deployed"} + derived, errors = [], [] + for path, data in modules.items(): + if b"sourceMappingURL" in data or any( + sibling in nodes for sibling in (path + ".map", path[:-3] + ".map")): + errors.append(f"{path}: unverified source map layout") + continue + try: + derived.append(derive_target(version, data, path, set(nodes))) + except BridgeError as error: + errors.append(f"{path}: {error}") + if not derived: + return {"status": "skipped-unknown", "version": version, + "detail": "auto-derivation refused: " + + ("; ".join(errors) or "no renderer styles asset")} + if len(derived) != 1: + return {"status": "skipped-unknown", "version": version, + "detail": "auto-derivation ambiguous across renderer assets"} + target = derived[0] + asset_path, original = target["asset"], modules[target["asset"]] + _original_module(original, target) + modified = transform_module(original, "patch", target) + if modified == original: + return {"status": "present", "version": version, "detail": "bridge already deployed"} + fd, name = tempfile.mkstemp(prefix=".zusage-bridge-", dir=str(asar_path.parent)) + stage = Path(name) + try: + with os.fdopen(fd, "wb") as dest, asar_path.open("rb") as stream: + _rewrite_to(stream, base, nodes, header, asset_path, original, modified, dest) + dest.flush() + os.fsync(dest.fileno()) + if asar.source_fingerprint(asar_path) != before: + raise BridgeError("Source archive changed") + os.chmod(stage, stat.S_IMODE(asar_path.stat().st_mode)) + try: + os.replace(stage, asar_path) + except OSError as error: + # 发布被系统拒绝(客户端占用/目录只读):stage 是完整可发布的 + # 带桥包,移交给调用方转存待替换链,绝不静默丢弃(issue #67)。 + handover = {"status": "replace-failed", "version": version, + "detail": str(error), "stage": str(stage), "source": before} + stage = None # 已移交:finally 不得清理 + return handover + stage = None + finally: + if stage is not None: + stage.unlink(missing_ok=True) + return {"status": "patched", "version": version, + "detail": "auto-derived bridge deployed" if auto else "bridge deployed"} + except PermissionError as error: + # 权限不足要单独可辨:Windows 的 os.access 看不见 ACL(目录恒报可写), + # 调用方只能按本标记给用户提权指引(issue #67 场景 1) + return {"status": "skipped-error", "version": None, "detail": str(error), + "permission": True} + except Exception as error: # 安装链的旁路增强:任何异常都只报告 + return {"status": "skipped-error", "version": None, "detail": str(error)} + + +def remove_deployed(asar_path): + """卸载链对称清理:只剥离本项目的桥,其余内容不动。 + + 表内版本按指纹逐版本识别;表外版本(自动推导补的桥)按模板剥离桥块与 + 属性串。与 ensure_deployed 同样的锁/原子替换/不抛出约定,状态 dict 同构: + 所有读取与判定都在锁内,发布前复核 source fingerprint,避免用锁外旧布局写坏归档。""" + try: + from runtime_lock import file_lock + asar_path = Path(asar_path) + with file_lock(asar_path.with_name(asar_path.name + ".zusage.lock")): + before = asar.source_fingerprint(asar_path) + with asar_path.open("rb") as stream: + header, base = asar.read_header(stream) + nodes = dict(asar.iter_files(header)) + package = json.loads(_read(stream, nodes, base, "/package.json")) + version = package.get("version") + target = next((item for item in HOST_TARGETS if item["version"] == version), None) + auto = target is None + if auto: + bridged = [] + for path in sorted(path for path in nodes if AUTO_ASSET_RE.fullmatch(path)): + data = _read(stream, nodes, base, path) + if MARKER in data: + bridged.append((path, data)) + if not bridged: + return {"status": "skipped-unknown", "version": version, + "detail": "host version not pinned; nothing to remove"} + if len(bridged) != 1: + return {"status": "skipped-unknown", "version": version, + "detail": "multiple bridged renderer assets"} + asset_path, original = bridged[0] + restored = _strip_auto_bridge(original) + else: + asset_path = target["asset"] + original = _read(stream, nodes, base, asset_path) + if MARKER not in original: + return {"status": "absent", "version": target["version"], "detail": "no bridge present"} + restored = transform_module(original, "remove") + fd, name = tempfile.mkstemp(prefix=".zusage-bridge-", dir=str(asar_path.parent)) + stage = Path(name) + try: + with os.fdopen(fd, "wb") as dest, asar_path.open("rb") as stream: + _rewrite_to(stream, base, nodes, header, asset_path, original, restored, dest) + dest.flush() + os.fsync(dest.fileno()) + if asar.source_fingerprint(asar_path) != before: + raise BridgeError("Source archive changed") + os.chmod(stage, stat.S_IMODE(asar_path.stat().st_mode)) + os.replace(stage, asar_path) + stage = None + finally: + if stage is not None: + stage.unlink(missing_ok=True) + return {"status": "removed", "version": version, + "detail": "auto-derived bridge removed" if auto else "bridge removed"} + except Exception as error: + return {"status": "skipped-error", "version": None, "detail": str(error)} + + +def _rewrite_to(stream, base, nodes, header, asset_path, original, modified, dest): + """把单拼接结果写入 dest(调用方负责锁与原子发布)。""" + before = {"size": None} + new_header = copy.deepcopy(header) + new_nodes = dict(asar.iter_files(new_header)) + asset_node = nodes[asset_path] + start, end = int(asset_node["offset"]), int(asset_node["offset"]) + asset_node["size"] + delta = len(modified) - len(original) + for path, node in nodes.items(): + if node.get("unpacked") or "link" in node or "files" in node: + continue + offset, size = int(node["offset"]), node["size"] + if path != asset_path and offset >= end: + new_nodes[path]["offset"] = str(offset + delta) + new_nodes[asset_path]["size"] = len(modified) + new_nodes[asset_path]["integrity"] = asar.compute_integrity(modified) + encoded = json.dumps(new_header, ensure_ascii=False, separators=(",", ":")).encode() + padded = (len(encoded) + 7) // 4 * 4 + dest.write(struct.pack("<4I", 4, padded + 4, padded, len(encoded))) + dest.write(encoded) + dest.write(b"\0" * (padded - len(encoded) - 4)) + stream.seek(base) + remaining = start + while remaining: + chunk = stream.read(min(asar.BLOCK, remaining)) + if not chunk: + raise BridgeError("Truncated payload") + dest.write(chunk) + remaining -= len(chunk) + dest.write(modified) + stream.seek(base + end) + shutil.copyfileobj(stream, dest, asar.BLOCK) + + +def derive_from_asar(source): + """CLI 推导入口:读 asar 的渲染资产并返回可粘贴的条目。 + + 已带桥的安装也接受:先按指纹或标记还原原始模块再推导,维护者可直接对 + 真机安装运行。""" + with Path(source).open("rb") as stream: + header, base = asar.read_header(stream) + nodes = dict(asar.iter_files(header)) + package = json.loads(_read(stream, nodes, base, "/package.json")) + if (package.get("name"), package.get("type")) != ("@zcode/desktop", "module"): + raise BridgeError("Unknown host package") + results, errors = [], [] + for path in sorted(path for path in nodes if AUTO_ASSET_RE.fullmatch(path)): + data = _read(stream, nodes, base, path) + if b"sourceMappingURL" in data or any( + sibling in nodes for sibling in (path + ".map", path[:-3] + ".map")): + errors.append(f"{path}: unverified source map layout") + continue + if MARKER in data: + try: + target = target_for_module(data) + data = (transform_module(data, "remove") if target is not None + else _strip_auto_bridge(data)) + except BridgeError as error: + errors.append(f"{path}: {error}") + continue + try: + results.append(derive_target(package.get("version"), data, path, set(nodes))) + except BridgeError as error: + errors.append(f"{path}: {error}") + if not results: + raise BridgeError("No derivable renderer asset: " + ("; ".join(errors) or "none")) + if len(results) != 1: + raise BridgeError("Ambiguous renderer assets for derivation") + return results[0] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("patch", "remove", "derive")) + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path, nargs="?") + args = parser.parse_args() + try: + if args.operation == "derive": + print(repr(derive_from_asar(args.source))) + return + if args.output is None: + parser.error("output is required for patch/remove") + print(rewrite_asar(args.source, args.output, args.operation)) + except (BridgeError, OSError, KeyError, AssertionError, struct.error, ValueError) as error: + parser.exit(1, f"renderer bridge refused: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/external_plugins/zcode-token-usage-statusbar/restart_notice.py b/external_plugins/zcode-token-usage-statusbar/restart_notice.py new file mode 100644 index 0000000..0aa3aa6 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/restart_notice.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""重新注入后的会话提醒;标记由客户端 loader 成功加载时清除。""" +import hashlib +import json +import os +import sys +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).parent.resolve() +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) +from runtime_lock import record_restart as _record_restart + +MARKER = ".plugin-restart-required" +PREFIX = "🔴 zcode-token-usage-statusbar 插件提醒:" +RESTART_ACTION = ("请完全退出 ZCode(所有窗口):从 ZCode 菜单选择「退出」,然后重新打开 ZCode," + "让窗口底部的用量状态条加载新版本。只关窗口、刷新页面或重开会话无效。") +MESSAGE = PREFIX + "用量状态条已安装或更新。" + RESTART_ACTION +INSTALL_MESSAGE = (PREFIX + "窗口底部的用量状态条还没有安装好。插件默认自动安装,但当前尚未完成。" + "需要请运行 /install-overlay(会修改客户端文件)," + "安装成功后再退出并重开 ZCode;仅重启不能完成安装。" + "不需要请运行 /uninstall-overlay,之后不再提示。") + + +def runtime_dir(): + return Path(os.environ.get("ZUSAGE_PLUGIN_RUNTIME") or + Path.home() / ".zcode" / "zcode-token-usage-statusbar") + + +def record_restart(runtime, data=None): + """同步传目标 hashes;重新注入可无 hashes,合并/确认共用永久 marker 锁。""" + runtime = Path(runtime) + runtime.mkdir(parents=True, exist_ok=True) + return _record_restart(runtime, data or {}) + + +def installation_status(runtime): + try: + from overlay_install import injected_status, resolve_paths + return injected_status(resolve_paths(runtime)[1]) + except Exception: + return None + + +def visible_context(label, detail, message): + return ("【用户可见·" + label + "】" + detail + + "请在下一条最终回复正文的第一行,用 Markdown 引用块原样提醒用户,整条提示加粗并保留红色圆点;" + "不要使用会被客户端过滤的 HTML 颜色标签:\n> **" + message + "**" + + "\n提醒之后继续回答用户当前问题;不要自行退出或重启客户端。") + + +def reminder(runtime=None, injected=None, restart_files=()): + """只读判定;两个 hook 提示通道和 CLI 共用同一文案。""" + runtime = Path(runtime) if runtime is not None else runtime_dir() + marker = runtime / MARKER + if not marker.is_file() or (runtime / ".plugin-overlay-optout").exists(): + return None + status = installation_status(runtime) if injected is None else injected + if status is None: + return None + if status is False: + return {"message": INSTALL_MESSAGE, + "context": visible_context("安装提醒", "客户端尚未完成状态条注入,重启不能代替安装。", INSTALL_MESSAGE)} + message = MESSAGE + detail = "检测到尚未确认加载的状态条更新记录。" + incomplete = (runtime / "plugin-runtime-pending.json").exists() + try: + data = json.loads(marker.read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("state") not in (None, "pending-sync", "pending-install", "installed"): + raise ValueError("提醒记录形状或状态无效") + hashes = data.get("hashes", {}) + if not isinstance(hashes, dict) or any( + name not in ("inject-main.cjs", "plugin-updater.cjs", "plugin-release.cjs", + "plugin-storage.cjs", "quota-client.cjs", "overlay.js", "zusage.py") + or not isinstance(digest, str) or len(digest) != 64 + or any(char not in "0123456789abcdef" for char in digest) + for name, digest in hashes.items()): + raise ValueError("提醒记录 hashes 无效") + if data.get("state") == "pending-sync" or hashes: + incomplete = True + record = json.loads((runtime / "plugin-runtime.json").read_text(encoding="utf-8")) + complete = (not (runtime / "plugin-runtime-pending.json").exists() and hashes + and all(record.get("hashes", {}).get(name) == digest + and hashlib.sha256((runtime / name).read_bytes()).hexdigest() == digest + for name, digest in hashes.items())) + incomplete = not complete + if complete: + detail = "状态条运行时同步已完成,但客户端尚未确认重新加载。" + elif data.get("state") in ("pending-install", "installed"): + # 发布前记录的是意图;仅实际注入为 True 才能解释为安装已完成。 + detail = "状态条注入已完成,但客户端尚未确认重新加载。" + if data.get("state") == "pending-install" or data.get("installation") is not None: + published_at = data.get("publishedAt") + incomplete = incomplete or not (isinstance(published_at, (int, float)) + and not isinstance(published_at, bool)) + except FileNotFoundError: + if not marker.exists(): + return None # loader 在读取期间完成确认,不重建或补发提醒。 + incomplete = True + except (OSError, ValueError, AttributeError, TypeError): + incomplete = True + if incomplete: + detail = "状态条运行时同步尚未完成或提醒记录不可验证,不能确认安装成功。" + message = (PREFIX + "用量状态条的安装或更新还没有完成,或记录无法确认。请运行 " + "/install-overlay 重试;仅重启不能保证修复。" + "如果仍然失败,请查看插件日志。") + label = "安装提醒" + else: + label = "重启提醒" + if restart_files: + message = (PREFIX + "用量状态条已更新,重启 ZCode 后生效。" + RESTART_ACTION + + " 更新文件:" + " / ".join(restart_files)) + return {"message": message, "context": visible_context(label, detail, message)} + + +def reminder_context(runtime=None, injected=None): + notice = reminder(runtime, injected) + return notice["context"] if notice else "" + + +def session_notice(runtime, output, injected=None, restart_files=()): + """仅展示仍待确认的记录;安装成功后不得重新记录已被 ack 的意图。""" + notice = reminder(runtime, injected, restart_files) + if notice: + output["systemMessage"] = notice["message"] + output["hookSpecificOutput"] = { + "hookEventName": "SessionStart", "additionalContext": notice["context"]} + return output diff --git a/external_plugins/zcode-token-usage-statusbar/runtime_lock.py b/external_plugins/zcode-token-usage-statusbar/runtime_lock.py new file mode 100644 index 0000000..c7b40c4 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/runtime_lock.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""Permanent-file OS locks shared by the runtime synchronizer and Node helpers.""" +import argparse +import errno +import json +import os +import stat +import sys +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def file_lock(path, timeout=10.0): + """Acquire an advisory lock or raise TimeoutError; never unlink the lock file.""" + fd = os.open(os.fspath(path), os.O_RDWR | os.O_CREAT, 0o600) + locked = False + deadline = time.monotonic() + max(0.0, timeout) + try: + if os.name == "nt": + import msvcrt + if os.fstat(fd).st_size == 0: + os.write(fd, b"\0") + else: + import fcntl + while True: + try: + if os.name == "nt": + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + break + except OSError as exc: + if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("runtime lock is busy: " + os.fspath(path)) from exc + time.sleep(min(0.05, remaining)) + yield + finally: + try: + if locked: + if os.name == "nt": + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _read_marker(marker): + try: + data = json.loads(marker.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _hashes(value): + if not isinstance(value, dict): + return {} + return {key: digest for key, digest in value.items() + if isinstance(key, str) and not key.startswith("_") + and isinstance(digest, str) and digest} + + +def _write_marker(marker, data, preserve=None): + fd, name = tempfile.mkstemp(prefix=marker.name + ".", suffix=".tmp", dir=marker.parent) + try: + if preserve is not None: + if os.name != "nt": + current = os.fstat(fd) + if (current.st_uid, current.st_gid) != (preserve.st_uid, preserve.st_gid): + os.fchown(fd, preserve.st_uid, preserve.st_gid) + os.chmod(name, stat.S_IMODE(preserve.st_mode)) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + fd = None + json.dump(data, stream, ensure_ascii=False) + stream.write("\n") + os.replace(name, marker) + finally: + if fd is not None: + os.close(fd) + try: + os.unlink(name) + except FileNotFoundError: + pass + + +def update_config(runtime, changes, defaults=None): + """Merge only the requested keys after rereading config under its shared lock.""" + if not isinstance(changes, dict) or (defaults is not None and not isinstance(defaults, dict)): + raise ValueError("config changes/defaults must be JSON objects") + runtime = Path(runtime) + runtime.mkdir(parents=True, exist_ok=True) + config = runtime / "config.json" + lock_path = runtime / "plugin-config.lock" + with file_lock(lock_path): + owner = config.stat() if config.exists() else runtime.stat() + if os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() == 0: + lock_stat = lock_path.stat() + if (lock_stat.st_uid, lock_stat.st_gid) != (owner.st_uid, owner.st_gid): + os.chown(lock_path, owner.st_uid, owner.st_gid) + try: + current = json.loads(config.read_text(encoding="utf-8")) + except FileNotFoundError: + current = {} + if not isinstance(current, dict): + raise ValueError("config.json must be a JSON object") + updated = dict(current) + for key, value in (defaults or {}).items(): + updated.setdefault(key, value) + updated.update(changes) + if updated == current and config.exists(): + return False + _write_marker(config, updated, config.stat() if config.exists() else None) + return True + + +def record_restart(runtime, data): + """Merge a restart notification while holding the independent marker lock.""" + if not isinstance(data, dict): + raise ValueError("restart data must be a JSON object") + runtime = Path(runtime) + marker = runtime / ".plugin-restart-required" + with file_lock(runtime / "plugin-restart.lock"): + previous = _read_marker(marker) + hashes = _hashes(previous.get("hashes")) + hashes.update(_hashes(data.get("hashes"))) + if data.get("state") == "pending-install": + previous.pop("publishedAt", None) # A retry/new intent cannot inherit an earlier publication. + previous.update(data) + previous.update(createdAt=time.time(), hashes=hashes) + _write_marker(marker, previous) + return {"ok": True} + + +@contextmanager +def publish_install(runtime, installation): + """ASAR publication and its existing intent share the ack lock; never recreate a marker.""" + runtime = Path(runtime) + marker = runtime / ".plugin-restart-required" + if not marker.is_file(): + yield + return + with file_lock(runtime / "plugin-restart.lock"): + data = _read_marker(marker) + owned = data.get("installation") == installation + yield + if owned and marker.is_file() and _read_marker(marker) == data: + data["publishedAt"] = time.time() * 1000 + try: + _write_marker(marker, data) + except OSError as exc: + # ASAR is already published. Leave the intent unconfirmed, not a false success. + print("[zusage-plugin] installation publication record failed: " + str(exc), file=sys.stderr) + + +def ack_restart(runtime, loaded): + """Remove only hashes actually loaded; preserve newer legacy notifications.""" + if not isinstance(loaded, dict): + raise ValueError("loaded hashes must be a JSON object") + runtime = Path(runtime) + marker = runtime / ".plugin-restart-required" + with file_lock(runtime / "plugin-restart.lock"): + if not marker.exists(): + return {"ok": True, "cleared": False} + data = _read_marker(marker) + loaded_at = loaded.get("_loadedAt") + created_at = data.get("createdAt") + if not isinstance(created_at, (int, float)) or isinstance(created_at, bool): + created_at = marker.stat().st_mtime + if isinstance(loaded_at, (int, float)) and created_at * 1000 > loaded_at: + return {"ok": True, "cleared": False} + installation = data.get("installation") + if data.get("state") == "pending-install" or installation is not None: + published_at = data.get("publishedAt") + if (not isinstance(installation, dict) + or not isinstance(published_at, (int, float)) or isinstance(published_at, bool) + or not isinstance(loaded_at, (int, float)) or isinstance(loaded_at, bool) + or loaded_at <= published_at): + return {"ok": True, "cleared": False} + hashes = _hashes(data.get("hashes")) + if hashes: + remaining = {key: digest for key, digest in hashes.items() if loaded.get(key) != digest} + if remaining: + data["hashes"] = remaining + _write_marker(marker, data) + return {"ok": True, "cleared": False} + marker.unlink() + return {"ok": True, "cleared": True} + + +def main(): + parser = argparse.ArgumentParser() + operation = parser.add_mutually_exclusive_group(required=True) + operation.add_argument("--hold", metavar="LOCKPATH") + operation.add_argument("--record-restart", metavar="RUNTIME") + operation.add_argument("--ack-restart", nargs=2, metavar=("RUNTIME", "HASHES_JSON")) + operation.add_argument("--update-config", metavar="RUNTIME") + args = parser.parse_args() + if args.hold: + try: + with file_lock(args.hold, timeout=0): + print(json.dumps({"locked": True}, separators=(",", ":")), flush=True) + while sys.stdin.buffer.read(65536): + pass + except TimeoutError: + print(json.dumps({"locked": False}, separators=(",", ":")), flush=True) + elif args.record_restart: + print(json.dumps(record_restart(args.record_restart, json.load(sys.stdin)))) + elif args.update_config: + data = json.load(sys.stdin) + if not isinstance(data, dict) or "changes" not in data: + raise ValueError("config update must contain changes") + changed = update_config(args.update_config, data["changes"], data.get("defaults")) + print(json.dumps({"ok": True, "changed": changed})) + else: + print(json.dumps(ack_restart(args.ack_restart[0], json.loads(args.ack_restart[1])))) + + +if __name__ == "__main__": + main() diff --git a/external_plugins/zcode-token-usage-statusbar/skills/zcode-token-usage/SKILL.md b/external_plugins/zcode-token-usage-statusbar/skills/zcode-token-usage/SKILL.md new file mode 100644 index 0000000..ddb7628 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/skills/zcode-token-usage/SKILL.md @@ -0,0 +1,11 @@ +--- +name: zcode-token-usage +description: 查询 ZCode token 用量统计(当前会话/今日/近 N 天/按会话/按模型/按工作区)。用户询问 token 消耗、用量、花费类统计时使用。 +when_to_use: 用户想了解 token 用量、消耗统计或某个会话/工作区/模型的用量明细时 +license: MIT +--- +用本插件提供的 MCP 工具 `token_usage`(stdio server,只读 `~/.zcode/cli/db/db.sqlite`)查询 ZCode token 用量;工具不可用时退回 CLI:`python3 "<插件根目录>/zusage.py" <子命令>`(插件根目录 = 本 SKILL.md 所在目录的上一级的上一级)。 + +scope 取值:current(默认,当前会话)/ today / week / days:N / sessions[:N] / models[:days] / session:<会话id前缀> / workspace:<目录名子串>。 + +展示要求:报表原样整理后回复;用户要详细表格时用完整数值(带千分位),不要缩写;workspace 未命中时按工具返回的候选目录引导用户换更准的子串重试。 diff --git a/external_plugins/zcode-token-usage-statusbar/token_rate.py b/external_plugins/zcode-token-usage-statusbar/token_rate.py new file mode 100644 index 0000000..bbecc2e --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/token_rate.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""逐问 token 输出速率:从 ZCode 自身 usage 数据库(model_usage 表)计算真实生成速率。 + +用法: + python3 token_rate.py 最近一次请求 + 会话统计(人类可读) + python3 token_rate.py --turn 最新一问(本次提问触发的轮次)即时速率 + python3 token_rate.py --turn --current + 同上,但本问尚无入库数据时输出为空(--current 守卫, + 无身份时仅严格时间过滤,不保证轮次归属) + python3 token_rate.py --turn --turn-id turn_x --session sess_x + 固定宿主轮次身份,不依赖提问时间状态 + python3 token_rate.py --json JSON 输出 + python3 token_rate.py --session sess_xxx 显式指定会话(优先于下面两个环境变量;钩子下达 + 的统计指令带此参数,防止模型迟迟才跑该命令时, + 并行会话抢先提问把"最近提问会话"状态覆盖掉) + ZCODE_SESSION_ID=xxx python3 ... 只统计指定会话 + ZCODE_USAGE_DB=/path/db.sqlite 指定数据库路径(默认按用户主目录解析) + ZUSAGE_RATE_STATE_FILE=/path 指定钩子状态文件(测试接缝) + +只读打开 WAL 数据库,不影响运行中的客户端。 +速率口径(2026-10-14 据真库 raw_usage_json 与思考段时间分布裁决): + 分子 = output_tokens(ZCode 的该列已包含思考 token,provider 原生 totalTokens + = input + output 不另加 reasoning,不能再相加); + 分母 = 纯生成时长——无思考段 completed_at − first_token_at,含思考段 + completed_at − started_at(思考 token 在 first_token_at 之前流出,窗口须前移), + 按段累加,段间工具等待不计入。Claude 系列不单独上报思考 token(reasoning_tokens + 恒为 0)也按含思考段计时(2026-10-15 真库裁决,见 hides_reasoning)。 +""" +import json +import math +import os +import sqlite3 +import sys +import tempfile +import time +from pathlib import Path + +from runtime_lock import file_lock +from usage_stats import (MIN_GEN_MS, MAX_GEN_MS, hides_reasoning, request_stats, + aggregate_requests) + +DB_PATH = os.environ.get("ZCODE_USAGE_DB") or str( + Path.home() / ".zcode" / "cli" / "db" / "db.sqlite") +# 钩子状态文件:记录"用户最后所处的会话"与最近提问时刻(--current 守卫依赖 promptTs) +STATE_FILE = os.environ.get("ZUSAGE_RATE_STATE_FILE") or str( + Path.home() / ".zcode" / "zusage-rate.last-session.json") +STATE_TTL_MS = 7 * 24 * 3600 * 1000 # 过旧的状态文件视为失效 +N = 5 # 上轮行的统计窗口(均/峰) + + +def valid_state_ts(value, now=None): + now = time.time() * 1000 if now is None else now + return (isinstance(value, (int, float)) and not isinstance(value, bool) + and math.isfinite(value) and 0 <= now - value <= STATE_TTL_MS) + + +def read_state(): + try: + st = json.loads(Path(STATE_FILE).read_text(encoding="utf-8")) + except Exception: + return None + return st if isinstance(st, dict) and valid_state_ts(st.get("ts")) else None + + +def _replace_state_file(tmp, path): + """os.replace 短暂重试:Windows 上读者/杀软正打开目标文件时(无 FILE_SHARE_DELETE) + 替换会被拒绝,静默放弃会丢掉提问时刻、让 --current 守卫整轮失效;读者都是微秒级 + 短开,重试窗口远大于它。POSIX 的 rename 不受并发读者影响,重试零成本。""" + for attempt in range(20): + try: + os.replace(tmp, path) + return + except PermissionError: + if attempt == 19: + raise + time.sleep(0.01) + + +def write_session_state(session_id, source): + """持久文件锁内合并每会话提问时刻,再原子替换;会话启动不伪造提问。""" + path = Path(STATE_FILE) + tmp = None + try: + path.parent.mkdir(parents=True, exist_ok=True) + with file_lock(str(path) + ".lock"): + now = int(time.time() * 1000) + try: + prev = json.loads(path.read_text(encoding="utf-8")) + except Exception: + prev = None + prompts = prev.get("prompts") if isinstance(prev, dict) else None + # 迁移仅含全局字段的旧状态,且不把 SessionStart 的 ts 当提问。 + if not isinstance(prompts, dict): + prompts = {} + if isinstance(prev, dict) and prev.get("sessionId") \ + and valid_state_ts(prev.get("promptTs"), now): + prompts[prev["sessionId"]] = prev["promptTs"] + prompts = {k: v for k, v in prompts.items() if valid_state_ts(v, now)} + if source == "prompt-submit": + prompts[session_id] = max(prompts.get(session_id, 0), now) + data = {"sessionId": session_id, "ts": now, "source": source, "prompts": prompts} + if session_id in prompts: + data["promptTs"] = prompts[session_id] + fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(data, stream) + _replace_state_file(tmp, path) + except Exception: + pass + finally: + if tmp is not None: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + + +def open_db(): + """只读连接;库不存在/损坏返回 None(连接 ro URI 失败不会新建空库)。""" + try: + con = sqlite3.connect(f"file:{Path(DB_PATH).as_posix()}?mode=ro", uri=True, timeout=3) + con.row_factory = sqlite3.Row + return con + except sqlite3.OperationalError: + return None + + +def fallback_session_id(db): + """未显式指定会话时:状态文件里"用户最后所处的会话"(切会话即跟随)→ 全局最近完成的会话。""" + st = read_state() + if st and st.get("sessionId"): + return st["sessionId"] + if db is None: + return None + row = db.execute( + "select session_id from model_usage where status='completed'" + " order by completed_at desc limit 1").fetchone() + return row[0] if row else None + + +def scope_for(db, sid): + """有 main_turn 数据时只统计 main_turn,否则回退为该会话全部请求。""" + cols = ("model_id, output_tokens, reasoning_tokens, input_tokens," + " cache_read_input_tokens, started_at, first_token_at, completed_at," + " time_to_first_token_ms, status") + base = f"select {cols} from model_usage where status='completed' and query_source='main_turn'" + tail = " and session_id = ?" if sid else "" + args = [sid] if sid else [] + if db is not None: + try: + has_main = db.execute(base + tail + " limit 1", args).fetchone() + except sqlite3.OperationalError: + has_main = None + if not has_main: + base = base.replace(" and query_source='main_turn'", "") + return base + tail, args + + +def to_item(r): + item = request_stats(r) + item.update(inputTokens=r["input_tokens"] or 0, + cacheRead=r["cache_read_input_tokens"] or 0) + return item + + +def session_aggregate(db, scope_sql, args, rated): + """会话累计用独立 SUM(不受窗口限制);均/峰由已评分窗口计算。""" + if db is None: + return None + row = db.execute( + f"select count(*), coalesce(sum(output_tokens),0), coalesce(sum(reasoning_tokens),0)," + f" coalesce(sum(input_tokens),0), coalesce(sum(cache_read_input_tokens),0)" + f" from ({scope_sql})", args).fetchone() + return { + "samples": len(rated), + "requests": row[0], + "avg": _round1(sum(i["tokPerSec"] for i in rated) / len(rated)) if rated else None, + "max": max(i["tokPerSec"] for i in rated) if rated else None, + "min": min(i["tokPerSec"] for i in rated) if rated else None, + "totalOutput": row[1], + "totalReasoning": row[2], + "totalInput": row[3], + "totalCacheRead": row[4], + } + + +def query_latest(session_id=None): + """最近一次完成请求 + 会话统计(prompt-submit 注入的「上轮」行用)。""" + db = open_db() + try: + sid = session_id or fallback_session_id(db) + scope_sql, args = scope_for(db, sid) + if db is None: + items, rated, session = [], [], None + else: + rows = db.execute(scope_sql + " order by completed_at desc limit ?", args + [N]).fetchall() + items = [to_item(r) for r in rows] + rated = [i for i in items if i["tokPerSec"] is not None] + session = session_aggregate(db, scope_sql, args, rated) + latest = items[0] if items else None + return {"sessionId": sid, "latest": latest, "session": session, "history": items[::-1]} + finally: + if db is not None: + db.close() + + +# 一次用户消息的各段共享 turn_id。按整轮最早 started_at 排序,不能用完成时间: +# 上轮迟到的完成/工具后续段可能比本轮更晚,但轮次身份仍属上轮。 +def latest_turn_id(db, sid, prompt_ts=None): + if db is None: + return None + sql = ("select turn_id from model_usage where session_id=? and query_source='main_turn'" + " and turn_id is not null and turn_id != '' group by turn_id") + args = [sid] + if prompt_ts is not None: + # 时间仅是旧宿主的有限兼容过滤,不证明身份;不允许向前借用旧轮。 + sql += " having min(started_at) >= ?" + args.append(prompt_ts) + try: + row = db.execute(sql + " order by min(started_at) desc, turn_id desc limit 1", args).fetchone() + except sqlite3.OperationalError: + return None + return row[0] if row else None + + +def last_prompt_ts(session_id=None, state=None): + """仅接受本会话的有效提问时刻;映射缺项不借别的会话/SessionStart 的时刻。""" + st = read_state() if state is None else state + if not st: + return None + sid = session_id or st.get("sessionId") + prompts = st.get("prompts") + if isinstance(prompts, dict): + ts = prompts.get(sid) + elif st.get("sessionId") == sid: + ts = st.get("promptTs") + else: + ts = None + return ts if valid_state_ts(ts) else None + + +def query_turn(session_id=None, current=False, turn_id=None): + db = open_db() + try: + sid = session_id or fallback_session_id(db) + if not sid: + return {"sessionId": None, "turnId": None, "turn": None, "session": None} + scope_sql, args = scope_for(db, sid) + session = None + if db is not None: + win = db.execute(scope_sql + " order by completed_at desc limit ?", args + [N]).fetchall() + rated = [i for i in map(to_item, win) if i["tokPerSec"] is not None] + session = session_aggregate(db, scope_sql, args, rated) + prompt_ts = last_prompt_ts(sid) if current else None + if current and turn_id is None and prompt_ts is None: + return {"sessionId": sid, "turnId": None, "turn": None, + "noCurrentTurnData": True, "currentTurnUnavailableReason": "missingPromptState", "session": session} + identity_source = "explicitTurnId" if turn_id else ("promptTimeCompatibility" if current else "latestTurn") + turn_id = turn_id or latest_turn_id(db, sid, prompt_ts) + if not turn_id or db is None: + result = {"sessionId": sid, "turnId": turn_id, "turn": None, "session": session} + if current: + has_identity = db is not None and latest_turn_id(db, sid) is not None + result.update(noCurrentTurnData=True, + currentTurnUnavailableReason="noMatchingTurn" if has_identity else "missingTurnIdentity") + return result + try: + rows = db.execute(scope_sql + " and turn_id = ? order by started_at asc, completed_at asc", + args + [turn_id]).fetchall() + except sqlite3.OperationalError: + rows = [] + if not rows: + result = {"sessionId": sid, "turnId": turn_id, "turn": None, "session": session, + "turnIdentitySource": identity_source, "currentTurnIdentityVerified": False} + if current: + result.update(noCurrentTurnData=True, currentTurnUnavailableReason="noMatchingTurn") + return result + items = [to_item(r) for r in rows] + turn = aggregate_requests(items) + turn.update(ttftMs=items[0]["ttftMs"], firstAt=items[0]["completedAt"], + lastAt=max(i["completedAt"] for i in items)) + return {"sessionId": sid, "turnId": turn_id, "turn": turn, "session": session, + "turnIdentitySource": identity_source, + "currentTurnIdentityVerified": identity_source == "explicitTurnId"} + finally: + if db is not None: + db.close() + + +# 紧凑单位(注入行等需一眼扫读处):千以下原始、千~万一位小数 k、万~百万取整 k、百万以上一位小数 M +def fmt_compact(n): + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 10_000: + return f"{round(n / 1000)}k" + if n >= 1000: + return f"{n / 1000:.1f}k" + return str(n) + + +def fmt_num(n): + return f"{n:,}" + + +# 万/亿单位(本问统计行缺省;渲染端「中文单位」开关关闭时经 --no-cn-units 退回千分位/k):≥1 亿用亿、 +# ≥1 万用万,至多两位小数去尾零、数字与单位不留空格(58.25万 / 142万 / 1亿),不足 1 万沿用千分位。与 overlay.js fmt() 同规则。 +def fmt_cn(n): + if n >= 100_000_000: + return _cn_unit(n / 100_000_000) + "亿" + if n >= 10_000: + return _cn_unit(n / 10_000) + "万" + return fmt_num(n) + + +def _cn_unit(x): + return f"{x:.2f}".rstrip("0").rstrip(".") + + +def _hhmmss(ms): + return time.strftime("%H:%M:%S", time.localtime(ms / 1000)) + + +def _cn_fulltime(ms): + """本问统计行的采样时刻:完整中文日期时间(2026 年 10 月 23 日 11 点 20 分 28 秒),数字不补零。""" + t = time.localtime(ms / 1000) + return (f"{t.tm_year} 年 {t.tm_mon} 月 {t.tm_mday} 日 " + f"{t.tm_hour} 点 {t.tm_min} 分 {t.tm_sec} 秒") + + +def _sec(ms): + return f"{ms / 1000:.1f}" if isinstance(ms, (int, float)) else "-" + + +def _round1(x): + """一位小数;整数值去掉 .0(JS Math.round 的显示口径:400 而非 400.0)。""" + r = round(x, 1) + return int(r) if r == int(r) else r + + +# 本问统计行(回复收尾自测用)。调用方判定 turn 为空时(含 --current 守卫)不显示任何行。 +# cn 缺省 True 与渲染端「中文单位」开关默认开启一致;只有开关关闭时 inject-main 才传 --no-cn-units。 +def format_turn_line(r, cn=True): + t = r.get("turn") + if not t: + return "暂无本轮请求记录" + num = fmt_cn if cn else fmt_num + big = fmt_cn if cn else fmt_compact + parts = [ + f"⚡ {t['tokPerSec'] if t['tokPerSec'] is not None else '-'} token/s(本轮)", + f"首字 {_sec(t['ttftMs'])}s", + f"输出 {num(t['totalOutput'])}" + f"{'(含 ' + num(t['totalReasoning']) + ' 思考)' if t['totalReasoning'] else ''}" + f" token / 生成 {_sec(t['genMs']) if t['genMs'] > 0 else '-'}s", + ] + if t["requests"] > 1: + parts.append(f"{t['requests']} 段 / 峰 {t['peak']} token/s" if t["peak"] is not None else f"{t['requests']} 段 / 峰 -") + if r.get("session"): + s = r["session"] + parts.append(f"累计 {big(s['totalOutput'])} token") + parts.append(f"⏱ {_cn_fulltime(t['lastAt'])}") + return " · ".join(parts) + + +# 上轮行(prompt-submit 注入、CLI 默认输出用) +def format_line(r): + l = r.get("latest") + if not l: + return "暂无已完成的模型请求" + parts = [ + f"⚡ {l['tokPerSec'] if l['tokPerSec'] is not None else '-'} token/s(上轮)", + f"首字 {_sec(l['ttftMs'])}s", + f"输出 {fmt_num(l['outputTokens'])}" + f"{'(含 ' + fmt_num(l['reasoningTokens']) + ' 思考)' if l['reasoningTokens'] else ''}" + f" token / 生成 {_sec(l['genMs'])}s", + ] + s = r.get("session") + if s: + if s["samples"]: + parts.append(f"近{s['samples']}次均 {s['avg']} / 峰 {s['max']} token/s") + parts.append(f"累计 {fmt_compact(s['totalOutput'])} token") + parts.append(f"⏱ {_hhmmss(l['completedAt'])}") + return " · ".join(parts) + + +def _value_arg(argv, name): + """解析 --session / --session=;返回 None 表示命令行未显式指定。""" + for i, a in enumerate(argv): + if a == name: + return argv[i + 1] if i + 1 < len(argv) else None + if a.startswith(name + "="): + return a[len(name) + 1:] + return None + + +def main(argv): + json_out = "--json" in argv + # 显式 --session 优先级最高:钩子下达统计指令时会带上本会话 id(见 prompt-submit.py), + # 模型执行该命令的时刻可能远晚于钩子触发,其间若有并行会话提问,会改写状态文件里 + # "用户最后所处的会话"(fallback_session_id 的依据),env 变量在 Bash 工具里也不存在, + # 没有 --session 时会错把别的会话的数据当成本问结果(2026-10-15 真机事故)。 + sid = (_value_arg(argv, "--session") or os.environ.get("ZCODE_SESSION_ID") + or os.environ.get("CLAUDE_SESSION_ID") or None) + if "--turn" in argv: + r = query_turn(sid, current="--current" in argv, turn_id=_value_arg(argv, "--turn-id")) + if json_out: + print(json.dumps(r, ensure_ascii=False)) + elif r.get("turn"): + print(format_turn_line(r, cn="--no-cn-units" not in argv)) + # --current 且本问尚无数据:不输出任何行,调用方据此不显示统计(绝不回退到上一轮) + else: + r = query_latest(sid) + if json_out: + print(json.dumps(r, ensure_ascii=False)) + else: + print(format_line(r)) + s = r.get("session") + if s: + print(f"会话累计:输出 {fmt_num(s['totalOutput'])}" + f"{'(含 ' + fmt_num(s['totalReasoning']) + ' 思考)' if s['totalReasoning'] else ''}" + f" token · 输入 {fmt_num(s['totalInput'])} token(其中缓存读 {fmt_num(s['totalCacheRead'])})" + f" · 请求 {s['requests']} 次") + return 0 + + +if __name__ == "__main__": + # Windows 默认(无全局 PYTHONUTF8)下中文输出会按区域编码(GBK 等)写管道, + # 逐问速率行经代理终端/对话流展示前按 UTF-8 解码——与 usage_mcp.py 同款防护。 + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + sys.exit(main(sys.argv[1:])) diff --git a/external_plugins/zcode-token-usage-statusbar/turn-stats.js b/external_plugins/zcode-token-usage-statusbar/turn-stats.js new file mode 100644 index 0000000..a1e48e3 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/turn-stats.js @@ -0,0 +1,177 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.ZUsageTurnStats = api; +})(typeof globalThis === 'object' ? globalThis : this, function () { + 'use strict'; + + function createTracker(options = {}) { + const now = options.now || (() => performance.now()); + const turns = new Map(); + let epoch = null; + let session = null; + let sequence = -1; + let disposed = false; + + function ingest(snapshot) { + if (disposed || !snapshot || typeof snapshot.sessionId !== 'string' || + typeof snapshot.logEpoch !== 'string' || !Number.isSafeInteger(snapshot.seq) || + !snapshot.rows || !Array.isArray(snapshot.rows.window)) return false; + if (epoch !== snapshot.logEpoch || session !== snapshot.sessionId) { + turns.clear(); + epoch = snapshot.logEpoch; + session = snapshot.sessionId; + sequence = -1; + } + if (snapshot.seq <= sequence) return false; + sequence = snapshot.seq; + const at = now(); + const currentIds = new Set(snapshot.rows.window.map(row => row && row.rowId)); + for (const row of snapshot.rows.window) { + if (!row || typeof row.turnId !== 'string' || !Number.isSafeInteger(row.rowId)) continue; + const key = row.productTurnId || row.turnId; + let turn = turns.get(key); + if (!turn) { + turn = { sessionId: snapshot.sessionId, turnId: key, rows: new Map(), samples: [], observedAt: at }; + turns.set(key, turn); + } + turn.rows.set(row.rowId, row); + } + for (const turn of turns.values()) { + let characters = 0; + for (const id of turn.rows.keys()) if (!currentIds.has(id)) turn.rows.delete(id); + if (turn.rows.size === 0) { turns.delete(turn.turnId); continue; } + for (const row of turn.rows.values()) { + const text = row.kind === 'assistantText' || row.kind === 'reasoning' ? row.text : + row.kind === 'toolCall' ? row.inputText : ''; + if (typeof text === 'string') characters += Array.from(text).length; + // 只要曾经见过这一轮处于未完成状态,就记作"亲历过":区分"实时追踪到的完成" + // 与"刚绑定/重绑时一步到位捞到的历史存量已完成轮次"(后者可能缺详细字段, + // 调用方据此决定是否展示"完成态"结论,而不是把陈年旧轮误判成刚发生的异常)。 + if (row.kind === 'turnHeader' && row.state && + row.state !== 'completedSuccess' && row.state !== 'completedInterrupted' && row.state !== 'failed') { + turn.seenIncomplete = true; + } + } + const last = turn.samples[turn.samples.length - 1]; + if (last && last.characters > characters) turn.samples.length = 0; + if (!last || last.characters !== characters) turn.samples.push({ at, characters }); + while (turn.samples.length > 2 && turn.samples[1].at < at - 2000) turn.samples.shift(); + } + return true; + } + + function get(turnId) { + const turn = turns.get(turnId); + if (!turn) return null; + const header = Array.from(turn.rows.values()).find(row => row.kind === 'turnHeader'); + if (!header) return null; + const complete = ['completedSuccess', 'completedInterrupted', 'failed'].includes(header.state); + const samples = turn.samples; + const first = samples[0]; + const last = samples[samples.length - 1]; + const sampledAt = now(); + const elapsed = first ? sampledAt - first.at : 0; + const characterRate = !complete && elapsed >= 200 && last && sampledAt - last.at <= 2000 ? + Math.max(0, last.characters - first.characters) * 1000 / elapsed : null; + const outputTokens = Number.isFinite(header.outputTokens) && header.outputTokens >= 0 ? header.outputTokens : null; + const modelMs = Number.isFinite(header.modelDurationMs) && header.modelDurationMs >= 0 ? header.modelDurationMs : null; + const durationMs = Number.isFinite(header.activeMs) && header.activeMs >= 0 ? header.activeMs : + Number.isFinite(header.endedAt) && Number.isFinite(header.startedAt) ? Math.max(0, header.endedAt - header.startedAt) : null; + // 本轮最后一个活动行(现存行里 rowId 最大的非 header 行——实时重算而不是缓存 + // 引用:行被移出快照窗口后缓存对象不会跟着更新,状态会永远卡在旧的 running 上, + // loading 状态词就会一直显示"正在读取"。窗口 ≤60 行,重算开销可忽略)若是一个 + // 仍在进行中的工具调用(宿主从 tool_input_start 起就算"进行中":inputStreaming/ + // pendingApproval/running 三态同权),暴露其名与起始时刻,供调用方在 loading + // 插槽旁显示"正在读取 · Xs"这类状态词(v76)。 + let act = null; + for (const row of turn.rows.values()) { + if (row.kind !== 'turnHeader' && (!act || row.rowId > act.rowId)) act = row; + } + const runningTool = act && act.kind === 'toolCall' && typeof act.status === 'string' && + (act.status === 'inputStreaming' || act.status === 'pendingApproval' || act.status === 'running') + ? { name: typeof act.toolName === 'string' ? act.toolName : '', + startedAt: Number.isFinite(act.startedAt) ? act.startedAt : null } + : act && act.kind === 'subagent' && act.status === 'running' + ? { name: 'Agent', startedAt: null, // 子智能体行无 startedAt:只给动词,不给秒数 + // 行投影自带 childSessionId(宿主 subagent 行 schema):调用方据此绑定子代理 + // 会话 store 估算其运行期输出速率;无该字段时为 null,调用方按无数据降级。 + childSessionId: typeof act.childSessionId === 'string' && act.childSessionId ? act.childSessionId : null } + : null; + // 本轮全部运行中的子智能体行(并行派发时有多行):runningTool 只反映最后一个 + // 活动行,多槽位速率估算需要完整集合——按 rowId 升序稳定输出,无 childSessionId + // 的行无法绑定子会话 store,跳过。 + const runningSubagents = []; + if (!complete) { + const subs = Array.from(turn.rows.values()) + .filter(row => row.kind === 'subagent' && row.status === 'running' && + typeof row.childSessionId === 'string' && row.childSessionId) + .sort((a, b) => a.rowId - b.rowId); + for (const row of subs) runningSubagents.push({ childSessionId: row.childSessionId }); + } + // 当前轮处于哪个阶段(按最后一个活动行的类型判定,供调用方换图标/文案): + // reasoning 行流式输出 = 模型在思考;toolCall 进行中 = 工具执行;assistantText = 正文流式。 + const phase = complete ? null : + act && act.kind === 'reasoning' ? 'thinking' : + runningTool ? 'tool' : + act && act.kind === 'assistantText' ? 'writing' : null; + return { + sessionId: turn.sessionId, turnId, state: header.state, complete, + outputTokens, modelMs, durationMs, + // 宿主恢复的起止时刻用于诊断;缺失或非有限数保留为未知。 + startedAt: Number.isFinite(header.startedAt) ? header.startedAt : null, + endedAt: Number.isFinite(header.endedAt) ? header.endedAt : null, + toolMs: Number.isFinite(header.toolDurationMs) && header.toolDurationMs >= 0 ? header.toolDurationMs : null, + characterRate, observedMs: Math.max(0, sampledAt - turn.observedAt), + runningTool, // null=没有进行中的工具(流式正文阶段/全部完成);name 可能为空串(fallback 显示"执行中") + runningSubagents, // 全部 running subagent 行的 childSessionId(v97 多槽位);完成态恒 [] + phase, // 'thinking' | 'tool' | 'writing' | null(完成态恒 null) + totalCharacters: last ? last.characters : 0, // 当前累计字符数:供调用方做 c2t(字符→token)自校准 + observedLive: !!turn.seenIncomplete, // 是否亲历过"未完成→完成":false=刚绑定/重绑时 + // 一步到位捞到的历史存量已完成轮次,而非这次订阅期间实时追踪到的完成 + tokenRate: complete && outputTokens !== null && modelMs >= 200 && modelMs < 3600000 ? outputTokens * 1000 / modelMs : null, + source: 'host-projection', + }; + } + + return { ingest, get, clear() { turns.clear(); epoch = null; sequence = -1; }, dispose() { disposed = true; turns.clear(); } }; + } + + function subscribeStore(store, tracker, changed) { + if (!store || typeof store.getState !== 'function' || typeof store.subscribe !== 'function') return null; + let closed = false; + const update = () => { + if (closed) return; + const state = store.getState(); + if (tracker.ingest(state && state.snapshot)) changed(); + }; + const off = store.subscribe(update); + if (typeof off !== 'function') { closed = true; return null; } + update(); + return () => { if (closed) return; closed = true; try { off(); } finally { tracker.dispose(); } }; + } + + // cn=true 时 token 数量按万/亿显示(≥1 亿用亿、≥1 万用万,至多两位小数去尾零,数字与单位不留空格), + // 与 overlay.js fmt() / token_rate.py fmt_cn 同规则;不足 1 万沿用千分位。 + function formatTokens(n, cn) { + if (cn && n >= 1e8) return trimFixed(n / 1e8) + '亿'; + if (cn && n >= 1e4) return trimFixed(n / 1e4) + '万'; + return n.toLocaleString(); + } + function trimFixed(x) { return x.toFixed(2).replace(/\.?0+$/, ''); } + + function format(stats, cn) { + if (!stats) return ''; + if (!stats.complete) { + const speed = stats.characterRate === null ? '' : ` · ${stats.characterRate.toFixed(1)} 字符/s`; + return `⚡ 生成中 · 已观察 ${(stats.observedMs / 1000).toFixed(1)}s${speed}`; + } + const speed = stats.tokenRate === null ? '速率不可用' : `${stats.tokenRate.toFixed(1)} token/s`; + const output = stats.outputTokens === null ? '输出未知' : `输出 ${formatTokens(stats.outputTokens, cn)} token`; + const duration = stats.durationMs === null ? '' : ` · 耗时 ${(stats.durationMs / 1000).toFixed(1)}s`; + const status = stats.state === 'failed' ? ' · 失败' : stats.state === 'completedInterrupted' ? ' · 已中断' : ''; + return `⚡ ${speed} · ${output}${duration}${status}`; + } + + return { createTracker, subscribeStore, format, formatTokens }; +}); diff --git a/external_plugins/zcode-token-usage-statusbar/usage_mcp.py b/external_plugins/zcode-token-usage-statusbar/usage_mcp.py new file mode 100644 index 0000000..daabbe3 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/usage_mcp.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""ZCode token 用量 MCP server(stdio, 零依赖;注册名 zcode-token-usage-statusbar)。 + +提供工具 token_usage(scope):current(默认)/today/days:N/sessions[:N]/models[:days]/session:/workspace:<目录关键词>。 +协议仅实现 initialize / tools/list / tools/call;调试信息只走 stderr。 +""" +import json +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import zusage # noqa: E402 + +PROTOCOL_VERSION = "2024-11-05" + +L = zusage.L # 输出语言与 CLI 同源(config.json 的 "lang") + + +def tool_token_usage(scope: str = "current") -> str: + con = zusage.connect() + try: + if scope in ("current", "now"): + return zusage.render_current(con) + if scope == "today": + return zusage.render_today(con) + if scope.startswith("days:"): + return zusage.render_days(con, int(scope.split(":", 1)[1])) + if scope == "week": + return zusage.render_days(con, 7) + if scope.startswith("sessions"): + n = int(scope.split(":", 1)[1]) if ":" in scope else 10 + return zusage.render_sessions(con, n) + if scope.startswith("models"): + d = int(scope.split(":", 1)[1]) if ":" in scope else 7 + return zusage.render_models(con, d) + if scope.startswith("session:"): + return zusage.render_session_detail(con, scope.split(":", 1)[1].strip()) + if scope.startswith("workspace:"): + return zusage.render_workspace(con, scope.split(":", 1)[1].strip()) + return L(f"未知 scope: {scope!r}。可用: current | today | week | days:N | sessions[:N] | models[:days] | session: | workspace:<目录关键词>", + f"Unknown scope: {scope!r}. Available: current | today | week | days:N | sessions[:N] | models[:days] | session: | workspace:") + finally: + con.close() + + +TOOLS = [ + { + "name": "token_usage", + "description": ( + L("查询 ZCode 的 token 用量(数据来自本地 db.sqlite,只读)。" + "scope 可选: current(当前会话+今日,默认), today, week, days:N(近N天每日), " + "sessions:N(最近N个会话), models:days(按模型), session:(指定会话,含按模型与子代理明细), " + "workspace:<目录关键词>(按工作区聚合主会话+子代理,含按会话与按模型明细)。", + "Query ZCode token usage (from the local db.sqlite, read-only). " + "scope: current(current session+today, default), today, week, days:N(last N days per day), " + "sessions:N(last N sessions), models:days(by model), session:(specific session, with per-model and subagent details), " + "workspace:(aggregate a workspace's main sessions+subagents, with per-session and per-model details).") + ), + "inputSchema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": L("查询范围,默认 current", "Query scope, default current"), + } + }, + }, + } +] + + +def reply(req_id, result): + print(json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}), flush=True) + + +def main(): + for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + method = msg.get("method", "") + req_id = msg.get("id") + if method == "initialize": + reply( + req_id, + { + "protocolVersion": msg.get("params", {}).get( + "protocolVersion", PROTOCOL_VERSION + ), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "zcode-token-usage", "version": "1.0.0"}, + }, + ) + elif method == "tools/list": + reply(req_id, {"tools": TOOLS}) + elif method == "tools/call": + params = msg.get("params", {}) + args = params.get("arguments") or {} + try: + text = tool_token_usage(**args) + reply(req_id, {"content": [{"type": "text", "text": text}], "isError": False}) + except Exception as e: # noqa: BLE001 + hint = "" + if isinstance(e, sqlite3.OperationalError): + # 库结构漂移(ZCode 升级改列/表)给出可行动提示,与 zusage.py CLI 同款 + hint = L("\n多半是 ZCode 升级改了用量库结构(表/列变更)。请更新本插件后再查询;" + "已落库的历史数据不受影响。", + "\nMost likely ZCode changed the usage DB schema in an update. " + "Update this plugin before querying; stored history is unaffected.") + reply( + req_id, + { + "content": [{"type": "text", + "text": L(f"查询失败: {e}", f"Query failed: {e}") + hint}], + "isError": True, + }, + ) + elif req_id is not None: + print( + json.dumps( + { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"unknown method {method}"}, + } + ), + flush=True, + ) + + +if __name__ == "__main__": + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + main() diff --git a/external_plugins/zcode-token-usage-statusbar/usage_stats.py b/external_plugins/zcode-token-usage-statusbar/usage_stats.py new file mode 100644 index 0000000..220a191 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/usage_stats.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Usage rate calculations shared by CLI and snapshots; no database or host state.""" +import math + +MIN_GEN_MS = 200 +MAX_GEN_MS = 3_600_000 + + +def hides_reasoning(model_id): + return "claude" in (model_id or "").lower() + + +def round1(value): + rounded = round(value, 1) + return int(rounded) if rounded == int(rounded) else rounded + + +def _number(value): + return (isinstance(value, (int, float)) and not isinstance(value, bool) + and math.isfinite(value)) + + +def request_stats(row): + """Output includes reasoning. Missing/invalid generation windows stay unrated.""" + output = row["output_tokens"] or 0 + reasoning = row["reasoning_tokens"] or 0 + completed = row["completed_at"] + thinking = reasoning > 0 or hides_reasoning(row["model_id"]) + start = row["started_at"] if thinking else row["first_token_at"] + duration = completed - start if _number(completed) and _number(start) and completed > start else None + reason = None + if duration is None: + reason = "missingGenerationWindow" + elif not MIN_GEN_MS <= duration < MAX_GEN_MS: + reason = "generationWindowOutOfRange" + elif not _number(output) or output <= 0: + reason = "noOutput" + rated = reason is None + ttft = row["time_to_first_token_ms"] + return { + "model": row["model_id"], + "outputTokens": output, + "reasoningTokens": reasoning, + "ttftMs": ttft if _number(ttft) and ttft >= 0 else None, + "genMs": duration, + "tokPerSec": round1(output / duration * 1000) if rated else None, + "completedAt": completed, + "ratedOutput": output if rated else 0, + "unratedOutput": 0 if rated else output, + "rateUnavailableReason": reason, + } + + +def aggregate_requests(items): + """Weighted rate uses only rated output/windows, never all output as numerator.""" + items = list(items) + rated = [item for item in items if item["tokPerSec"] is not None] + output = sum(item["outputTokens"] for item in items) + rated_output = sum(item["outputTokens"] for item in rated) + duration = sum(item["genMs"] for item in rated) + return { + "requests": len(items), + "rated": len(rated), + "unrated": len(items) - len(rated), + "totalOutput": output, + "totalReasoning": sum(item["reasoningTokens"] for item in items), + "ratedOutput": rated_output, + "unratedOutput": output - rated_output, + "rateCoverage": rated_output / output if output > 0 else None, + "genMs": duration, + "tokPerSec": round1(rated_output / duration * 1000) if rated else None, + "peak": max((item["tokPerSec"] for item in rated), default=None), + } diff --git a/external_plugins/zcode-token-usage-statusbar/zusage.py b/external_plugins/zcode-token-usage-statusbar/zusage.py new file mode 100644 index 0000000..0b348d9 --- /dev/null +++ b/external_plugins/zcode-token-usage-statusbar/zusage.py @@ -0,0 +1,1819 @@ +# -*- coding: utf-8 -*- +"""ZCode token 用量查询(只读访问 ~/.zcode/cli/db/db.sqlite)。 + +既可作 CLI(python zusage.py [now|today|days N|sessions [N]|models [days]|workspace 关键词|session id前缀|watch [sec]]), +也可被 usage_mcp.py import 为查询库。 +""" +import hashlib +import fnmatch +import json +import os +import re +import shlex +import sqlite3 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta +from pathlib import Path + +DB_PATH = Path.home() / ".zcode" / "cli" / "db" / "db.sqlite" +CONFIG_PATH = Path(__file__).parent / "config.json" + + +def load_config(): + cfg = {"context_window": 128000, "poll_ms": 2000} + if CONFIG_PATH.exists(): + try: + cfg.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8"))) + except Exception: + pass + return cfg + + +def connect(): + """只读连接优先;WAL shm 不可用时退回普通连接(全程只 SELECT)。 + fallback 前先确认文件存在:sqlite3.connect 对不存在的路径会新建空库, + 可能与尚未初始化数据库的 ZCode 产生交互(v32 审查修复)。""" + try: + con = sqlite3.connect(f"file:{DB_PATH.as_posix()}?mode=ro", uri=True, timeout=3) + con.execute("select 1 from model_usage limit 1").fetchone() + except sqlite3.OperationalError: + if not DB_PATH.exists(): + raise + con = sqlite3.connect(str(DB_PATH), timeout=3) + con.row_factory = sqlite3.Row + return con + + +def _ts(ms): + return datetime.fromtimestamp(ms / 1000) if ms else None + + +def _day_start(d=None): + d = d or datetime.now() + return d.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _epoch_ms(dt): + return int(dt.timestamp() * 1000) + + +def _load_lang(): + """CLI/MCP 输出语言:config.json 的 "lang"(zh/en,缺省 zh)——与安装器/监控窗口同源。 + 查找顺序:脚本目录(--dev/仓库直跑)→ 标准数据目录(标准安装的 config 不在脚本目录)。""" + for base in (CONFIG_PATH, + Path.home() / ".zcode" / "zcode-token-usage-statusbar" / "config.json"): + try: + lang = json.loads(base.read_text(encoding="utf-8")).get("lang") + if lang in ("zh", "en"): + return lang + except (OSError, ValueError): + pass + return "zh" + + +_LANG = _load_lang() + + +def L(zh, en): + """双语输出(与 install.py / overlay.js 的 L 同款约定)。""" + return en if _LANG == "en" else zh + + +def fmt(n): + n = n or 0 + if n >= 1_000_000_000: + return f"{n / 1e9:.2f}B" + if n >= 1_000_000: + return f"{n / 1e6:.2f}M" + if n >= 1_000: + return f"{n / 1e3:.1f}K" + return str(n) + + +SESSION_TIMEOUT_MS = 30 * 60 * 1000 # 最近30分钟有请求视为活跃会话 +_TPS_MIN_GEN_MS = 200 # 有效样本下限,同 token_rate.MIN_GEN_MS +_TPS_MAX_GEN_MS = 3_600_000 # 有效样本上限(1h),同 token_rate.MAX_GEN_MS +_TPS_LOOKBACK = 20 # first_token_at 缺失时向前找有效请求的扫描条数上限 + + +_AGG_TTL_MS = 2000 # 聚合短缓存:今日合计/会话池是慢变量,2s 陈旧无感;泵 1.5s 拉一次省约一半全表聚合 +_AGG_CACHE = {} + + +def _cached_agg(key, fn): + now = int(time.time() * 1000) + hit = _AGG_CACHE.get(key) + if hit and now - hit[0] < _AGG_TTL_MS: + return hit[1] + val = fn() + if len(_AGG_CACHE) > 8: + _AGG_CACHE.clear() # 键固定只有几个,正常到不了;保险防膨胀 + _AGG_CACHE[key] = (now, val) + return val + + +def _scan_session_ids(con): + """全库 session_id → max(completed_at) 一次分组扫描(731MB 库 ~55ms)。 + 最新会话与 recent 池原先各自独立做同样的全表 group by(3 次 118ms), + 合并为一次扫描并挂 2s TTL。""" + def run(): + return con.execute( + """select session_id, max(completed_at) as m + from model_usage group by session_id""").fetchall() + return _cached_agg("scan", run) + + +def session_usage(con, sid): + # last_request_input 取最近一次 completed 请求的 input_tokens(= 当前上下文容量)。 + # 不能用 max(input_tokens):会话压缩后 input 骤降,峰值永远不回落(v28 修复)。 + r = con.execute( + """select count(*), coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), + coalesce(sum(reasoning_tokens),0), coalesce(sum(cache_read_input_tokens),0), + coalesce(sum(cache_creation_input_tokens),0), coalesce(sum(computed_total_tokens),0), + coalesce(sum(tool_call_count),0), + (select m2.input_tokens from model_usage m2 + where m2.session_id = m.session_id and m2.status = 'completed' + order by m2.completed_at desc, m2.id desc limit 1), + count(distinct turn_id), coalesce(sum(retry_count),0) + from model_usage m where m.session_id=? and status='completed'""", + (sid,), + ).fetchone() + return { + "requests": r[0], "input": r[1], "output": r[2], "reasoning": r[3], + "cache_read": r[4], "cache_write": r[5], "total": r[6], "tool_calls": r[7], + "last_request_input": r[8] or 0, "turns": r[9], "retries": r[10], + } + + +def range_usage(con, start_ms, end_ms): + # 末尾三列(reasoning/retry/cache_write)是 v30/v31 追加,CLI 老代码按索引 0-4 取值不受影响 + return con.execute( + """select count(*), coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), + coalesce(sum(cache_read_input_tokens),0), coalesce(sum(computed_total_tokens),0), + coalesce(sum(duration_ms),0), coalesce(sum(reasoning_tokens),0), + coalesce(sum(retry_count),0), coalesce(sum(cache_creation_input_tokens),0) + from model_usage where status='completed' and completed_at between ? and ?""", + (start_ms, end_ms), + ).fetchone() + + +def _today_usage(con, start_ms, end_ms): + return con.execute( + """select count(*), coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), + coalesce(sum(cache_read_input_tokens),0), coalesce(sum(computed_total_tokens),0), + coalesce(sum(duration_ms),0), coalesce(sum(reasoning_tokens),0), + coalesce(sum(retry_count),0), coalesce(sum(cache_creation_input_tokens),0), + min(started_at), max(completed_at) + from model_usage where status='completed' and completed_at between ? and ?""", + (start_ms, end_ms), + ).fetchone() + + +def daily_usage(con, days): + """近 N 天(含今天),按本地日聚合。返回 [(date, row), ...] 旧→新。""" + out = [] + for i in range(days - 1, -1, -1): + d0 = _day_start(datetime.now() - timedelta(days=i)) + r = range_usage(con, _epoch_ms(d0), _epoch_ms(d0 + timedelta(days=1))) + out.append((d0.strftime("%m-%d %a"), r)) + return out + + +def recent_sessions(con, limit=10): + return con.execute( + """select s.id, coalesce(s.title,'(无标题)'), coalesce(s.directory,''), + count(m.id), coalesce(sum(m.input_tokens),0), coalesce(sum(m.output_tokens),0), + coalesce(sum(m.computed_total_tokens),0), max(m.completed_at) + from model_usage m left join session s on s.id = m.session_id + where m.status='completed' + group by m.session_id order by max(m.completed_at) desc limit ?""", + (limit,), + ).fetchall() + + +def model_usage(con, days=7): + since = _epoch_ms(datetime.now() - timedelta(days=days)) + return con.execute( + """select provider_id, model_id, count(*), coalesce(sum(input_tokens),0), + coalesce(sum(output_tokens),0), coalesce(sum(computed_total_tokens),0) + from model_usage where status='completed' and completed_at >= ? + group by provider_id, model_id order by sum(computed_total_tokens) desc""", + (since,), + ).fetchall() + + +# ---------- 展示 ---------- + +def render_current(con): + """当前会话 = _scan_session_ids 最近活跃的会话(子代理会话也在候选内)。""" + rows = _scan_session_ids(con) + sess, last_at = None, 0 + if rows: + sid, last_at = max(rows, key=lambda r: r[1]) + sess = con.execute("select * from session where id=?", (sid,)).fetchone() + lines = [] + if sess is not None: + u = session_usage(con, sess["id"]) + active = last_at and (int(time.time() * 1000) - last_at) < SESSION_TIMEOUT_MS + mark = L("🟢 活跃", "🟢 active") if active else L("⚪ 闲置", "⚪ idle") + lines.append(L(f"■ 当前会话 [{mark}] ", f"■ Current session [{mark}] ") + sess["title"]) + if sess["directory"]: + lines.append(L(f" 目录: {sess['directory']}", f" dir: {sess['directory']}")) + lines.append( + L(f" {u['turns']} 轮 / {u['requests']} 次请求 / {u['tool_calls']} 次工具调用", + f" {u['turns']} turns / {u['requests']} requests / {u['tool_calls']} tool calls") + ) + lines.append( + L(f" input {fmt(u['input'])} (其中 cache read {fmt(u['cache_read'])}) " + f"output {fmt(u['output'])} 合计 {fmt(u['total'])}", + f" input {fmt(u['input'])} (incl. cache read {fmt(u['cache_read'])}) " + f"output {fmt(u['output'])} total {fmt(u['total'])}") + ) + lines.append(L(f" 上下文容量 ≈ {fmt(u['last_request_input'])} tokens (最近一次请求输入)", + f" context capacity ≈ {fmt(u['last_request_input'])} tokens (latest request input)")) + if last_at: + la = _ts(last_at) + lines.append(L(f" 最后活动: {la:%H:%M:%S}", + f" last activity: {la:%H:%M:%S}")) + lines.append("") + today = range_usage(con, _epoch_ms(_day_start()), _epoch_ms(_day_start() + timedelta(days=1))) + lines.append(L(f"■ 今日 ({datetime.now():%Y-%m-%d %a})", f"■ Today ({datetime.now():%Y-%m-%d %a})")) + lines.append( + L(f" {today[0]} 次请求 input {fmt(today[1])} (cache read {fmt(today[3])}) " + f"output {fmt(today[2])} 合计 {fmt(today[4])}", + f" {today[0]} requests input {fmt(today[1])} (cache read {fmt(today[3])}) " + f"output {fmt(today[2])} total {fmt(today[4])}") + ) + return "\n".join(lines) + + +def render_days(con, days): + lines = [L(f"■ 近 {days} 天每日用量 (合计=input+output)", f"■ Last {days} days (total=input+output)")] + for label, r in daily_usage(con, days): + bar = "█" * max(0, min(30, int(r[4] / 200_000))) + lines.append( + L(f" {label} {r[0]:>4} 次 in {fmt(r[1]):>8} out {fmt(r[2]):>7} 合计 {fmt(r[4]):>8} {bar}", + f" {label} {r[0]:>4} req in {fmt(r[1]):>8} out {fmt(r[2]):>7} total {fmt(r[4]):>8} {bar}") + ) + return "\n".join(lines) + + +def render_sessions(con, limit=10): + lines = [L(f"■ 最近 {limit} 个会话", f"■ Last {limit} sessions")] + for sid, title, d, n, inp, outp, total, last in recent_sessions(con, limit): + t = _ts(last) + if _LANG == "en" and title == "(无标题)": + title = "(untitled)" + dname = d.replace("\\", "/").rsplit("/", 1)[-1] if d else "?" + lines.append( + L(f" {t:%m-%d %H:%M} {fmt(total):>8} ({fmt(inp)} in / {fmt(outp)} out, {n} 请求) ", + f" {t:%m-%d %H:%M} {fmt(total):>8} ({fmt(inp)} in / {fmt(outp)} out, {n} requests) ") + + f"[{dname}] {title[:32]} [{sid[:13]}]" + ) + return "\n".join(lines) + + +def render_models(con, days=7): + lines = [L(f"■ 近 {days} 天按模型", f"■ Last {days} days by model")] + for prov, model, n, inp, outp, total in model_usage(con, days): + lines.append( + L(f" {model:<22} {n:>5} 次 in {fmt(inp):>8} out {fmt(outp):>7} 合计 {fmt(total):>8} ({prov})", + f" {model:<22} {n:>5} req in {fmt(inp):>8} out {fmt(outp):>7} total {fmt(total):>8} ({prov})") + ) + return "\n".join(lines) + + +# ---------- 工作区报表 ---------- +# 口径:主会话 = directory 命中且无父且非 sess_subagent 前缀;子代理按 parent_id 归并去重。 +# 子代理会话(sess_subagent_*)的 directory 字段也等于工作区目录,直接按 directory 聚合会把 +# 主/子重复计数,必须走 parent_id 归并。报表单连接一次算齐——库实时写入,分次查询之间对不上账。 +# 会话迁移过工作区时历史用量全部记在当前目录名下(model_usage 无工作区维度)。 + +_SUB_PREFIX_LEN = 13 # len("sess_subagent") + + +def _usage_rows(con, sids): + """一批会话的 completed 用量(口径与 session_usage 一致),返回 session_id → row。""" + qm = ",".join("?" * len(sids)) + per = {} + for r in con.execute( + f"""select session_id, count(*) n, + coalesce(sum(input_tokens),0) inp, coalesce(sum(output_tokens),0) outp, + coalesce(sum(reasoning_tokens),0) reas, + coalesce(sum(cache_read_input_tokens),0) crd, + coalesce(sum(computed_total_tokens),0) tot, + min(completed_at) first_at, max(completed_at) last_at + from model_usage where status='completed' and session_id in ({qm}) + group by session_id""", sids): + per[r["session_id"]] = r + return per + + +def _match_workspaces(con, pattern): + """工作区目录匹配:子串(SQLite LIKE 对 ASCII 大小写不敏感)或单词首字母缩写 + (abc → **A**pple **B**anana **C**herry)。用户对工作区的称呼常是缩写而非子串。 + 返回 [(directory, 会话数)],按会话数降序。""" + pat = pattern.lower() + out = [] + for d, n in con.execute( + "select directory, count(*) n from session group by directory order by n desc"): + if pat in d.lower(): + out.append((d, n)) + continue + initials = "".join(w[0] for w in re.split(r"[^A-Za-z0-9]+", d) if w).lower() + if len(pat) >= 2 and pat in initials: + out.append((d, n)) + return out + + +def render_workspace(con, pattern, top=15): + """workspace:<目录关键词>:按工作区聚合主会话+子代理,含按会话与按模型两张明细。""" + pattern = (pattern or "").strip() + dirs = _match_workspaces(con, pattern) if pattern else [] + if not dirs: + lines = [L(f"未找到目录含 {pattern!r} 的工作区。现有工作区(按会话数前 10):", + f"No workspace with directory matching {pattern!r}. Existing workspaces (top 10 by sessions):")] + for d, n in con.execute( + "select directory, count(*) n from session group by directory order by n desc limit 10"): + lines.append(f" {n:>4} {d}") + return "\n".join(lines) + + dir_names = [d for d, _ in dirs] + qd = ",".join("?" * len(dir_names)) + mains = con.execute( + f"""select id, title, time_created from session + where directory in ({qd}) and parent_id is null and substr(id,1,{_SUB_PREFIX_LEN})!='sess_subagent' + order by time_created""", dir_names).fetchall() + main_ids = [r["id"] for r in mains] + subs = [] + if main_ids: + qm = ",".join("?" * len(main_ids)) + subs = con.execute( + f"select id, title, parent_id from session where parent_id in ({qm})", main_ids).fetchall() + all_ids = list(dict.fromkeys(main_ids + [r["id"] for r in subs])) + per = _usage_rows(con, all_ids) if all_ids else {} + qm = ",".join("?" * len(all_ids)) + models = con.execute( + f"""select model_id, count(*) n, + coalesce(sum(input_tokens),0) inp, coalesce(sum(output_tokens),0) outp, + coalesce(sum(computed_total_tokens),0) tot + from model_usage where status='completed' and session_id in ({qm}) + group by model_id order by tot desc""", all_ids).fetchall() if all_ids else [] + + def agg(ids): + rows = [per[i] for i in ids if i in per] + return {k: sum(r[k] for r in rows) + for k in ("n", "inp", "outp", "reas", "crd", "tot")} + + sub_of = {} + for s in subs: + sub_of.setdefault(s["parent_id"], []).append(s["id"]) + sub_ids = [i for ids in sub_of.values() for i in ids] + tm, ts, ta = agg(main_ids), agg(sub_ids), agg(all_ids) + + if len(dirs) == 1: + lines = [L(f"■ 工作区 {dirs[0][0]}", f"■ Workspace {dirs[0][0]}")] + else: + lines = [L(f"■ 工作区(匹配 {pattern!r},命中 {len(dirs)} 个)", + f"■ Workspaces matching {pattern!r} ({len(dirs)} dirs)")] + for d, n in dirs[:5]: + lines.append(f" {n:>4} {d}") + if not per: + lines.append(L("该工作区暂无 completed 请求。", "No completed requests in this workspace yet.")) + return "\n".join(lines) + firsts = [r["first_at"] for r in per.values() if r["first_at"]] + lasts = [r["last_at"] for r in per.values() if r["last_at"]] + lines.append(L( + f"主会话 {len(main_ids)} 个(其中无请求 {len(main_ids) - len([i for i in main_ids if i in per])} 个)" + f" + 子代理 {len(subs)} 个 | " + f"{_ts(min(firsts)):%m-%d %H:%M} ~ {_ts(max(lasts)):%m-%d %H:%M}", + f"{len(main_ids)} main sessions ({len(main_ids) - len([i for i in main_ids if i in per])} without requests)" + f" + {len(subs)} subagents | {_ts(min(firsts)):%m-%d %H:%M} ~ {_ts(max(lasts)):%m-%d %H:%M}")) + lines.append(L( + f"请求 {ta['n']:,}(主 {tm['n']:,} / 子 {ts['n']:,}) " + f"input {ta['inp']:,}(缓存读 {ta['crd']:,}) " + f"output {ta['outp']:,}(含 reasoning {ta['reas']:,})", + f"{ta['n']:,} requests (main {tm['n']:,} / sub {ts['n']:,}) " + f"input {ta['inp']:,} (cache-read {ta['crd']:,}) " + f"output {ta['outp']:,} (incl. reasoning {ta['reas']:,})")) + lines.append(L( + f"合计(=input+output,缓存读已含在 input 内) {ta['tot']:,}", + f"Total (=input+output; cache-read already inside input) {ta['tot']:,}")) + + ranked = [] + for m in mains: + if m["id"] in per: + su = agg(sub_of.get(m["id"], [])) + ranked.append((m, per[m["id"]], su)) + ranked.sort(key=lambda x: -(x[1]["tot"] + x[2]["tot"])) + lines.append(L(f"■ 按会话(含其子代理,按含子合计降序,前 {top})", + f"■ By session (incl. subagents, top {top} by combined total)")) + for m, u, su in ranked[:top]: + nsub = len(sub_of.get(m["id"], [])) + subtxt = (L(f" +子{nsub}个 {su['tot']:,}", f" +{nsub} sub {su['tot']:,}") if nsub else "") + t = (m["title"] or L("(无标题)", "(untitled)")).replace("\n", " ") + lines.append( + f" {_ts(u['last_at']):%m-%d %H:%M} {u['n']:,}req in {u['inp']:,} out {u['outp']:,} " + f"{L('合计', 'total')} {u['tot']:,}{subtxt} {t[:26]}") + if len(ranked) > top: + rest = agg([m["id"] for m, _, _ in ranked[top:]]) + lines.append(L(f" (另有 {len(ranked) - top} 个会话 合计 {rest['tot']:,})", + f" (…plus {len(ranked) - top} sessions totalling {rest['tot']:,})")) + + lines.append(L("■ 按模型", "■ By model")) + for r in models: + lines.append( + f" {r['model_id']:<24} {r['n']:,}req in {r['inp']:,} out {r['outp']:,} " + f"{L('合计', 'total')} {r['tot']:,}") + return "\n".join(lines) + + +# ---------- 机器可读快照(悬浮条数据源) ---------- + +CATALOG_PATH = Path(r"D:\ZCode\resources\model-providers\models_catalog_china_llm_zcode_2026-06-03.json") + + +_CTX_WINDOW_CACHE = {} + + +def lookup_context_window(model_id): + """按模型 id 在客户端自带模型目录里查 contextWindow;查不到返回 None。带缓存。""" + want = (model_id or "").strip().lower() + if not want: + return None + if want in _CTX_WINDOW_CACHE: + return _CTX_WINDOW_CACHE[want] + result = None + try: + cat = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + except Exception: + return None + for p in cat.get("providers", []): + for m in p.get("models", []): + if str(m.get("id", "")).lower() == want: + cw = m.get("contextWindow") + result = int(cw) if cw else None + break + _CTX_WINDOW_CACHE[want] = result + return result + + +def _prompt_matches(title, prompt): + """子会话 title 是否为 Agent part prompt 的截断头(ZCode 规则:prompt 前 57 字符 + "...")。""" + t = (title or "").strip() + if not t or not prompt: + return False + if t.endswith("..."): + base = t[:-3] + return len(base) >= 15 and prompt.startswith(base) + return prompt.startswith(t) + + +# 任务名解析缓存(serve 常驻进程内):like 扫描父会话全部 part 的 data(本会话可达 +# 2456 行大 JSON,实测 29ms/次,泵 1.5s 拉一次全付)。失效键 = 父会话 part 的 +# 行数+最新 time_updated(索引聚合,<1ms);普通消息 part 新增也会失效,宁多算不漏算。 +_TASK_CACHE = {} + + +def _sub_agent_tasks(con, sid): + stamp = con.execute( + "select count(*), coalesce(max(time_updated),0) from part where session_id=?", (sid,) + ).fetchone() + key = (stamp[0], stamp[1]) + hit = _TASK_CACHE.get(sid) + if hit and hit[0] == key: + return hit[1] + sub_rows_titles = {r[0]: r[1] for r in con.execute( + "select id, title from session where parent_id=?", (sid,))} + agent_parts = [] + for prow in con.execute( + """select data, time_created from part + where session_id=? and data like '%"tool":"Agent"%' order by time_created""", + (sid,), + ): + try: + o = json.loads(prow[0]) + st = o.get("state") or {} + inp = st.get("input") or {} + desc = str(inp.get("description") or "").strip() + if desc: + agent_parts.append((desc, str(inp.get("prompt") or "").strip(), + str(st.get("output") or ""))) + except Exception: + continue # part.data 解析失败跳过该条,不影响其余 + tasks = {} + for desc, _prompt, out in agent_parts: + m = re.search(r"(?:^|\n)agentId:\s*([^\s(]+)", out) + if m: + tasks["sess_subagent_" + m.group(1)] = desc + used = set(tasks) + for desc, prompt, out in agent_parts: + if re.search(r"(?:^|\n)agentId:\s*([^\s(]+)", out): + continue # 已走回执关联 + cands = [s for s, t in sub_rows_titles.items() + if s not in used and _prompt_matches(t, prompt)] + if len(cands) == 1: + tasks[cands[0]] = desc + used.add(cands[0]) + if len(_TASK_CACHE) > 32: # 防长驻膨胀:会话数有限,正常到不了;超了整表清掉 + _TASK_CACHE.clear() + _TASK_CACHE[sid] = (key, tasks) + return tasks + + +def render_session_detail(con, prefix): + """session::会话总量 + 按模型 + 子代理明细(任务名与状态条"子智能体目录"同源)。""" + rows = con.execute("select id from session where id like ?", (prefix + "%",)).fetchall() + if not rows: + rows = con.execute( + "select distinct session_id from model_usage where session_id like ?", + (prefix + "%",)).fetchall() + if not rows: + return L(f"未找到 id 前缀为 {prefix!r} 的会话", + f"No session found with id prefix {prefix!r}") + sid = rows[0][0] + sess = con.execute("select * from session where id=?", (sid,)).fetchone() + u = session_usage(con, sid) + head = L(f"■ 会话 {sid}", f"■ Session {sid}") + if sess is not None: + head += f" {sess['title']}" + if sess["directory"]: + head += L(f"\n 目录: {sess['directory']}", f"\n dir: {sess['directory']}") + body = L( + f" {u['turns']} 轮 / {u['requests']} 次请求\n" + f" input {fmt(u['input'])} (其中 cache read {fmt(u['cache_read'])}) " + f"output {fmt(u['output'])} 合计 {fmt(u['total'])}(合计=input+output,缓存读已含在 input 内)\n" + f" 上下文容量 ≈ {fmt(u['last_request_input'])} tokens", + f" {u['turns']} turns / {u['requests']} requests\n" + f" input {fmt(u['input'])} (incl. cache read {fmt(u['cache_read'])}) " + f"output {fmt(u['output'])} total {fmt(u['total'])} (total=input+output; cache-read already inside input)\n" + f" context capacity ≈ {fmt(u['last_request_input'])} tokens") + lines = [head, body] + last = con.execute( + "select max(completed_at) from model_usage where session_id=?", (sid,)).fetchone()[0] + if last: + lines.append(L(f" 最后活动: {_ts(last):%m-%d %H:%M}", + f" last activity: {_ts(last):%m-%d %H:%M}")) + mrows = con.execute( + """select model_id, count(*) n, coalesce(sum(input_tokens),0), + coalesce(sum(output_tokens),0), coalesce(sum(computed_total_tokens),0) + from model_usage where session_id=? and status='completed' + group by model_id order by sum(computed_total_tokens) desc limit 6""", + (sid,)).fetchall() + if mrows: + lines.append(L(" 按模型:", " By model:")) + for r in mrows: + lines.append( + f" {r['model_id']:<24} {r['n']:,}req in {fmt(r[2])} out {fmt(r[3])} " + f"{L('合计', 'total')} {fmt(r[4])}") + sub_rows = con.execute( + """select m.session_id, coalesce(s2.title,''), count(*), + coalesce(sum(m.computed_total_tokens),0), max(m.completed_at) + from model_usage m join session s2 on m.session_id = s2.id + where m.query_source='subagent' and s2.parent_id=? and m.status='completed' + group by m.session_id order by max(m.completed_at) desc""", (sid,)).fetchall() + if sub_rows: + tasks = _sub_agent_tasks(con, sid) + lines.append(L( + f" 子代理: {len(sub_rows)} 个 / {sum(r[2] for r in sub_rows):,}req / " + f"合计 {sum(r[3] for r in sub_rows):,}", + f" Subagents: {len(sub_rows)} / {sum(r[2] for r in sub_rows):,}req / " + f"total {sum(r[3] for r in sub_rows):,}")) + for r in sub_rows[:8]: + name = tasks.get(r[0]) or r[1] or r[0] + lines.append( + f" {fmt(r[3]):>8} {r[2]:>3}req {str(name).replace(chr(10), ' ')[:30]} [{r[0][-6:]}]") + if len(sub_rows) > 8: + lines.append(L(f" (另有 {len(sub_rows) - 8} 个)", f" (…plus {len(sub_rows) - 8} more)")) + return "\n".join(lines) + + +def _session_start_ms(con, sid, srow=None): + """会话起点:首个已完成请求的 started_at;无请求(纯新建/纯问答)回退会话创建时间。 + 供悬浮条「会话累计」tooltip 的起始时间展示用。""" + r = con.execute( + "select min(started_at) from model_usage where session_id=? and status='completed'", + (sid,), + ).fetchone() + if r and r[0]: + return int(r[0]) + if srow is None: + srow = con.execute("select time_created from session where id=?", (sid,)).fetchone() + return int(srow["time_created"]) if srow and srow["time_created"] else 0 + + +def _light_snapshot(con, sid, cfg): + """recent 池轻量行(v8 瘦身):两条聚合 SQL 出齐顶层计数,明细字段给空结构。 + 状态条只渲染 1 个会话,recent 长尾行仅作会话切换兜底;兜底候选(force + latest + + 最近活跃前 2)恒为完整快照,轻量行只在极端长尾被兜底选中时降级显示(缺明细不缺计数)。 + sub.list 聚合数字置 0 而非缺失:overlay 的子代理面板/汇总按字段渲染,结构必须完整。""" + u = session_usage(con, sid) + srow = con.execute( + "select title, summary_additions, summary_deletions, summary_files, parent_id, time_created from session where id=?", + (sid,), + ).fetchone() + lr = con.execute("select max(completed_at) from model_usage where session_id=?", (sid,)).fetchone() + now_ms = int(time.time() * 1000) + return { + "sid": sid, + "title": (srow["title"] if srow is not None else "") or "", + "active": bool(lr[0] and now_ms - lr[0] < SESSION_TIMEOUT_MS), + "turns": u["turns"], "requests": u["requests"], "input": u["input"], + "output": u["output"], "reasoning": u["reasoning"], "cache_read": u["cache_read"], + "cache_write": u["cache_write"], "total": u["total"], "tool_calls": u["tool_calls"], + "retries": u["retries"], "ctx": u["last_request_input"], + "updated": (_ts(lr[0]).strftime("%H:%M:%S") if lr[0] else ""), + "started": _session_start_ms(con, sid, srow), + "sub": {"requests": 0, "total": 0, "input": 0, "output": 0, "cache_read": 0, + "reasoning": 0, "cache_write": 0, "active": False, "rate_avg": 0, "list": []}, + "tools": {"total": u["tool_calls"], "errors": 0, "list": []}, + "last_turn": dict(_EMPTY_TURN), + "last": {"duration_ms": 0, "ttft_ms": 0, "model": "", "provider_id": "", "tps": 0, "startedAt": 0, "endedAt": 0}, + "code": {"add": (srow["summary_additions"] if srow is not None else None), + "del": (srow["summary_deletions"] if srow is not None else None), + "files": (srow["summary_files"] if srow is not None else None)}, + "ctx_exc": 0, + "context_window": cfg["context_window"], "context_auto": False, + "last_at": con.execute( + "select max(completed_at) from model_usage where session_id=? and status='completed'", (sid,), + ).fetchone()[0] or 0, + "is_sub": sid.startswith("sess_subagent"), + "parent": (srow["parent_id"] if srow is not None else None), + } + + +def hides_reasoning(model_id): + """Claude 系列不单独上报思考 token:reasoning_tokens 恒为 0,output_tokens 却已含思考, + 且思考在 first_token_at 之前流出。按首字窗口计时会把思考期漏出分母(2026-10-15 真库: + claude-opus-5-5 有 35% 请求按首字窗口超过 300 tok/s),须同含思考段一样从 started_at 起算。 + 与 token_rate.hides_reasoning 同一规则。""" + return "claude" in (model_id or "").lower() + + +def _row_gen_ms(r): + """按 token_rate.py 同款口径算一行 model_usage 的生成时长;first_token_at 缺失 + 或窗口非正则不可信,返回 None(该请求不参与 last.tps 计算)。""" + ft, ct, st = r["first_token_at"], r["completed_at"], r["started_at"] + if not ft or not ct or ct <= ft: + return None + gen_ms = ct - ft + if (r["reasoning_tokens"] or hides_reasoning(r["model_id"])) and st and ct > st: + gen_ms = ct - st # 含思考段/Claude 系列:窗口前移到 started_at + return gen_ms + + +def _rate_sample(row): + if row and (row["output_tokens"] or 0) > 0: + gen_ms = _row_gen_ms(row) + if gen_ms and _TPS_MIN_GEN_MS <= gen_ms < _TPS_MAX_GEN_MS: + return {"tps": round(row["output_tokens"] / (gen_ms / 1000), 1), + "startedAt": row["started_at"] or 0, "endedAt": row["completed_at"] or 0} + return None + + +def _last_valid_rate(con, sid, last): + """最近有效生成速度及同一请求的起止;最多回查 20 条,缺有效样本时隐藏。""" + sample = _rate_sample(last) + if sample is not None: + return sample + rows = con.execute( + """select output_tokens, first_token_at, completed_at, started_at, reasoning_tokens, model_id + from model_usage where session_id=? and status='completed' + order by completed_at desc limit ?""", + (sid, _TPS_LOOKBACK), + ).fetchall() + for row in rows: + sample = _rate_sample(row) + if sample is not None: + return sample + return {"tps": 0, "startedAt": 0, "endedAt": 0} + + +def _turn_rate(rows): + output, unrated, duration = 0, 0, 0 + for row in rows: + tokens = row["output_tokens"] or 0 + gen_ms = _row_gen_ms(row) + if tokens > 0 and gen_ms and _TPS_MIN_GEN_MS <= gen_ms < _TPS_MAX_GEN_MS: + output += tokens + duration += gen_ms + else: + unrated += tokens + return {"tps": round(output / (duration / 1000), 1) if duration else 0, + "rated_output": output, "unrated_output": unrated, "gen_ms": duration} + + +def _rated_tps(tokens, gen_ms): + """加权平均 t/s:Σ有效 output ÷ Σ有效生成窗口;窗口为 0(无有效样本)时返回 0。""" + return round(tokens / (gen_ms / 1000), 1) if gen_ms else 0 + + +def _session_snapshot(con, sid, cfg): + """单个会话的完整快照(最新会话与 recent 列表共用一份结构)。""" + srow = con.execute("select * from session where id=?", (sid,)).fetchone() + u = session_usage(con, sid) + last = con.execute( + """select duration_ms, coalesce(time_to_first_token_ms,0), completed_at, turn_id, model_id, + output_tokens, first_token_at, started_at, reasoning_tokens, provider_id + from model_usage where session_id=? and status='completed' + and (query_source is null or query_source='main_turn') + order by completed_at desc limit 1""", + (sid,), + ).fetchone() + # 本轮数据源 = turn_usage 表的每轮完整聚合(v30 起,替代从 model_usage 手工按轮求和): + # 自带请求/重试/工具/工具错误计数与 reasoning 拆分,一轮一行。 + lt = {"requests": 0, "retries": 0, "tool_calls": 0, "tool_errors": 0, "input": 0, + "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0, + "total": 0, "duration_ms": 0, "ttft_ms": 0, "started": 0, "ended": 0, + "tps": 0, "rated_output": 0, "unrated_output": 0, "gen_ms": 0} + if last and last["turn_id"]: + t = con.execute( + """select coalesce(model_request_count,0), coalesce(model_retry_count,0), + coalesce(tool_call_count,0), coalesce(tool_error_count,0), + coalesce(input_tokens,0), coalesce(output_tokens,0), coalesce(reasoning_tokens,0), + coalesce(cache_read_input_tokens,0), coalesce(cache_creation_input_tokens,0), + coalesce(computed_total_tokens,0), + coalesce(duration_ms,0), coalesce(time_to_first_token_ms,0) + from turn_usage where session_id=? and turn_id=? and status='completed'""", + (sid, last["turn_id"]), + ).fetchone() + # 本轮起止:首个请求的 started_at / 最后完成请求的 completed_at(tooltip 时间范围用) + ts_row = con.execute( + """select min(started_at), max(completed_at) from model_usage + where session_id=? and turn_id=? and status='completed'""", + (sid, last["turn_id"]), + ).fetchone() + turn_started = (ts_row[0] or 0) if ts_row else 0 + turn_ended = (ts_row[1] or 0) if ts_row else 0 + if t: + lt = {"requests": t[0], "retries": t[1], "tool_calls": t[2], "tool_errors": t[3], + "input": t[4], "output": t[5], "reasoning": t[6], "cache_read": t[7], + "cache_write": t[8], "total": t[9], "duration_ms": t[10], "ttft_ms": t[11], + "started": turn_started, "ended": turn_ended} + else: + # 轮进行中 turn_usage 还没落 completed 行:回退 model_usage 现场聚合,保持实时显示 + r = con.execute( + """select count(*), coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), + coalesce(sum(computed_total_tokens),0), coalesce(sum(duration_ms),0), + coalesce(sum(cache_read_input_tokens),0), coalesce(sum(reasoning_tokens),0), + coalesce(sum(cache_creation_input_tokens),0) + from model_usage where session_id=? and turn_id=? and status='completed'""", + (sid, last["turn_id"]), + ).fetchone() + lt = {"requests": r[0], "retries": 0, "tool_calls": 0, "tool_errors": 0, + "input": r[1], "output": r[2], "reasoning": r[6], "cache_read": r[5], + "cache_write": r[7], "total": r[3], "duration_ms": r[4], "ttft_ms": 0, + "started": turn_started, "ended": turn_ended} + lt.update(_turn_rate(con.execute( + """select output_tokens, first_token_at, completed_at, started_at, reasoning_tokens, model_id + from model_usage where session_id=? and turn_id=? and status='completed'""", + (sid, last["turn_id"]), + ).fetchall())) + lr = con.execute("select max(completed_at) from model_usage where session_id=?", (sid,)).fetchone() + active = bool(lr and lr[0] and int(time.time() * 1000) - lr[0] < SESSION_TIMEOUT_MS) + ctx_auto = lookup_context_window(last["model_id"]) if last else None + rate = _last_valid_rate(con, sid, last) + sub_rows = con.execute( + """select m.session_id, coalesce(m.agent,''), coalesce(s2.title,''), count(*), + coalesce(sum(m.computed_total_tokens),0), + coalesce(sum(m.input_tokens),0), coalesce(sum(m.output_tokens),0), + coalesce(sum(m.cache_read_input_tokens),0), coalesce(sum(m.reasoning_tokens),0), + coalesce(sum(m.cache_creation_input_tokens),0), max(m.completed_at), s2.time_created + from model_usage m join session s2 on m.session_id = s2.id + where m.query_source='subagent' and s2.parent_id=? and m.status='completed' + group by m.session_id order by max(m.completed_at) desc""", + (sid,), + ).fetchall() + # 子代理任务名:Agent 工具调用 part 的 input.description(与右侧"子智能体目录"面板同源)。 + # 主关联用官方回执:完成后 part.state.output 尾部带 "agentId: agent_xxx" 行, + # 子会话 id 即 "sess_subagent_"+agentId(客户端自己的硬关联,无歧义)。 + # 兜底只给运行中尚未出现回执的条目:prompt 前缀匹配且候选唯一才绑(同前缀多候选宁可 + # 无名,不做时间猜测——历史 part 不带 childSessionId,时间最近邻存在错位风险)。 + sub_tasks = _sub_agent_tasks(con, sid) if sub_rows else {} + # 子代理已完成段速率(v97):每子会话 Σ有效 output / Σ有效生成窗口的加权平均, + # 口径与 _turn_rate 完全一致(first_token_at 缺失即无窗、含思考段窗口用 started_at); + # 无效段不进分母。运行中的请求未入库,这里只有已完成段。 + sub_gen = {} + if sub_rows: + for r in con.execute( + """select m.session_id, m.output_tokens, m.first_token_at, m.completed_at, + m.started_at, m.reasoning_tokens, m.model_id + from model_usage m join session s2 on m.session_id = s2.id + where m.query_source='subagent' and s2.parent_id=? and m.status='completed'""", + (sid,), + ): + g = sub_gen.setdefault(r["session_id"], [0, 0]) + tokens = r["output_tokens"] or 0 + gen_ms = _row_gen_ms(r) + if tokens > 0 and gen_ms and _TPS_MIN_GEN_MS <= gen_ms < _TPS_MAX_GEN_MS: + g[0] += tokens + g[1] += gen_ms + now_ms = int(time.time() * 1000) + sub = { + "requests": sum(r[3] for r in sub_rows), + "total": sum(r[4] for r in sub_rows), + "input": sum(r[5] for r in sub_rows), + "output": sum(r[6] for r in sub_rows), + "cache_read": sum(r[7] for r in sub_rows), + "reasoning": sum(r[8] for r in sub_rows), + "cache_write": sum(r[9] for r in sub_rows), + "active": any(r[10] and now_ms - r[10] < 30000 for r in sub_rows), + "rate_avg": _rated_tps(sum(g[0] for g in sub_gen.values()), + sum(g[1] for g in sub_gen.values())), + "list": [ + {"sid": r[0], "agent": r[1], "title": r[2], "task": sub_tasks.get(r[0], ""), + "requests": r[3], "total": r[4], + "input": r[5], "output": r[6], "cache_read": r[7], "reasoning": r[8], + "cache_write": r[9], "last": (r[10] or 0), + "tps_avg": _rated_tps(*sub_gen.get(r[0], [0, 0])), + "active": bool(r[10] and now_ms - r[10] < 30000)} + for r in sub_rows + ], + } + # 工具调用明细(tool_usage 表,本会话按工具分组;status=running 的行不计入) + tool_rows = con.execute( + """select tool_name, count(*), coalesce(sum(duration_ms),0), + sum(case when status='error' then 1 else 0 end) + from tool_usage where session_id=? and status in ('completed','error') + group by tool_name order by count(*) desc, tool_name""", + (sid,), + ).fetchall() + tools = { + "total": sum(r[1] for r in tool_rows), + "errors": sum(r[3] or 0 for r in tool_rows), + "list": [ + {"name": r[0], "count": r[1], "duration_ms": r[2], "errors": r[3] or 0} + for r in tool_rows + ], + } + return { + "sid": sid, + "title": srow["title"] if srow is not None else "", + "active": active, + "turns": u["turns"] if u else 0, + "requests": u["requests"] if u else 0, + "input": u["input"] if u else 0, + "output": u["output"] if u else 0, + "reasoning": u["reasoning"] if u else 0, + "cache_read": u["cache_read"] if u else 0, + "cache_write": u["cache_write"] if u else 0, + "total": u["total"] if u else 0, + "tool_calls": u["tool_calls"] if u else 0, + "retries": u["retries"] if u else 0, + "ctx": u["last_request_input"] if u else 0, + "updated": (_ts(lr[0]).strftime("%H:%M:%S") if lr and lr[0] else ""), + "started": _session_start_ms(con, sid, srow), + "sub": sub, + "tools": tools, + "last_turn": lt, + "last": { + "duration_ms": last["duration_ms"] if last else 0, + "ttft_ms": last[1] if last else 0, + "model": last["model_id"] if last else "", + # v94:当前模型归属(额度 chip 按 provider 段选数据源)。可选字段——远端 + # 旧版 zusage.py 不返回它,消费端按缺省 "" 容错(last.startedAt 同款先例)。 + "provider_id": (last["provider_id"] or "") if last else "", + **rate, + }, + # 代码变更统计(session 表 summary_* 列,ZCode 目前未填充、全库为 NULL,有值才会显示) + "code": { + "add": (srow["summary_additions"] if srow is not None else None), + "del": (srow["summary_deletions"] if srow is not None else None), + "files": (srow["summary_files"] if srow is not None else None), + }, + # 最近一次"上下文超限被拒"的时刻(该请求 status=error,不会进 completed 统计); + # 晚于最近成功请求 = 仍处于超限状态,进度条亮红告警 + "ctx_exc": con.execute( + """select coalesce(max(coalesce(completed_at, started_at)),0) + from model_usage where session_id=? and context_exceeded=1""", + (sid,), + ).fetchone()[0], + "context_window": ctx_auto or cfg["context_window"], + "context_auto": ctx_auto is not None, + "last_at": con.execute( + "select max(completed_at) from model_usage where session_id=? and status='completed'", (sid,), + ).fetchone()[0] or 0, + "is_sub": sid.startswith("sess_subagent"), + "parent": (srow["parent_id"] if srow is not None else None), + } + + +# ---------- SCNet 超算互联网(阶段 3:外部快照优先,缺失时按官方 Credits 抵扣表本地估算) ---------- +# 抵扣表转写自 dsh-cost-meter(MIT,lib/coding-plans.js SCNET_CREDIT_RATES); +# 单位 = Credits / 百万 tokens,数值可能随官方调整,执行/发版前按官方最新核对。 +SCNET_CREDIT_RATES = { + "GLM-5.2": (7543, 189, 26400), "GLM-5.1": (8743, 175, 32057), "GLM-5": (8743, 175, 32057), + "DeepSeek-V4-Pro": (10286, 86, 20571), "DeepSeek-V4-Flash": (1200, 24, 2400), + "DeepSeek-V4-Flash-0731": (1543, 31, 3086), + "Kimi-K3": (34286, 343, 171429), "Kimi-K2.7-Code": (8357, 167, 34714), + "Kimi-K2.6": (8357, 167, 34714), "Kimi-K2.5": (5143, 103, 27000), + "MiniMax-M3": (3600, 72, 14400), "MiniMax-M2.7": (3600, 72, 14400), + "MiniMax-M2.5": (2520, 50, 10080), "Qwen3.8-max": (18514, 231, 49371), +} + + +def _scnet_source_cfg(): + """quota-secrets.json 的 scnet 段(与 config.json 同目录)。未启用/未配 planCredits 返回 None。""" + try: + d = json.loads((Path(__file__).parent / "quota-secrets.json").read_text(encoding="utf-8")) + src = ((d.get("sources") or {}).get("scnet") or {}) + except Exception: + return None + credits = src.get("planCredits") + if src.get("enabled") is not True or not isinstance(credits, (int, float)) or credits <= 0: + return None + return src + + +def _scnet_period(src): + """计费周期 [start_ms, end_ms):planStart(YYYY-MM-DD) 锚日每月同日;缺省自然月。 + 月末锚日(29/30/31)在短月按当月最后一天收敛(2 月不抛 ValueError、不早移周期)。""" + import calendar + from datetime import datetime + + tz = datetime.now().astimezone() + anchor = None + for fmt in ("%Y-%m-%d", "%Y/%m/%d"): + try: + anchor = datetime.strptime(str(src.get("planStart") or ""), fmt) + break + except Exception: + continue + + def clamp_day(year, month, day): + return min(day, calendar.monthrange(year, month)[1]) + + def add_months(year, month, n): + idx = (year * 12 + (month - 1)) + n + return idx // 12, idx % 12 + 1 + + now_local = tz.replace(tzinfo=None) + if anchor is None: + y, m = now_local.year, now_local.month + sd = 1 + else: + sd = anchor.day + y, m = now_local.year, now_local.month + # 从当前月往回找:第一个"锚日(或短月收敛日)"不晚于 now 的月即周期起始月 + while True: + d = clamp_day(y, m, sd) + cand = datetime(y, m, d) + if cand <= now_local or (anchor is None and m == now_local.month and y == now_local.year): + start = cand + break + y, m = add_months(y, m, -1) + ny, nm = add_months(start.year, start.month, 1) + end = datetime(ny, nm, clamp_day(ny, nm, sd)) + return int(start.timestamp() * 1000), int(end.timestamp() * 1000) + + +def _scnet_snapshot(src): + """外部控制台额度快照:scnet_official.json(≤16KB、24h 内有效;resetsAt 缺省须落在本周期内)。""" + path = Path(__file__).parent / "scnet_official.json" + try: + if path.stat().st_size > 16 * 1024: + return None + d = json.loads(path.read_text(encoding="utf-8")) + used, total = d.get("used"), d.get("total") + fetched = d.get("fetchedAt") or d.get("at") + if not all(isinstance(v, (int, float)) and v > 0 for v in (used, total)): + return None + if not isinstance(fetched, (int, float)) or fetched < 1000 or time.time() * 1000 - fetched > 24 * 3600 * 1000: + return None + resets = d.get("resetsAt") + if resets is None: + s_ms, e_ms = _scnet_period(src) + if not (s_ms <= fetched < e_ms): + return None + return {"used": used, "total": total, "resetsAt": resets} + except Exception: + return None + + +def _scnet_estimate(con): + """快照优先;无快照按抵扣表 × 本地账本估算(main_turn 的 input/cacheRead/output 分费率求和)。""" + if con is None: + return None + src = _scnet_source_cfg() + if src is None: + return None + plan_credits = float(src["planCredits"]) + snap = _scnet_snapshot(src) + s_ms, e_ms = _scnet_period(src) + if snap is not None and snap["total"] > 0: + return {"source": "snapshot", "credits_used": round(float(snap["used"]), 2), + "plan_credits": round(float(snap["total"]), 2), "resets_at": snap["resetsAt"]} + patterns = src.get("modelPatterns") or {} + if not isinstance(patterns, dict) or not patterns: + return None + compiled = [] + for pat, key in patterns.items(): + rate = SCNET_CREDIT_RATES.get(str(key)) + if rate: + compiled.append((re.compile(fnmatch.translate(str(pat)), re.IGNORECASE), rate)) + if not compiled: + return None + credits = 0.0 + try: + rows = con.execute( + """select model_id, coalesce(input_tokens,0), coalesce(cache_read_input_tokens,0), + coalesce(cache_creation_input_tokens,0), coalesce(output_tokens,0) + from model_usage + where status='completed' and (query_source is null or query_source='main_turn') + and completed_at>=? and completed_at [查询时刻ms, 生效TTLms] +_REMOTE_NEG_TTL_OK = 2 * 60 * 1000 # 远端确认"无此会话"后多久内不再问(远端新会话极少数竞态下最迟 2 分钟自愈) +_REMOTE_NEG_TTL_ERR = 60 * 1000 # ssh 失败/超时:短退避,尽快自愈 +_REMOTE_TARGET_KEY_RE = re.compile(r"[a-z][a-z0-9]{0,31}") +_REMOTE_SID_RE = re.compile(r"[A-Za-z0-9_-]{1,80}") + +_DESKTOP_SETTING_SUBPATH = (".zcode", "v2", "setting.json") +_CLIENT_STATE_CACHE = {"sig": None, "entries": []} + + +def _desktop_home(): + """客户端 setting.json 的 HOME 基准:dev 实例可用 ZCODE_DESKTOP_HOME_DIR 改写 + (与客户端 packages/services/src/setting 的解析顺序对齐),否则当前用户家目录。""" + return (os.environ.get("ZCODE_DESKTOP_HOME_DIR", "").strip() + or os.environ.get("HOME", "").strip() + or os.environ.get("USERPROFILE", "").strip() or str(Path.home())) + + +def _desktop_setting_path(): + return Path(_desktop_home()).joinpath(*_DESKTOP_SETTING_SUBPATH) + + +def _read_client_remote_entries(): + """客户端持久化的远程工作区条目(lastWorkspaceSession,兼容旧键 remoteWorkspaceHistory)。 + 带 mtime 缓存(文件每次变更整写一遍,stat 开销可忽略);解析失败一律返回 [],只读不写。""" + sig = None + try: + p = _desktop_setting_path() + sig = (str(p), p.stat().st_mtime_ns) + except OSError: + pass + if sig is None: + _CLIENT_STATE_CACHE["sig"] = None + _CLIENT_STATE_CACHE["entries"] = [] + return [] + if _CLIENT_STATE_CACHE["sig"] == sig: + return _CLIENT_STATE_CACHE["entries"] + entries = [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return [] + raw = data.get("lastWorkspaceSession") + legacy = data.get("remoteWorkspaceHistory") + legacy = legacy if isinstance(legacy, list) else [] + if isinstance(raw, list): + by_id = {e.get("id"): e for e in legacy if isinstance(e, dict)} + raw = [by_id.get(e.get("historyId"), e) if isinstance(e, dict) else e for e in raw] + else: + raw = legacy + if isinstance(raw, list): + entries = [e for e in raw if isinstance(e, dict)] + except (OSError, ValueError): + entries = [] + _CLIENT_STATE_CACHE["sig"] = sig + _CLIENT_STATE_CACHE["entries"] = entries + return entries + + +def _read_client_server_history(): + """3.16 起客户端把会话握手升级出的 remote:server::/路径 存活身份单独记在 + sourceServerWorkspaceHistory,lastWorkspaceSession 持久化的仍是传输级 legacy 身份, + 两者只共享 workspacePath。与 _read_client_remote_entries 同文件同 mtime 缓存模式; + 解析失败返回 [],只读不写。""" + sig = None + try: + p = _desktop_setting_path() + sig = (str(p), p.stat().st_mtime_ns) + except OSError: + pass + if sig is None: + _CLIENT_STATE_CACHE["server_sig"] = None + _CLIENT_STATE_CACHE["server_hist"] = [] + return [] + if _CLIENT_STATE_CACHE.get("server_sig") == sig: + return _CLIENT_STATE_CACHE["server_hist"] + hist = [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + raw = data.get("sourceServerWorkspaceHistory") if isinstance(data, dict) else None + if isinstance(raw, list): + hist = [e for e in raw if isinstance(e, dict)] + except (OSError, ValueError): + hist = [] + _CLIENT_STATE_CACHE["server_sig"] = sig + _CLIENT_STATE_CACHE["server_hist"] = hist + return hist + + +def _server_idents_by_path(): + """归一路径 → 握手升级身份列表。只采 remote:server: 前缀且带 sourceServerId 的条目; + 同一路径混入多个 sourceServerId 时无法唯一归属服务器,整路径放弃(fail-closed)。""" + by_path, owners = {}, {} + for e in _read_client_server_history(): + ident = str(e.get("workspaceIdentity") or "").strip() + server = str(e.get("sourceServerId") or "").strip() + if not ident.startswith("remote:server:") or not server: + continue + # 缺 workspacePath 的条目无法按路径与传输条目关联(客户端字段 optional), + # 跳过,避免与同样缺路径的传输条目在归一 "/" 上跨条目碰撞。 + if not str(e.get("workspacePath") or "").strip(): + continue + path = _normalize_identity_path(e.get("workspacePath")) + idents = by_path.setdefault(path, []) + if ident not in idents: + idents.append(ident) + owners.setdefault(path, set()).add(server) + return {p: idents for p, idents in by_path.items() if len(owners[p]) == 1} + + +def _normalize_identity_path(p): + """客户端 normalizeWorkspacePathForIdentity 同款:反斜杠归一、折叠重复斜杠、去尾补头。""" + s = str(p or "").replace("\\", "/") + while "//" in s: + s = s.replace("//", "/") + s = s.rstrip("/") or "/" + return s if s.startswith("/") else "/" + s + + +def _ssh_identity(host, port, username, path): + """客户端 workspaceIdentity 构造算法(remote-workspace-identity.ts)的 Python 版: + remote:ssh::::<归一路径>。重连换代(remoteSessionId 变)不影响它, + 是 sid→目标路由的稳定键。""" + try: + port = int(port or 22) + except (TypeError, ValueError): + port = 22 + return "remote:ssh:{}:{}:{}:{}".format( + str(host).strip().lower(), port, str(username).strip(), _normalize_identity_path(path)) + + +def _wsl_identity(distro, user, path): + """客户端 buildRemoteWorkspaceIdentity wsl 分支同款: + remote:wsl:[:]:<归一路径>。user 段可选(旧版客户端无 user 格式,靠 + 路径必以 / 开头区分);v3.16 起握手后客户端会把会话身份升级为 remote:server:, + 但持久化条目里拿到的就是当时身份,优先原样采用,这里只兜底构造。 + 客户端固有歧义:缺省 distro 与字面 "default" 构造出同一身份(bss 的 ||"default"), + 两者对应不同通道时路由随条目顺序漂移,客户端自身无法区分,接受。""" + d = str(distro or "").strip() or "default" + u = str(user or "").strip() + if u: + return "remote:wsl:{}:{}:{}".format(d, u, _normalize_identity_path(path)) + return "remote:wsl:{}:{}".format(d, _normalize_identity_path(path)) + + +def _docker_identity(container, path): + """客户端 buildRemoteWorkspaceIdentity docker 分支同款:remote:docker::<归一路径>。""" + return "remote:docker:{}:{}".format(str(container or "").strip(), _normalize_identity_path(path)) + + +def _auto_targets(cfg): + """客户端 setting.json → 自动远端目标列表。auto:false 或递归子查询(ZUSAGE_REMOTE_CHILD=1) + 时返回 []。ssh 按连接(别名/密钥+主机+端口+用户)合并;wsl 按(发行版+用户)、docker 按 + 容器合并为一条通道,多工作区聚合 identities;纯密码凭据(passwordCredentialKey 且无密钥/ + 别名)的 ssh 无法免交互,跳过。workspaceIdentity 一律原样采用(客户端会透传到 DOM/IPC, + 路由按整串相等),缺失时才按同款算法兜底构造。server 类型没有免交互 exec 通道,不生成。 + 3.16 起存活会话身份握手后升级为 remote:server::/路径,记在 + sourceServerWorkspaceHistory,与 lastWorkspaceSession 只共享 workspacePath:按归一路径 + 把 server 身份并进路径匹配的传输通道,使 3.15/3.16 两种身份形态都能路由;同一路径命中 + 多条通道或多个服务器视为歧义,不并(fail-closed)。""" + r = cfg.get("remote") + if isinstance(r, dict) and r.get("auto") is False: + return [] + if os.environ.get("ZUSAGE_REMOTE_CHILD") == "1": + return [] + # wsl --exec / docker exec 不经登录 shell,进程 CWD 不可靠,自动通道的默认脚本 + # 必须带 ~ 前缀(exec 端 runpy bootstrap 展开);用户显式 remote.script 优先。 + explicit_script = isinstance(r, dict) and bool(r.get("script")) + server_idents = _server_idents_by_path() + path_channels = {} + merged = {} + for e in _read_client_remote_entries(): + t = e.get("target") + if not isinstance(t, dict): + continue + ident = str(e.get("workspaceIdentity") or "").strip() + kind = t.get("kind") + if kind == "ssh": + host = str(t.get("host") or "").strip() + username = str(t.get("username") or "").strip() + if not host or not username: + continue + alias = str(t.get("sshConfigAlias") or "").strip() + key_path = str(t.get("privateKeyPath") or "").strip() + if not alias and not key_path and t.get("passwordCredentialKey"): + continue # 密码认证拿不到明文,ssh 也无法免交互 + try: + port = int(t.get("port") or 22) + except (TypeError, ValueError): + port = 22 + ident = ident or _ssh_identity(host, port, username, e.get("workspacePath")) + ck = (kind, alias, host.lower(), port, username, key_path) + ent = merged.get(ck) + if ent is None: + argv = ["ssh", "-o", "BatchMode=yes"] + if alias: + argv.append(alias) # 别名交给用户 ssh_config 解析 host/port/密钥 + else: + if port != 22: + argv += ["-p", str(port)] + if key_path: + argv += ["-i", key_path, "-o", "IdentitiesOnly=yes"] + argv.append(f"{username}@{host}") + ent = {"ssh_argv": argv, "host_label": alias or host, "identities": []} + merged[ck] = ent + elif kind == "wsl": + distro = str(t.get("distro") or "").strip() + user = str(t.get("user") or "").strip() + ident = ident or _wsl_identity(distro, user, e.get("workspacePath")) + # distro 匹配大小写不敏感能并通道;user 是 Linux 用户名、大小写敏感不能并。 + ck = (kind, distro.lower(), user) + ent = merged.get(ck) + if ent is None: + # --exec 对齐客户端 v3.16.1 buildWslArgs:绕过发行版默认 shell 的 + # 变量/管道展开,argv(含 runpy bootstrap 的分号引号)原样到达; + # -d/-u 仅在真值时附加(distro 缺省走默认发行版,客户端同款)。 + argv = ["wsl.exe"] + if distro: + argv += ["-d", distro] + if user: + argv += ["-u", user] + argv.append("--exec") + ent = {"ssh_argv": argv, "host_label": f"wsl:{distro or 'default'}", "identities": []} + if not explicit_script: + ent["script"] = "~/.zcode/zcode-token-usage-statusbar/zusage.py" + merged[ck] = ent + elif kind == "docker": + container = str(t.get("container") or "").strip() + if not container: + continue + ident = ident or _docker_identity(container, e.get("workspacePath")) + # Docker 容器名大小写敏感(与 ssh host 不同),原样作键避免不同容器误并。 + ck = (kind, container) + ent = merged.get(ck) + if ent is None: + ent = {"ssh_argv": ["docker", "exec", "-i", container], + "host_label": f"docker:{container}", "identities": []} + if not explicit_script: + ent["script"] = "~/.zcode/zcode-token-usage-statusbar/zusage.py" + merged[ck] = ent + else: + continue + if ident not in ent["identities"]: + ent["identities"].append(ident) + path_key = _normalize_identity_path(e.get("workspacePath")) + if path_key in server_idents: + # 先只登记路径→通道归属,循环结束后统一并入:同一路径命中多条通道 + # 时无法唯一归属,整路径放弃(fail-closed),不按条目顺序猜首条。 + path_channels.setdefault(path_key, set()).add(ck) + for path_key, cks in path_channels.items(): + if len(cks) != 1: + continue + ent = merged[next(iter(cks))] + for idn in server_idents[path_key]: + if idn not in ent["identities"]: + ent["identities"].append(idn) + return sorted(merged.values(), key=lambda x: (x["host_label"], x["identities"])) + + +def _normalize_remote_entry(ent): + """单目标条目 → 归一化 rcfg 或 None。字段同旧 _remote_cfg,另收 identities[](路由键)。""" + if not isinstance(ent, dict): + return None + ssh = ent.get("ssh") + if ssh is None: + ssh = ent.get("ssh_argv") or [] + if isinstance(ssh, list): + ssh_argv = [str(x) for x in ssh] + else: + try: + ssh_argv = shlex.split(str(ssh)) + except ValueError: + ssh_argv = str(ssh).split() + if not ssh_argv: + return None + idents = ent.get("identities") + if not isinstance(idents, list): + idents = [ent.get("identity")] if ent.get("identity") else [] + return { + "ssh_argv": ssh_argv, + "script": str(ent.get("script") or ".zcode/zcode-token-usage-statusbar/zusage.py"), + "python": str(ent.get("python") or "python3"), + "timeout_s": min(6.0, max(1.0, float(ent.get("timeout_s") or 6))), + "host_label": str(ent.get("host_label") or (ssh_argv[-1] if len(ssh_argv) > 1 else "remote")), + "identities": [str(x).strip() for x in idents if str(x).strip()], + } + + +def _remote_setup(cfg): + """自动目标只接受 identity 路由;enabled:true 保留旧单通道兜底。""" + if os.environ.get("ZUSAGE_REMOTE_CHILD") == "1": + return None + r = cfg.get("remote") + r = r if isinstance(r, dict) else {} + if r.get("auto") is False and r.get("enabled") is not True: + return None + targets, identities = {}, {} + default = None + + def add(ent): + defaults = {k: r[k] for k in ("script", "python", "timeout_s") if k in r} + try: + rc = _normalize_remote_entry({**defaults, **ent}) + except (TypeError, ValueError): + return None + if not rc: + return None + fingerprint = json.dumps([rc["ssh_argv"], rc["python"], rc["script"]], ensure_ascii=True) + key = "t" + hashlib.sha256(fingerprint.encode()).hexdigest()[:16] + if key in targets: + rc["identities"] = list(dict.fromkeys(targets[key]["identities"] + rc["identities"])) + targets[key] = rc + for ident in rc["identities"]: + identities[ident] = key + return key + + if r.get("auto") is not False: + for ent in _auto_targets(cfg): + add(ent) + # 后加入的手动 identity 覆盖自动连接参数,不依赖目标列表顺序。 + for ent in (r.get("targets") or []): + if isinstance(ent, dict): + key = add(ent) + if r.get("enabled") is True and default is None: + default = key + if r.get("enabled") is True and r.get("ssh"): + default = add({"ssh": r["ssh"], "identities": [], "host_label": r.get("host_label")}) + if not targets: + return None + return {"targets": targets, "default": default, "identities": identities, + "auto": r.get("auto") is not False} + + +def _parse_routes(s): + """路由段 'sid@key,sid2@key2' → {sid: key}。逐 token 白名单校验(泵侧同款),脏 token 忽略。""" + routes = {} + for tok in str(s or "").split(","): + if "@" not in tok: + continue + sid, key = tok.split("@", 1) + sid, key = sid.strip(), key.strip() + if _REMOTE_SID_RE.fullmatch(sid) and _REMOTE_TARGET_KEY_RE.fullmatch(key): + routes[sid] = key + return routes + + +def _parse_query(line): + """兼容旧逗号协议与携带 workspaceIdentity 的 JSON 请求。""" + if line.lstrip().startswith("{"): + data = json.loads(line) + if not isinstance(data, dict): + raise ValueError("query must be an object") + sids = data.get("sids") or [] + if not isinstance(sids, list): + raise ValueError("sids must be an array") + return ",".join(str(s) for s in sids), data.get("scopes") or [] + if "|" in line: + sids, rstr = line.split("|", 1) + return sids.strip(), _parse_routes(rstr) + return line.strip(), {} + + +def _remote_exec(sids, rcfg, key): + """执行通道返回 (by_sid, today, error),远端子查询禁止再次访问远端。""" + argv = ["env", "ZUSAGE_REMOTE_CHILD=1", rcfg["python"], rcfg["script"], "json", ",".join(sids)] + channel = rcfg["ssh_argv"] + if Path(channel[0]).stem.lower() == "ssh": + # OpenSSH 将参数重新拼成 shell 文本,必须在远端边界引用,而非依赖本地 argv。 + def quote(arg): + if arg.startswith("~/"): + return '"$HOME"/' + shlex.quote(arg[2:]) + return shlex.quote(arg) + cmd = channel + [" ".join(quote(arg) for arg in argv)] + else: + # docker/wsl/kubectl 的 exec 接口直接接收 argv,不解析 shell assignment。 + script = rcfg["script"] + if script.startswith("~/"): + bootstrap = "import os,runpy,sys;p=os.path.expanduser(sys.argv[1]);sys.argv=sys.argv[1:];runpy.run_path(p,run_name='__main__')" + argv = ["env", "ZUSAGE_REMOTE_CHILD=1", rcfg["python"], "-c", bootstrap, script, "json", ",".join(sids)] + cmd = channel + argv + err = None + payload = None + try: + p = subprocess.run(cmd, capture_output=True, timeout=rcfg["timeout_s"]) + out = p.stdout.decode("utf-8", "replace") + if p.returncode != 0: + err = f"ssh rc={p.returncode}: {p.stderr.decode('utf-8', 'replace').strip()[:160]}" + else: + # 取最后一行能解析的 JSON 对象:容忍远端登录 banner 混进 stdout + for line in reversed(out.splitlines()): + line = line.strip() + if line.startswith("{"): + try: + payload = json.loads(line) + break + except ValueError: + continue + if payload is None: + err = "remote returned no JSON payload" + except subprocess.TimeoutExpired: + err = f"ssh timeout after {rcfg['timeout_s']:g}s" + except OSError as e: + err = f"ssh spawn failed: {e}" + if payload is None: + return {}, None, err or "remote query failed" + if payload.get("error") or payload.get("local_error"): + return {}, None, "remote query failed: " + str(payload.get("error") or payload["local_error"])[:160] + # known_sids = 远端库里真实存在的 force 会话(session 行或任一 usage 行)。 + # 远端对未知 sid 也会产出零值快照,不按它过滤的话"确无此会话"永远判不了, + # 负缓存失效 → 本地新会话每轮都白跑一次 ssh。缺 known_sids 字段 = 远端是旧版 + # 脚本:退化为按活跃度识别(零值快照 requests/last_at 恒为 0)。 + known = payload.get("known_sids") + legacy = not isinstance(known, list) + known = set(known or []) + by = {} + for rec in payload.get("recent") or []: + if not isinstance(rec, dict) or rec.get("sid") not in sids: + continue + # 远端旧版脚本(无 known_sids)退化为按活跃度识别:零值快照 requests/last_at 恒为 0 + found = (rec["sid"] in known) if not legacy else ( + (rec.get("requests") or 0) > 0 or (rec.get("last_at") or 0) > 0) + if found: + rec["remote"] = True + rec["remote_host"] = rcfg["host_label"] + rec["remote_target"] = key + by[rec["sid"]] = rec + return by, payload.get("today"), err + + +def _remote_fetch(force_sids, have_local, setup, routes=None, scopes=None): + """按 identity 查目标;旧请求仅手动默认兜底,负缓存按 (sid, target) 隔离。""" + now_ms = int(time.time() * 1000) + routes = routes or {} + default = setup.get("default") + + def resolve(s): + key = routes.get(s) + return key if key in setup["targets"] else default # 未知/过期目标键回退默认通道 + + missing = [] + for s in force_sids: + if s in have_local: + continue + ent = _REMOTE_NEG.get((s, resolve(s))) + if ent and now_ms - ent[0] < ent[1]: + continue + missing.append(s) + if not missing and scopes is None: + errs = {resolve(s): _REMOTE_NEG[(s, resolve(s))][2] for s in force_sids + if (s, resolve(s)) in _REMOTE_NEG and len(_REMOTE_NEG[(s, resolve(s))]) > 2 + and _REMOTE_NEG[(s, resolve(s))][2]} + return {}, {}, errs + groups = {} + scoped_keys = {} + if scopes is not None: + for sid, ident in scopes: + if not ident.startswith("remote:"): + continue + key = setup["identities"].get(ident) + scoped_keys[(sid, ident)] = key + if key and (not _REMOTE_NEG.get((sid, key)) or + now_ms - _REMOTE_NEG[(sid, key)][0] >= _REMOTE_NEG[(sid, key)][1]): + bucket = groups.setdefault(key, []) + if sid not in bucket: + bucket.append(sid) + else: + for s in missing: + key = resolve(s) + if key in setup["targets"]: + groups.setdefault(key, []).append(s) + by, today, errs = {}, {}, {} + per_target = {} + selected = {(sid, key) for (sid, _), key in scoped_keys.items()} if scopes is not None else { + (sid, resolve(sid)) for sid in force_sids} + for sid, key in selected: + cached = _REMOTE_NEG.get((sid, key)) + if cached and now_ms - cached[0] < cached[1] and len(cached) > 2 and cached[2]: + errs[key] = cached[2] + # 各目标独立执行;总预算6秒,排队目标不再启动,避免超过桌面15秒看门狗。 + deadline = time.monotonic() + 6.0 + def bounded_query(item): + key, sids = item + remaining = deadline - time.monotonic() + if remaining <= 0: + return key, sids, ({}, None, "remote query budget exhausted") + rc = dict(setup["targets"][key], timeout_s=min(setup["targets"][key]["timeout_s"], remaining)) + try: + result = _remote_exec(sids, rc, key) + except Exception as exc: + result = ({}, None, "remote query failed: " + str(exc)[:160]) + return key, sids, result + + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(bounded_query, groups.items())) + for key, sids, (by_sid, t, err) in results: + per_target[key] = by_sid + by.update(by_sid) + if isinstance(t, dict): + today[key] = t + if err: + errs[key] = err + for s in sids: + if s in by_sid: + _REMOTE_NEG.pop((s, key), None) + else: + _REMOTE_NEG[(s, key)] = (int(time.time() * 1000), _REMOTE_NEG_TTL_ERR if err else _REMOTE_NEG_TTL_OK, err) + if scopes is not None: + by = {} + for (sid, ident), key in scoped_keys.items(): + rec = dict(per_target.get(key, {}).get(sid) or {}) + rec.update(sid=sid, workspace_identity=ident, remote=True, + remote_target=key or "", remote_host=setup["targets"].get(key, {}).get("host_label", "")) + if not per_target.get(key, {}).get(sid): + error = (errs.get(key) or "remote session not found") if key else "remote target not configured" + rec.update(remote_missing=True, remote_error=error) + by[(sid, ident)] = rec + if len(_REMOTE_NEG) > 512: + _REMOTE_NEG.clear() # 防长驻膨胀(多目标下键数翻倍,上限同步放宽) + return by, today, errs + + +def _empty_snapshot(sid, cfg): + return {"sid": sid, "title": "", "active": False, "turns": 0, "requests": 0, + "input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0, + "total": 0, "tool_calls": 0, "retries": 0, "ctx": 0, "updated": "", "last_at": 0, "started": 0, + "last_turn": dict(_EMPTY_TURN), "last": {"duration_ms": 0, "ttft_ms": 0, "model": "", "tps": 0, "startedAt": 0, "endedAt": 0}, + "code": {"add": None, "del": None, "files": None}, "ctx_exc": 0, + "tools": {"total": 0, "errors": 0, "list": []}, + "sub": {"requests": 0, "total": 0, "input": 0, "output": 0, "cache_read": 0, + "reasoning": 0, "cache_write": 0, "active": False, "rate_avg": 0, "list": []}, + "context_window": cfg["context_window"], "context_auto": False} + + +def snapshot(force_sid="", routes=None): + """单次快照:最新会话 + 最近 6+6 会话池 + 今日。 + force_sid:各窗口当前会话 id,逗号分隔(泵按窗口汇总上报:主进程 IPC 映射的焦点会话 + + overlay localStorage 键兜底),全部强制纳入快照——不在 6+6 池里的会话(新开的、久远的) + 也能被各窗口找到自己的会话并显示(v32 起;v34 改为多窗口多 sid)。 + routes:sid→目标键(泵按窗口 workspaceIdentity 路由);未路由会话走默认目标。""" + cfg = load_config() + scopes = None + if isinstance(routes, list): + scopes = [] + for scope in routes: + if not isinstance(scope, dict): + continue + sid, ident = str(scope.get("sid") or ""), str(scope.get("workspaceIdentity") or "").strip() + if _REMOTE_SID_RE.fullmatch(sid) and ident and (sid, ident) not in scopes: + scopes.append((sid, ident)) + force_sid = ",".join([str(force_sid)] + [s for s, _ in scopes]) + routes = {} + con = None + local_error = None + try: + con = connect() + except sqlite3.Error as exc: + local_error = str(exc)[:200] + try: + force_sids = [] + for fs in str(force_sid).split(","): + fs = fs.strip() + if fs and re.fullmatch(r"[A-Za-z0-9_-]+", fs) and fs not in force_sids: + force_sids.append(fs) + try: + day = _day_start() + start_ms, end_ms = _epoch_ms(day), _epoch_ms(day + timedelta(days=1)) + today = _cached_agg(("today", start_ms), lambda: _today_usage( + con, start_ms, end_ms)) if con else (0,) * 11 + rows = _scan_session_ids(con) if con else [] + have_local = set() + if con and force_sids: + qmarks = ",".join("?" * len(force_sids)) + have_local = {r[0] for r in con.execute( + f"select id from session where id in ({qmarks})", force_sids)} + have_local |= {r[0] for r in con.execute( + f"select distinct session_id from model_usage where session_id in ({qmarks})", force_sids)} + except sqlite3.Error as exc: + local_error = str(exc)[:200] + con.close() + con = None + today, rows, have_local = (0,) * 11, [], set() + known_sids = [s for s in force_sids if s in have_local] + # 远端补齐(v12):本地不存在的 force 会话按路由分组查各自远端库 + setup = _remote_setup(cfg) + remote_by, remote_today, remote_errs = {}, {}, {} + if force_sids and (setup or scopes is not None): + remote_by, remote_today, remote_errs = _remote_fetch( + force_sids, have_local, setup or {"targets": {}, "identities": {}, "default": None}, routes, scopes) + remote_err = next(iter(remote_errs.values()), None) # 兼容旧单错误字段(第一个目标) + # 本地库里没有的 force 会话(远端/他机会话,或数据还没落库)。渲染端据此给提示: + # 零值不再只能靠用户猜"插件坏了"(issue #14),也区分"远端也没查到"(issue #10)。 + not_local = [s for s in force_sids if s not in have_local] if force_sids else [] + def local_snapshot(sid, light=False): + nonlocal local_error + if con is None: + return _empty_snapshot(sid, cfg) + try: + return (_light_snapshot if light else _session_snapshot)(con, sid, cfg) + except sqlite3.Error as exc: + # 本地明细表缺失不能丢弃已成功的远端分片,失败行单独降级。 + local_error = str(exc)[:200] + return _empty_snapshot(sid, cfg) + + latest_sid = max(rows, key=lambda r: r[1])[0] if rows else None + base = local_snapshot(latest_sid) if latest_sid else _empty_snapshot("", cfg) + normal = sorted((r for r in rows if not str(r[0]).startswith("sess_subagent")), + key=lambda r: -r[1])[:6] + suba = sorted((r for r in rows if str(r[0]).startswith("sess_subagent")), + key=lambda r: -r[1])[:6] + ids = [r[0] for r in normal] + [r[0] for r in suba] + seen = set() + ids = [x for x in ids if not (x in seen or seen.add(x))] + if latest_sid and latest_sid not in ids: + ids.insert(0, latest_sid) + # 远端命中的会话不进本地 ids 循环(本地 _session_snapshot 查不到只会产出零值行) + remote_ids = [s for s in force_sids if s in remote_by] + for fs in reversed(force_sids): + if fs not in ids and fs not in remote_ids: + ids.insert(0, fs) + # recent 池瘦身(v8):状态条只渲染 1 个会话,完整快照只给兜底候选 + # (force + latest + 最近活跃前 2),其余长尾行走轻量查询(单会话 38ms→2ms)。 + full_n = len(force_sids) + 3 + rec = [] + for i, sid in enumerate(ids): + if sid == latest_sid: + rec.append(base) + else: + rec.append(local_snapshot(sid, light=i >= full_n)) + # rec 建好后把远端快照挂到池首:overlay 按 sid 匹配 mine,顺序只影响兜底展示 + rec = ([remote_by[s] for s in reversed(remote_ids)] + rec) if scopes is None else ( + [{**_empty_snapshot(sid, cfg), **row} for (sid, _), row in remote_by.items()] + rec) + if scopes is not None: + local_rows = {row["sid"]: row for row in rec if not row.get("remote")} + for sid, ident in scopes: + if not ident.startswith("remote:"): + rec.insert(0, {**local_rows.get(sid, _empty_snapshot(sid, cfg)), "workspace_identity": ident}) + return { + "local_error": local_error, + "session": base, + "last_turn": base["last_turn"], + "last": base["last"], + "today": { + "requests": today[0], "input": today[1], "output": today[2], + "cache_read": today[3], "total": today[4], + "reasoning": today[6], "retries": today[7], "cache_write": today[8], + "started": today[9] or 0, "last_at": today[10] or 0, + }, + # 远端数据源(v12):显示远端会话时 overlay 按其 remote_target 取对应主机的今日值; + # dict 键=目标键。remote_error 保留首个错误(旧字段),remote_errors 按目标细分。 + "remote_today": remote_today, + "remote_error": remote_err, + "remote_errors": remote_errs, + # 目标清单(泵路由用):identity → 键;host_label 供日志/诊断 + "remote_targets": [ + {"key": k, "host_label": rc["host_label"], "identities": rc["identities"]} + for k, rc in (setup or {}).get("targets", {}).items() + ], + "not_local": not_local, # 不在本地库的 force 会话 id(issue #14 提示用) + # v96 额度 chip 的会话无历史回退:全局最近一个完成的套餐档请求(账号级数据, + # 与会话无关)。会话自身没有任何请求历史(新会话/刚切模型还没发请求)时, + # 渲染端用它跟随账号当前套餐档,消除"切模型到首个请求完成之间 chip 空窗"。 + # 只取 account:*(内置套餐档);最近请求是自定义 provider 时为空串,不回退。 + "provider_fallback": ((con.execute( + """select provider_id from model_usage + where status='completed' and (query_source is null or query_source='main_turn') + and provider_id like 'account:%' + order by completed_at desc limit 1""" + ).fetchone() or ("",))[0] or "") if con is not None else "", + # SCNet 账本估算(无凭据):主进程注入 quota-client 的 scnet 源;未配置为 None + "scnet_estimate": _scnet_estimate(con), + "remote_enabled": bool(setup), # 是否存在可用远端目标(区分"没配"与"配了没查到") + "known_sids": known_sids, + "context_window": base["context_window"], + "context_auto": base["context_auto"], + "code": base["code"], + "ctx_exc": base["ctx_exc"], + "tools": base["tools"], + "recent": rec, + } + finally: + if con is not None: + con.close() + + +# ---------- CLI ---------- + +def serve(): + """常驻查询服务(v8 泵配套):省每次 ~60-100ms 的解释器启动+模块加载费。 + stdin 每行一个请求(空行 = 无强制会话),stdout 每行一个 JSON 快照。 + stdin EOF = 泵已退出(管道断开),随即退出——孤儿进程自清理。 + 每请求 snapshot() 内部新开 sqlite 连接:不持长读事务,WAL checkpoint 不受阻碍。""" + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + while True: + line = sys.stdin.readline() + if not line: + return + try: + force_sid, routes = _parse_query(line.strip()) + out = json.dumps(snapshot(force_sid, routes)) + except Exception as e: + out = json.dumps({"error": str(e)[:200]}) # 单次查询失败不杀常驻进程 + sys.stdout.write(out + "\n") + sys.stdout.flush() + + +def main(argv): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + cmd = argv[0] if argv else "now" + try: + if cmd == "json": + force_sid, routes = _parse_query(argv[1] if len(argv) > 1 else "") + if len(argv) > 2 and not isinstance(routes, list): + routes = _parse_routes(argv[2]) + print(json.dumps(snapshot(force_sid, routes))) + return 0 + if cmd == "serve": + serve() + return 0 + con = connect() + if cmd == "now": + print(render_current(con)) + elif cmd == "today": + print(render_today(con)) + elif cmd == "days": + print(render_days(con, int(argv[1]) if len(argv) > 1 else 7)) + elif cmd == "sessions": + print(render_sessions(con, int(argv[1]) if len(argv) > 1 else 10)) + elif cmd == "models": + print(render_models(con, int(argv[1]) if len(argv) > 1 else 7)) + elif cmd == "workspace": + print(render_workspace(con, argv[1] if len(argv) > 1 else "")) + elif cmd == "session": + print(render_session_detail(con, argv[1] if len(argv) > 1 else "")) + elif cmd == "watch": + sec = int(argv[1]) if len(argv) > 1 else 5 + while True: + print("\x1b[2J\x1b[H", end="") + print(render_current(con), flush=True) + print(L(f"\n(每 {sec}s 刷新, Ctrl+C 退出)", f"(refreshes every {sec}s, Ctrl+C to quit)"), flush=True) + time.sleep(sec) + else: + print(__doc__) + con.close() + except sqlite3.OperationalError as e: + # 列/表变更不在 connect 的表存在性探测内(那会破坏按需裁剪的测试夹具库), + # 在入口统一转换成可行动提示,替代整段查询栈回溯。 + print(L(f"查询失败:{e}", f"query failed: {e}")) + print(L("多半是 ZCode 升级改了用量库结构(表/列变更)。请更新本插件后再查询;" + "已落库的历史数据不受影响。", + "Most likely ZCode changed the usage DB schema in an update. " + "Update this plugin before querying; stored history is unaffected.")) + return 1 + return 0 + + +def render_today(con): + today = range_usage(con, _epoch_ms(_day_start()), _epoch_ms(_day_start() + timedelta(days=1))) + head = L(f"■ 今日 ({datetime.now():%Y-%m-%d %a})", f"■ Today ({datetime.now():%Y-%m-%d %a})") + row = L(f" {today[0]} 次请求 input {fmt(today[1])} (cache read {fmt(today[3])}) " + f"output {fmt(today[2])} 合计 {fmt(today[4])}", + f" {today[0]} requests input {fmt(today[1])} (cache read {fmt(today[3])}) " + f"output {fmt(today[2])} total {fmt(today[4])}") + return head + "\n" + row + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/marketplace.json b/marketplace.json index 884bfc8..808824c 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1409,6 +1409,36 @@ "mcp", "verification" ] + }, + { + "name": "zcode-token-usage-statusbar", + "displayName": "Token Usage Statusbar", + "displayName_i18n": { + "en": "Token Usage Statusbar", + "zh-CN": "Token 用量状态栏" + }, + "source": "./external_plugins/zcode-token-usage-statusbar", + "description": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "description_i18n": { + "en": "ZCode token usage query via the token_usage MCP tool, /usage command, and skill (reads the local usage DB). By default auto-installs the desktop floating usage status bar (modifies app.asar; fully quit and reopen ZCode to take effect; opt-out respected). Appends a per-answer rate line at the end of each reply (rendered deterministically via the renderer bridge; tok/s, TTFT, output tokens).", + "zh-CN": "ZCode token 用量查询(token_usage MCP 工具 + /usage 命令 + 技能,读本地 usage 数据库);缺配置文件或缺字段时默认为桌面客户端自动安装/修复悬浮用量状态条(修改 app.asar,成功后需完全退出并重开;显式 false 保留,环境变量优先,卸载豁免阻止重注入,无环境变量覆盖时损坏/非布尔配置不开启);每次回答末尾自动追加逐问速率行(由前端确定性渲染,依赖 renderer 桥;即时 tok/s 含思考、首字延迟、输出 token、生成耗时、段数/峰值与会话累计,--current 守卫防旧数据充数)。" + }, + "version": "1.5.3", + "author": { + "name": "ByteRax", + "url": "https://github.com/ByteRax" + }, + "category": "developer-tools", + "keywords": [ + "token", + "usage", + "statusbar", + "monitor", + "zusage" + ], + "homepage": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "repository": "https://github.com/ByteRax/zcode-token-usage-statusbar", + "license": "MIT" } ] } \ No newline at end of file