diff --git a/external_plugins/zcode-context/.gitignore b/external_plugins/zcode-context/.gitignore
new file mode 100644
index 00000000..f3701516
--- /dev/null
+++ b/external_plugins/zcode-context/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+*.log
+.cache/
+dist/
+.DS_Store
+Thumbs.db
diff --git a/external_plugins/zcode-context/.zcode-plugin/plugin.json b/external_plugins/zcode-context/.zcode-plugin/plugin.json
new file mode 100644
index 00000000..15e88de6
--- /dev/null
+++ b/external_plugins/zcode-context/.zcode-plugin/plugin.json
@@ -0,0 +1,27 @@
+{
+ "name": "zcode-context",
+ "displayName": "Context Insight",
+ "description": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.",
+ "description_i18n": {
+ "en": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.",
+ "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(步进/轮次 × 总计/增量,可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。"
+ },
+ "version": "0.2.1",
+ "author": {
+ "name": "AwenJackson",
+ "url": "https://github.com/AwenJackson"
+ },
+ "repository": "https://github.com/AwenJackson/zcode-context",
+ "keywords": [
+ "context-window",
+ "token-usage",
+ "token-statistics",
+ "cost-estimation",
+ "cache-hit-rate",
+ "llm-observability",
+ "dashboard",
+ "agent-monitoring",
+ "context-insight",
+ "dsh-context"
+ ]
+}
diff --git a/external_plugins/zcode-context/LICENSE b/external_plugins/zcode-context/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/external_plugins/zcode-context/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/external_plugins/zcode-context/README.md b/external_plugins/zcode-context/README.md
new file mode 100644
index 00000000..9c2ad9c0
--- /dev/null
+++ b/external_plugins/zcode-context/README.md
@@ -0,0 +1,136 @@
+
+
+# 🧠 zcode-context · 上下文洞察 (Context Insight)
+
+**A context-usage insight dashboard for [ZCode](https://zcode.ai) — inspired by [dsh-context](https://github.com/bowenliang123/dsh-context) for DeepSeek Harness.**
+
+[](#安装)
+[](./LICENSE)
+[](#)
+[](https://github.com/zai-org/zcode-plugins)
+[](https://github.com/AwenJackson/zcode-context/stargazers)
+
+*ZCode 上下文洞察仪表盘 · Token 统计 / 费用估算 / 缓存命中率 / 上下文浏览器 / Agent 编队 · 纯本地数据,无需其他插件,无需系统 Node.js*
+
+
+
+---
+
+## 📸 界面导览 / Screenshots
+
+插件共四个视图:**洞察(总览)→ 会话 → 上下文(单会话详情)→ 编队**。以下按页面逐一说明。
+
+### 图 1 · 洞察页 — KPI 总览与用量趋势(Overview · KPIs & Usage)
+
+
+
+- **六张 KPI 卡**(顶部居中):全部会话(含总请求次数)、Token 消耗(含输出量)、预估费用(按公开牌价估算)、缓存命中率(`cacheRead / input`,越大说明缓存复用越好、费用越低)、工具调用(含命令执行成功与失败次数)、活跃时长(模型等待 + 输出速度 tok/s)。
+- **近 7 天用量图**(右上):Token(蓝柱)+ 费用(绿柱)双柱对照,横轴为日期;**悬停任意柱**会弹出摘要:日期、Token 总量、费用、请求次数。
+- **Token 统计环形图**:总量居中,环形按 缓存读取 / 模型思考 / 未命中输入 / 输出 / 缓存写入 五类拆分;右侧图例给出每类的占比与 ≈值,**悬停分段或图例会互相高亮联动**。
+- **耗时统计环形图**:总时长居中,按 模型等待(Σ首字延迟)/ 模型思考 / 模型回复 / 工具执行(请求间隙估算)/ 其他 拆分,图例附次数口径;下方时间轴条直观显示各段占比。
+- **时间范围切换**(右上角):今天 / 近 7 天 / 近 30 天;⟳ 刷新、✕ 关闭。
+- 顶部四个视图 Tab:**洞察 · 会话 · 上下文 · 编队**。
+
+### 图 2 · 洞察页 — 热力图 / 排行 / 会话卡片(Overview · Rankings & Sessions)
+
+
+
+- **活跃热力图**:近 8 周活跃度,按天着色,支持 **Token 消耗 / 会话** 双指标切换;**悬停显示当日会话数与 Token 量,点击格子可筛选当天的会话卡片**。
+- **工具调用排行**:按调用次数排序(图例:Bash ×4047、Edit ×1010、Read ×459…),行内附彩色占比条;**悬停显示次数与占比,点击行会在下方出现「筛选:Bash ✕」chip,把会话卡片过滤到使用过该工具的会话;再次点击行或点 chip 取消**。
+- **命令执行排行**:取 Bash/PowerShell 命令首词统计(cd ×1189、grep ×844…),并显示总执行次数与失败次数。
+- **项目分布**:按工作区聚合的 Token 排行,同样支持点击筛选。
+- **会话卡片**:标题、时间、工作区 chip、迷你上下文占用环(≥85% 变红)、轮次 / Token / 费用、最近消息摘要;卡片底部的「筛选」chip 显示当前生效的筛选条件。
+
+### 图 3 · 会话页(Sessions)
+
+
+
+- **搜索框**:按标题模糊搜索;**工作区 chips**:全部 12 / masscms_sass_backend 4 / plugins 3 …,点击即过滤。
+- **三种排序**:Token 总用量 / 最近更新 / 当前上下文(上下文占比从高到低,快速找出快爆窗口的会话)。
+- **每张卡片**:迷你环形图中心直接显示当前上下文占用百分比(如 100%、93%、52%),占用 ≥85% 环变红色预警;右侧显示 轮次、Token、总预估费用。
+- 点击任意卡片进入该会话的 **上下文** 详情页。
+
+### 图 4 · 上下文页 — 统计头卡与构成(Context · Stats & Composition)
+
+
+
+- **上下文统计头卡**:轮次(点击查看趋势)、步骤(点击查看趋势)、工具调用、回复块、Token 总用量 + 费用。
+- **输入输出行**:用户 / 读取 / 写入 / 搜索 / 命令的可点击 chip —— **点击「读取」会滚动到文件活动卡并切到「读取」筛选;工具调用行同理,点击某工具名会把事件日志过滤到该工具**。
+- **Token 统计**:本会话的缓存读取 / 未命中输入 / 输出拆分与缓存命中率。
+- **耗时统计**:模型等待 / 模型回复 / 工具执行的三段环形 + 时间轴。
+- **当前上下文条**:当前窗口占用(如 224k / 205k tokens),分段显示六类构成占比,末段为斜纹的**压缩预留区**;**悬停分段显示「≈值 · N% 占已用」,点击分段会滚动到下方上下文浏览器对应分组并自动展开**。
+- **上下文费用构成**:把当前上下文按六类 token 乘以最近模型牌价估算为 CNY,环形图 + 逐类百分比(悬停显示 ≈tokens × 单价 = ¥ 口径)。
+- **上下文趋势**:总计 / 增量 × **步进 / 轮次** 四种组合切换;蓝线=输入(上下文规模)、绿线=输出、紫虚线=单请求耗时、红虚线=模型窗口上限、琥珀虚线=compact 压缩点。**悬停任意步骤弹出「第 T 轮 · 第 S 步」摘要(输入/输出/缓存命中率/耗时/Δ增量),点击可固定该步详情**(见图 5 底部横条)。
+
+### 图 5 · 上下文页 — 趋势固定详情 / 事件日志 / 文件活动(Context · Trend Detail, Events & Files)
+
+
+
+- **RequestDetail 固定条**(趋势图下方):点击趋势图某一步后固定显示「第 20 轮 · 第 83 步 · 输入 235k · 输出 1.3k · 缓存命中 66.6% · Δ输入 −133587 · 耗时 46s · 模型名」,点 ✕ 取消固定。
+- **事件日志**:类型筛选胶囊 **全部 400 / 消息 399 / 工具 0 / 压缩 1 / 错误 0**(dsh `toggleKind` 行为);每行显示时间、类型标签、内容,**行点击展开全文**,长文本有「展开」提示。
+- **文件活动**:头标签兼筛选 **全部 / 读取 16 / 写入 15 / 编辑 105** + **路径搜索框**;每行显示文件名、最后操作时间、读/写/改计数(写绿色、改红色),**行点击展开完整路径与操作明细**。
+- 悬停任意元素均显示口径摘要 tooltip。
+
+### 图 6 · 上下文页 — 上下文浏览器(Context · Browser, DNA 模式)
+
+
+
+- **七分组可折叠列表**(对齐 dsh 上下文浏览器):系统提示词 / 工具定义 / 用户消息 / 助手回复 / 思考 / 工具结果 / 注入内容。
+- 每组显示 ≈Token 与占比,消息类组带 **`+N 条` 绿色 delta 徽标**(如 +821 条、+462 块、+269 段、+463 条)。
+- **系统提示词组展开后显示快照前 400 字**(来自 `runtime/prompt_snapshot`);**工具定义组展开显示全部工具名清单**。
+- **组与上方「当前上下文条」双向联动**:悬停任一侧,对应分段/分组互相高亮。
+- 右侧「上下文注入与事件」卡:本轮会话注入的系统提示词与工具定义条目数。
+
+### 图 7 · 编队页(Formation)
+
+
+
+- **渐变横幅**:多 Agent 编队统计 —— Agent 数 / 运行中 / 工作项 / 消息数。
+- **Agent 网络**:主会话节点(蓝「主」徽标)+ 子 Agent 树(缩进连线),每张节点卡含彩色徽标、Token / 费用、占用进度条(≥70% 琥珀、≥90% 红)、活跃时长与请求数。
+ - **悬停子节点会点亮父链**:其余节点降透明,仅主节点与该子节点保持高亮;
+ - **悬停弹出详情 tooltip**(Token、请求、活跃、子会话 id);
+ - **点击子节点直接打开该子会话的上下文详情**(子会话真实存在于本地库)。
+- **跨 Agent 协作**:按时间列出主/子 Agent 的每次请求事件(模型、输入 → 输出 token),行点击展开。
+
+---
+
+## ✨ 数据亮点 / Highlights
+
+- **全局悬浮摘要**:所有图表(环形分段、用量柱、热力格、排行行、趋势点)悬停 140ms 后弹出统计口径 tooltip。
+- **点击下钻**:排行行 → 会话筛选;热力格 → 按天筛选;KPI 卡 → 跳转对应视图;趋势点 → 固定单步详情;上下文条分段 → 浏览器分组;编队节点 → 子会话详情。
+- **缓存命中率** = cacheRead / (input + cacheRead + cacheWrite),与 dsh-context 口径一致。
+- **耗时分解**:模型等待 = Σ首字延迟(ttft);思考/回复按 reasoning/output token 占比从生成时长拆分;工具执行 ≈ 请求间隙(≤10 分钟);其他 = 活跃时长 − 上述。
+- **费用**:内置牌价表(DeepSeek / GLM / 豆包系列)× USD→CNY 7.2 估算,未收录模型用保守默认价,界面统一标注「估算」。
+- **上下文构成**:按字符数 ÷ 3.5 启发式估算 token,界面明确标注「估算」;单请求精确值以 `model_usage.input_tokens` 为准。
+
+## 📦 安装 / Install
+
+**方式一:本地插件市场**
+1. Clone 本仓库到任意目录,例如 `D:\plugins\zcode-context`
+2. ZCode → 插件市场 → 添加 → 添加插件市场,填入该目录
+3. 安装 **上下文洞察 (zcode-context)**,**完全退出并重开 ZCode**(含托盘进程)
+4. 左侧边栏底部出现「上下文洞察」入口
+
+**方式二:从 GitHub 安装(待官方市场收录后可用)**
+```
+# ZCode 插件市场 → 添加 → GitHub 仓库
+https://github.com/AwenJackson/zcode-context
+```
+
+> 安装/更新后需要重启 ZCode:插件通过 `app.asar` 注入侧边栏入口与面板(带完整性校验与自动备份,可用 `/remove-panel` 一键卸载)。
+>
+> **安装后没有看到入口?** 这是 asar 注入的正常流程:插件已备好补丁,等 ZCode 完全退出后自动完成替换。请**完全退出 ZCode(含托盘进程)再重开**;若重开后仍没有入口,再重启一次。运行 `/context-status` 可随时查看当前状态(已注入 / 等待重启 / 需管理员 / 出错)与下一步建议。
+
+## 🧰 原理与隐私 / How it works & Privacy
+
+- 数据全部来自本机 ZCode SQLite(`~/.zcode/cli/db/db.sqlite` 的 `model_usage` / `session` / `part` / `message` / `prompt_snapshot` 表)与 `~/.zcode/cli/agents//` 子代理元数据,**只读、零网络请求、不上报**。
+- 无需其他插件;**无需系统 `node` 命令**(使用 Electron 内置 Node 运行时 `ELECTRON_RUN_AS_NODE=1`)。
+- 磁盘缓存 + 后台刷新 + 启动预取:打开面板即出数据。
+- 注入状态写在 `~/.zcode/zcode-context/runtime/status.json`,日志在 `inject.log`;卸载:`/remove-panel` 或 `node scripts/unpatch.mjs`。
+- 上下文窗口按模型 id 查内置表(DeepSeek V4 系 1M、GLM 200K、豆包 256K 等),未收录模型按 128K 保守估计。
+
+## 🏷 关键词 / Keywords
+`zcode` · `zcode-plugin` · `context-window` · `token-usage` · `token-statistics` · `cost-estimation` · `llm-observability` · `dashboard` · `context-insight` · `agent-monitoring` · `dsh-context` · `deepseek` · `glm` · `sqlite` · `electron`
+
+## 📄 License
+[Apache License 2.0](./LICENSE) · 灵感与交互设计参考 [dsh-context](https://github.com/bowenliang123/dsh-context)(感谢 [@bowenliang123](https://github.com/bowenliang123))
diff --git a/external_plugins/zcode-context/commands/context-status.md b/external_plugins/zcode-context/commands/context-status.md
new file mode 100644
index 00000000..23a4dbb3
--- /dev/null
+++ b/external_plugins/zcode-context/commands/context-status.md
@@ -0,0 +1,11 @@
+---
+description: 查看上下文洞察的注入状态(已注入 / 等待重启 / 需管理员 / 出错)与下一步建议
+---
+
+运行以下命令查看 zcode-context 的当前注入状态,把输出原样汇报给用户(它已包含下一步建议):
+
+```bash
+node "${ZCODE_PLUGIN_ROOT}/scripts/status.mjs"
+```
+
+如果输出显示 `pending-restart`,提醒用户:完全退出 ZCode(含托盘进程)再重开即可;重开后仍无入口就再重启一次。
diff --git a/external_plugins/zcode-context/commands/remove-panel.md b/external_plugins/zcode-context/commands/remove-panel.md
new file mode 100644
index 00000000..470e4137
--- /dev/null
+++ b/external_plugins/zcode-context/commands/remove-panel.md
@@ -0,0 +1,9 @@
+---
+description: 移除上下文洞察的侧边栏注入(只剥离本插件自己的注入行,保留其他插件)
+---
+
+运行以下命令移除 zcode-context 的 app.asar 注入(完成后需要完全退出并重开 ZCode 生效):
+
+```bash
+node "${ZCODE_PLUGIN_ROOT}/scripts/unpatch.mjs"
+```
diff --git a/external_plugins/zcode-context/docs/screenshots/01-overview-top.png b/external_plugins/zcode-context/docs/screenshots/01-overview-top.png
new file mode 100644
index 00000000..bf227a09
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/01-overview-top.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png b/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png
new file mode 100644
index 00000000..c4928a9a
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/03-sessions.png b/external_plugins/zcode-context/docs/screenshots/03-sessions.png
new file mode 100644
index 00000000..9db907eb
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/03-sessions.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/04-context-stats.png b/external_plugins/zcode-context/docs/screenshots/04-context-stats.png
new file mode 100644
index 00000000..612b61d3
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/04-context-stats.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/05-context-trend.png b/external_plugins/zcode-context/docs/screenshots/05-context-trend.png
new file mode 100644
index 00000000..dcbb7913
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/05-context-trend.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/06-context-browser.png b/external_plugins/zcode-context/docs/screenshots/06-context-browser.png
new file mode 100644
index 00000000..3aef0970
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/06-context-browser.png differ
diff --git a/external_plugins/zcode-context/docs/screenshots/07-formation.png b/external_plugins/zcode-context/docs/screenshots/07-formation.png
new file mode 100644
index 00000000..fa6523e2
Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/07-formation.png differ
diff --git a/external_plugins/zcode-context/hooks/finalize.mjs b/external_plugins/zcode-context/hooks/finalize.mjs
new file mode 100644
index 00000000..50659ca9
--- /dev/null
+++ b/external_plugins/zcode-context/hooks/finalize.mjs
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+/**
+ * Detached finalizer for the zcode-context app.asar injection.
+ *
+ * ZCode keeps app.asar open while it runs. If the atomic replace fails, this
+ * process waits for the lock to clear, verifies that app.asar is still the same
+ * source the stage was built from, then replaces it. It never overwrites an
+ * asar that changed in the meantime (for example after an official update).
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+const stage = arg("--stage");
+const asar = arg("--asar");
+const runtime =
+ arg("--runtime") ||
+ (stage && fs.existsSync(path.join(path.dirname(stage), "scripts", "asar-patch.mjs"))
+ ? path.dirname(stage)
+ : path.join(os.homedir(), ".zcode", "zcode-context", "runtime"));
+const metaPath = stage ? `${stage}.json` : "";
+const statusPath = runtime ? path.join(runtime, "status.json") : "";
+
+function log(...args) {
+ if (!runtime) return;
+ try {
+ fs.appendFileSync(
+ path.join(runtime, "inject.log"),
+ `[${new Date().toISOString()}] [finalize] ${args.join(" ")}\n`,
+ );
+ } catch {
+ // logging must never throw
+ }
+}
+
+function writeStatus(state, message, extra = {}) {
+ if (!statusPath) return;
+ try {
+ fs.mkdirSync(runtime, { recursive: true });
+ const tmp = `${statusPath}.tmp`;
+ fs.writeFileSync(
+ tmp,
+ JSON.stringify(
+ {
+ state,
+ message,
+ asarPath: asar || "",
+ injected: state === "injected",
+ pending: state === "pending-restart",
+ updatedAt: new Date().toISOString(),
+ ...extra,
+ },
+ null,
+ 2,
+ ),
+ );
+ fs.renameSync(tmp, statusPath);
+ } catch {
+ // status is diagnostic only
+ }
+}
+
+function readMeta() {
+ try {
+ return JSON.parse(fs.readFileSync(metaPath, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function sameSource(meta) {
+ try {
+ const stat = fs.statSync(asar);
+ return stat.size === meta.source.size && stat.mtimeMs === meta.source.mtimeMs;
+ } catch {
+ return false;
+ }
+}
+
+function removeStage() {
+ try {
+ fs.unlinkSync(stage);
+ } catch {
+ // ignore
+ }
+ try {
+ fs.unlinkSync(metaPath);
+ } catch {
+ // ignore
+ }
+}
+
+async function main() {
+ if (!stage || !asar) throw new Error("--stage and --asar are required");
+ const started = Date.now();
+ const deadline = started + 2 * 60 * 60 * 1000;
+ const patchMod = await import(
+ `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}`
+ );
+
+ while (Date.now() < deadline) {
+ if (!fs.existsSync(stage)) return;
+ const meta = readMeta();
+ if (!meta || !meta.source) {
+ log("stage metadata missing; discarding", stage);
+ removeStage();
+ return;
+ }
+ if (!sameSource(meta)) {
+ log("app.asar changed since the stage was built; discarding", stage);
+ removeStage();
+ writeStatus("error", "ZCode 已更新或 app.asar 已变化,请重启 ZCode 触发重新注入。");
+ return;
+ }
+ try {
+ // Never promote an unvalidated stage. Full hash check of every entry.
+ patchMod.validateAsar(stage, { expectBalance: true });
+ } catch (error) {
+ log("stage validation failed; discarding:", error.message);
+ removeStage();
+ writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`);
+ return;
+ }
+ try {
+ const result = patchMod.replaceAsarWithBackup(stage, asar, {
+ stageOptions: { expectBalance: true },
+ postOptions: { expectBalance: true },
+ });
+ try {
+ fs.unlinkSync(metaPath);
+ } catch {
+ // ignore
+ }
+ log("replacement completed; backup:", result.backup);
+ writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", {
+ backup: result.backup,
+ });
+ return;
+ } catch (error) {
+ log("replacement blocked, retrying:", error.message);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+ writeStatus("pending-restart", "等待 ZCode 退出超时;请完全退出 ZCode 后重启。");
+}
+
+main().catch((error) => {
+ log("fatal:", error.stack || error.message);
+ writeStatus("error", `注入收尾失败:${error.message || error}`);
+ process.exitCode = 1;
+});
diff --git a/external_plugins/zcode-context/hooks/hooks.json b/external_plugins/zcode-context/hooks/hooks.json
new file mode 100644
index 00000000..1abab472
--- /dev/null
+++ b/external_plugins/zcode-context/hooks/hooks.json
@@ -0,0 +1,19 @@
+{
+ "description": "zcode-context: 会话启动时同步运行时文件,并检查/修复上下文洞察入口的 app.asar 注入(会修改 ZCode 的 app.asar,安装或更新后需完全退出并重开)。",
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "startup|resume",
+ "hooks": [
+ {
+ "type": "command",
+ "shell": true,
+ "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
+ "timeoutMs": 90000,
+ "statusMessage": "zcode-context: 检查上下文洞察入口…"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/external_plugins/zcode-context/hooks/post-patch.mjs b/external_plugins/zcode-context/hooks/post-patch.mjs
new file mode 100644
index 00000000..286a1efd
--- /dev/null
+++ b/external_plugins/zcode-context/hooks/post-patch.mjs
@@ -0,0 +1,197 @@
+#!/usr/bin/env node
+/**
+ * Detached follow-up patch after another asar injector finishes.
+ *
+ * If the token-usage statusbar has a pending app.asar repack, the hook lets it
+ * finish first. This process waits for `app.asar.zusage.tmp` to disappear, then
+ * appends the zcode-context line to the new asar so the user only needs one
+ * restart instead of two.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+const asar = arg("--asar");
+const runtime = arg("--runtime");
+const waitFor = arg("--wait-for") || (asar ? `${asar}.zusage.tmp` : "");
+const statusPath = runtime ? path.join(runtime, "status.json") : "";
+const lockPath = asar ? `${asar}.zcode-context.postpatch.lock` : "";
+
+if (lockPath) {
+ process.on("exit", () => {
+ try {
+ fs.unlinkSync(lockPath);
+ } catch {
+ // ignore
+ }
+ });
+}
+
+function log(...args) {
+ if (!runtime) return;
+ try {
+ fs.appendFileSync(
+ path.join(runtime, "inject.log"),
+ `[${new Date().toISOString()}] [post-patch] ${args.join(" ")}\n`,
+ );
+ } catch {
+ // ignore
+ }
+}
+
+function writeStatus(state, message, extra = {}) {
+ if (!statusPath) return;
+ try {
+ const tmp = `${statusPath}.tmp`;
+ fs.writeFileSync(
+ tmp,
+ JSON.stringify(
+ {
+ state,
+ message,
+ asarPath: asar || "",
+ injected: state === "injected",
+ pending: state === "pending-restart",
+ updatedAt: new Date().toISOString(),
+ ...extra,
+ },
+ null,
+ 2,
+ ),
+ );
+ fs.renameSync(tmp, statusPath);
+ } catch {
+ // ignore
+ }
+}
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function main() {
+ if (!asar || !runtime) throw new Error("--asar and --runtime are required");
+ const patchMod = await import(
+ `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}`
+ );
+ const stage = `${asar}.zcode-context.tmp`;
+ const metaPath = `${stage}.json`;
+ const deadline = Date.now() + 2 * 60 * 60 * 1000;
+
+ while (Date.now() < deadline) {
+ let inspection;
+ try {
+ inspection = patchMod.inspectAsar(asar);
+ } catch (error) {
+ log("cannot read app.asar:", error.message);
+ await sleep(5000);
+ continue;
+ }
+ if (inspection.hasBalanceLine) {
+ writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", {
+ asarPath: asar,
+ injected: true,
+ pending: false,
+ });
+ return;
+ }
+ if (waitFor && fs.existsSync(waitFor)) {
+ await sleep(3000);
+ continue;
+ }
+
+ let before;
+ try {
+ before = fs.statSync(asar);
+ } catch {
+ await sleep(5000);
+ continue;
+ }
+ try {
+ const result = patchMod.buildPatchedAsar({
+ asarPath: asar,
+ loaderPath: path.join(runtime, "sidebar", "inject-main.cjs"),
+ stagePath: stage,
+ });
+ if (result.status === "already") {
+ writeStatus("injected", "上下文洞察入口已注入。", { asarPath: asar, injected: true });
+ return;
+ }
+ const after = fs.statSync(asar);
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
+ try {
+ fs.unlinkSync(stage);
+ fs.unlinkSync(metaPath);
+ } catch {
+ // ignore
+ }
+ await sleep(5000);
+ continue;
+ }
+ fs.writeFileSync(
+ metaPath,
+ JSON.stringify(
+ { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() },
+ null,
+ 2,
+ ),
+ );
+ try {
+ patchMod.validateAsar(stage, { expectBalance: true });
+ } catch (error) {
+ try {
+ fs.unlinkSync(stage);
+ fs.unlinkSync(metaPath);
+ } catch {
+ // ignore
+ }
+ log("stage validation failed; discarding:", error.message);
+ writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar });
+ return;
+ }
+ try {
+ const replaced = patchMod.replaceAsarWithBackup(stage, asar, {
+ stageOptions: { expectBalance: true },
+ postOptions: { expectBalance: true },
+ });
+ try {
+ fs.unlinkSync(metaPath);
+ } catch {
+ // ignore
+ }
+ log("follow-up patch completed; backup:", replaced.backup);
+ writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", {
+ asarPath: asar,
+ injected: true,
+ pending: false,
+ backup: replaced.backup,
+ });
+ return;
+ } catch {
+ // ZCode is running again; retry until it quits or the next session repairs it.
+ await sleep(5000);
+ }
+ } catch (error) {
+ const code = error?.code || "";
+ if (["EACCES", "EPERM", "EROFS"].includes(code)) {
+ writeStatus("needs-admin", "写入 app.asar 需要管理员权限。", { asarPath: asar });
+ return;
+ }
+ log("patch attempt failed:", error.message);
+ await sleep(5000);
+ }
+ }
+ writeStatus("waiting-zusage", "等待其他插件完成 app.asar 更新超时;请重启 ZCode 后重试。", {
+ asarPath: asar,
+ pending: true,
+ });
+}
+
+main().catch((error) => {
+ log("fatal:", error.stack || error.message);
+ writeStatus("error", `后续注入失败:${error.message || error}`);
+ process.exitCode = 1;
+});
diff --git a/external_plugins/zcode-context/hooks/session-start.mjs b/external_plugins/zcode-context/hooks/session-start.mjs
new file mode 100644
index 00000000..0a13ec5e
--- /dev/null
+++ b/external_plugins/zcode-context/hooks/session-start.mjs
@@ -0,0 +1,388 @@
+#!/usr/bin/env node
+/**
+ * SessionStart hook for zcode-context.
+ *
+ * 1. Sync the injector runtime into a stable user directory.
+ * 2. Make sure app.asar contains the loader line (repair after ZCode updates).
+ * 3. If app.asar is locked, stage the patch and start a detached finalizer.
+ *
+ * The hook always prints one JSON object and never fails the session.
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const RUNTIME =
+ (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) ||
+ path.join(os.homedir(), ".zcode", "zcode-context", "runtime");
+const STATUS_PATH = path.join(RUNTIME, "status.json");
+const VERSION = "0.1.0";
+
+const FILES = [
+ ["scripts/asar-patch.mjs", "scripts/asar-patch.mjs"],
+ ["scripts/restore-backup.mjs", "scripts/restore-backup.mjs"],
+ ["scripts/query-overview.mjs", "scripts/query-overview.mjs"],
+ ["scripts/query-sessions.mjs", "scripts/query-sessions.mjs"],
+ ["scripts/query-session.mjs", "scripts/query-session.mjs"],
+ ["scripts/query-agents.mjs", "scripts/query-agents.mjs"],
+ ["scripts/lib/common.mjs", "scripts/lib/common.mjs"],
+ ["scripts/status.mjs", "scripts/status.mjs"],
+ ["scripts/lib/pricing.mjs", "scripts/lib/pricing.mjs"],
+ ["panel/inject-main.cjs", "panel/inject-main.cjs"],
+ ["panel/panel-widget.js", "panel/panel-widget.js"],
+ ["hooks/finalize.mjs", "hooks/finalize.mjs"],
+ ["hooks/post-patch.mjs", "hooks/post-patch.mjs"],
+];
+
+function readPreviousStatus() {
+ try {
+ return JSON.parse(fs.readFileSync(STATUS_PATH, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function writeStatus(state, message, extra = {}) {
+ try {
+ fs.mkdirSync(RUNTIME, { recursive: true });
+ const tmp = `${STATUS_PATH}.tmp`;
+ fs.writeFileSync(
+ tmp,
+ JSON.stringify(
+ {
+ state,
+ message,
+ updatedAt: new Date().toISOString(),
+ pluginRoot: PLUGIN_ROOT,
+ runtime: RUNTIME,
+ version: VERSION,
+ ...extra,
+ },
+ null,
+ 2,
+ ),
+ );
+ fs.renameSync(tmp, STATUS_PATH);
+ } catch {
+ // diagnostic only
+ }
+}
+
+function copyRuntime() {
+ const errors = [];
+ for (const [srcRel, dstRel] of FILES) {
+ const src = path.join(PLUGIN_ROOT, srcRel);
+ const dst = path.join(RUNTIME, dstRel);
+ try {
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
+ fs.copyFileSync(src, dst);
+ } catch (error) {
+ errors.push(`${srcRel}: ${error.message}`);
+ }
+ }
+ try {
+ fs.mkdirSync(RUNTIME, { recursive: true });
+ fs.writeFileSync(
+ path.join(RUNTIME, "config.json"),
+ JSON.stringify(
+ {
+ pluginRoot: PLUGIN_ROOT,
+ version: VERSION,
+ loader: path.join(RUNTIME, "sidebar", "inject-main.cjs"),
+ updatedAt: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+ } catch (error) {
+ errors.push(`config.json: ${error.message}`);
+ }
+ return errors;
+}
+
+function removeStage(stage) {
+ for (const file of [stage, `${stage}.json`]) {
+ try {
+ fs.unlinkSync(file);
+ } catch {
+ // ignore
+ }
+ }
+}
+
+function spawnPostPatch(asar) {
+ const lock = `${asar}.zcode-context.postpatch.lock`;
+ try {
+ const stat = fs.statSync(lock);
+ if (Date.now() - stat.mtimeMs < 30 * 60 * 1000) return false;
+ } catch {
+ // no live lock
+ }
+ try {
+ fs.writeFileSync(lock, String(Date.now()));
+ } catch {
+ // ignore; the process still de-duplicates by checking the marker
+ }
+ try {
+ const child = spawn(
+ process.execPath,
+ [
+ path.join(RUNTIME, "hooks", "post-patch.mjs"),
+ "--asar",
+ asar,
+ "--runtime",
+ RUNTIME,
+ "--wait-for",
+ `${asar}.zusage.tmp`,
+ ],
+ { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME },
+ );
+ child.unref();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function spawnFinalizer(stage, asar) {
+ try {
+ const child = spawn(
+ process.execPath,
+ [
+ path.join(RUNTIME, "hooks", "finalize.mjs"),
+ "--stage",
+ stage,
+ "--asar",
+ asar,
+ "--runtime",
+ RUNTIME,
+ ],
+ { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME },
+ );
+ child.unref();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function main() {
+ const copyErrors = copyRuntime();
+ const patchMod = await import(
+ `${pathToFileURL(path.join(RUNTIME, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}`
+ );
+
+ const asar = patchMod.findAppAsar(process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR);
+ if (!asar) {
+ writeStatus("unknown", "未找到 ZCode 的 app.asar;上下文洞察入口未注入。", { pluginRoot: PLUGIN_ROOT });
+ return { suppressOutput: true };
+ }
+
+ const stage = `${asar}.zcode-context.tmp`;
+ const metaPath = `${stage}.json`;
+ let inspection;
+ try {
+ inspection = patchMod.inspectAsar(asar);
+ } catch (error) {
+ writeStatus("error", `无法读取 app.asar:${error.message}`, { asarPath: asar });
+ return { suppressOutput: true };
+ }
+
+ if (inspection.hasBalanceLine) {
+ removeStage(stage);
+ writeStatus("injected", "上下文洞察入口已注入;如刚更新 ZCode,请重启后生效。", {
+ asarPath: asar,
+ injected: true,
+ pending: false,
+ hasZusageLine: inspection.hasZusageLine,
+ });
+ return { suppressOutput: true };
+ }
+
+ // Do not race with the token-usage statusbar's own pending repack. Let it
+ // finish, then patch the new asar with a detached follow-up process.
+ if (fs.existsSync(`${asar}.zusage.tmp`)) {
+ spawnPostPatch(asar);
+ writeStatus(
+ "waiting-zusage",
+ "检测到 token 用量悬浮条正在更新 app.asar;已排定后续注入,等它完成后自动写入。",
+ { asarPath: asar, injected: false, pending: true },
+ );
+ return { suppressOutput: true };
+ }
+
+ // A stage from an earlier session may still be waiting for ZCode to quit.
+ if (fs.existsSync(stage) && fs.existsSync(metaPath)) {
+ try {
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
+ const stat = fs.statSync(asar);
+ if (meta?.source?.size === stat.size && meta?.source?.mtimeMs === stat.mtimeMs) {
+ try {
+ patchMod.validateAsar(stage, { expectBalance: true });
+ } catch (error) {
+ removeStage(stage);
+ writeStatus("error", `旧补丁校验失败,已丢弃:${error.message}`, { asarPath: asar });
+ return { suppressOutput: true };
+ }
+ try {
+ const replaced = patchMod.replaceAsarWithBackup(stage, asar, {
+ stageOptions: { expectBalance: true },
+ postOptions: { expectBalance: true },
+ });
+ removeStage(stage);
+ writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", {
+ asarPath: asar,
+ injected: true,
+ backup: replaced.backup,
+ });
+ return { suppressOutput: true };
+ } catch {
+ spawnFinalizer(stage, asar);
+ writeStatus("pending-restart", "上下文洞察入口已排定,完全退出 ZCode 后自动完成替换;重开后生效。", {
+ asarPath: asar,
+ injected: false,
+ pending: true,
+ });
+ return { suppressOutput: true };
+ }
+ }
+ removeStage(stage);
+ } catch {
+ removeStage(stage);
+ }
+ }
+
+ let before;
+ try {
+ before = fs.statSync(asar);
+ } catch (error) {
+ writeStatus("error", `无法读取 app.asar 状态:${error.message}`, { asarPath: asar });
+ return { suppressOutput: true };
+ }
+
+ let result;
+ try {
+ result = patchMod.buildPatchedAsar({
+ asarPath: asar,
+ loaderPath: path.join(RUNTIME, "sidebar", "inject-main.cjs"),
+ stagePath: stage,
+ });
+ } catch (error) {
+ const code = error?.code || "";
+ const needsAdmin = ["EACCES", "EPERM", "EROFS"].includes(code);
+ writeStatus(
+ needsAdmin ? "needs-admin" : "error",
+ needsAdmin
+ ? "写入 app.asar 需要管理员权限。请以管理员身份运行 ZCode,或手动执行注入脚本。"
+ : `生成上下文洞察入口补丁失败:${error.message}`,
+ { asarPath: asar, injected: false, pending: false },
+ );
+ return { suppressOutput: true };
+ }
+
+ if (result.status === "already") {
+ writeStatus("injected", "上下文洞察入口已经注入。", { asarPath: asar, injected: true });
+ return { suppressOutput: true };
+ }
+
+ // If an official update rewrote the asar during the repack, discard the stage.
+ try {
+ const after = fs.statSync(asar);
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
+ removeStage(stage);
+ writeStatus("error", "重打包期间 app.asar 发生变化,已丢弃本次补丁;请重启 ZCode 重试。", {
+ asarPath: asar,
+ injected: false,
+ });
+ return { suppressOutput: true };
+ }
+ } catch {
+ removeStage(stage);
+ writeStatus("error", "重打包后无法确认 app.asar 状态,已丢弃本次补丁。", { asarPath: asar });
+ return { suppressOutput: true };
+ }
+
+ try {
+ fs.writeFileSync(
+ metaPath,
+ JSON.stringify(
+ { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() },
+ null,
+ 2,
+ ),
+ );
+ } catch (error) {
+ removeStage(stage);
+ writeStatus("error", `无法写入补丁元数据:${error.message}`, { asarPath: asar });
+ return { suppressOutput: true };
+ }
+
+ try {
+ patchMod.validateAsar(stage, { expectBalance: true });
+ } catch (error) {
+ removeStage(stage);
+ writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar });
+ return { suppressOutput: true };
+ }
+
+ try {
+ const replaced = patchMod.replaceAsarWithBackup(stage, asar, {
+ stageOptions: { expectBalance: true },
+ postOptions: { expectBalance: true },
+ });
+ removeStage(stage);
+ writeStatus("injected", "上下文洞察入口已注入;请完全退出并重开 ZCode 生效。", {
+ asarPath: asar,
+ injected: true,
+ pending: false,
+ backup: replaced.backup,
+ });
+ return {
+ suppressOutput: true,
+ systemMessage: "上下文洞察入口已安装,请完全退出并重开 ZCode 生效。",
+ };
+ } catch {
+ if (!spawnFinalizer(stage, asar)) {
+ removeStage(stage);
+ writeStatus("error", "app.asar 被占用,且无法启动收尾进程;请完全退出 ZCode 后重启。", {
+ asarPath: asar,
+ });
+ return { suppressOutput: true };
+ }
+ writeStatus("pending-restart", "上下文洞察入口已排定,完全退出 ZCode 后自动完成替换;重开后生效。", {
+ asarPath: asar,
+ injected: false,
+ pending: true,
+ });
+ return {
+ suppressOutput: true,
+ systemMessage: "上下文洞察入口已排定,完全退出 ZCode 后会自动完成替换;重开后生效。",
+ };
+ }
+}
+
+function output(payload) {
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
+}
+
+try {
+ const previous = readPreviousStatus();
+ const result = await main();
+ const current = readPreviousStatus();
+ if (
+ current &&
+ (current.state === "pending-restart" || current.state === "needs-admin") &&
+ (!previous || previous.state !== current.state) &&
+ result.systemMessage === undefined
+ ) {
+ result.systemMessage = current.message;
+ }
+ output(result);
+} catch (error) {
+ writeStatus("error", `上下文洞察入口自检失败:${error.message || error}`);
+ output({ suppressOutput: true, systemMessage: `上下文洞察上下文洞察入口自检失败:${error.message || error}` });
+}
diff --git a/external_plugins/zcode-context/marketplace.json b/external_plugins/zcode-context/marketplace.json
new file mode 100644
index 00000000..b5221995
--- /dev/null
+++ b/external_plugins/zcode-context/marketplace.json
@@ -0,0 +1,19 @@
+{
+ "name": "zcode-context",
+ "plugins": [
+ {
+ "name": "zcode-context",
+ "source": ".",
+ "version": "0.2.1",
+ "description": "Context-usage insight dashboard for ZCode: overview KPIs, 7-day token+cost chart, 8-week activity heatmap, click-to-filter tool/command/project rankings, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log & file activity filters, cost composition, agent formation view. Purely local read-only SQLite; no other plugins or system Node.js required. Injected via app.asar; requires a restart.",
+ "displayName": "Context Insight",
+ "displayName_i18n": {
+ "zh-CN": "上下文洞察"
+ },
+ "description_i18n": {
+ "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。通过 app.asar 注入,安装/更新后需重启 ZCode。"
+ },
+ "category": "developer-tools"
+ }
+ ]
+}
diff --git a/external_plugins/zcode-context/panel/inject-main.cjs b/external_plugins/zcode-context/panel/inject-main.cjs
new file mode 100644
index 00000000..a910cf68
--- /dev/null
+++ b/external_plugins/zcode-context/panel/inject-main.cjs
@@ -0,0 +1,384 @@
+/* zcode-context panel loader (Electron main process).
+ *
+ * Loaded by the dynamic import line appended to app.asar's main entry.
+ * It injects the "context insight" entry into ZCode's left sidebar and serves
+ * dashboard data requests from the renderer through one-shot query scripts.
+ *
+ * Everything here is best-effort: any failure only writes a local log line and
+ * must never prevent ZCode from starting or rendering.
+ */
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const { spawn } = require("child_process");
+const { app, webContents } = require("electron");
+
+const HERE = __dirname;
+const RUNTIME = path.resolve(HERE, "..");
+log("loader loaded; runtime:", RUNTIME);
+const CONFIG = readJson(path.join(RUNTIME, "config.json"), {});
+const QUERY_DIR = path.join(RUNTIME, "scripts");
+// The loader may be loaded from panel/ or sidebar/ (older injection lines);
+// resolve the widget from whichever directory actually has it.
+const WIDGET = [path.join(HERE, "panel-widget.js"), path.join(HERE, "..", "panel", "panel-widget.js"), path.join(RUNTIME, "panel", "panel-widget.js")].find(
+ (candidate) => {
+ try {
+ return fs.statSync(candidate).isFile();
+ } catch {
+ return false;
+ }
+ },
+);
+const LOG_FILE = path.join(RUNTIME, "inject.log");
+const QUICK_TIMEOUT_MS = 45 * 1000;
+const DETAIL_TIMEOUT_MS = 90 * 1000;
+const REQUEST_POLL_MS = 700;
+
+let widgetSource = "";
+try {
+ widgetSource = fs.readFileSync(WIDGET, "utf8");
+ new Function(widgetSource); // reject a partially written file before it reaches the renderer
+} catch (error) {
+ log("widget source unavailable:", error.message);
+}
+
+function log(...args) {
+ try {
+ const line = `[${new Date().toISOString()}] ${args
+ .map((value) => (value instanceof Error ? value.stack || value.message : String(value)))
+ .join(" ")}\n`;
+ try {
+ if (fs.statSync(LOG_FILE).size > 256 * 1024) fs.truncateSync(LOG_FILE, 0);
+ } catch {
+ // log file does not exist yet
+ }
+ fs.appendFileSync(LOG_FILE, line);
+ } catch {
+ // logging must never throw
+ }
+}
+
+function readJson(file, fallback) {
+ try {
+ return JSON.parse(fs.readFileSync(file, "utf8"));
+ } catch {
+ return fallback;
+ }
+}
+
+function pluginAvailable() {
+ const root = CONFIG.pluginRoot;
+ if (!root) return true;
+ try {
+ return fs.statSync(root).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function isMainWindowState(wc) {
+ try {
+ return wc.getType() === "window";
+ } catch {
+ return false;
+ }
+}
+
+function isLocalHostPage(wc) {
+ try {
+ const url = wc.getURL();
+ if (!url.startsWith("file:")) return false;
+ const relative = path.relative(process.resourcesPath, require("url").fileURLToPath(url));
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
+ } catch {
+ return false;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* One-shot query runner (Electron in Node mode, PATH-node fallback) */
+/* ------------------------------------------------------------------ */
+
+function runScriptGeneric(command, script, args, timeoutMs) {
+ return new Promise((resolve) => {
+ let stdout = "";
+ let stderr = "";
+ let settled = false;
+ let child;
+ const finish = (result) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(result);
+ };
+ const timer = setTimeout(() => {
+ try {
+ child && child.kill();
+ } catch {
+ // ignore
+ }
+ finish({ ok: false, error: "script timeout" });
+ }, timeoutMs);
+ try {
+ child = spawn(command, [...(command === process.execPath ? [] : ["--no-warnings"]), script, ...args], {
+ cwd: RUNTIME,
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: command === process.execPath ? "1" : process.env.ELECTRON_RUN_AS_NODE },
+ windowsHide: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ } catch (error) {
+ finish({ ok: false, error: error.message });
+ return;
+ }
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk.toString();
+ });
+ child.on("error", (error) => finish({ ok: false, error: error.message }));
+ child.on("close", (code) => {
+ const text = stdout.trim();
+ if (!text) return finish({ ok: false, error: `script exited ${code}: ${stderr.slice(0, 300)}` });
+ try {
+ finish({ ok: true, data: JSON.parse(text) });
+ } catch (error) {
+ finish({ ok: false, error: `script returned invalid JSON: ${error.message}` });
+ }
+ });
+ });
+}
+
+async function runQuery(scriptName, args, timeoutMs) {
+ const script = path.join(QUERY_DIR, scriptName);
+ let result = await runScriptGeneric(process.execPath, script, args, timeoutMs);
+ if (!result.ok) {
+ log(`electron run failed for ${scriptName}, retrying with node:`, result.error);
+ result = await runScriptGeneric(process.env.ZCONTEXT_NODE || "node", script, args, timeoutMs);
+ }
+ return result;
+}
+
+function rangeArgs(query) {
+ const out = [];
+ const start = Number(query && query.start);
+ const end = Number(query && query.end);
+ const now = Date.now();
+ const safeStart = Number.isFinite(start) && start > 0 ? Math.round(start) : now - 7 * 24 * 3600 * 1000;
+ const safeEnd = Number.isFinite(end) && end > safeStart ? Math.round(end) : now;
+ out.push("--start", String(safeStart), "--end", String(safeEnd));
+ if (typeof query.directory === "string" && query.directory) out.push("--directory", query.directory);
+ if (typeof query.order === "string" && query.order) out.push("--order", query.order);
+ if (Number.isFinite(Number(query.limit))) out.push("--limit", String(Math.round(Number(query.limit))));
+ if (typeof query.sessionId === "string" && query.sessionId) out.push("--session-id", query.sessionId);
+ return out;
+}
+
+const OPS = {
+ overview: { script: "query-overview.mjs", timeout: QUICK_TIMEOUT_MS },
+ sessions: { script: "query-sessions.mjs", timeout: QUICK_TIMEOUT_MS },
+ session: { script: "query-session.mjs", timeout: DETAIL_TIMEOUT_MS },
+ agents: { script: "query-agents.mjs", timeout: DETAIL_TIMEOUT_MS },
+};
+
+/* ------------------------------------------------------------------ */
+/* Disk cache: instant open + stale-while-revalidate + prefetch */
+/* ------------------------------------------------------------------ */
+
+const CACHE_DIR = path.join(RUNTIME, "cache");
+const FRESH_MS = { overview: 90 * 1000, sessions: 90 * 1000, session: 30 * 1000, agents: 30 * 1000 };
+const REFRESHING = new Set();
+
+function cacheKeyArgs(op, args) {
+ // Normalize time bounds so identical ranges hit the same cache file:
+ // start buckets to the hour, end buckets to 10 minutes.
+ const out = { ...(args || {}) };
+ if (Number.isFinite(Number(out.start))) out.start = Math.floor(Number(out.start) / 3600000) * 3600000;
+ if (Number.isFinite(Number(out.end))) out.end = Math.ceil(Number(out.end) / 600000) * 600000;
+ return out;
+}
+
+function cacheFile(op, args) {
+ const key = JSON.stringify(cacheKeyArgs(op, args));
+ let h = 0;
+ for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
+ return path.join(CACHE_DIR, op + "-" + (h >>> 0).toString(36) + ".json");
+}
+
+function readCache(file) {
+ try {
+ const st = fs.statSync(file);
+ return { data: JSON.parse(fs.readFileSync(file, "utf8")), age: Date.now() - st.mtimeMs };
+ } catch {
+ return null;
+ }
+}
+
+function writeCache(file, data) {
+ try {
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(data));
+ } catch {
+ // cache write is best-effort
+ }
+}
+
+function backgroundRefresh(op, args, file) {
+ const opDef = OPS[op];
+ if (!opDef || REFRESHING.has(file)) return;
+ REFRESHING.add(file);
+ runQuery(opDef.script, rangeArgs(args), opDef.timeout)
+ .then((result) => {
+ REFRESHING.delete(file);
+ if (result.ok && result.data && result.data.ok !== false) writeCache(file, result.data);
+ })
+ .catch(() => REFRESHING.delete(file));
+}
+
+function prefetchCaches() {
+ try {
+ const now = Date.now();
+ const base = { start: now - 7 * 24 * 3600 * 1000, end: now, order: "tokens" };
+ backgroundRefresh("overview", base, cacheFile("overview", base));
+ backgroundRefresh("sessions", { ...base, limit: 60 }, cacheFile("sessions", { ...base, limit: 60 }));
+ log("prefetch scheduled");
+ } catch (error) {
+ log("prefetch failed:", error.message);
+ }
+}
+
+async function handleRequest(req) {
+ if (!req || typeof req !== "object" || typeof req.id !== "string") return null;
+ log("handleRequest op:", req.op, "args:", JSON.stringify(req.args || {}));
+ try {
+ const op = OPS[req.op];
+ if (!op) {
+ return { id: req.id, result: { ok: false, error: { message: `未知操作:${String(req.op)}` } } };
+ }
+ if (req.op !== "overview" && req.op !== "sessions" && !req.args?.sessionId) {
+ return { id: req.id, result: { ok: false, error: { message: "缺少 sessionId" } } };
+ }
+ const args = req.args || {};
+ const file = cacheFile(req.op, args);
+ const cached = readCache(file);
+ if (cached && cached.age < (FRESH_MS[req.op] || 60000)) {
+ log("cache hit", req.op, Math.round(cached.age / 1000) + "s old");
+ return { id: req.id, result: cached.data };
+ }
+ if (cached) {
+ // stale-while-revalidate: answer instantly, refresh for the next open
+ log("cache stale", req.op, Math.round(cached.age / 1000) + "s old; refreshing");
+ backgroundRefresh(req.op, args, file);
+ return { id: req.id, result: cached.data };
+ }
+ const result = await runQuery(op.script, rangeArgs(args), op.timeout);
+ log("runQuery", op.script, "ok:", result.ok, result.ok ? "" : result.error);
+ if (!result.ok) {
+ return { id: req.id, result: { ok: false, error: { message: result.error || "查询失败" } } };
+ }
+ if (result.data && result.data.ok !== false) writeCache(file, result.data);
+ return { id: req.id, result: result.data };
+ } catch (error) {
+ return { id: req.id, result: { ok: false, error: { message: error.message || String(error) } } };
+ }
+}
+
+let pumping = false;
+
+async function pumpRequests() {
+ if (pumping) return;
+ pumping = true;
+ try {
+ for (const wc of webContents.getAllWebContents()) {
+ if (!isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) continue;
+ let raw;
+ try {
+ raw = await wc.executeJavaScript(
+ "(function(){try{var q=window.__zcodeContextRequests;if(!Array.isArray(q)||!q.length)return '';window.__zcodeContextRequests=[];return JSON.stringify(q)}catch(e){return ''}})()",
+ true,
+ );
+ } catch {
+ continue;
+ }
+ if (!raw) continue;
+ let pageUrl = "";
+ try {
+ pageUrl = String(wc.getURL() || "").slice(0, 80);
+ } catch {
+ // ignore
+ }
+ log("pump picked", raw.length, "chars from", pageUrl);
+ try {
+ const dbg = await wc.executeJavaScript(
+ "window.__zcodeContextDebugInfo ? JSON.stringify(window.__zcodeContextDebugInfo()) : ''",
+ true,
+ );
+ if (dbg) log("page debug:", dbg.slice(0, 800));
+ } catch {
+ // debug read is best-effort
+ }
+ let requests;
+ try {
+ requests = JSON.parse(raw);
+ } catch {
+ continue;
+ }
+ for (const req of Array.isArray(requests) ? requests : []) {
+ const reply = await handleRequest(req);
+ if (!reply) continue;
+ const json = JSON.stringify(reply.result).replace(/[\u2028\u2029]/g, (c) =>
+ c === "\u2028" ? "\\u2028" : "\\u2029",
+ );
+ if (wc.isDestroyed()) continue;
+ wc.executeJavaScript(
+ `window.__zcodeContextRequestResult && window.__zcodeContextRequestResult(${JSON.stringify(reply.id)}, ${json})`,
+ true,
+ )
+ .then(() => log("delivered", reply.id))
+ .catch((error) => log("deliver failed for", reply.id, ":", error.message));
+ }
+ }
+ } finally {
+ pumping = false;
+ }
+}
+
+function inject(wc) {
+ if (!widgetSource || !isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) return;
+ if (!pluginAvailable()) {
+ log("plugin root missing; panel disabled");
+ return;
+ }
+ const cleanup =
+ "(function(){try{if(window.__zcodeContextDispose)window.__zcodeContextDispose()}catch(e){}})();";
+ const config = {
+ runtime: RUNTIME,
+ pluginRoot: CONFIG.pluginRoot || "",
+ version: CONFIG.version || "0.1.0",
+ };
+ const source = `${cleanup}window.__zcodeContextConfig=${JSON.stringify(config)};\n${widgetSource}`;
+ wc.executeJavaScript(source, true).catch((error) => log("executeJavaScript failed:", error.message));
+}
+
+function hook(wc) {
+ if (!isMainWindowState(wc)) return;
+ wc.on("dom-ready", () => inject(wc));
+ wc.on("did-finish-load", () => inject(wc));
+ if (!wc.isLoading()) inject(wc);
+}
+
+app.on("web-contents-created", (_event, wc) => hook(wc));
+for (const wc of webContents.getAllWebContents()) hook(wc);
+
+app.whenReady()
+ .then(() => {
+ setInterval(pumpRequests, REQUEST_POLL_MS);
+ setTimeout(prefetchCaches, 6000);
+ })
+ .catch((error) => log("whenReady failed:", error.message));
+
+module.exports = {
+ inject,
+ pumpRequests,
+ _state: () => ({ runtime: RUNTIME, widgetReady: Boolean(widgetSource) }),
+};
diff --git a/external_plugins/zcode-context/panel/panel-widget.js b/external_plugins/zcode-context/panel/panel-widget.js
new file mode 100644
index 00000000..599c615e
--- /dev/null
+++ b/external_plugins/zcode-context/panel/panel-widget.js
@@ -0,0 +1,2115 @@
+/* zcode-context renderer: sidebar entry + context insight dashboard.
+ *
+ * Injected into ZCode's local renderer pages by panel/inject-main.cjs.
+ * Data flows through window.__zcodeContextRequests (renderer -> main) and
+ * window.__zcodeContextRequestResult (main -> renderer). All charts are
+ * inline SVG; no external libraries. Visual language mirrors the dsh-context
+ * reference: dense dark glass panels, thin multi-color donuts, dot+count
+ * ranking rows, tabular numerals.
+ */
+"use strict";
+(function () {
+ if (window.__zcodeContextPanelBooted) return;
+ window.__zcodeContextPanelBooted = true;
+ var BOOT_ID = "b" + ((Number(window.__zciBootCounter) || 0) + 1);
+ window.__zciBootCounter = Number(window.__zciBootCounter) || 0;
+ window.__zciBootCounter += 1;
+ window.__zciBoots = window.__zciBoots || [];
+ window.__zciBoots.push(BOOT_ID);
+ try {
+ if (window.__zciObserver) {
+ window.__zciObserver.disconnect();
+ window.__zciObserver = null;
+ }
+ } catch {
+ // ignore
+ }
+ try {
+ var oldEntry = document.getElementById("zci-entry");
+ if (oldEntry) oldEntry.remove();
+ document.querySelectorAll(".zci-overlay").forEach(function (el) {
+ el.remove();
+ });
+ var oldStyle = document.getElementById("zci-style");
+ if (oldStyle) oldStyle.remove();
+ } catch {
+ // ignore
+ }
+
+ var config = window.__zcodeContextConfig || {};
+
+ /* ---------------------------------------------------------------- */
+ /* Styles */
+ /* ---------------------------------------------------------------- */
+
+ var CSS = `
+.zci-entry{display:flex;align-items:center;gap:9px;padding:8px 12px;margin:2px 10px;border-radius:10px;cursor:pointer;color:#aeb4c0;font-size:13px;user-select:none;transition:background .15s,color .15s}
+.zci-entry:hover{background:rgba(255,255,255,.08);color:#fff}
+.zci-entry svg{flex:0 0 auto}
+
+.zci-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;color:#e6e9f0;font-size:12px;line-height:1.5;overflow:hidden;
+ font-family:-apple-system,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;
+ background:#0d0f13;background-image:radial-gradient(1000px 460px at 20% -10%,rgba(70,110,255,.12),transparent 60%),radial-gradient(800px 420px at 90% -8%,rgba(34,211,197,.06),transparent 60%)}
+.zci-head{display:flex;align-items:center;gap:12px;padding:14px 20px 8px;flex:0 0 auto}
+.zci-title h1{font-size:16px;font-weight:700;margin:0}
+.zci-title .sub{font-size:11px;color:#757d8c}
+.zci-spacer{flex:1}
+.zci-seg{display:flex;gap:2px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.07);border-radius:999px;padding:2px}
+.zci-seg .zci-pill{border:none;background:transparent;color:#a6adba;border-radius:999px;padding:4px 13px;cursor:pointer;font-size:12px;transition:all .15s}
+.zci-seg .zci-pill:hover{color:#fff}
+.zci-seg .zci-pill.zci-on{background:linear-gradient(135deg,#5b8cff,#3b6dff);color:#fff}
+.zci-link{border:none;background:transparent;color:#8b93a3;cursor:pointer;font-size:12px;padding:4px 6px}
+.zci-link:hover{color:#fff}
+.zci-link.zci-on{color:#fff;text-shadow:0 0 8px rgba(255,255,255,.35)}
+.zci-iconbtn{width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05);color:#c6ccd8;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s}
+.zci-iconbtn:hover{background:rgba(255,255,255,.12);color:#fff}
+.zci-iconbtn.zci-accent{background:linear-gradient(135deg,#5b8cff,#3b6dff);border-color:transparent;color:#fff}
+.zci-tabs{display:flex;gap:4px;padding:2px 20px 12px;flex:0 0 auto}
+.zci-tab{padding:6px 16px;cursor:pointer;border-radius:999px;color:#9aa1af;font-size:12px;transition:all .15s}
+.zci-tab:hover{color:#e6e9f0;background:rgba(255,255,255,.05)}
+.zci-tab.zci-on{color:#fff;background:linear-gradient(135deg,rgba(91,140,255,.85),rgba(59,109,255,.85))}
+.zci-body{flex:1;overflow:auto;padding:2px 20px 44px}
+.zci-grid>*,.zci-row>*,.zci-row-2>*,.zci-row-3>*{min-width:0}
+
+.zci-card{background:rgba(10,12,16,.72);border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:13px 15px;backdrop-filter:blur(14px);transition:border-color .15s}
+.zci-card.zci-hover{cursor:pointer;transition:border-color .15s,background .15s}
+.zci-card.zci-hover:hover{border-color:rgba(120,150,255,.4);background:rgba(18,22,30,.8)}
+.zci-card h3{margin:0 0 10px;font-size:12px;font-weight:650;color:#cdd3de;display:flex;align-items:center;gap:7px}
+.zci-card h3 .zci-ico{width:7px;height:7px;border-radius:50%;display:inline-block}
+.zci-card h3 .zci-cnt{margin-left:auto;color:#757d8c;font-weight:400;font-size:11px;font-variant-numeric:tabular-nums}
+
+.zci-top{display:grid;grid-template-columns:1fr 320px;gap:10px}
+.zci-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
+.zci-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:14px 10px;min-height:86px}
+.zci-kpi .zci-kpi-label{font-size:12px;color:#8b93a3}
+.zci-kpi .zci-kpi-value{font-size:23px;font-weight:750;margin:5px 0 2px;color:#f2f5fb;font-variant-numeric:tabular-nums;letter-spacing:.3px}
+.zci-kpi .zci-kpi-sub{font-size:11px;color:#5f6675}
+
+.zci-row{display:grid;gap:10px;grid-template-columns:1fr 1fr;align-items:stretch;margin-top:10px}
+.zci-row-2{display:grid;gap:10px;grid-template-columns:1fr 1fr;align-items:stretch;margin-top:10px}
+.zci-row-3{display:grid;gap:10px;grid-template-columns:1.2fr 1fr 1fr 1fr;align-items:stretch;margin-top:10px}
+
+/* donut + legend */
+.zci-donut-wrap{display:flex;gap:16px;align-items:center}
+.zci-legend{flex:1;display:grid;grid-template-columns:repeat(3,1fr);gap:8px 14px;align-content:center}
+.zci-legend.zci-2col{grid-template-columns:repeat(2,1fr)}
+.zci-lg{display:flex;flex-direction:column;gap:1px}
+.zci-lg .l1{display:flex;align-items:center;gap:6px;color:#a6adba;font-size:11px;white-space:nowrap}
+.zci-lg .l1 .dot{width:8px;height:8px;border-radius:2.5px;flex:0 0 auto}
+.zci-lg .l1 .pct{margin-left:auto;color:#e6e9f0;font-weight:600;font-variant-numeric:tabular-nums}
+.zci-lg .l2{padding-left:14px;color:#757d8c;font-size:11px;font-variant-numeric:tabular-nums}
+
+/* ranking rows: dot + name + xN with thin colored underline */
+.zci-rank{display:flex;flex-direction:column}
+.zci-rank-row{padding:4px 2px 3px;cursor:default}
+.zci-rank-row:hover{background:rgba(255,255,255,.04);border-radius:6px}
+.zci-rank-row .r1{display:flex;align-items:center;gap:7px}
+.zci-rank-row .dot{width:7px;height:7px;border-radius:50%;flex:0 0 auto}
+.zci-rank-row .name{flex:1;color:#b8bfcc;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.zci-rank-row .n{color:#8b93a3;font-variant-numeric:tabular-nums;font-size:11px}
+.zci-rank-row .bar{height:2px;border-radius:1px;margin-top:3px;background:rgba(255,255,255,.04);position:relative;overflow:hidden}
+.zci-rank-row .bar i{position:absolute;left:0;top:0;bottom:0;border-radius:1px}
+
+/* usage tall chart */
+.zci-usage{display:flex;flex-direction:column}
+.zci-usage svg{flex:1}
+
+/* heatmap */
+.zci-heat-tools{display:flex;align-items:center;gap:8px;margin-left:auto}
+.zci-heat{padding:2px 0}
+.zci-heat text{font-size:9px;fill:#6d7484}
+
+/* timeline strip */
+.zci-timeline{margin-top:10px}
+.zci-timeline .bar{height:6px;border-radius:3px;overflow:hidden;display:flex;background:rgba(255,255,255,.05)}
+.zci-timeline .bar span{height:100%}
+.zci-timeline .ticks{display:flex;justify-content:space-between;color:#5f6675;font-size:10px;margin-top:3px;font-variant-numeric:tabular-nums}
+
+/* sessions */
+.zci-sess-head{display:flex;align-items:center;gap:10px;margin:12px 0 8px}
+.zci-sess-head h2{margin:0;font-size:13px;font-weight:650;color:#cdd3de;display:flex;align-items:center;gap:7px}
+.zci-sess-head h2 .zci-ico{width:7px;height:7px;border-radius:50%;background:#5b8cff}
+.zci-sess-head .zci-spacer{flex:1}
+.zci-search{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:999px;color:#e6e9f0;font-size:12px;padding:5px 14px;width:210px;outline:none}
+.zci-search:focus{border-color:rgba(120,150,255,.5)}
+.zci-chips{display:flex;gap:6px;margin:0 0 10px;flex-wrap:wrap}
+.zci-chip{font-size:11px;padding:3px 10px;border-radius:999px;background:rgba(91,140,255,.14);color:#9db9ff;border:1px solid rgba(91,140,255,.28);cursor:pointer;white-space:nowrap}
+.zci-chip:hover{background:rgba(91,140,255,.25)}
+.zci-chip.zci-on{background:linear-gradient(135deg,#5b8cff,#3b6dff);color:#fff;border-color:transparent}
+.zci-chip.zci-plain{background:rgba(255,255,255,.05);color:#a6adba;border-color:rgba(255,255,255,.1)}
+.zci-chip.zci-amber{background:rgba(247,185,85,.14);color:#f7cd85;border-color:rgba(247,185,85,.3);cursor:default}
+
+.zci-sessions{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px}
+.zci-scard{display:flex;flex-direction:column;gap:7px}
+.zci-scard .zci-shead{display:flex;align-items:center;gap:6px}
+.zci-scard .t{font-weight:650;font-size:12px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#e6e9f0}
+.zci-scard .time{color:#5f6675;font-size:10px;white-space:nowrap}
+.zci-scard .zci-smid{display:flex;align-items:center;gap:10px}
+.zci-scard .zci-sstats{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;font-size:11px;color:#8b93a3}
+.zci-scard .zci-sstats b{color:#dfe3ec;font-weight:600;font-variant-numeric:tabular-nums}
+.zci-scard .zci-sstats .wide{grid-column:1/-1}
+.zci-scard .zci-slast{font-size:11px;color:#5f6675;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* detail header stats */
+.zci-dstats{display:flex;gap:0;align-items:stretch;flex-wrap:wrap}
+.zci-dstat{flex:1;text-align:center;padding:2px 14px;border-right:1px solid rgba(255,255,255,.06)}
+.zci-dstat:last-child{border-right:none}
+.zci-dstat .v{font-size:20px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums}
+.zci-dstat .l{font-size:11px;color:#8b93a3}
+.zci-dstat .s{font-size:10px;color:#5f6675}
+.zci-io-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:11px;color:#a6adba;margin-top:9px;padding-top:9px;border-top:1px dashed rgba(255,255,255,.07)}
+.zci-io-line .n{color:#5f6675;font-variant-numeric:tabular-nums}
+.zci-io-line .tot{margin-left:auto;color:#757d8c;font-variant-numeric:tabular-nums}
+
+/* context bar */
+.zci-ctx-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
+.zci-ctx-head .big{font-size:19px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums}
+.zci-ctx-head .big small{font-size:12px;color:#8b93a3;font-weight:400}
+.zci-ctx-head .pct{margin-left:auto;font-size:12px;color:#cdd3de}
+.zci-ctx-head .pct b{font-size:15px;color:#f2f5fb}
+.zci-ctxbar{position:relative;height:14px;border-radius:7px;overflow:hidden;display:flex;background:rgba(255,255,255,.05);margin:10px 0 8px}
+.zci-ctxbar .seg{height:100%}
+.zci-ctxbar .reserve{height:100%;background:repeating-linear-gradient(135deg,rgba(255,255,255,.12) 0 5px,transparent 5px 10px)}
+.zci-ctxline{height:8px;border-radius:4px;background:rgba(255,255,255,.06);overflow:hidden;display:flex}
+.zci-ctxline .seg{height:100%}
+.zci-ctx-leg{display:grid;grid-template-columns:repeat(4,1fr);gap:6px 16px;margin-top:8px}
+.zci-ctx-leg .zci-lg .l2{font-size:10px}
+
+/* cost composition */
+.zci-cost-head{display:flex;gap:26px;align-items:baseline;flex-wrap:wrap;margin-bottom:8px}
+.zci-cost-head .item{display:flex;flex-direction:column}
+.zci-cost-head .item .v{font-size:20px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums}
+.zci-cost-head .item .l{font-size:11px;color:#8b93a3}
+.zci-cost-head .item .s{font-size:10px;color:#5f6675}
+.zci-cost-donut{display:flex;gap:18px;align-items:center}
+.zci-donut-100{position:relative}
+.zci-donut-100 .c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
+.zci-donut-100 .c .v{font-size:16px;font-weight:750;color:#2bd576}
+.zci-donut-100 .c .l{font-size:10px;color:#757d8c}
+
+/* trend */
+.zci-trend-tools{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
+.zci-trend-tools .grp{margin-left:auto;display:flex;gap:2px;background:rgba(255,255,255,.06);border-radius:999px;padding:2px}
+.zci-trend-tools .grp:first-of-type{margin-left:0}
+
+/* events & files */
+.zci-events{max-height:320px;overflow:auto;font-size:11px;padding-right:4px}
+.zci-ev{display:flex;gap:8px;padding:4px 0;border-bottom:1px dashed rgba(255,255,255,.05);align-items:baseline}
+.zci-ev:last-child{border-bottom:none}
+.zci-ev .k{flex:0 0 74px;color:#5f6675;font-variant-numeric:tabular-nums}
+.zci-ev .tag{flex:0 0 66px;font-size:10px;padding:1px 7px;border-radius:999px;text-align:center;border:1px solid}
+.zci-ev .tag.zci-t-tool{color:#9db9ff;border-color:rgba(91,140,255,.35);background:rgba(91,140,255,.1)}
+.zci-ev .tag.zci-t-compact{color:#f7cd85;border-color:rgba(247,185,85,.35);background:rgba(247,185,85,.1)}
+.zci-ev .tag.zci-t-error{color:#ff8fa0;border-color:rgba(255,107,129,.4);background:rgba(255,107,129,.12)}
+.zci-ev .tag.zci-t-msg{color:#a3b3c9;border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.05)}
+.zci-ev .txt{flex:1;min-width:0;color:#b8bfcc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+
+.zci-files{font-size:11px}
+.zci-files .fhead{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
+.zci-file{display:flex;gap:8px;padding:4px 8px;border-radius:6px;align-items:center}
+.zci-file:hover{background:rgba(255,255,255,.05)}
+.zci-file .p{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#b8bfcc;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px}
+.zci-file .c{color:#8b93a3;font-variant-numeric:tabular-nums;flex:0 0 auto;font-size:10px}
+.zci-file .c b{color:#2bd576;font-weight:600}
+.zci-file .c i{color:#ff8fa0;font-style:normal;font-weight:600}
+
+/* context browser (right rail) */
+.zci-browser{display:flex;flex-direction:column;gap:6px}
+.zci-brow-row{background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:7px 10px}
+.zci-brow-row .r1{display:flex;align-items:center;gap:7px;font-size:11px;color:#cdd3de}
+.zci-brow-row .r1 .dot{width:7px;height:7px;border-radius:50%}
+.zci-brow-row .r1 .name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.zci-brow-row .r1 .items{color:#757d8c}
+.zci-brow-row .r1 .add{color:#2bd576;font-variant-numeric:tabular-nums}
+.zci-brow-row .r1 .val{color:#8b93a3;font-variant-numeric:tabular-nums;margin-left:auto}
+.zci-brow-row .r1 .pct{color:#5f6675;font-variant-numeric:tabular-nums}
+
+/* formation */
+.zci-banner{display:flex;align-items:center;gap:12px;border-radius:12px;padding:14px 18px;margin-bottom:10px;
+ background:linear-gradient(120deg,rgba(122,90,248,.55),rgba(59,109,255,.45));border:1px solid rgba(167,139,250,.3)}
+.zci-banner .t{font-size:16px;font-weight:750;color:#fff}
+.zci-banner .d{font-size:11px;color:rgba(255,255,255,.75)}
+.zci-banner .stats{margin-left:auto;display:flex;gap:8px}
+.zci-banner .stat{background:rgba(0,0,0,.25);border-radius:8px;padding:5px 12px;text-align:center;min-width:56px}
+.zci-banner .stat .v{font-size:15px;font-weight:700;color:#fff;font-variant-numeric:tabular-nums}
+.zci-banner .stat .l{font-size:10px;color:rgba(255,255,255,.7)}
+
+.zci-net{position:relative}
+.zci-node{display:flex;flex-direction:column;gap:6px}
+.zci-node .zci-ahead{display:flex;align-items:center;gap:8px}
+.zci-badge{width:26px;height:26px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;flex:0 0 auto}
+.zci-node .name{font-weight:650;font-size:12px;color:#e6e9f0}
+.zci-node .val{font-size:11px;color:#8b93a3;font-variant-numeric:tabular-nums}
+.zci-node .pbar{height:6px;border-radius:3px;background:rgba(255,255,255,.06);overflow:hidden}
+.zci-node .pbar i{display:block;height:100%;border-radius:3px}
+.zci-node .meta{font-size:10px;color:#5f6675;display:flex;gap:10px}
+.zci-net-children{margin:8px 0 0 13px;padding-left:16px;border-left:1px solid rgba(255,255,255,.14);display:flex;flex-direction:column;gap:8px}
+
+.zci-empty{padding:40px 16px;text-align:center;color:#5f6675;font-size:12px}
+.zci-err{color:#ff8fa0;padding:16px 20px;font-size:13px}
+.zci-err + pre{white-space:pre-wrap;font-size:11px;color:#ff8fa0;padding:0 20px 16px;margin:0;opacity:.8}
+.zci-note{font-size:11px;color:#757d8c}
+.zci-trend-toggle{display:flex;gap:2px;background:rgba(255,255,255,.06);border-radius:999px;padding:2px;width:fit-content;margin-bottom:8px}
+.zci-trend-toggle .zci-pill{border:none;background:transparent;color:#a6adba;border-radius:999px;padding:3px 12px;cursor:pointer;font-size:11px}
+.zci-trend-toggle .zci-pill.zci-on{background:rgba(91,140,255,.4);color:#fff}
+.zci-back{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.zci-back .t{font-weight:700;font-size:14px;color:#e6e9f0}
+.zci-btn{border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05);color:#c6ccd8;border-radius:999px;padding:4px 14px;cursor:pointer;font-size:12px}
+.zci-btn:hover{background:rgba(255,255,255,.12);color:#fff}
+
+/* global tooltip */
+.zci-tip{position:fixed;z-index:100000;pointer-events:none;background:rgba(16,19,26,.96);border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:7px 11px;font-size:11px;color:#dfe3ec;line-height:1.55;box-shadow:0 6px 24px rgba(0,0,0,.5);max-width:340px;display:none;font-variant-numeric:tabular-nums}
+.zci-tip b{color:#fff;font-weight:650}
+.zci-tip .dim{color:#8b93a3}
+
+/* clickable rows / cells */
+.zci-rank-row.zci-ck{cursor:pointer}
+.zci-rank-row.zci-ck:hover{background:rgba(255,255,255,.06)}
+.zci-rank-row.zci-sel{background:rgba(91,140,255,.16);border-radius:6px;outline:1px solid rgba(91,140,255,.4)}
+.zci-rank-row.zci-sel .name{color:#fff}
+.zci-heat rect[data-day]{cursor:pointer}
+.zci-heat rect[data-day]:hover{stroke:#fff;stroke-width:1}
+.zci-kpi.zci-ck{cursor:pointer}
+.zci-dseg{cursor:pointer;transition:opacity .12s}
+.zci-dimmed{opacity:.18}
+.zci-lg.zci-lgck{cursor:default;border-radius:5px;padding:1px 3px;transition:background .12s}
+.zci-lg.zci-lgck:hover{background:rgba(255,255,255,.05)}
+
+/* ctx bar segments */
+.zci-ctxbar .seg{cursor:pointer;transition:filter .12s}
+.zci-ctxbar .seg:hover{filter:brightness(1.35)}
+.zci-ctxbar .seg.zci-seg-hot{outline:1.5px solid #fff;outline-offset:-1px;filter:brightness(1.35)}
+.zci-ctxbar .reserve{cursor:help}
+
+/* trend hover + pinned detail */
+.zci-trend-hit{cursor:pointer}
+.zci-trend-hit:hover{fill:rgba(255,255,255,.07)}
+.zci-xhair{pointer-events:none}
+.zci-reqdetail{margin-top:8px;border:1px dashed rgba(91,140,255,.4);border-radius:8px;padding:8px 12px;background:rgba(91,140,255,.08);display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:#a6adba;align-items:center}
+.zci-reqdetail b{color:#f2f5fb;font-variant-numeric:tabular-nums;font-weight:650}
+.zci-reqdetail .x{margin-left:auto;color:#757d8c;cursor:pointer}
+.zci-reqdetail .x:hover{color:#fff}
+
+/* events expand + pills */
+.zci-ev{cursor:pointer}
+.zci-ev.open .txt{white-space:normal;overflow:visible}
+.zci-ev .more{color:#5b8cff;font-size:10px;flex:0 0 auto}
+.zci-dstat.zci-ck{cursor:pointer;border-radius:8px}
+.zci-dstat.zci-ck:hover{background:rgba(255,255,255,.05)}
+.zci-dstat.zci-ck .s{color:#5b8cff}
+
+/* files */
+.zci-file{cursor:pointer}
+.zci-file .fmeta{flex:0 0 auto;color:#5f6675;font-size:10px}
+.zci-file .fdetail{flex-basis:100%;color:#757d8c;font-size:10px;padding-left:4px;display:none}
+.zci-file.open .fdetail{display:block}
+.zci-file.open{background:rgba(91,140,255,.08);flex-wrap:wrap}
+.zci-filesearch{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:999px;color:#e6e9f0;font-size:11px;padding:3px 12px;width:170px;outline:none}
+
+/* browser rows collapsible */
+.zci-brow-row{cursor:pointer;transition:background .12s,border-color .12s}
+.zci-brow-row:hover{background:rgba(255,255,255,.08);border-color:rgba(120,150,255,.35)}
+.zci-brow-row.zci-bhot{border-color:rgba(120,150,255,.55);background:rgba(91,140,255,.12)}
+.zci-brow-row .bdetail{display:none;margin-top:6px;padding-top:6px;border-top:1px dashed rgba(255,255,255,.08);font-size:10px;color:#8b93a3;line-height:1.6;max-height:180px;overflow:auto}
+.zci-brow-row.open .bdetail{display:block}
+.zci-brow-row .caret{font-size:9px;color:#5f6675;flex:0 0 auto;transition:transform .15s}
+.zci-brow-row.open .caret{transform:rotate(90deg)}
+.zci-brow-row .bdetail .tnames{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}
+.zci-brow-row .bdetail .tnames span{background:rgba(255,255,255,.07);border-radius:4px;padding:1px 6px;font-family:ui-monospace,Consolas,monospace;font-size:10px}
+
+/* agent nodes */
+.zci-node.zci-ck{cursor:pointer}
+.zci-node .pbar{position:relative}
+.zci-net.zci-dimnet .zci-node{opacity:.25;transition:opacity .15s}
+.zci-net.zci-dimnet .zci-node.zci-chain{opacity:1}
+.zci-press-ok{background:rgba(43,213,118,.7)}
+.zci-press-warn{background:rgba(245,158,66,.85)}
+.zci-press-hot{background:rgba(255,107,129,.9)}
+`;
+
+ function ensureStyle() {
+ if (document.getElementById("zci-style")) return;
+ var style = document.createElement("style");
+ style.id = "zci-style";
+ style.textContent = CSS;
+ document.head.appendChild(style);
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* Data plumbing */
+ /* ---------------------------------------------------------------- */
+
+ var requestSeq = 0;
+ window.__zciPending = window.__zciPending || new Map();
+ var pending = window.__zciPending;
+ window.__zciDeliveredLog = [];
+
+ window.__zcodeContextRequestResult = function (id, result) {
+ try {
+ window.__zciDeliveredLog.push({ id: id, at: Date.now(), type: typeof result, ok: result ? result.ok : null });
+ if (window.__zciDeliveredLog.length > 20) window.__zciDeliveredLog.shift();
+ } catch {
+ // diagnostics only
+ }
+ var entry = pending.get(id);
+ if (!entry) {
+ window.__zciOrphans = window.__zciOrphans || [];
+ window.__zciOrphans.push({ id: id, boot: BOOT_ID, at: Date.now() });
+ if (window.__zciOrphans.length > 20) window.__zciOrphans.shift();
+ return;
+ }
+ pending.delete(id);
+ if (result && result.ok === false) entry.reject(new Error(result.error?.message || "查询失败"));
+ else entry.resolve(result);
+ };
+
+ window.__zcodeContextDebugInfo = function () {
+ return {
+ boot: BOOT_ID,
+ boots: window.__zciBoots,
+ booted: true,
+ tab: state.tab,
+ loading: state.loading,
+ error: state.error ? String(state.error).slice(0, 120) : null,
+ pending: Array.from(pending.keys()),
+ queue: Array.isArray(window.__zcodeContextRequests) ? window.__zcodeContextRequests.length : -1,
+ delivered: window.__zciDeliveredLog.slice(-8),
+ orphans: (window.__zciOrphans || []).slice(-5),
+ overlayShown: state.overlayEl ? state.overlayEl.style.display !== "none" : false,
+ };
+ };
+
+ function query(op, args) {
+ return new Promise(function (resolve, reject) {
+ var id = "q" + ++requestSeq;
+ pending.set(id, { resolve: resolve, reject: reject });
+ window.__zcodeContextRequests = window.__zcodeContextRequests || [];
+ window.__zcodeContextRequests.push({ id: id, op: op, args: args || {}, boot: BOOT_ID });
+ setTimeout(function () {
+ if (pending.has(id)) {
+ pending.delete(id);
+ reject(new Error("查询超时"));
+ }
+ }, 95000);
+ });
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* Formatting helpers */
+ /* ---------------------------------------------------------------- */
+
+ function fmtTokens(n) {
+ n = Number(n) || 0;
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
+ if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 1 : 2) + "M";
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + "k";
+ return String(Math.round(n));
+ }
+ function fmtCost(n) {
+ return "¥" + (Number(n) || 0).toFixed(2);
+ }
+ function fmtDuration(ms) {
+ ms = Number(ms) || 0;
+ if (ms >= 3600000) return Math.floor(ms / 3600000) + "h" + Math.round((ms % 3600000) / 60000) + "m";
+ if (ms >= 60000) return Math.floor(ms / 60000) + "m" + Math.round((ms % 60000) / 1000) + "s";
+ return Math.round(ms / 1000) + "s";
+ }
+ function fmtPct(p) {
+ return ((Number(p) || 0) * 100).toFixed(1) + "%";
+ }
+ function fmtTime(ts) {
+ if (!ts) return "-";
+ var d = new Date(Number(ts));
+ return d.toLocaleString(undefined, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
+ }
+ function esc(s) {
+ return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
+ return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
+ });
+ }
+
+ var C = {
+ blue: "#4b7bff",
+ green: "#2bd576",
+ amber: "#f59e42",
+ red: "#ff6b81",
+ purple: "#a78bfa",
+ cyan: "#22d3c5",
+ magenta: "#e04fa3",
+ gray: "#697182",
+ };
+ var RANK_COLORS = [C.amber, C.green, C.red, C.blue, C.cyan, C.purple, C.magenta, "#8fa0b8", "#d0a24a", "#5ec9b0"];
+
+ /* ---------------------------------------------------------------- */
+ /* Global floating tooltip (140ms delay, follows the cursor) */
+ /* ---------------------------------------------------------------- */
+
+ var tipEl = null;
+ var tipTimer = null;
+ function tipEnsure() {
+ if (tipEl && tipEl.isConnected) return tipEl;
+ tipEl = document.createElement("div");
+ tipEl.className = "zci-tip";
+ document.body.appendChild(tipEl);
+ return tipEl;
+ }
+ function tipShow(html, x, y) {
+ var el = tipEnsure();
+ el.innerHTML = html;
+ el.style.display = "block";
+ var w = el.offsetWidth || 160;
+ var h = el.offsetHeight || 40;
+ var px = Math.min(x + 14, window.innerWidth - w - 10);
+ var py = y + 16;
+ if (py + h > window.innerHeight - 8) py = y - h - 10;
+ el.style.left = Math.max(6, px) + "px";
+ el.style.top = Math.max(6, py) + "px";
+ }
+ function tipHide() {
+ if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; }
+ if (tipEl) tipEl.style.display = "none";
+ }
+ /** Bind [data-tip] elements under root: hover shows the floating tooltip. */
+ function bindTips(root) {
+ if (!root || !root.querySelectorAll) return;
+ root.querySelectorAll("[data-tip]").forEach(function (el) {
+ if (el.__zciTip) return;
+ el.__zciTip = true;
+ el.addEventListener("mousemove", function (e) {
+ var html = el.getAttribute("data-tip");
+ if (!html) return;
+ if (tipTimer) clearTimeout(tipTimer);
+ tipTimer = setTimeout(function () { tipShow(html, e.clientX, e.clientY); }, 140);
+ });
+ el.addEventListener("mouseleave", tipHide);
+ el.addEventListener("mousedown", tipHide);
+ });
+ }
+
+ /** Donut hover <-> legend highlight within each .zci-donut-wrap. */
+ function linkDonuts(root) {
+ if (!root) return;
+ root.querySelectorAll(".zci-donut-wrap").forEach(function (wrap) {
+ if (wrap.__zciLinked) return;
+ wrap.__zciLinked = true;
+ var segs = wrap.querySelectorAll(".zci-dseg");
+ var rows = wrap.querySelectorAll("[data-lkey]");
+ function setHot(key) {
+ segs.forEach(function (s) {
+ s.classList.toggle("zci-dimmed", key != null && s.getAttribute("data-dkey") !== key);
+ });
+ rows.forEach(function (r) {
+ r.classList.toggle("zci-dimmed", key != null && r.getAttribute("data-lkey") !== key);
+ r.style.background = r.getAttribute("data-lkey") === key ? "rgba(255,255,255,.07)" : "";
+ });
+ }
+ wrap.addEventListener("mouseover", function (e) {
+ var t = e.target.closest("[data-dkey],[data-lkey]");
+ setHot(t ? t.getAttribute("data-dkey") || t.getAttribute("data-lkey") : null);
+ });
+ wrap.addEventListener("mouseleave", function () { setHot(null); });
+ });
+ }
+
+ /** One delegated pass: tooltips, donut linking, filter chips. Call after each innerHTML write. */
+ function bindAll(root) {
+ bindTips(root);
+ linkDonuts(root);
+ }
+
+
+ /* ---------------------------------------------------------------- */
+ /* SVG chart helpers */
+ /* ---------------------------------------------------------------- */
+
+ function donut(parts, size, thickness, centerLabel, centerSub) {
+ size = size || 118;
+ thickness = thickness || 13;
+ var total = parts.reduce(function (a, p) { return a + p.value; }, 0);
+ var r = (size - thickness) / 2 - 1;
+ var c = size / 2;
+ var circ = 2 * Math.PI * r;
+ var shown = parts.filter(function (p) { return p.value > 0; });
+ var gap = shown.length > 1 ? 2 : 0;
+ var offset = 0;
+ var segs = "";
+ if (total <= 0) {
+ segs = ' ';
+ } else {
+ shown.forEach(function (p) {
+ var frac = p.value / total;
+ var len = Math.max(0.5, frac * circ - gap);
+ var tip = p.tip || (p.label + (p.valueText ? " " + p.valueText : ""));
+ segs +=
+ '" + p.label + "" + (p.valueText ? " " + p.valueText : "") + " · " + ((frac * 100).toFixed(1) + "% 占比")) : "") +
+ '" cx="' + c + '" cy="' + c + '" r="' + r + '" fill="none" stroke="' + p.color +
+ '" stroke-width="' + thickness + '" stroke-linecap="round" stroke-dasharray="' + len + " " + (circ - len) +
+ '" stroke-dashoffset="' + -offset + '" transform="rotate(-90 ' + c + " " + c + ')"> ';
+ offset += frac * circ;
+ });
+ }
+ var center = "";
+ if (centerLabel) {
+ // adaptive center font: small donuts (mini rings) shrink to fit
+ var bigFs = size <= 64 ? (String(centerLabel).length > 4 ? 8 : 10) : String(centerLabel).length > 6 ? 12 : 15;
+ var subFs = size <= 64 ? 7 : 9;
+ var subDy = size <= 64 ? 10 : 14;
+ center =
+ '' + esc(centerLabel) + " " +
+ '' + esc(centerSub || "") + " ";
+ }
+ return '' + segs + center + " ";
+ }
+
+ /** dsh-style legend: label + pct on first line, ≈value on second. */
+ function legendGrid(parts, opts) {
+ opts = opts || {};
+ var total = parts.reduce(function (a, p) { return a + p.value; }, 0);
+ var cls = "zci-legend" + (opts.cols === 2 ? " zci-2col" : "");
+ var rows = parts
+ .filter(function (p) { return p.value > 0 || opts.showZero; })
+ .map(function (p) {
+ var pct = total > 0 ? ((p.value / total) * 100).toFixed(1) + "%" : "0%";
+ var val = p.valueText || fmtTokens(p.value);
+ var tip = p.tip ? '" data-tip="' + esc(p.tip) : "";
+ return (
+ ' ' +
+ '' + esc(p.label) + ' ' + pct + '
' +
+ '
≈' + esc(val) + "
"
+ );
+ })
+ .join("");
+ return '' + rows + "
";
+ }
+
+ /** ranking rows: dot + name + ×n + thin colored underline.
+ * With opts.filterType the rows become clickable session-card filters. */
+ function rankRows(items, colorFn, opts) {
+ opts = opts || {};
+ var max = 1;
+ items.forEach(function (it) {
+ max = Math.max(max, it.calls);
+ });
+ var total = items.reduce(function (a, it) { return a + it.calls; }, 0) || 1;
+ return items
+ .map(function (it, i) {
+ var col = colorFn(it, i);
+ var pct = Math.max(2, (it.calls / max) * 100);
+ var clickable = Boolean(opts.filterType);
+ var selected = clickable && state.filter && state.filter.type === opts.filterType && String(state.filter.value) === String(it.name);
+ var cls = "zci-rank-row" + (clickable ? " zci-ck" : "") + (selected ? " zci-sel" : "");
+ var tip = it.tip || ("" + esc(it.name) + " ×" + it.calls + " · " + ((it.calls / total) * 100).toFixed(1) + "% 占比" + (clickable ? "点击筛选相关会话
" : ""));
+ return (
+ '' +
+ '' + esc(it.name) +
+ ' ×' + it.calls + "
" +
+ '
'
+ );
+ })
+ .join("");
+ }
+
+ /** Delegated click binding for [data-ft]/[data-fv] filter rows + filter chip. */
+ function bindFilterRows(root, rerender) {
+ if (!root) return;
+ root.querySelectorAll("[data-ft]").forEach(function (el) {
+ if (el.__zciF) return;
+ el.__zciF = true;
+ el.addEventListener("click", function () {
+ var type = el.getAttribute("data-ft");
+ var value = el.getAttribute("data-fv");
+ if (state.filter && state.filter.type === type && String(state.filter.value) === value) state.filter = null;
+ else state.filter = { type: type, value: value, label: value };
+ rerender();
+ });
+ });
+ }
+
+ function filterChipHtml() {
+ if (!state.filter) return "";
+ return '筛选:' + esc(state.filter.label) + " ✕ " +
+ '再次点击排行行或此标签取消筛选
';
+ }
+
+ /** Apply state.filter to a session card list. */
+ function applyCardFilter(list) {
+ if (!state.filter) return list;
+ var f = state.filter;
+ return list.filter(function (card) {
+ if (f.type === "tool") return ((card.tools || []).some(function (t) { return t.tool === f.value; }));
+ if (f.type === "cmd") {
+ var bash = (card.tools || []).some(function (t) { return t.tool === "Bash" || t.tool === "PowerShell" || t.tool === "pwsh"; });
+ return bash;
+ }
+ if (f.type === "proj") return card.workspace === f.value;
+ if (f.type === "day") {
+ var d = new Date(Number(card.lastActiveAt) || 0);
+ var key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
+ return key === f.value;
+ }
+ return true;
+ });
+ }
+
+ /** tall usage chart with y-axis labels and thin paired bars */
+ function usageChart(series) {
+ if (!series.length) return '暂无数据
';
+ var w = 300;
+ var h = 240;
+ var padL = 40;
+ var padB = 22;
+ var plotW = w - padL - 6;
+ var plotH = h - padB - 10;
+ var max = 1;
+ series.forEach(function (s) { max = Math.max(max, s.tokens); });
+ var ticks = [0, 0.25, 0.5, 0.75, 1].map(function (f) { return { v: max * f, y: 10 + plotH - f * plotH }; });
+ var n = series.length;
+ var slot = plotW / n;
+ var bw = Math.min(9, slot * 0.4);
+ var svg = '';
+ ticks.forEach(function (t) {
+ svg += '' + fmtTokens(t.v) + " ";
+ svg += ' ';
+ });
+ series.forEach(function (s, i) {
+ var x = padL + slot * i + (slot - bw * 2.3) / 2;
+ var hT = Math.max(2, (s.tokens / max) * plotH);
+ var hC = Math.max(2, ((s.cost * 4e6) / max) * plotH);
+ var tip = "" + esc(s.key) + " Token " + fmtTokens(s.tokens) + " 费用 " + fmtCost(s.cost) + " · " + s.requests + " 次请求";
+ svg += ' ';
+ svg += ' ';
+ if (n <= 16 || i % Math.ceil(n / 12) === 0) {
+ svg += '' + esc(s.key.slice(5)) + " ";
+ }
+ });
+ svg += " ";
+ return svg;
+ }
+
+ function heatmap(days, metric) {
+ metric = metric || "tokens";
+ var map = {};
+ days.forEach(function (d) { map[d.day] = d; });
+ var today = new Date();
+ var cells = [];
+ var maxV = 1;
+ days.forEach(function (d) { maxV = Math.max(maxV, metric === "sessions" ? d.sessions : d.tokens); });
+ var start = new Date(today.getTime() - 55 * 86400000);
+ start = new Date(start.getFullYear(), start.getMonth(), start.getDate());
+ var weekdayLabels = ["", "一", "", "三", "", "五", ""];
+ var svgCells = "";
+ var monthMarks = [];
+ var lastMonth = -1;
+ var idx = 0;
+ for (var d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {
+ var key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
+ var cell = map[key];
+ var level = 0;
+ if (cell) {
+ var v = metric === "sessions" ? cell.sessions : cell.tokens;
+ level = v > 0 ? Math.min(4, Math.ceil((v / maxV) * 4)) : 0;
+ }
+ var col = Math.floor(idx / 8);
+ var row = idx % 8;
+ if (row === 0 && d.getMonth() !== lastMonth) {
+ lastMonth = d.getMonth();
+ monthMarks.push({ x: col * 15.4 + 34, label: d.getMonth() + 1 + "月" });
+ }
+ var val = cell ? (metric === "sessions" ? cell.sessions : cell.tokens) : 0;
+ var tip = cell ? "" + esc(key) + " " + cell.sessions + " 会话 · " + fmtTokens(cell.tokens) + (val > 0 ? "点击筛选当天的会话
" : "") : "" + esc(key) + " 无活动";
+ svgCells +=
+ ' ';
+ idx += 1;
+ }
+ var svg = '';
+ monthMarks.forEach(function (m) {
+ svg += '' + esc(m.label) + " ";
+ });
+ weekdayLabels.forEach(function (w, i) {
+ if (w) svg += '' + w + " ";
+ });
+ svg += svgCells + " ";
+ return (
+ svg +
+ '近 8 周活跃度 少 ' +
+ ["rgba(255,255,255,.06)", "#1d3a70", "#2b57b0", "#3f74e8", "#6d9bff"].map(function (col) {
+ return ' ';
+ }).join("") + " 多
"
+ );
+ }
+
+ function trendChart(trend, windowTokens, mode, pinnedIndex) {
+ if (!trend.length) return '暂无请求数据
';
+ var width = 760;
+ var height = 250;
+ var n = trend.length;
+ var maxV = 1;
+ trend.forEach(function (t) {
+ maxV = Math.max(maxV, mode === "delta" ? Math.max(t.inputTokens, t.outputTokens) : t.inputTokens);
+ });
+ if (mode !== "delta" && windowTokens > 0) maxV = Math.max(maxV, windowTokens * 1.06);
+ var maxDur = 1;
+ trend.forEach(function (t) { maxDur = Math.max(maxDur, t.durationMs || 0); });
+ var padL = 8;
+ var yOf = function (v) {
+ return height - 30 - (v / maxV) * (height - 52);
+ };
+ var ydOf = function (v) {
+ return height - 30 - (v / maxDur) * (height - 52);
+ };
+ var xOf = function (i) {
+ return padL + (i / Math.max(1, n - 1)) * (width - padL * 2);
+ };
+ var uid = "tg" + Math.floor(Math.random() * 1e6);
+ var inputLine = "";
+ trend.forEach(function (t, i) {
+ inputLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + yOf(t.inputTokens).toFixed(1);
+ });
+ var outputLine = "";
+ trend.forEach(function (t, i) {
+ outputLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + yOf(t.outputTokens).toFixed(1);
+ });
+ var durLine = "";
+ trend.forEach(function (t, i) {
+ durLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + ydOf(t.durationMs || 0).toFixed(1);
+ });
+ var marks = "";
+ if (mode !== "delta" && windowTokens > 0 && windowTokens <= maxV) {
+ marks +=
+ ' ' +
+ '窗口 ' + fmtTokens(windowTokens) + " ";
+ }
+ trend.forEach(function (t, i) {
+ if (t.compact) {
+ marks += ' ';
+ }
+ });
+ // pinned marker
+ if (pinnedIndex != null && trend[pinnedIndex]) {
+ marks += ' ';
+ }
+ // right-side duration ticks
+ var durTicks = [0, 0.5, 1].map(function (f) {
+ return { y: height - 30 - f * (height - 52), text: fmtDuration(maxDur * f) };
+ });
+ // per-point hover/click hit regions
+ var slot = (width - padL * 2) / Math.max(1, n);
+ var hits = "";
+ trend.forEach(function (t, i) {
+ var prev = trend[i - 1];
+ var deltaIn = prev ? t.inputTokens - prev.inputTokens : t.inputTokens;
+ var tip =
+ "" + (t.turn != null ? "第 " + t.turn + " 轮 · " : "") + "第 " + (t.index + 1) + " 步 " +
+ " 输入 " + fmtTokens(t.inputTokens) + " · 输出 " + fmtTokens(t.outputTokens) +
+ " 缓存读 " + fmtTokens(t.cacheReadTokens) + "(" + fmtPct(t.cacheRate || 0) + ")" +
+ " 耗时 " + fmtDuration(t.durationMs) + (prev ? " Δ输入 " + (deltaIn >= 0 ? "+" : "") + fmtTokens(deltaIn) : "") +
+ (t.compact ? " ✂ compact 压缩" : "") +
+ "点击固定该步详情
";
+ hits +=
+ ' ';
+ });
+ var svg =
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ marks +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ hits +
+ durTicks.map(function (t) {
+ return '' + esc(t.text) + " ";
+ }).join("") +
+ " ";
+ return svg;
+ }
+
+ function timelineStrip(segments, totalLabel) {
+ var total = segments.reduce(function (a, s) { return a + s.value; }, 0) || 1;
+ var bars = segments
+ .filter(function (s) { return s.value > 0; })
+ .map(function (s) {
+ return ' ';
+ })
+ .join("");
+ return (
+ '' + bars + '
0 ' + esc(totalLabel || "") + "
"
+ );
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* State + views */
+ /* ---------------------------------------------------------------- */
+
+ var state = {
+ tab: "overview",
+ range: 7,
+ directory: "",
+ directoryFilter: "",
+ order: "tokens",
+ search: "",
+ heatMetric: "tokens",
+ filter: null,
+ evFilter: null,
+ evOpen: {},
+ fileFilter: "",
+ fileSearch: "",
+ fileOpen: {},
+ browserOpen: {},
+ pinnedStep: null,
+ trendGran: "step",
+ overview: null,
+ sessions: null,
+ sessionDetail: null,
+ detailSessionId: null,
+ trendMode: "total",
+ loading: false,
+ error: null,
+ overlayEl: null,
+ tabsEl: null,
+ bodyEl: null,
+ onKey: null,
+ observer: null,
+ };
+
+ function rangeStartMs() {
+ if (state.range === "today") {
+ var d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d.getTime();
+ }
+ return Date.now() - state.range * 86400000;
+ }
+
+ function sectionCard(title, color, countText, bodyHtml) {
+ return (
+ '
' + esc(title) +
+ (countText ? '' + countText + " " : "") + " " + (bodyHtml || "") + ""
+ );
+ }
+
+ function timeDonutParts(ts) {
+ return [
+ { label: "模型等待", value: ts.modelWaitMs, valueText: fmtDuration(ts.modelWaitMs), color: C.amber, tip: "模型等待 " + fmtDuration(ts.modelWaitMs) + "Σ 各请求首字延迟(ttft)
" },
+ { label: "模型思考", value: ts.thinkMs || 0, valueText: fmtDuration(ts.thinkMs || 0), color: C.purple, tip: "模型思考 " + fmtDuration(ts.thinkMs || 0) + "生成时长 × reasoning token 占比
" },
+ { label: "模型回复", value: ts.replyMs || 0, valueText: fmtDuration(ts.replyMs || 0), color: C.green, tip: "模型回复 " + fmtDuration(ts.replyMs || 0) + "生成时长 × 输出 token 占比
" },
+ { label: "工具执行", value: ts.toolMs, valueText: fmtDuration(ts.toolMs), color: C.blue, tip: "工具执行 " + fmtDuration(ts.toolMs) + "相邻请求之间的空闲间隔(≤120s)
" },
+ { label: "其他", value: ts.otherMs, valueText: fmtDuration(ts.otherMs), color: C.gray, tip: "其他 " + fmtDuration(ts.otherMs) + "活跃时长 − 上述各项
" },
+ ];
+ }
+
+ function timeLegendRows(parts, counts) {
+ var total = parts.reduce(function (a, p) { return a + p.value; }, 0) || 1;
+ return parts
+ .filter(function (p) { return p.value > 0; })
+ .map(function (p) {
+ var pct = ((p.value / total) * 100).toFixed(1) + "%";
+ var extra = counts && counts[p.label] ? " · " + counts[p.label] : "";
+ return (
+ ' ' +
+ '' + esc(p.label) + ' ' + pct + '
' +
+ '
≈' + esc(fmtDuration(p.value)) + esc(extra) + "
"
+ );
+ })
+ .join("");
+ }
+
+ function renderOverview(body) {
+ var o = state.overview;
+ if (!o) return;
+ var s = o.summary;
+ var ts = o.timeStats || { modelWaitMs: s.activeMs, toolMs: 0, otherMs: 0, outputSpeed: s.outputSpeed };
+ var usageParts = usageDonutParts(o);
+ var timeParts = timeDonutParts(ts);
+
+ var kpis =
+ kpi("全部会话", fmtTokens(s.sessions), "含 " + s.requests + " 次请求", { kpiId: "sessions", tip: "点击查看会话列表 按当前时间范围统计
" }) +
+ kpi("Token 消耗", fmtTokens(s.totalTokens), "输出 " + fmtTokens(s.outputTokens), { tip: "所有请求 computed_total_tokens 合计" }) +
+ kpi("预估费用", fmtCost(s.cost), "按公开牌价估算", { tip: "按内置牌价表 估算:输入/输出/缓存读写分别计价实际账单以模型服务商为准
" }) +
+ kpi("缓存命中", fmtPct(s.cacheHitRate), "cacheRead / input", { tip: "cache_read / input (dsh 口径)越高说明缓存复用越好、费用越低
" }) +
+ kpi("工具调用", fmtTokens(s.toolCalls), "命令执行 " + s.commandExec + " 次" + ((o.commandStats && o.commandStats.failed) ? " · 失败 " + o.commandStats.failed : ""), { kpiId: "tools", tip: "点击滚动到工具调用 排行" }) +
+ kpi("活跃时长", fmtDuration(ts.modelWaitMs + (ts.thinkMs || 0) + (ts.replyMs || 0)), "模型等待 · " + s.outputSpeed + " tok/s", { tip: "模型等待=Σ首字延迟;生成按思考/回复 token 占比拆分工具执行按请求间隙估算
" });
+
+ var usageCard = sectionCard("近 7 天用量", "", "", '' + usageChart(o.series.slice(-14)) +
+ '
Token 费用
');
+
+ var tokenCard = sectionCard("Token 统计", C.blue, "", '' +
+ donut(usageParts, 118, 13, fmtTokens(s.totalTokens), "总用量") +
+ legendGrid(usageParts) + "
");
+
+ var timeTotalMs = ts.modelWaitMs + (ts.thinkMs || 0) + (ts.replyMs || 0) + ts.toolMs + ts.otherMs;
+ var timeCard = sectionCard("耗时统计", C.amber, "输出速度 " + s.outputSpeed + " tok/s", '' +
+ donut(timeParts, 118, 13, fmtDuration(timeTotalMs), "总时长") +
+ '
' + timeLegendRows(timeParts, { 模型等待: s.requests + " 次", 工具执行: s.toolCalls + " 次" }) + "
" +
+ timelineStrip(timeParts, fmtDuration(ts.spanMs || timeTotalMs)));
+
+ var heatCard = sectionCard("活跃", C.green, "", '' +
+ '' + heatmap(o.heatmap, state.heatMetric) + "
");
+
+ var toolItems = (o.tools || []).slice(0, 10).map(function (t, i) {
+ return { name: t.tool, calls: t.calls };
+ });
+ var toolCard = sectionCard("工具调用", C.cyan, (o.tools || []).length + " 个工具 · " + s.toolCalls + " 次调用",
+ '' + rankRows(toolItems, function (it, i) { return RANK_COLORS[i % RANK_COLORS.length]; }, { filterType: "tool" }) + "
");
+
+ var cmdItems = (o.commands || []).map(function (c, i) {
+ return { name: c.cmd, calls: c.calls };
+ });
+ var cmdCard = sectionCard("命令执行", C.red, (o.commands || []).length + " 个程序 · " + s.commandExec + " 次执行" + ((o.commandStats && o.commandStats.failed) ? " · 失败 " + o.commandStats.failed : ""),
+ '' + rankRows(cmdItems, function (it, i) { return RANK_COLORS[(i + 3) % RANK_COLORS.length]; }, { filterType: "cmd" }) + "
");
+
+ var projItems = (o.projects || []).slice(0, 8).map(function (p, i) {
+ return { name: p.workspace, calls: Math.max(1, Math.round(p.tokens / 1e6)), tip: "" + esc(p.workspace) + " " + fmtTokens(p.tokens) + " tokens · " + p.sessions + " 会话点击筛选该工作区的会话
" };
+ });
+ var projCard = sectionCard("项目分布", C.purple, (o.projects || []).length + " 个工作区",
+ '' + rankRows(projItems, function (it, i) { return RANK_COLORS[(i + 5) % RANK_COLORS.length]; }, { filterType: "proj" }) + "
");
+
+ var filteredCards = applyCardFilter((o.sessions || []).slice(0, 12));
+ var sessionsHtml = renderSessionCards(filteredCards, true);
+ var sessCard =
+ ' 会话共 ' + s.sessions + " " +
+ '
' +
+ 'Token 总用量 ' +
+ '最近更新
' +
+ filterChipHtml() +
+ '' + (sessionsHtml || '
没有匹配当前筛选的会话
') + "
";
+
+ body.innerHTML =
+ '' + kpis + "
" + usageCard + "
" +
+ '' + tokenCard + timeCard + "
" +
+ '' + heatCard + toolCard + cmdCard + projCard + "
" +
+ sessCard;
+
+ body.querySelectorAll("[data-session]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ openSession(el.getAttribute("data-session"));
+ });
+ });
+ body.querySelectorAll("[data-order]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.order = el.getAttribute("data-order");
+ load();
+ });
+ });
+ body.querySelectorAll("[data-heat]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.heatMetric = el.getAttribute("data-heat");
+ render();
+ });
+ });
+ body.querySelectorAll("[data-kpi]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var id = el.getAttribute("data-kpi");
+ if (id === "sessions") {
+ state.tab = "sessions";
+ if (!state.sessions) loadSessions();
+ else render();
+ } else if (id === "tools") {
+ var card = body.querySelectorAll(".zci-card")[3];
+ try {
+ var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) {
+ return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("工具调用") >= 0;
+ });
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
+ } catch {
+ // ignore
+ }
+ }
+ });
+ });
+ // heatmap day cells -> filter session cards by that day
+ body.querySelectorAll("[data-day]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var day = el.getAttribute("data-day");
+ if (state.filter && state.filter.type === "day" && state.filter.value === day) state.filter = null;
+ else state.filter = { type: "day", value: day, label: day };
+ render();
+ });
+ });
+ body.querySelectorAll("[data-fclear]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.filter = null;
+ render();
+ });
+ });
+ bindFilterRows(body, render);
+ bindAll(body);
+ }
+
+ function kpi(label, value, sub, opts) {
+ opts = opts || {};
+ return (
+ '' + esc(label) +
+ '
' + esc(value) + '
' + esc(sub || "") + "
"
+ );
+ }
+
+ function compositionParts(detail) {
+ var c = detail.composition;
+ return [
+ { label: "系统提示词", value: c.systemPrompt.tokens, color: C.purple },
+ { label: "工具定义", value: c.toolDefs.tokens, color: C.amber },
+ { label: "用户消息", value: c.userMessages.tokens, color: C.blue },
+ { label: "助手回复", value: c.assistantText.tokens, color: C.green },
+ { label: "思考", value: c.reasoning.tokens, color: C.cyan },
+ { label: "工具结果", value: c.toolResults.tokens, color: C.red },
+ ];
+ }
+
+ function usageDonutParts(overview) {
+ var s = overview.summary;
+ return [
+ { label: "缓存读取", value: s.cacheReadTokens, color: C.cyan },
+ { label: "模型思考", value: s.reasoningTokens, color: C.purple },
+ { label: "未命中输入", value: Math.max(0, s.inputTokens - s.cacheReadTokens - s.cacheWriteTokens), color: C.blue },
+ { label: "输出", value: s.outputTokens, color: C.green },
+ { label: "缓存写入", value: s.cacheWriteTokens, color: C.amber },
+ ];
+ }
+
+ function sessionUsageParts(d) {
+ var agg = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
+ (d.usage || []).forEach(function (r) {
+ agg.input += r.inputTokens || 0;
+ agg.output += r.outputTokens || 0;
+ agg.reasoning += r.reasoningTokens || 0;
+ agg.cacheRead += r.cacheReadTokens || 0;
+ });
+ return [
+ { label: "缓存读取", value: agg.cacheRead, color: C.cyan },
+ { label: "模型思考", value: agg.reasoning, color: C.purple },
+ { label: "未命中输入", value: Math.max(0, agg.input - agg.cacheRead - agg.cacheWrite), color: C.blue },
+ { label: "输出", value: agg.output, color: C.green },
+ ];
+ }
+
+ function renderSessionCards(list, compact) {
+ var maxTokens = 1;
+ list.forEach(function (card) { maxTokens = Math.max(maxTokens, card.tokens || 0); });
+ return list
+ .map(function (card) {
+ var pct = card.contextPct != null ? Math.min(100, Math.round(card.contextPct * 100)) : Math.min(100, Math.round(((card.tokens || 0) / maxTokens) * 100));
+ var donutColor = pct >= 85 ? C.red : C.blue;
+ var miniDonut =
+ donut(
+ [
+ { label: "上下文占比", value: Math.max(0.5, pct), color: donutColor },
+ { label: "余量", value: Math.max(0, 100 - pct), color: "rgba(255,255,255,.08)" },
+ ],
+ 56,
+ 7,
+ pct + "%",
+ "上下文",
+ );
+ return (
+ '' +
+ '
' + esc(card.title) + ' ' + esc(fmtTime(card.lastActiveAt)) + "
" +
+ '
' + esc(card.workspace) + " " +
+ (card.compacted ? '已压缩 ' : "") + "
" +
+ '
' + miniDonut +
+ '
轮次 ' + (card.turns || card.requests) + " Token " + fmtTokens(card.tokens) + " " +
+ '总预估费用 ' + fmtCost(card.cost) + "
" +
+ (card.lastMessage ? '
' + esc(card.lastMessage.slice(0, 80)) + "
" : "") +
+ "
"
+ );
+ })
+ .join("");
+ }
+
+ function renderSessions(body) {
+ var list = state.sessions || [];
+ var workspaces = {};
+ list.forEach(function (s) { workspaces[s.workspace] = true; });
+ var kw = state.search.trim().toLowerCase();
+ var filtered = list.filter(function (s) {
+ return (!state.directoryFilter || s.workspace === state.directoryFilter) &&
+ (!kw || String(s.title).toLowerCase().indexOf(kw) >= 0);
+ });
+ var chips = Object.keys(workspaces)
+ .slice(0, 9)
+ .map(function (w) {
+ var n = list.filter(function (s) { return s.workspace === w; }).length;
+ return '' + esc(w) + " " + n + " ";
+ })
+ .join("");
+ body.innerHTML =
+ ' 会话共 ' + list.length + " " +
+ '
' +
+ '
' +
+ 'Token 总用量 ' +
+ '最近更新 ' +
+ '当前上下文
' +
+ '全部 ' + list.length + " " + chips + "
" +
+ '' + renderSessionCards(filtered, false) + "
";
+ body.querySelector(".zci-search").addEventListener("input", function (e) {
+ state.search = e.target.value;
+ render();
+ var input = body.querySelector(".zci-search");
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+ });
+ body.querySelectorAll("[data-session]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ openSession(el.getAttribute("data-session"));
+ });
+ });
+ body.querySelectorAll("[data-ws]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.directoryFilter = el.getAttribute("data-ws");
+ render();
+ });
+ });
+ body.querySelectorAll("[data-order]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.order = el.getAttribute("data-order");
+ loadSessions();
+ });
+ });
+ bindAll(body);
+ }
+
+ function ioCell(label, value) {
+ return '' + esc(label) + ' ' + value + " ";
+ }
+
+ /** clickable io chip: filters the 文件活动 card (op) or 事件日志 card (tool). */
+ function ioCellChip(label, value, op) {
+ if (!value) return ioCell(label, value);
+ return '' + esc(label) + ' ' + value + " ";
+ }
+
+ function renderSessionDetail(body) {
+ var d = state.sessionDetail;
+ if (!d) return;
+ var st = d.stats;
+ var io = d.ioCounts || { user: 0, read: 0, write: 0, edit: 0, search: 0, bash: 0, tools: 0 };
+ var compParts = compositionParts(d);
+ var compTotal = compParts.reduce(function (a, p) { return a + p.value; }, 0) || 1;
+ var win = st.contextWindow || 1;
+ var usedPct = Math.min(1, st.currentContextTokens / win);
+
+ // context bar segments with hatched reserve; hover tip + click -> browser group
+ var segs = compParts
+ .filter(function (p) { return p.value > 0; })
+ .map(function (p) {
+ var segPct = (p.value / compTotal) * 100;
+ var tip = "" + p.label + " ≈" + fmtTokens(p.value) + " · " + segPct.toFixed(1) + "% 占已用点击在上下文浏览器中展开
";
+ return ' ';
+ })
+ .join("");
+ var reserve = Math.max(0, 100 - usedPct * 100);
+
+ var headerStats =
+ '' +
+ '' + d.requests + '
步骤
点击查看趋势
' +
+ '' +
+ '' + (d.composition.assistantText.items || 0) + '
回复块
' +
+ '' + fmtTokens(st.totalTokens) + '
Token 总用量
' + fmtCost(st.cost) + "
";
+
+ var ioLine =
+ '输入输出 ' +
+ ioCell("用户", io.user) +
+ ioCellChip("读取", io.read, "read") + ioCellChip("写入", io.write, "write") +
+ ioCellChip("搜索", io.search, "search") + ioCellChip("命令", io.bash, "bash") +
+ '合计 ' + (io.read + io.write + io.edit + io.search + io.bash) + "
";
+
+ var toolItems = (d.toolTop || []).map(function (t) {
+ return { name: t.tool, calls: t.calls };
+ });
+ var ioToolsLine =
+ '工具调用 ' +
+ toolItems
+ .slice(0, 8)
+ .map(function (t) {
+ return '' + esc(t.tool) + ' ' + t.calls + " ";
+ })
+ .join("") +
+ '合计 ' + io.tools + "
";
+
+ var ts = (st && st.timing) || d.timeStats || { modelWaitMs: st.modelMs, toolMs: 0, otherMs: 0, outputSpeed: st.outputSpeed };
+ var timeParts = timeDonutParts(ts);
+ var sUsageParts = sessionUsageParts(d);
+ var cacheRate = st.cacheRate != null ? st.cacheRate : 0;
+
+ var costComp = d.costComposition || { totalTokens: st.compositionTokens, totalCost: st.cost, parts: [] };
+ var costColors = [C.purple, C.amber, C.blue, C.green, C.cyan, C.red];
+ var costParts = (costComp.parts || []).map(function (p, i) {
+ return {
+ label: p.label,
+ value: Math.max(0.5, p.pct * 100),
+ valueText: "¥" + Number(p.cost).toFixed(5),
+ color: costColors[i % costColors.length],
+ tip: "" + esc(p.label) + " ≈" + fmtTokens(Math.max(0.5, p.pct * 100) / 100 * costComp.totalTokens) + " tokens ≈¥" + Number(p.cost).toFixed(5) + "按最近模型牌价估算
",
+ };
+ });
+
+ // browser groups (collapsible; expand content per group)
+ var compGroups = [
+ { label: "系统提示词", detail: (d.promptPreview || "(无快照)"), raw: true },
+ { label: "工具定义", toolNames: true },
+ { label: "用户消息", items: (d.composition.userMessages.items || 0) + " 条" },
+ { label: "助手回复", items: (d.composition.assistantText.items || 0) + " 块" },
+ { label: "思考", items: (d.composition.reasoning.items || 0) + " 段" },
+ { label: "工具结果", items: (d.composition.toolResults.items || 0) + " 条" },
+ ];
+ var browserRows = compParts
+ .map(function (p) {
+ var g = compGroups.find(function (x) { return x.label === p.label; }) || {};
+ var pct = ((p.value / compTotal) * 100).toFixed(1) + "%";
+ var add = g.items ? '+' + esc(g.items) + " " : "";
+ var detail = "";
+ if (g.raw) {
+ detail = '系统提示词快照(前 400 字):
' + esc(g.detail) + (g.detail && g.detail.length >= 400 ? "…" : "") + "
";
+ } else if (g.toolNames) {
+ var names = d.toolNames || [];
+ detail = '工具定义 ' + names.length + " 个:
" +
+ (names.length ? '
' + names.map(function (n) { return "" + esc(n) + " "; }).join("") + "
" : "
(未捕获工具清单)
") + "
";
+ } else {
+ detail = '条目数:' + esc(g.items || "0") + " · 占已用上下文 " + pct + "
";
+ }
+ var open = state.browserOpen[p.label] ? " open" : "";
+ return (
+ '▶ ' +
+ '' + esc(p.label) + ' ' + add +
+ '≈' + fmtTokens(p.value) + ' ' + pct + "
" + detail + "
"
+ );
+ })
+ .join("");
+
+ var fileTotalWrite = (d.files || []).reduce(function (a, f) { return a + f.write; }, 0);
+ var fileTotalEdit = (d.files || []).reduce(function (a, f) { return a + f.edit; }, 0);
+ var fileTotalRead = (d.files || []).reduce(function (a, f) { return a + f.read; }, 0);
+
+ // --- 趋势数据:步进=逐请求;轮次=按 turn 聚合 ---
+ var trendRows = d.trend || [];
+ if (state.trendGran === "turn") {
+ var byTurn = new Map();
+ trendRows.forEach(function (t) {
+ var key = t.turn || 1;
+ var agg = byTurn.get(key);
+ if (!agg) {
+ agg = { index: key - 1, turn: key, startedAt: t.startedAt, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheRate: 0, durationMs: 0, compact: false, n: 0 };
+ byTurn.set(key, agg);
+ }
+ agg.inputTokens = Math.max(agg.inputTokens, t.inputTokens);
+ agg.outputTokens += t.outputTokens;
+ agg.reasoningTokens += t.reasoningTokens;
+ agg.cacheReadTokens += t.cacheReadTokens;
+ agg.durationMs += t.durationMs || 0;
+ agg.compact = agg.compact || t.compact;
+ agg.n += 1;
+ });
+ trendRows = Array.from(byTurn.values()).sort(function (a, b) { return a.index - b.index; });
+ if (state.pinnedStep != null && !trendRows[state.pinnedStep]) state.pinnedStep = null;
+ }
+
+ function requestDetailHtml(dd) {
+ if (state.pinnedStep == null) return "";
+ var t = (dd.trend || [])[state.pinnedStep];
+ var u = (dd.usage || [])[state.pinnedStep];
+ if (!t && !u) return "";
+ var row = u || t;
+ var input = row.inputTokens != null ? row.inputTokens : row.input_tokens;
+ var output = row.outputTokens != null ? row.outputTokens : row.output_tokens;
+ var cacheRead = row.cacheReadTokens != null ? row.cacheReadTokens : row.cache_read_input_tokens;
+ var model = row.modelId || row.model_id || "";
+ var prev = (dd.trend || [])[state.pinnedStep - 1];
+ var delta = prev ? input - prev.inputTokens : input;
+ return (
+ '' + (t && t.turn != null ? "第 " + t.turn + " 轮 · " : "") + "第 " + (state.pinnedStep + 1) + " 步 " +
+ "输入 " + fmtTokens(input) + " 输出 " + fmtTokens(output) + " " +
+ "缓存命中 " + fmtPct(t && t.cacheRate) + " " +
+ "Δ输入 " + (delta >= 0 ? "+" : "") + fmtTokens(delta) + " " +
+ "耗时 " + fmtDuration(row.durationMs) + " " +
+ (model ? "" + esc(String(model).slice(0, 40)) + " " : "") +
+ '✕ 取消固定
'
+ );
+ }
+
+ // --- 事件:筛选 + 计数 + 行点击展开 ---
+ var evCounts = { all: (d.events || []).length, message: 0, tool: 0, compact: 0, error: 0 };
+ (d.events || []).forEach(function (ev) {
+ if (evCounts[ev.kind] != null) evCounts[ev.kind] += 1;
+ });
+ var evShown = (d.events || []).filter(function (ev) {
+ if (state.evFilter && state.evFilter.indexOf("tool:") === 0) return ev.kind === "tool" && ev.tool === state.evFilter.slice(5);
+ if (!state.evFilter) return true;
+ return ev.kind === state.evFilter;
+ });
+ var eventsHtml =
+ evShown
+ .slice(-120)
+ .reverse()
+ .map(function (ev) {
+ var cls = ev.kind === "error" ? "zci-t-error" : ev.kind === "compact" ? "zci-t-compact" : ev.kind === "tool" ? "zci-t-tool" : "zci-t-msg";
+ var tag = ev.kind === "tool" ? ev.tool : ev.kind === "compact" ? "压缩" : ev.kind === "error" ? "错误" : "消息";
+ var id = "ev" + (ev.at || 0) + Math.abs(String(ev.text || "").length);
+ var open = state.evOpen[id] ? " open" : "";
+ var long = (ev.text || "").length > 96;
+ return (
+ '点击展开全文
" : "") + '">' +
+ '' + esc(fmtTime(ev.at)) + ' ' + esc(String(tag).slice(0, 10)) + ' ' +
+ '' + esc(ev.text || "") + " " +
+ (long && !state.evOpen[id] ? '展开 ' : "") +
+ ""
+ );
+ })
+ .join("") || '暂无事件
';
+
+ // --- 文件:筛选 + 搜索 + 行点击展开明细 ---
+ var filesFiltered = (d.files || []).filter(function (f) {
+ if (state.fileFilter && !(f[state.fileFilter] > 0)) return false;
+ if (state.fileSearch) {
+ var kw = state.fileSearch.toLowerCase();
+ return String(f.file || "").toLowerCase().indexOf(kw) >= 0 || String(f.full || "").toLowerCase().indexOf(kw) >= 0;
+ }
+ return true;
+ });
+ var filesHtml =
+ filesFiltered
+ .slice(0, 60)
+ .map(function (f, i) {
+ var id = String(f.full || f.file || i);
+ var open = state.fileOpen[id] ? " open" : "";
+ return (
+ '读 " + f.read + " · 写 " + f.write + " · 改 " + f.edit + "
点击展开明细
\">" +
+ '
' + esc(f.file) + ' ' + esc(fmtTime(f.at)) + ' ' +
+ '
读 ' + f.read + " · 写 " + f.write + " · 改 " + f.edit + " " +
+ '
完整路径:' + esc(f.full) + " · 最后操作 " + esc(fmtTime(f.at)) + " "
+ );
+ })
+ .join("") || '暂无文件活动
';
+
+ var parts = [
+ '← 返回 ' + esc(d.session.title) + " " +
+ '' + esc(d.session.workspace) + " " +
+ '' + esc(fmtTime(d.session.startedAt)) + "
",
+
+ ' 上下文统计' +
+ '
' + headerStats + "
" + ioLine + ioToolsLine + "
",
+
+ '' +
+ sectionCard("Token 统计", C.blue, "缓存命中 " + fmtPct(cacheRate),
+ '
' + donut(sUsageParts, 110, 12, fmtTokens(st.totalTokens), "总用量") + legendGrid(sUsageParts, { cols: 2 }) + "
") +
+ sectionCard("耗时统计", C.amber, "输出速度 " + st.outputSpeed + " tok/s",
+ '
' + donut(timeParts, 110, 12, fmtDuration(ts.modelWaitMs), "总时长") +
+ '
' + timeLegendRows(timeParts, { 模型等待: d.requests + " 次" }) + "
" +
+ timelineStrip(timeParts, fmtDuration(ts.spanMs || ts.modelWaitMs))) +
+ "
",
+
+ '' +
+ '
当前上下文' + esc(st.lastModelId) + " " +
+ '
' + fmtTokens(st.currentContextTokens) + ' / ' + fmtTokens(win) + " tokens " +
+ '' + fmtPct(usedPct) + " 上下文已用
" +
+ '
' + segs + '
' +
+ '
' + compParts.map(function (p) {
+ var pct = ((p.value / compTotal) * 100).toFixed(0) + "%";
+ return '
' + esc(p.label) + ' ' + pct + '
≈' + fmtTokens(p.value) + "
";
+ }).join("") + "
",
+
+ '
上下文费用构成估算 · CNY ' +
+ '
' +
+ '
≈ ' + fmtTokens(costComp.totalTokens) + ' 当前上下文 tokens(估算)
' +
+ '
≈ ' + (Number(costComp.totalCost) || 0).toFixed(5) + ' 非缓存输入估算 按最近模型牌价
' +
+ '
' +
+ '
' + donut(costParts, 104, 12, "", "") +
+ '
100% 参考费用占比
' +
+ legendGrid(costParts, { cols: 2 }) + "
",
+ "
",
+
+ ' 上下文趋势' + d.requests + " 步 · " + d.turns + " 轮 " +
+ '
' +
+ trendChart(trendRows, win, state.trendMode, state.pinnedStep) +
+ requestDetailHtml(d) +
+ '
输入(上下文) 输出 compact 压缩 模型窗口上限
',
+
+ '' +
+ '
事件日志' + evCounts.all + " 条 " +
+ '
' +
+ '全部 ' + evCounts.all + " " +
+ '消息 ' + evCounts.message + " " +
+ '工具 ' + evCounts.tool + " " +
+ '压缩 ' + evCounts.compact + " " +
+ '错误 ' + evCounts.error + " " +
+ (state.evFilter && state.evFilter.indexOf("tool:") === 0 ? '' + esc(state.evFilter.slice(5)) + " ✕ " : "") +
+ "
" +
+ '
' + eventsHtml + "
" +
+ '
文件活动' + filesFiltered.length + "/" + (d.files || []).length + " 个 · 读" + fileTotalRead + " 写" + fileTotalWrite + " 改" + fileTotalEdit + " " +
+ '
",
+
+ '' +
+ '
上下文浏览器DNA 模式 · 当前 ' +
+ '
' + browserRows + "
" +
+ '
上下文注入与事件注入 ' + (d.composition.systemPrompt.items || 0) + " " +
+ '
' +
+ '本轮会话注入 ' + (d.composition.systemPrompt.items || 0) + ' 项系统提示 · 工具定义 ' + (d.composition.toolDefs.items || 0) + " 项
" +
+ '
上下文文件(注入/压缩/切换事件)与压缩历史暂由事件日志承载。
',
+ ];
+ body.innerHTML = parts.join("");
+ body.querySelector("[data-back]").addEventListener("click", backToOverview);
+ body.querySelectorAll("[data-tmode]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ state.trendMode = btn.getAttribute("data-tmode");
+ render();
+ });
+ });
+ body.querySelectorAll("[data-tgran]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ state.trendGran = btn.getAttribute("data-tgran");
+ state.pinnedStep = null;
+ render();
+ });
+ });
+ // header figures -> scroll to the trend card
+ body.querySelectorAll("[data-jump]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) {
+ return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("上下文趋势") >= 0;
+ });
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
+ });
+ });
+ // io chips -> filter file card + scroll
+ body.querySelectorAll("[data-iofilter]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var op = el.getAttribute("data-iofilter");
+ state.fileFilter = state.fileFilter === op ? "" : op;
+ var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) {
+ return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("文件活动") >= 0;
+ });
+ if (target) {
+ render();
+ var again = Array.from(state.bodyEl.querySelectorAll(".zci-card")).find(function (c) {
+ return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("文件活动") >= 0;
+ });
+ if (again) again.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ });
+ });
+ // tool chips -> filter event log to that tool + scroll
+ body.querySelectorAll("[data-evtool]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var tool = el.getAttribute("data-evtool");
+ state.evFilter = state.evFilter === "tool:" + tool ? null : "tool:" + tool;
+ render();
+ var again = Array.from(state.bodyEl.querySelectorAll(".zci-card")).find(function (c) {
+ return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("事件日志") >= 0;
+ });
+ if (again) again.scrollIntoView({ behavior: "smooth", block: "center" });
+ });
+ });
+ // ctx bar segments -> expand + scroll to browser group
+ body.querySelectorAll(".zci-ctxbar [data-bgroup]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var label = el.getAttribute("data-bgroup");
+ state.browserOpen[label] = true;
+ render();
+ var row = state.bodyEl.querySelector('[data-brow="' + label.replace(/"/g, '\\"') + '"]');
+ if (row) row.scrollIntoView({ behavior: "smooth", block: "center" });
+ });
+ });
+ // ctx bar hover <-> browser row highlight (bidirectional)
+ var ctxSegs = body.querySelectorAll(".zci-ctxbar [data-bgroup]");
+ var browRows = body.querySelectorAll("[data-brow]");
+ function hotGroup(label) {
+ ctxSegs.forEach(function (s) {
+ s.classList.toggle("zci-seg-hot", s.getAttribute("data-bgroup") === label);
+ });
+ browRows.forEach(function (r) {
+ r.classList.toggle("zci-bhot", r.getAttribute("data-brow") === label);
+ });
+ }
+ ctxSegs.forEach(function (s) {
+ s.addEventListener("mouseenter", function () { hotGroup(s.getAttribute("data-bgroup")); });
+ s.addEventListener("mouseleave", function () { hotGroup(null); });
+ });
+ browRows.forEach(function (r) {
+ r.addEventListener("mouseenter", function () { hotGroup(r.getAttribute("data-brow")); });
+ r.addEventListener("mouseleave", function () { hotGroup(null); });
+ });
+ // browser rows expand/collapse
+ body.querySelectorAll("[data-brow]").forEach(function (row) {
+ row.addEventListener("click", function (e) {
+ if (e.target.closest(".bdetail a")) return;
+ var label = row.getAttribute("data-brow");
+ state.browserOpen[label] = !state.browserOpen[label];
+ render();
+ });
+ });
+ // event pills
+ body.querySelectorAll("[data-evk]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var k = el.getAttribute("data-evk");
+ state.evFilter = k || null;
+ render();
+ });
+ });
+ body.querySelectorAll("[data-evk-clear]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.evFilter = null;
+ render();
+ });
+ });
+ // event rows expand full text
+ body.querySelectorAll("[data-ev]").forEach(function (row) {
+ row.addEventListener("click", function () {
+ var id = row.getAttribute("data-ev");
+ state.evOpen[id] = !state.evOpen[id];
+ render();
+ });
+ });
+ // file pills + search + row expand
+ body.querySelectorAll("[data-fop]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ state.fileFilter = el.getAttribute("data-fop") || "";
+ render();
+ });
+ });
+ var fileSearch = body.querySelector("[data-filesearch]");
+ if (fileSearch) {
+ fileSearch.addEventListener("input", function (e) {
+ state.fileSearch = e.target.value;
+ render();
+ var again = state.bodyEl.querySelector("[data-filesearch]");
+ if (again) {
+ again.focus();
+ again.setSelectionRange(again.value.length, again.value.length);
+ }
+ });
+ }
+ body.querySelectorAll("[data-file]").forEach(function (row) {
+ row.addEventListener("click", function () {
+ var id = row.getAttribute("data-file");
+ state.fileOpen[id] = !state.fileOpen[id];
+ render();
+ });
+ });
+ // trend points: click to pin request detail
+ body.querySelectorAll(".zci-trend-hit").forEach(function (hit) {
+ hit.addEventListener("click", function () {
+ var i = Number(hit.getAttribute("data-step"));
+ state.pinnedStep = state.pinnedStep === i ? null : i;
+ render();
+ });
+ });
+ var unpin = body.querySelector("[data-unpin]");
+ if (unpin) {
+ unpin.addEventListener("click", function () {
+ state.pinnedStep = null;
+ render();
+ });
+ }
+ bindAll(body);
+ }
+
+ function renderAgents(body) {
+ var data = state.agentsData;
+ if (!data) return;
+ var maxTokens = Math.max.apply(
+ null,
+ [1].concat(
+ data.agents.map(function (a) { return a.tokens; }),
+ ),
+ );
+ var main = { name: data.sessionTitle, tokens: 0, requests: 0, activeMs: 0 };
+ var usageMain = data.events.filter(function (e) { return e.sessionId === data.sessionId; });
+ if (usageMain.length) {
+ main.tokens = usageMain.reduce(function (a, e) { return a + e.inputTokens + e.outputTokens; }, 0);
+ main.requests = usageMain.length;
+ }
+ var running = data.agents.filter(function (a) { return a.lastAt > Date.now() - 3600000; }).length;
+ var msgs = data.events.length;
+
+ var agentColors = [C.blue, C.purple, C.cyan, C.green, C.amber, C.red];
+ function pressCls(pct) {
+ return pct >= 90 ? "zci-press-hot" : pct >= 70 ? "zci-press-warn" : "zci-press-ok";
+ }
+ function nodeCard(a, color, badge, child) {
+ var pct = Math.max(2, Math.min(100, (a.tokens / maxTokens) * 100));
+ var ctxPct = a.contextPct != null ? a.contextPct * 100 : null;
+ var tip =
+ "" + esc(a.name) + " " +
+ " Token " + fmtTokens(a.tokens) + " · " + (a.requests || 0) + " 请求" +
+ " 活跃 " + fmtDuration(a.activeMs || 0) +
+ (child && child.childSessionId ? " 子会话 " + esc(child.childSessionId.slice(0, 24)) + "…点击打开该子会话详情
" : "主会话节点
");
+ return (
+ '' +
+ '
' + esc(badge) + " " +
+ '
' + esc(a.name) + '
' + fmtTokens(a.tokens) + " · " + fmtCost(a.cost || 0) + "
" +
+ (ctxPct != null ? '
' + Math.round(ctxPct) + "% " : "") + "
" +
+ '
' +
+ '
' + esc(fmtDuration(a.activeMs || 0)) + " " + (a.requests || 0) + " 请求 " +
+ (a.desc ? "" + esc(String(a.desc).slice(0, 40)) + " " : "") + "
"
+ );
+ }
+
+ var childRows = (data.agents || [])
+ .map(function (a, i) {
+ var col = agentColors[i % agentColors.length];
+ var badge = String(a.profileName || a.profileId || "AG").slice(0, 2).toUpperCase();
+ return nodeCard(
+ {
+ name: a.profileName || a.profileId || "agent",
+ tokens: a.tokens,
+ cost: 0,
+ activeMs: a.activeMs,
+ requests: a.requests,
+ desc: a.description,
+ contextPct: a.contextPct,
+ },
+ col,
+ badge,
+ a,
+ );
+ })
+ .join("");
+
+ var evRows = (data.events || [])
+ .slice(-100)
+ .reverse()
+ .map(function (ev, i) {
+ var who = ev.sessionId === data.sessionId ? "主" : "子";
+ var id = "ag" + i;
+ var open = state.evOpen[id] ? " open" : "";
+ return (
+ '点击展开
\">" + esc(fmtTime(ev.at)) + ' [' + esc(who) + '] ' +
+ '' + esc(ev.modelId) + " · " + fmtTokens(ev.inputTokens) + " → " + fmtTokens(ev.outputTokens) + ' ' + (open ? "收起" : "展开") + " "
+ );
+ })
+ .join("");
+
+ var parts = [
+ '← 返回 编队视图
',
+ '多 Agent 编队
在同一时间线上,观察 Agent 间的通讯、活跃状态与委派工作。
' +
+ '
' + ((data.agents || []).length + 1) + '
Agent
' +
+ '
' +
+ '
' + (data.agents || []).length + '
工作项
' +
+ '
',
+ '' +
+ '
Agent 网络' + ((data.agents || []).length + 1) + " 个节点 " +
+ '
' + nodeCard({ name: main.name, tokens: main.tokens, requests: main.requests, activeMs: 0, cost: 0 }, C.blue, "主") +
+ ((data.agents || []).length ? '
' + childRows + "
" : "") +
+ "
" +
+ '
跨 Agent 协作' + msgs + " 条消息 " +
+ '
' + (evRows || '
这个编队还没有跨 Agent 事件
') + "
",
+ ];
+ body.innerHTML = parts.join("");
+ body.querySelector("[data-back]").addEventListener("click", backToOverview);
+ // 点击子 Agent 节点 -> 打开该子会话详情
+ body.querySelectorAll("[data-open]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ openSession(el.getAttribute("data-open"), "session");
+ });
+ });
+ // hover 子节点 -> 点亮自身与主节点(父链),其余降透明
+ var net = body.querySelector(".zci-net");
+ if (net) {
+ net.addEventListener("mouseover", function (e) {
+ var node = e.target.closest("[data-agent]");
+ net.classList.toggle("zci-dimnet", Boolean(node));
+ var mainCard = net.querySelector(".zci-node");
+ body.querySelectorAll("[data-agent]").forEach(function (n) {
+ n.classList.toggle("zci-chain", n === node);
+ });
+ if (mainCard) mainCard.classList.toggle("zci-chain", Boolean(node));
+ });
+ net.addEventListener("mouseleave", function () {
+ net.classList.remove("zci-dimnet");
+ body.querySelectorAll("[data-agent]").forEach(function (n) {
+ n.classList.remove("zci-chain");
+ });
+ var mainCard = net.querySelector(".zci-node");
+ if (mainCard) mainCard.classList.remove("zci-chain");
+ });
+ }
+ // 协作事件行点击展开
+ body.querySelectorAll("[data-agev]").forEach(function (row) {
+ row.addEventListener("click", function () {
+ var id = row.getAttribute("data-agev");
+ state.evOpen[id] = !state.evOpen[id];
+ render();
+ });
+ });
+ bindAll(body);
+ }
+
+ function render() {
+ var body = state.bodyEl;
+ if (!body) return;
+ var tabs =
+ '洞察
' +
+ '会话
' +
+ '上下文
' +
+ '编队
';
+ state.tabsEl.innerHTML = tabs;
+ state.tabsEl.querySelectorAll("[data-tab]").forEach(function (el) {
+ el.addEventListener("click", function () {
+ var target = el.getAttribute("data-tab");
+ if ((target === "session" || target === "agents") && !state.detailSessionId) {
+ // pick the most recently active session — the one the user is likely in
+ var sessions = (state.sessions && state.sessions.length ? state.sessions : null) ||
+ (state.overview && state.overview.sessions) || [];
+ var pick = null;
+ sessions.forEach(function (s) {
+ if (!pick || (s.lastActiveAt || 0) > (pick.lastActiveAt || 0)) pick = s;
+ });
+ if (pick && pick.id) {
+ openSession(pick.id, target);
+ return;
+ }
+ state.tab = "sessions";
+ loadSessions(true);
+ return;
+ }
+ state.tab = target;
+ if (state.tab === "agents") loadAgents(state.detailSessionId);
+ else if (state.tab === "sessions" && !state.sessions) loadSessions();
+ else render();
+ });
+ });
+
+ if (state.error) {
+ body.innerHTML =
+ '' + esc(state.error) + '
重试
';
+ body.querySelector("[data-retry]").addEventListener("click", function () {
+ state.error = null;
+ load();
+ });
+ return;
+ }
+ if (state.loading) {
+ body.innerHTML = '加载中…
';
+ return;
+ }
+ function safe(name, fn) {
+ try {
+ fn();
+ } catch (e) {
+ body.innerHTML =
+ '渲染出错(' + esc(name) + '):
' +
+ esc(String((e && (e.stack || e.message)) || e)) + " ";
+ }
+ }
+ if (state.tab === "overview") safe("overview", function () { renderOverview(body); });
+ else if (state.tab === "sessions") safe("sessions", function () { renderSessions(body); });
+ else if (state.tab === "session") safe("session", function () { renderSessionDetail(body); });
+ else if (state.tab === "agents") safe("agents", function () { renderAgents(body); });
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* Loading */
+ /* ---------------------------------------------------------------- */
+
+ function load() {
+ if (state.tab === "agents" && state.detailSessionId) return loadAgents(state.detailSessionId);
+ state.loading = true;
+ state.error = null;
+ render();
+ var args = { start: rangeStartMs(), end: Date.now(), order: state.order };
+ if (state.tab === "sessions") return loadSessions();
+ query("overview", args)
+ .then(function (data) {
+ state.overview = data && data.ok !== false ? data : state.overview;
+ state.loading = false;
+ if (state.tab === "overview") render();
+ })
+ .catch(function (e) {
+ state.loading = false;
+ state.error = e.message;
+ if (state.tab === "overview") render();
+ });
+ }
+
+ function loadSessions(openAfter) {
+ state.loading = true;
+ state.error = null;
+ render();
+ query("sessions", { start: rangeStartMs(), end: Date.now(), order: state.order, limit: 60 })
+ .then(function (data) {
+ state.sessions = (data && data.sessions) || [];
+ state.loading = false;
+ if (openAfter && state.sessions.length) {
+ openSession(state.sessions[0].id, state.tab === "agents" ? "agents" : "session");
+ return;
+ }
+ render();
+ })
+ .catch(function (e) {
+ state.loading = false;
+ state.error = e.message;
+ render();
+ });
+ }
+
+ function openSession(id, targetTab) {
+ state.detailSessionId = id;
+ state.tab = targetTab || "session";
+ state.loading = true;
+ state.error = null;
+ render();
+ if (state.tab === "agents") return loadAgents(id);
+ query("session", { sessionId: id })
+ .then(function (data) {
+ if (!data || data.ok === false) throw new Error((data && data.error && data.error.message) || "查询失败");
+ state.sessionDetail = data;
+ state.loading = false;
+ render();
+ })
+ .catch(function (e) {
+ state.loading = false;
+ state.error = e.message;
+ render();
+ });
+ }
+
+ function loadAgents(id) {
+ state.loading = true;
+ state.error = null;
+ render();
+ query("agents", { sessionId: id })
+ .then(function (data) {
+ if (!data || data.ok === false) throw new Error((data && data.error && data.error.message) || "查询失败");
+ state.agentsData = data;
+ state.loading = false;
+ render();
+ })
+ .catch(function (e) {
+ state.loading = false;
+ state.error = e.message;
+ render();
+ });
+ }
+
+ function backToOverview() {
+ state.tab = "overview";
+ state.sessionDetail = null;
+ state.agentsData = null;
+ state.detailSessionId = null;
+ render();
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* Overlay + sidebar entry */
+ /* ---------------------------------------------------------------- */
+
+ function buildOverlay() {
+ var overlay = document.createElement("div");
+ overlay.className = "zci-overlay";
+ overlay.innerHTML =
+ '
上下文洞察 数据来自本机 ZCode 用量库 · 费用为牌价估算 ' +
+ '
' +
+ '今天 ' +
+ '近 7 天 ' +
+ '近 30 天
' +
+ '
⟳ ' +
+ '
✕ ' +
+ '
';
+ document.body.appendChild(overlay);
+ function syncOffset() {
+ var aside = findAside();
+ var w = 262;
+ try {
+ if (aside) {
+ var rect = aside.getBoundingClientRect();
+ if (rect && rect.width > 80) w = rect.width;
+ }
+ } catch {
+ // keep default
+ }
+ overlay.style.left = w + 6 + "px";
+ }
+ syncOffset();
+ window.addEventListener("resize", syncOffset);
+ state.overlayEl = overlay;
+ state.tabsEl = overlay.querySelector(".zci-tabs");
+ state.bodyEl = overlay.querySelector(".zci-body");
+
+ overlay.querySelectorAll("[data-range]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ state.range = btn.getAttribute("data-range") === "today" ? "today" : Number(btn.getAttribute("data-range"));
+ overlay.querySelectorAll("[data-range]").forEach(function (b) {
+ b.classList.remove("zci-on");
+ });
+ btn.classList.add("zci-on");
+ if (state.tab === "session" || state.tab === "agents") state.tab = "overview";
+ load();
+ });
+ });
+ overlay.querySelector("[data-refresh]").addEventListener("click", load);
+ overlay.querySelector("[data-close]").addEventListener("click", closeOverlay);
+ state.onKey = function (e) {
+ if (e.key === "Escape") closeOverlay();
+ };
+ document.addEventListener("keydown", state.onKey, true);
+ load();
+ }
+
+ function openOverlay() {
+ if (!state.overlayEl) buildOverlay();
+ else {
+ state.overlayEl.style.display = "flex";
+ load();
+ }
+ }
+
+ function closeOverlay() {
+ if (state.overlayEl) state.overlayEl.style.display = "none";
+ if (state.onKey) document.removeEventListener("keydown", state.onKey, true);
+ }
+
+ function visible(el) {
+ try {
+ var rect = el.getBoundingClientRect();
+ return rect && rect.width >= 80 && rect.height >= 80;
+ } catch {
+ return true;
+ }
+ }
+
+ function findAside() {
+ var list = [
+ document.querySelector('aside[data-testid="sidebar"]'),
+ document.querySelector('[data-workspace-sidebar-panel="true"] aside'),
+ document.querySelector('nav[data-testid="sidebar"]'),
+ ].filter(Boolean);
+ return list.find(visible) || list[0] || null;
+ }
+
+ function isCompactProfileBlock(el, aside, panel) {
+ if (!el || el === aside || el === panel || el === document.body) return false;
+ if (!(aside.contains(el) || panel.contains(el))) return false;
+ if (el.querySelector('aside[data-testid="sidebar"], nav[data-testid="sidebar"]')) return false;
+ try {
+ var rect = el.getBoundingClientRect();
+ if (!rect || rect.height <= 0 || rect.height > 140) return false;
+ } catch {
+ return false;
+ }
+ return true;
+ }
+
+ function findProfileBlock(aside) {
+ var panel = aside.closest('[data-workspace-sidebar-panel="true"]') || aside.parentElement || aside;
+ var triggers = [];
+ ['[data-testid="login-trigger"]', '[data-testid="task-settings-button"]'].forEach(function (selector) {
+ [aside, panel].forEach(function (root) {
+ root.querySelectorAll(selector).forEach(function (el) {
+ if (!triggers.includes(el)) triggers.push(el);
+ });
+ });
+ });
+ var trigger = null;
+ var lowest = -Infinity;
+ for (var el of triggers) {
+ try {
+ var rect = el.getBoundingClientRect();
+ if (!rect || rect.height <= 0 || rect.top < lowest) continue;
+ lowest = rect.top;
+ trigger = el;
+ } catch {
+ // detached
+ }
+ }
+ if (!trigger) return null;
+ var best = null;
+ var node = trigger;
+ while (node && node !== aside && node !== panel && node !== document.body) {
+ if (isCompactProfileBlock(node, aside, panel)) best = node;
+ node = node.parentElement;
+ }
+ if (!best || !best.parentElement) return null;
+ return { parent: best.parentElement, node: best };
+ }
+
+ function mountEntry() {
+ var aside = findAside();
+ if (!aside) return false;
+ if (document.getElementById("zci-entry")) return true;
+ var entry = document.createElement("div");
+ entry.className = "zci-entry";
+ entry.id = "zci-entry";
+ entry.innerHTML =
+ '上下文洞察 ';
+ entry.addEventListener("click", openOverlay);
+ var footer = aside.querySelector(":scope > footer");
+ if (footer) {
+ aside.insertBefore(entry, footer);
+ return true;
+ }
+ var profile = findProfileBlock(aside);
+ if (profile && profile.parent && profile.node) {
+ profile.parent.insertBefore(entry, profile.node);
+ return true;
+ }
+ aside.appendChild(entry);
+ return true;
+ }
+
+ function tryMount(attempt) {
+ ensureStyle();
+ if (mountEntry()) {
+ if (state.observer) return;
+ state.observer = new MutationObserver(function () {
+ if (window.__zciLive !== BOOT_ID) return;
+ if (!document.getElementById("zci-entry")) mountEntry();
+ });
+ window.__zciObserver = state.observer;
+ window.__zciLive = BOOT_ID;
+ try {
+ state.observer.observe(document.body, { childList: true, subtree: true });
+ } catch {
+ // ignore
+ }
+ return;
+ }
+ if (attempt < 120) setTimeout(function () { tryMount(attempt + 1); }, 500);
+ }
+
+ tryMount(0);
+
+ window.__zcodeContextDispose = function () {
+ try {
+ if (window.__zciObserver) {
+ window.__zciObserver.disconnect();
+ window.__zciObserver = null;
+ }
+ var entry = document.getElementById("zci-entry");
+ if (entry) entry.remove();
+ var style = document.getElementById("zci-style");
+ if (style) style.remove();
+ if (state.overlayEl) state.overlayEl.remove();
+ document.removeEventListener("keydown", state.onKey, true);
+ window.__zcodeContextPanelBooted = false;
+ } catch (e) {
+ /* ignore */
+ }
+ };
+})();
diff --git a/external_plugins/zcode-context/scripts/asar-patch.mjs b/external_plugins/zcode-context/scripts/asar-patch.mjs
new file mode 100644
index 00000000..be5a48a6
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/asar-patch.mjs
@@ -0,0 +1,447 @@
+#!/usr/bin/env node
+/**
+ * Minimal, dependency-free app.asar entry-line patcher.
+ *
+ * It only appends/removes the zcode-context loader line in
+ * `out/main/index.js`; every other file is stream-copied byte-for-byte.
+ * The asar layout follows Electron's pickle format (same approach as the
+ * token-usage statusbar plugin), including per-file offsets and integrity.
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import crypto from "node:crypto";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const ENTRY = "/out/main/index.js";
+const ALIGN = 4;
+const BLOCK = 4 * 1024 * 1024;
+const RUNTIME_DIR =
+ (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) ||
+ path.join(os.homedir(), ".zcode", "zcode-context", "runtime");
+const LOADER_PATH = path.join(RUNTIME_DIR, "panel", "inject-main.cjs");
+const STAGE_SUFFIX = ".zcode-context.tmp";
+
+const INJECT_LINE = `\n;import(${JSON.stringify(pathToFileURL(LOADER_PATH).href)}).then(() => null, (e) => console.error("[zcode-context] load failed", e));`;
+const INJECT_RE =
+ /\n;import\("file:[^"]*inject-main\.cjs"\)\.then\(\(\) => null, \(e\) => console\.error\("\[zcode-context\] load failed", e\)\);?/g;
+
+export function injectionLine(loaderPath = LOADER_PATH) {
+ return `\n;import(${JSON.stringify(pathToFileURL(loaderPath).href)}).then(() => null, (e) => console.error("[zcode-context] load failed", e));`;
+}
+
+function readHeader(fd) {
+ const head = Buffer.alloc(16);
+ fs.readSync(fd, head, 0, 16, 0);
+ const a = head.readUInt32LE(0);
+ const b = head.readUInt32LE(4);
+ const c = head.readUInt32LE(8);
+ const d = head.readUInt32LE(12);
+ if (a !== 4) throw new Error(`not an asar archive (prefix ${a})`);
+ if (d <= 0 || d > 64 * 1024 * 1024) throw new Error(`invalid asar header length ${d}`);
+ const json = Buffer.alloc(d);
+ fs.readSync(fd, json, 0, d, 16);
+ const header = JSON.parse(json.toString("utf8"));
+ return { header, base: 8 + b, headerLength: d, headerBlock: c };
+}
+
+function iterFiles(node, prefix = "") {
+ const out = [];
+ for (const [name, child] of Object.entries(node.files || {})) {
+ const p = `${prefix}/${name}`;
+ if (child && typeof child === "object" && child.files) out.push(...iterFiles(child, p));
+ else out.push({ path: p, node: child });
+ }
+ return out;
+}
+
+function integrityOf(data) {
+ const hash = crypto.createHash("sha256").update(data).digest("hex");
+ const blocks = [];
+ if (data.length === 0) {
+ // Electron's asar writer records one empty block for zero-length files.
+ blocks.push(hash);
+ } else {
+ for (let i = 0; i < data.length; i += BLOCK) {
+ blocks.push(crypto.createHash("sha256").update(data.subarray(i, i + BLOCK)).digest("hex"));
+ }
+ }
+ return { algorithm: "SHA256", hash, blockSize: BLOCK, blocks };
+}
+
+function readEntry(asarPath) {
+ const fd = fs.openSync(asarPath, "r");
+ try {
+ const { header, base } = readHeader(fd);
+ const entry = iterFiles(header).find((item) => item.path === ENTRY);
+ if (!entry) throw new Error(`asar is missing ${ENTRY}`);
+ const data = Buffer.alloc(entry.node.size);
+ fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset));
+ return { header, base, entry: { ...entry, data } };
+ } finally {
+ fs.closeSync(fd);
+ }
+}
+
+export function inspectAsar(asarPath) {
+ const fd = fs.openSync(asarPath, "r");
+ try {
+ const { header, base } = readHeader(fd);
+ const files = iterFiles(header);
+ const entry = files.find((item) => item.path === ENTRY);
+ if (!entry) throw new Error(`asar is missing ${ENTRY}`);
+ const data = Buffer.alloc(entry.node.size);
+ fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset));
+ const text = data.toString("utf8");
+ INJECT_RE.lastIndex = 0;
+ return {
+ asarPath,
+ fileCount: files.length,
+ entrySize: data.length,
+ hasBalanceLine: INJECT_RE.test(text),
+ hasZusageLine: /\[zusage\] load failed/.test(text),
+ };
+ } finally {
+ INJECT_RE.lastIndex = 0;
+ fs.closeSync(fd);
+ }
+}
+
+/**
+ * Rebuild an asar with one replaced file.
+ *
+ * Critical invariant: `node.offset` is the SOURCE offset until the copy loop
+ * finishes. The new offsets are kept separately, otherwise every file after a
+ * size-changing replacement is read from the wrong place and the archive looks
+ * structurally valid while its contents are shifted.
+ */
+function repackAsar(asarPath, dstPath, replacements) {
+ const srcFd = fs.openSync(asarPath, "r");
+ try {
+ const { header, base } = readHeader(srcFd);
+ const copy = JSON.parse(JSON.stringify(header));
+ const entries = iterFiles(copy);
+ const missing = [...replacements.keys()].filter((p) => !entries.some((item) => item.path === p));
+ if (missing.length) throw new Error(`asar paths not found: ${missing.join(", ")}`);
+ for (const { path: p, node } of entries) {
+ if (node.unpacked && replacements.has(p)) {
+ throw new Error(`cannot replace unpacked asar entry: ${p}`);
+ }
+ }
+
+ // Immutable snapshot of the source layout. This is the fix for the
+ // 2026-11-13 corruption: never read from a rewritten offset.
+ const originalOffsets = new Map(
+ entries.map(({ path: p, node }) => [p, Number(node.offset)]),
+ );
+ const newOffsets = new Map();
+ let offset = 0;
+ for (const { path: p, node } of entries) {
+ if (node.unpacked) continue;
+ if (replacements.has(p)) {
+ const data = replacements.get(p);
+ node.size = data.length;
+ node.integrity = integrityOf(data);
+ }
+ newOffsets.set(p, offset);
+ offset += Number(node.size);
+ }
+ for (const { path: p, node } of entries) {
+ if (!node.unpacked) node.offset = String(newOffsets.get(p));
+ }
+
+ const headerBytes = Buffer.from(JSON.stringify(copy), "utf8");
+ const d = headerBytes.length;
+ const c = Math.ceil((d + 4) / ALIGN) * ALIGN;
+ const b = c + 4;
+ const prefix = Buffer.alloc(16);
+ prefix.writeUInt32LE(4, 0);
+ prefix.writeUInt32LE(b, 4);
+ prefix.writeUInt32LE(c, 8);
+ prefix.writeUInt32LE(d, 12);
+
+ const dstFd = fs.openSync(dstPath, "w");
+ try {
+ fs.writeSync(dstFd, prefix, 0, 16);
+ fs.writeSync(dstFd, headerBytes, 0, headerBytes.length);
+ const pad = Buffer.alloc(c - d - 4);
+ if (pad.length) fs.writeSync(dstFd, pad, 0, pad.length);
+
+ const chunk = Buffer.alloc(1024 * 1024);
+ for (const { path: p, node } of entries) {
+ if (node.unpacked) continue;
+ if (replacements.has(p)) {
+ const data = replacements.get(p);
+ fs.writeSync(dstFd, data, 0, data.length);
+ continue;
+ }
+ let remaining = Number(node.size);
+ let srcOffset = base + originalOffsets.get(p);
+ while (remaining > 0) {
+ const size = Math.min(chunk.length, remaining);
+ fs.readSync(srcFd, chunk, 0, size, srcOffset);
+ fs.writeSync(dstFd, chunk, 0, size);
+ srcOffset += size;
+ remaining -= size;
+ }
+ }
+ fs.fsyncSync(dstFd);
+ } finally {
+ fs.closeSync(dstFd);
+ }
+ return { fileCount: entries.length, headerLength: d };
+ } finally {
+ fs.closeSync(srcFd);
+ }
+}
+
+/**
+ * Fully validate an asar: header layout, contiguous offsets, bounds, and the
+ * SHA-256 (plus block hashes) of every entry against the header's integrity.
+ * This is intentionally expensive; it is the gate before any file replacement.
+ */
+export function validateAsar(asarPath, options = {}) {
+ const fd = fs.openSync(asarPath, "r");
+ let entryText = null;
+ try {
+ const stat = fs.fstatSync(fd);
+ const { header, base, headerLength, headerBlock } = readHeader(fd);
+ if (headerBlock !== Math.ceil((headerLength + 4) / ALIGN) * ALIGN) {
+ throw new Error(`invalid asar header alignment (d=${headerLength}, c=${headerBlock})`);
+ }
+ if (base !== 12 + headerBlock) {
+ throw new Error(`invalid asar data base (base=${base}, c=${headerBlock})`);
+ }
+ const entries = iterFiles(header).filter(({ node }) => !node.unpacked);
+ let expectedOffset = 0;
+ let entryCount = 0;
+ for (const { path: p, node } of entries) {
+ const off = Number(node.offset);
+ const size = Number(node.size);
+ if (!Number.isInteger(off) || !Number.isInteger(size) || off < 0 || size < 0) {
+ throw new Error(`invalid offset/size for ${p}`);
+ }
+ if (off !== expectedOffset) {
+ throw new Error(`non-contiguous offset for ${p}: ${off} != ${expectedOffset}`);
+ }
+ if (size > 64 * 1024 * 1024) throw new Error(`unreasonable entry size for ${p}: ${size}`);
+ if (base + off + size > stat.size) throw new Error(`entry out of bounds: ${p}`);
+ const data = Buffer.alloc(size);
+ if (size > 0) fs.readSync(fd, data, 0, size, base + off);
+ if (node.integrity && typeof node.integrity === "object") {
+ const actual = integrityOf(data);
+ if (node.integrity.hash && actual.hash !== node.integrity.hash) {
+ throw new Error(`integrity hash mismatch: ${p}`);
+ }
+ if (Array.isArray(node.integrity.blocks)) {
+ if (actual.blocks.length !== node.integrity.blocks.length) {
+ throw new Error(`integrity block count mismatch: ${p}`);
+ }
+ for (let i = 0; i < actual.blocks.length; i += 1) {
+ if (actual.blocks[i] !== node.integrity.blocks[i]) {
+ throw new Error(`integrity block mismatch: ${p} [${i}]`);
+ }
+ }
+ }
+ }
+ if (p === ENTRY) entryText = data.toString("utf8");
+ expectedOffset += size;
+ entryCount += 1;
+ }
+ if (base + expectedOffset !== stat.size) {
+ throw new Error(
+ `asar length mismatch: header end ${base + expectedOffset}, file size ${stat.size}`,
+ );
+ }
+ const hasBalanceLine = entryText ? /\[zcode-context\] load failed/.test(entryText) : false;
+ const hasZusageLine = entryText ? /\[zusage\] load failed/.test(entryText) : false;
+ if (options.expectBalance !== undefined && hasBalanceLine !== options.expectBalance) {
+ throw new Error(
+ `injection marker mismatch: expected balance=${options.expectBalance}, got ${hasBalanceLine}`,
+ );
+ }
+ if (options.expectZusage !== undefined && hasZusageLine !== options.expectZusage) {
+ throw new Error(
+ `zusage marker mismatch: expected ${options.expectZusage}, got ${hasZusageLine}`,
+ );
+ }
+ return {
+ ok: true,
+ asarPath,
+ fileCount: entryCount,
+ entrySize: entryText ? Buffer.byteLength(entryText) : 0,
+ hasBalanceLine,
+ hasZusageLine,
+ size: stat.size,
+ };
+ } finally {
+ fs.closeSync(fd);
+ }
+}
+
+export const BACKUP_SUFFIX = ".zcode-context.bak";
+
+export function backupPathOf(asarPath) {
+ return `${asarPath}${BACKUP_SUFFIX}`;
+}
+
+export function restoreBackup(asarPath) {
+ const backup = backupPathOf(asarPath);
+ if (!fs.existsSync(backup)) throw new Error(`backup not found: ${backup}`);
+ const check = validateAsar(backup);
+ fs.copyFileSync(backup, asarPath);
+ return { status: "restored", asarPath, backup, ...check };
+}
+
+export function replaceAsarWithBackup(stagePath, asarPath, options = {}) {
+ const stageCheck = validateAsar(stagePath, options.stageOptions || {});
+ const backup = backupPathOf(asarPath);
+ try {
+ fs.unlinkSync(backup);
+ } catch {
+ // no previous backup
+ }
+ try {
+ fs.linkSync(asarPath, backup);
+ } catch {
+ fs.copyFileSync(asarPath, backup);
+ }
+ try {
+ fs.renameSync(stagePath, asarPath);
+ const postCheck = validateAsar(asarPath, options.postOptions || {});
+ return { backup, stageCheck, postCheck };
+ } catch (error) {
+ try {
+ fs.copyFileSync(backup, asarPath);
+ } catch {
+ // leave the backup for manual restoration
+ }
+ throw error;
+ }
+}
+
+export function buildPatchedAsar({ asarPath, loaderPath = LOADER_PATH, stagePath }) {
+ const sourceCheck = validateAsar(asarPath);
+ const { entry } = readEntry(asarPath);
+ const text = entry.data.toString("utf8");
+ const stripped = text.replace(INJECT_RE, "");
+ const line = injectionLine(loaderPath);
+ const hadZusage = /\[zusage\] load failed/.test(text);
+ if (text.includes(line) || stripped + line === text) {
+ return { status: "already", asarPath, entrySize: entry.data.length, hasZusageLine: hadZusage };
+ }
+ const newEntry = Buffer.from(stripped + line, "utf8");
+ const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`;
+ const tmp = `${stage}.stage-${process.pid}`;
+ try {
+ repackAsar(asarPath, tmp, new Map([[ENTRY, newEntry]]));
+ const check = validateAsar(tmp, { expectBalance: true, expectZusage: hadZusage });
+ fs.renameSync(tmp, stage);
+ return { status: "patched", stagePath: stage, sourceCheck, ...check, asarPath };
+ } catch (error) {
+ try {
+ fs.unlinkSync(tmp);
+ } catch {
+ // ignore
+ }
+ throw error;
+ }
+}
+
+export function buildUnpatchedAsar({ asarPath, stagePath }) {
+ const sourceCheck = validateAsar(asarPath);
+ const { entry } = readEntry(asarPath);
+ const text = entry.data.toString("utf8");
+ INJECT_RE.lastIndex = 0;
+ if (!INJECT_RE.test(text)) {
+ INJECT_RE.lastIndex = 0;
+ return { status: "already-clean", asarPath, sourceCheck };
+ }
+ INJECT_RE.lastIndex = 0;
+ const stripped = text.replace(INJECT_RE, "");
+ const hadZusage = /\[zusage\] load failed/.test(text);
+ const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`;
+ const tmp = `${stage}.stage-${process.pid}`;
+ try {
+ repackAsar(asarPath, tmp, new Map([[ENTRY, Buffer.from(stripped, "utf8")]]));
+ const check = validateAsar(tmp, { expectBalance: false, expectZusage: hadZusage });
+ fs.renameSync(tmp, stage);
+ return { status: "unpatched", stagePath: stage, sourceCheck, ...check, asarPath };
+ } catch (error) {
+ try {
+ fs.unlinkSync(tmp);
+ } catch {
+ // ignore
+ }
+ throw error;
+ }
+}
+
+export function replaceAsar(stagePath, asarPath) {
+ return replaceAsarWithBackup(stagePath, asarPath);
+}
+
+export function findAppAsar(explicit) {
+ if (explicit) return explicit;
+ if (process.env.ZCODE_ASAR) return process.env.ZCODE_ASAR;
+ const candidates = [];
+ const add = (p) => p && candidates.push(p);
+ if (process.platform === "win32") {
+ add(path.join(process.env.ProgramFiles || "C:\\Program Files", "ZCode", "resources", "app.asar"));
+ add(path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "ZCode", "resources", "app.asar"));
+ add(path.join(process.env.LOCALAPPDATA || "", "Programs", "ZCode", "resources", "app.asar"));
+ add("D:\\Program Files\\ZCode\\resources\\app.asar");
+ add("D:\\ZCode\\resources\\app.asar");
+ } else if (process.platform === "darwin") {
+ add("/Applications/ZCode.app/Contents/Resources/app.asar");
+ } else {
+ add("/opt/ZCode/resources/app.asar");
+ add(path.join(process.cwd(), "resources", "app.asar"));
+ }
+ return candidates.find((p) => {
+ try {
+ return fs.statSync(p).isFile();
+ } catch {
+ return false;
+ }
+ }) || null;
+}
+
+function argValue(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
+ const command = process.argv[2] || "check";
+ const asar = findAppAsar(argValue("--asar"));
+ if (!asar) {
+ console.error("app.asar not found; pass --asar ");
+ process.exit(2);
+ }
+ try {
+ if (command === "check") {
+ console.log(JSON.stringify(inspectAsar(asar), null, 2));
+ } else if (command === "validate") {
+ console.log(JSON.stringify(validateAsar(asar), null, 2));
+ } else if (command === "patch") {
+ const result = buildPatchedAsar({ asarPath: asar, loaderPath: argValue("--loader") || LOADER_PATH });
+ console.log(JSON.stringify(result, null, 2));
+ } else if (command === "unpatch") {
+ const result = buildUnpatchedAsar({ asarPath: asar });
+ console.log(JSON.stringify(result, null, 2));
+ } else if (command === "finalize") {
+ const stage = argValue("--stage");
+ if (!stage) throw new Error("--stage is required");
+ const result = replaceAsarWithBackup(stage, asar);
+ console.log(JSON.stringify({ status: "replaced", asar, stage, ...result }, null, 2));
+ } else if (command === "restore") {
+ console.log(JSON.stringify(restoreBackup(asar), null, 2));
+ } else {
+ throw new Error(`unknown command: ${command}`);
+ }
+ } catch (error) {
+ console.error(error.stack || error.message || String(error));
+ process.exit(1);
+ }
+}
diff --git a/external_plugins/zcode-context/scripts/lib/common.mjs b/external_plugins/zcode-context/scripts/lib/common.mjs
new file mode 100644
index 00000000..e3061813
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/lib/common.mjs
@@ -0,0 +1,95 @@
+/**
+ * Shared helpers for zcode-context query scripts.
+ *
+ * Everything reads the local ZCode CLI database (read-only). Token estimates
+ * for context composition use a chars-per-token heuristic (about 3.5 bytes per
+ * token for mixed CJK/ASCII content), clearly marked as estimated in the UI.
+ */
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+export function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+export function num(value, fallback = 0) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+export function dbPath() {
+ return (
+ (process.env.ZCONTEXT_USAGE_DB && process.env.ZCONTEXT_USAGE_DB.trim()) ||
+ path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite")
+ );
+}
+
+export async function openDb() {
+ const file = dbPath();
+ if (!fs.existsSync(file)) {
+ return { error: { code: "db_missing", message: `用量数据库不存在:${file}` } };
+ }
+ let DatabaseSync;
+ try {
+ ({ DatabaseSync } = await import("node:sqlite"));
+ } catch (error) {
+ return {
+ error: {
+ code: "sqlite_unavailable",
+ message: `当前运行时不支持 node:sqlite(${error.message})。`,
+ },
+ };
+ }
+ try {
+ return { db: new DatabaseSync(file, { readOnly: true }) };
+ } catch (error) {
+ return { error: { code: "db_open_failed", message: error.message } };
+ }
+}
+
+/** Rough token estimate from a UTF-16 string length. Purely heuristic. */
+export function tokensOfBytes(chars) {
+ return Math.round(chars / 3.5);
+}
+
+/** JSON byte size used consistently for composition estimates. */
+export function sizeOf(value) {
+ if (value === undefined || value === null) return 0;
+ const text = typeof value === "string" ? value : JSON.stringify(value);
+ return text ? text.length : 0;
+}
+
+export function clampText(value, max = 160) {
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
+ return text.length > max ? `${text.slice(0, max)}…` : text;
+}
+
+/** Role of a message row (message.data JSON has `role`; parts have `type`). */
+export function roleOfMessage(data) {
+ return String(data.role || "");
+}
+
+/** True for synthetic/injected user messages that should not count as turns. */
+export function isSyntheticMessage(data) {
+ return Boolean(data.synthetic) || data.visibility === "hidden";
+}
+
+/** Context-window lookup lives in pricing.mjs; re-export for callers. */
+export { windowOfModel } from "./pricing.mjs";
+
+export function workspaceLabel(directory, projectId) {
+ const label =
+ String(directory || projectId || "")
+ .split(/[\\/]/)
+ .filter(Boolean)
+ .pop() || String(projectId || "default");
+ return label;
+}
+
+export function finish(result, startedAt) {
+ if (result && result.ok === false) return result;
+ return { ok: true, ...(result || {}), elapsedMs: Date.now() - startedAt };
+}
diff --git a/external_plugins/zcode-context/scripts/lib/pricing.mjs b/external_plugins/zcode-context/scripts/lib/pricing.mjs
new file mode 100644
index 00000000..f4f24720
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/lib/pricing.mjs
@@ -0,0 +1,81 @@
+/**
+ * Model pricing and context-window tables for zcode-context.
+ *
+ * All costs are estimates computed from public list prices (USD per million
+ * tokens), converted to CNY for display. Models without an entry fall back to
+ * DEFAULT_PRICE_USD and are flagged as estimates by the UI.
+ */
+
+export const USD_TO_CNY = 7.2;
+
+export const MODEL_PRICES_USD_PER_M = {
+ "deepseek-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 },
+ "deepseek-v4-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 },
+ "deepseek-v4.1-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 },
+ "deepseek-v4-1-flash-261017": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 },
+ "deepseek-v4-flash-vision-exp": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 },
+ "deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 },
+ "deepseek-reasoner": { input: 0.55, output: 1.66, cacheRead: 0.14, cacheWrite: 0 },
+ "deepseek-chat": { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0 },
+ "doubao-seed-2-0-code-preview-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 },
+ "doubao-seed-2-0-pro-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 },
+ "doubao-seed-2-0-lite-260428": { input: 0.089, output: 0.534, cacheRead: 0.018, cacheWrite: 0 },
+ "doubao-seed-2-0-mini-260428": { input: 0.03, output: 0.297, cacheRead: 0.006, cacheWrite: 0 },
+ "doubao-seed-2-1-pro-260628": { input: 0.891, output: 4.453, cacheRead: 0.178, cacheWrite: 0 },
+ "doubao-seed-2-1-turbo-260628": { input: 0.445, output: 2.227, cacheRead: 0.089, cacheWrite: 0 },
+ "glm-5.3": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 },
+ "glm-5.3-flash": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0.05 },
+};
+
+export const DEFAULT_PRICE_USD = { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25 };
+
+/** Context window sizes (input tokens). Longest-suffix match on model id. */
+export const MODEL_WINDOWS = [
+ { suffix: "deepseek-v4.1", window: 1048576 },
+ { suffix: "deepseek-v4", window: 1048576 },
+ { suffix: "glm-5.3", window: 204800 },
+ { suffix: "glm-4", window: 131072 },
+ { suffix: "doubao-seed", window: 262144 },
+ { suffix: "claude", window: 204800 },
+ { suffix: "gpt", window: 272000 },
+ { suffix: "deepseek", window: 131072 },
+];
+
+export const DEFAULT_WINDOW = 131072;
+
+export function priceOfModel(modelId) {
+ const key = String(modelId || "").toLowerCase();
+ return MODEL_PRICES_USD_PER_M[key] || null;
+}
+
+export function windowOfModel(modelId) {
+ const key = String(modelId || "").toLowerCase();
+ for (const entry of MODEL_WINDOWS) {
+ if (key.includes(entry.suffix)) return entry.window;
+ }
+ return DEFAULT_WINDOW;
+}
+
+/**
+ * Estimated cost (CNY) for one usage row. Cache-write tokens are billed on top
+ * of bare input; the caller passes already-split numbers.
+ */
+export function costOfUsage({ modelId, input = 0, output = 0, cacheRead = 0, cacheWrite = 0 }) {
+ const rates = priceOfModel(modelId) || DEFAULT_PRICE_USD;
+ const bareInput = Math.max(0, Number(input) - Number(cacheRead) - Number(cacheWrite));
+ const usd =
+ (bareInput * rates.input +
+ Number(cacheRead) * rates.cacheRead +
+ Number(cacheWrite) * rates.cacheWrite +
+ Number(output) * rates.output) /
+ 1_000_000;
+ return usd * USD_TO_CNY;
+}
+
+export function isKnownModel(modelId) {
+ return Boolean(priceOfModel(modelId));
+}
+
+export function round2(n) {
+ return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
+}
diff --git a/external_plugins/zcode-context/scripts/query-agents.mjs b/external_plugins/zcode-context/scripts/query-agents.mjs
new file mode 100644
index 00000000..f4b4d0dd
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/query-agents.mjs
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/**
+ * Agent formation view for the zcode-context dashboard.
+ *
+ * Prints one JSON document:
+ * { ok, sessionId, agents: [...], events: [...] }
+ *
+ * Agents come from the per-agent metadata files stored under the agents dir
+ * usage joined from the local database via the child session ids.
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { arg, num, openDb, clampText, finish } from "./lib/common.mjs";
+import { round2 } from "./lib/pricing.mjs";
+
+function agentsDirOf(sessionId) {
+ return path.join(os.homedir(), ".zcode", "cli", "agents", sessionId);
+}
+
+function readAgentMetadata(dir) {
+ try {
+ return JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function readAgentOutput(dir, max = 4000) {
+ for (const name of ["output.txt", "task.output"]) {
+ try {
+ const text = fs.readFileSync(path.join(dir, name), "utf8");
+ if (text) return text.length > max ? `${text.slice(0, max)}…` : text;
+ } catch {
+ // try next
+ }
+ }
+ return "";
+}
+
+async function main() {
+ const started = Date.now();
+ const sessionId = arg("--session-id") || "";
+ if (!sessionId) {
+ return { ok: false, error: { code: "bad_args", message: "缺少 --session-id" } };
+ }
+
+ const opened = await openDb();
+ if (opened.error) return opened;
+ const db = opened.db;
+ try {
+ const session = db.prepare("select * from session where id = ?").get(sessionId);
+ if (!session) {
+ return {
+ ok: false,
+ error: { code: "session_missing", message: `会话不存在:${sessionId}` },
+ };
+ }
+
+ const root = agentsDirOf(sessionId);
+ const agents = [];
+ if (fs.existsSync(root)) {
+ for (const name of fs.readdirSync(root)) {
+ const dir = path.join(root, name);
+ if (!fs.statSync(dir).isDirectory()) continue;
+ const meta = readAgentMetadata(dir);
+ if (!meta) continue;
+ const childId = String(meta.childSessionId || "");
+ const usage = childId
+ ? db
+ .prepare(
+ `select coalesce(sum(computed_total_tokens),0) as tokens,
+ coalesce(sum(duration_ms),0) as active_ms,
+ count(*) as requests,
+ max(started_at) as last_at
+ from model_usage where session_id = ? and status = 'completed'`,
+ )
+ .get(childId)
+ : { tokens: 0, active_ms: 0, requests: 0, last_at: 0 };
+ agents.push({
+ agentId: String(meta.agentId || name),
+ childSessionId: childId,
+ profileId: String(meta.profileId || ""),
+ profileName: String(meta.profileSnapshot?.name || meta.profileId || ""),
+ description: clampText(meta.description || "", 160),
+ createdAt: num(Date.parse(meta.createdAt || "") || 0),
+ parentToolUseId: String(meta.parentToolUseId || ""),
+ tokens: num(usage.tokens),
+ activeMs: num(usage.active_ms),
+ requests: num(usage.requests),
+ lastAt: num(usage.last_at),
+ output: readAgentOutput(dir),
+ });
+ }
+ }
+ agents.sort((a, b) => a.createdAt - b.createdAt);
+
+ // Cross-agent events: model_usage rows tagged with an agent column or the
+ // child sessions' activity, capped for large formations.
+ const events = db
+ .prepare(
+ `select u.started_at, u.model_id, u.session_id, u.agent, u.status,
+ u.input_tokens, u.output_tokens
+ from model_usage u
+ where (u.session_id = ? or u.session_id in (
+ select id from session where parent_id = ?
+ ))
+ and u.status in ('completed','error')
+ order by u.started_at asc
+ limit 400`,
+ )
+ .all(sessionId, sessionId)
+ .map((row) => ({
+ at: num(row.started_at),
+ sessionId: String(row.session_id),
+ agent: String(row.agent || ""),
+ modelId: String(row.model_id || ""),
+ status: String(row.status || ""),
+ inputTokens: num(row.input_tokens),
+ outputTokens: num(row.output_tokens),
+ }));
+
+ return finish(
+ {
+ ok: true,
+ sessionId,
+ sessionTitle: session.title,
+ agents,
+ events,
+ },
+ started,
+ );
+ } finally {
+ db.close();
+ }
+}
+
+try {
+ const result = await main();
+ process.stdout.write(JSON.stringify(result));
+} catch (error) {
+ process.stdout.write(
+ JSON.stringify({
+ ok: false,
+ error: { code: "agents_failed", message: error?.message || String(error) },
+ }),
+ );
+ process.exitCode = 1;
+}
diff --git a/external_plugins/zcode-context/scripts/query-overview.mjs b/external_plugins/zcode-context/scripts/query-overview.mjs
new file mode 100644
index 00000000..345d9c04
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/query-overview.mjs
@@ -0,0 +1,426 @@
+#!/usr/bin/env node
+/**
+ * Overview aggregation for the zcode-context dashboard.
+ *
+ * Prints one JSON document:
+ * { ok, range, summary, series, heatmap, tools, commands, models, projects, sessions }
+ *
+ * Reads the local ZCode CLI database (read-only) and keeps everything in
+ * local time so numbers match what the user sees elsewhere in the app.
+ */
+import {
+ arg,
+ num,
+ openDb,
+ clampText,
+ workspaceLabel,
+ finish,
+} from "./lib/common.mjs";
+import { costOfUsage, round2 } from "./lib/pricing.mjs";
+
+function dayExpr(columnMs) {
+ return `strftime('%Y-%m-%d', ${columnMs} / 1000, 'unixepoch', 'localtime')`;
+}
+
+function costOfUsageRow(row) {
+ return costOfUsage({
+ modelId: row.model_id,
+ input: num(row.input_tokens),
+ output: num(row.output_tokens),
+ cacheRead: num(row.cache_read_input_tokens),
+ cacheWrite: num(row.cache_creation_input_tokens),
+ });
+}
+
+async function main() {
+ const started = Date.now();
+ const now = Date.now();
+ const start = num(arg("--start"), now - 7 * 24 * 3600 * 1000);
+ const end = num(arg("--end"), now);
+ const directory = arg("--directory") || "";
+
+ const opened = await openDb();
+ if (opened.error) return opened;
+ const db = opened.db;
+ try {
+ const rangeArgs = [start, end];
+ let sessionFilter = "";
+ if (directory) {
+ sessionFilter = " and s.directory = ? ";
+ rangeArgs.push(directory);
+ }
+
+ const usageRows = db
+ .prepare(
+ `select u.session_id, u.provider_id, u.model_id, u.query_source, u.status,
+ u.duration_ms, u.time_to_first_token_ms, u.tool_call_count,
+ u.input_tokens, u.output_tokens, u.reasoning_tokens,
+ u.cache_creation_input_tokens, u.cache_read_input_tokens,
+ u.computed_total_tokens, u.retry_count, u.started_at
+ from model_usage u
+ join session s on s.id = u.session_id
+ where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter}
+ order by u.started_at asc`,
+ )
+ .all(...rangeArgs);
+
+ // 耗时统计:模型等待 = Σduration(按 ttft 拆等待/生成,生成再按 reasoning
+ // token 占比拆思考/回复);工具执行 ≈ 相邻请求之间的空闲间隔(下限 0)。
+ const timeStats = {
+ modelWaitMs: 0,
+ thinkMs: 0,
+ replyMs: 0,
+ toolMs: 0,
+ otherMs: 0,
+ spanMs: 0,
+ outputSpeed: 0,
+ };
+ {
+ let prevEnd = null;
+ let prevSid = null;
+ let ttftMs = 0;
+ let reasoningAll = 0;
+ let outputAll = 0;
+ const sessionSpan = new Map();
+ for (const row of usageRows) {
+ const dur = num(row.duration_ms);
+ ttftMs += Math.max(0, num(row.time_to_first_token_ms));
+ reasoningAll += num(row.reasoning_tokens);
+ outputAll += num(row.output_tokens);
+ const st = num(row.started_at);
+ const en = num(row.completed_at) || st + dur;
+ const sid = row.session_id;
+ const gap = prevEnd !== null && st > prevEnd ? st - prevEnd : 0;
+ if (gap > 0 && gap <= 600 * 1000) timeStats.toolMs += gap;
+ const span = sessionSpan.get(sid) || { span: 0, last: st, prev: null };
+ if (span.prev !== null) {
+ const g = st - span.prev;
+ if (g <= 10 * 60 * 1000) span.span += g;
+ }
+ span.span += dur;
+ span.prev = en;
+ sessionSpan.set(sid, span);
+ prevEnd = en;
+ prevSid = sid;
+ }
+ for (const span of sessionSpan.values()) {
+ timeStats.spanMs += span.span;
+ }
+ const genMs = Math.max(0, timeStats.modelWaitMs - ttftMs);
+ const thinkShare = reasoningAll + outputAll > 0 ? reasoningAll / (reasoningAll + outputAll) : 0.2;
+ timeStats.modelWaitMs = ttftMs;
+ timeStats.thinkMs = Math.round(genMs * thinkShare);
+ timeStats.replyMs = genMs - timeStats.thinkMs;
+ timeStats.otherMs = Math.max(
+ 0,
+ timeStats.spanMs - timeStats.modelWaitMs - genMs - timeStats.toolMs,
+ );
+ }
+
+ // 命令执行 Top:取 Bash 命令的首词;同时统计总执行/失败次数。
+ const commandMap = new Map();
+ const commandStats = { exec: 0, failed: 0 };
+ try {
+ const cmdRows = db
+ .prepare(
+ `select json_extract(p.data, '$.state.input.command') as cmd,
+ json_extract(p.data, '$.state.status') as status
+ from part p
+ join session s on s.id = p.session_id
+ where json_extract(p.data, '$.type') = 'tool'
+ and json_extract(p.data, '$.tool') in ('Bash','PowerShell','pwsh')
+ and p.time_created >= ? and p.time_created < ?${sessionFilter}
+ limit 4000`,
+ )
+ .all(...rangeArgs);
+ for (const r of cmdRows) {
+ commandStats.exec += 1;
+ if (String(r.status || "") === "error") commandStats.failed += 1;
+ const first = String(r.cmd || "").trim().split(/\s+/)[0]?.replace(/^["']|["']$/g, "");
+ if (!first) continue;
+ commandMap.set(first, (commandMap.get(first) || 0) + 1);
+ }
+ } catch {
+ // command breakdown is best-effort
+ }
+ const commands = [...commandMap.entries()]
+ .map(([cmd, calls]) => ({ cmd, calls }))
+ .sort((a, b) => b.calls - a.calls)
+ .slice(0, 10);
+
+ const summary = {
+ sessions: 0,
+ requests: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ totalTokens: 0,
+ cost: 0,
+ cacheHitRate: 0,
+ toolCalls: 0,
+ commandExec: 0,
+ activeMs: 0,
+ outputSpeed: 0,
+ unknownPriceRequests: 0,
+ };
+ const seriesMap = new Map();
+ const modelMap = new Map();
+ const projectMap = new Map();
+ const sessionMap = new Map();
+ let cacheReadTotal = 0;
+ let inputTotal = 0;
+ let outputTotal = 0;
+ let outputMs = 0;
+
+ const sessionStmt = db.prepare(
+ "select id, title, directory, project_id, time_created, time_updated from session where id = ?",
+ );
+ const lastTextStmt = db.prepare(
+ `select data from part
+ where session_id = ? and json_extract(data, '$.type') = 'text'
+ order by time_created desc limit 1`,
+ );
+ const dayStmt = db.prepare(
+ "select strftime('%Y-%m-%d', ? / 1000, 'unixepoch', 'localtime') as d",
+ );
+
+ for (const row of usageRows) {
+ const input = num(row.input_tokens);
+ const output = num(row.output_tokens);
+ const total = num(row.computed_total_tokens) || input + output;
+ const cost = costOfUsageRow(row);
+ const modelKey = row.model_id;
+
+ summary.requests += 1;
+ summary.inputTokens += input;
+ summary.outputTokens += output;
+ summary.reasoningTokens += num(row.reasoning_tokens);
+ summary.cacheReadTokens += num(row.cache_read_input_tokens);
+ summary.cacheWriteTokens += num(row.cache_creation_input_tokens);
+ summary.totalTokens += total;
+ summary.cost += cost;
+ summary.toolCalls += num(row.tool_call_count);
+ summary.activeMs += num(row.duration_ms);
+ cacheReadTotal += num(row.cache_read_input_tokens);
+ inputTotal += input;
+ outputTotal += output;
+ outputMs += num(row.duration_ms);
+
+ const bucket = dayStmt.get(row.started_at).d;
+ const seriesEntry = seriesMap.get(bucket) || {
+ key: bucket,
+ tokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cost: 0,
+ requests: 0,
+ };
+ seriesEntry.tokens += total;
+ seriesEntry.inputTokens += input;
+ seriesEntry.outputTokens += output;
+ seriesEntry.cost += cost;
+ seriesEntry.requests += 1;
+ seriesMap.set(bucket, seriesEntry);
+
+ const model = modelMap.get(modelKey) || {
+ modelId: modelKey,
+ providerId: String(row.provider_id || ""),
+ tokens: 0,
+ cost: 0,
+ requests: 0,
+ unknown: false,
+ };
+ model.tokens += total;
+ model.cost += cost;
+ model.requests += 1;
+ modelMap.set(modelKey, model);
+
+ const sess = sessionStmt.get(row.session_id);
+ if (sess) {
+ summary.sessions += sessionMap.has(sess.id) ? 0 : 1;
+ const card = sessionMap.get(sess.id) || {
+ id: sess.id,
+ title: sess.title,
+ directory: sess.directory,
+ workspace: workspaceLabel(sess.directory, sess.project_id),
+ startedAt: num(sess.time_created),
+ lastActiveAt: num(sess.time_updated),
+ tokens: 0,
+ cost: 0,
+ requests: 0,
+ toolCalls: 0,
+ };
+ card.tokens += total;
+ card.cost += cost;
+ card.requests += 1;
+ card.toolCalls += num(row.tool_call_count);
+ card.lastActiveAt = Math.max(card.lastActiveAt, num(row.started_at));
+ sessionMap.set(sess.id, card);
+ }
+ }
+
+ summary.cost = round2(summary.cost);
+ summary.cacheHitRate = inputTotal > 0 ? cacheReadTotal / inputTotal : 0;
+ summary.outputSpeed = outputMs > 0 ? Math.round((outputTotal / outputMs) * 1000) : 0;
+ timeStats.outputSpeed = summary.outputSpeed;
+
+ // Tool/command rankings come from the part table (authoritative).
+ const toolRows = db
+ .prepare(
+ `select json_extract(p.data, '$.tool') as tool, count(*) as calls
+ from part p
+ join session s on s.id = p.session_id
+ where json_extract(p.data, '$.type') = 'tool'
+ and p.time_created >= ? and p.time_created < ?${sessionFilter}
+ group by tool
+ order by calls desc
+ limit 12`,
+ )
+ .all(...rangeArgs);
+ const tools = toolRows.map((row) => ({
+ tool: String(row.tool || "?"),
+ calls: num(row.calls),
+ }));
+ summary.commandExec = num(
+ (
+ db
+ .prepare(
+ `select count(*) as c from part p join session s on s.id = p.session_id
+ where json_extract(p.data, '$.type') = 'tool'
+ and json_extract(p.data, '$.tool') in ('Bash','PowerShell','pwsh')
+ and p.time_created >= ? and p.time_created < ?${sessionFilter}`,
+ )
+ .get(...rangeArgs) || {}
+ ).c,
+ );
+
+ // Models aggregated without the probe rows used above.
+ const models = [...modelMap.values()]
+ .map((entry) => ({ ...entry, cost: round2(entry.cost) }))
+ .sort((a, b) => b.tokens - a.tokens)
+ .slice(0, 10);
+
+ // Projects
+ const projectRows = db
+ .prepare(
+ `select s.directory, s.project_id,
+ count(distinct s.id) as sessions,
+ coalesce(sum(u.computed_total_tokens),0) as tokens
+ from model_usage u
+ join session s on s.id = u.session_id
+ where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter}
+ group by s.directory, s.project_id
+ order by tokens desc
+ limit 12`,
+ )
+ .all(...rangeArgs);
+ const projects = projectRows.map((row) => ({
+ directory: String(row.directory || ""),
+ workspace: workspaceLabel(row.directory, row.project_id),
+ sessions: num(row.sessions),
+ tokens: num(row.tokens),
+ }));
+
+ // Heatmap: per-day tokens + sessions for the trailing 8 weeks.
+ const heatRows = db
+ .prepare(
+ `select ${dayExpr("u.started_at")} as day,
+ count(distinct u.session_id) as sessions,
+ coalesce(sum(u.computed_total_tokens),0) as tokens
+ from model_usage u
+ join session s on s.id = u.session_id
+ where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter}
+ group by day`,
+ )
+ .all(start - 7 * 24 * 3600 * 1000, end, ...(directory ? [directory] : []));
+ const heatmap = heatRows.map((row) => ({
+ day: String(row.day || ""),
+ sessions: num(row.sessions),
+ tokens: num(row.tokens),
+ }));
+
+ const series = [...seriesMap.values()]
+ .map((entry) => ({ ...entry, cost: round2(entry.cost) }))
+ .sort((a, b) => (a.key < b.key ? -1 : 1));
+
+ // 每会话工具列表(供排行行点击筛选会话卡)。
+ const toolsBySession = new Map();
+ try {
+ const toolRows = db
+ .prepare(
+ `select p.session_id, json_extract(p.data, '$.tool') as tool, count(*) as calls
+ from part p
+ join session s on s.id = p.session_id
+ where json_extract(p.data, '$.type') = 'tool'
+ and p.time_created >= ? and p.time_created < ?${sessionFilter}
+ group by p.session_id, tool`,
+ )
+ .all(start, end);
+ for (const r of toolRows) {
+ const sid = String(r.session_id || "");
+ if (!sid) continue;
+ const list = toolsBySession.get(sid) || [];
+ list.push({ tool: String(r.tool || "?"), calls: num(r.calls) });
+ toolsBySession.set(sid, list);
+ }
+ for (const list of toolsBySession.values()) {
+ list.sort((a, b) => b.calls - a.calls);
+ list.length = Math.min(list.length, 8);
+ }
+ } catch {
+ // tool lists stay empty
+ }
+
+ // Session cards (top 24 by tokens) with a last-message snippet.
+ const sessionCards = [...sessionMap.values()]
+ .sort((a, b) => b.tokens - a.tokens || b.lastActiveAt - a.lastActiveAt)
+ .slice(0, 24)
+ .map((card) => {
+ const last = lastTextStmt.get(card.id);
+ let snippet = "";
+ if (last) {
+ try {
+ snippet = clampText(JSON.parse(last.data).text || "");
+ } catch {
+ snippet = "";
+ }
+ }
+ return { ...card, cost: round2(card.cost), lastMessage: snippet, tools: toolsBySession.get(card.id) || [] };
+ });
+
+ return finish(
+ {
+ ok: true,
+ range: { start, end, directory: directory || "all" },
+ summary,
+ timeStats,
+ commandStats,
+ series,
+ heatmap,
+ tools,
+ commands,
+ models,
+ projects,
+ sessions: sessionCards,
+ },
+ started,
+ );
+ } finally {
+ db.close();
+ }
+}
+
+try {
+ const result = await main();
+ process.stdout.write(JSON.stringify(result));
+} catch (error) {
+ process.stdout.write(
+ JSON.stringify({
+ ok: false,
+ error: { code: "overview_failed", message: error?.message || String(error) },
+ }),
+ );
+ process.exitCode = 1;
+}
diff --git a/external_plugins/zcode-context/scripts/query-session.mjs b/external_plugins/zcode-context/scripts/query-session.mjs
new file mode 100644
index 00000000..068a8b12
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/query-session.mjs
@@ -0,0 +1,494 @@
+#!/usr/bin/env node
+/**
+ * Per-session detail for the zcode-context dashboard.
+ *
+ * Prints one JSON document:
+ * { ok, session, usage, turns, trend, composition, events, files, stats }
+ *
+ * The composition is estimated from the message/part tables (char counts and
+ * the ~3.5 chars/token heuristic) and clearly marked as an estimate. Events
+ * are capped so huge sessions stay responsive.
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ arg,
+ num,
+ openDb,
+ sizeOf,
+ tokensOfBytes,
+ clampText,
+ workspaceLabel,
+ finish,
+} from "./lib/common.mjs";
+import { costOfUsage, round2, windowOfModel } from "./lib/pricing.mjs";
+
+const MAX_EVENTS = 400;
+const MAX_TREND_POINTS = 400;
+
+async function main() {
+ const started = Date.now();
+ const sessionId = arg("--session-id") || "";
+ if (!sessionId) {
+ return { ok: false, error: { code: "bad_args", message: "缺少 --session-id" } };
+ }
+
+ const opened = await openDb();
+ if (opened.error) return opened;
+ const db = opened.db;
+ try {
+ const session = db
+ .prepare("select * from session where id = ?")
+ .get(sessionId);
+ if (!session) {
+ return {
+ ok: false,
+ error: { code: "session_missing", message: `会话不存在:${sessionId}` },
+ };
+ }
+
+ const usage = db
+ .prepare(
+ `select started_at, completed_at, duration_ms, time_to_first_token_ms,
+ provider_id, model_id, query_source, status, finish_reason,
+ tool_call_count, input_tokens, output_tokens, reasoning_tokens,
+ cache_creation_input_tokens, cache_read_input_tokens,
+ computed_total_tokens, retry_count, error_message
+ from model_usage
+ where session_id = ? and status = 'completed'
+ order by started_at asc`,
+ )
+ .all(sessionId);
+
+ // Turn boundaries (visible user messages) for 轮次 aggregation.
+ const turnBoundaries = [];
+ try {
+ const turnRows = db
+ .prepare("select data, time_created from message where session_id = ? order by time_created asc")
+ .all(sessionId);
+ for (const row of turnRows) {
+ try {
+ const d = JSON.parse(row.data);
+ if (d.role === "user" && !d.synthetic && d.visibility !== "hidden") {
+ turnBoundaries.push(num(row.time_created));
+ }
+ } catch {
+ // ignore malformed rows
+ }
+ }
+ } catch {
+ // turn boundaries stay empty
+ }
+ const turnIndexOf = (ts) => {
+ let n = 0;
+ for (const t of turnBoundaries) if (t <= ts) n += 1;
+ return Math.max(1, n);
+ };
+
+ // --- Trend: per-request context size (input) and output. ---
+ const step = Math.max(1, Math.ceil(usage.length / MAX_TREND_POINTS));
+ const trend = [];
+ for (let i = 0; i < usage.length; i += step) {
+ const rows = usage.slice(i, i + step);
+ const input = rows.reduce((acc, r) => acc + num(r.input_tokens), 0) / rows.length;
+ const output = rows.reduce((acc, r) => acc + num(r.output_tokens), 0);
+ const reasoning = rows.reduce((acc, r) => acc + num(r.reasoning_tokens), 0);
+ const cacheRead = rows.reduce((acc, r) => acc + num(r.cache_read_input_tokens), 0);
+ const cacheWrite = rows.reduce((acc, r) => acc + num(r.cache_creation_input_tokens), 0);
+ trend.push({
+ index: i,
+ turn: turnIndexOf(num(rows[0].started_at)),
+ startedAt: num(rows[0].started_at),
+ inputTokens: Math.round(input),
+ outputTokens: output,
+ reasoningTokens: reasoning,
+ cacheReadTokens: cacheRead,
+ cacheWriteTokens: cacheWrite,
+ cacheRate: input + cacheRead + cacheWrite > 0 ? cacheRead / (input + cacheRead + cacheWrite) : 0,
+ durationMs: Math.round(rows.reduce((acc, r) => acc + num(r.duration_ms), 0) / rows.length),
+ compact: rows.some((r) => String(r.query_source || "") === "compact"),
+ });
+ }
+
+ // --- Current context composition (estimate from the last user message's
+ // snapshot of system prompt + tools, plus part sizes up to the end). ---
+ const snapshotEntry = db
+ .prepare(
+ `select data from session_entry
+ where session_id = ? and type = 'runtime/prompt_snapshot'
+ order by time_updated desc limit 1`,
+ )
+ .get(sessionId);
+ const messageTools = db
+ .prepare(
+ `select data from message
+ where session_id = ? and json_extract(data, '$.tools') is not null
+ order by time_created desc limit 1`,
+ )
+ .get(sessionId);
+
+ let systemTokens = 0;
+ let toolDefTokens = 0;
+ let promptPreview = "";
+ let toolNames = [];
+ try {
+ if (snapshotEntry) {
+ const snap = JSON.parse(snapshotEntry.data);
+ const system = Array.isArray(snap.system) ? snap.system : [];
+ systemTokens = tokensOfBytes(system.reduce((acc, m) => acc + sizeOf(m.content), 0));
+ const sysText = system
+ .map((m) => (typeof m.content === "string" ? m.content : ""))
+ .filter(Boolean)
+ .join("\n");
+ promptPreview = clampText(sysText, 400);
+ }
+ if (messageTools) {
+ const msg = JSON.parse(messageTools.data);
+ toolDefTokens = tokensOfBytes(sizeOf(msg.tools || []));
+ toolNames = (msg.tools || [])
+ .map((t) => String(t && (t.name || t.function?.name) || ""))
+ .filter(Boolean)
+ .slice(0, 40);
+ }
+ } catch {
+ // keep zeros; composition stays an estimate
+ }
+
+ const partRows = db
+ .prepare(
+ `select data, time_created from part where session_id = ? order by time_created asc`,
+ )
+ .all(sessionId);
+
+ const composition = {
+ systemPrompt: { tokens: systemTokens, items: systemTokens > 0 ? 1 : 0, estimate: true },
+ toolDefs: { tokens: toolDefTokens, items: 0, estimate: true },
+ userMessages: { tokens: 0, items: 0, estimate: true },
+ assistantText: { tokens: 0, items: 0, estimate: true },
+ reasoning: { tokens: 0, items: 0, estimate: true },
+ toolResults: { tokens: 0, items: 0, estimate: true },
+ };
+ let toolDefsSeen = false;
+ const events = [];
+ const fileMap = new Map();
+ const ioCounts = { user: 0, read: 0, write: 0, edit: 0, search: 0, bash: 0, tools: 0 };
+ const commandStats = { exec: 0, failed: 0 };
+ const toolCount = new Map();
+
+ for (const row of partRows) {
+ let part;
+ try {
+ part = JSON.parse(row.data);
+ } catch {
+ continue;
+ }
+ const type = String(part.type || "");
+ if (type === "text") {
+ const tokens = tokensOfBytes(sizeOf(part.text));
+ // assistant text parts appear after a reasoning/tool part; user text
+ // parts come directly after a step-start following a user turn — the
+ // split below uses the part sequence heuristics from the local store.
+ composition.userMessages.tokens += tokens;
+ composition.userMessages.items += 1;
+ events.push({ kind: "message", role: "user", at: num(row.time_created), text: clampText(part.text, 500) });
+ } else if (type === "reasoning") {
+ const tokens = tokensOfBytes(sizeOf(part.text));
+ composition.reasoning.tokens += tokens;
+ composition.reasoning.items += 1;
+ } else if (type === "tool") {
+ const input = part.state?.input || {};
+ const output = part.state?.output;
+ const tool = String(part.tool || "?");
+ composition.toolResults.tokens += tokensOfBytes(sizeOf(output));
+ composition.toolResults.items += 1;
+ ioCounts.tools += 1;
+ toolCount.set(tool, (toolCount.get(tool) || 0) + 1);
+ if (tool === "Bash" || tool === "PowerShell" || tool === "pwsh") {
+ ioCounts.bash += 1;
+ commandStats.exec += 1;
+ if (String(part.state?.status || "") === "error") commandStats.failed += 1;
+ }
+ if (["WebFetch", "WebSearch", "Grep", "Glob"].includes(tool)) ioCounts.search += 1;
+ if (["Read", "Write", "Edit", "NotebookEdit"].includes(tool)) {
+ if (tool === "Read") ioCounts.read += 1;
+ if (tool === "Write") ioCounts.write += 1;
+ if (tool === "Edit") ioCounts.edit += 1;
+ const file = String(input.file_path || input.notebook_path || "");
+ if (file) {
+ const entry = fileMap.get(file) || { file, read: 0, write: 0, edit: 0, at: 0 };
+ if (tool === "Read") entry.read += 1;
+ if (tool === "Write") entry.write += 1;
+ if (tool === "Edit") entry.edit += 1;
+ entry.at = Math.max(entry.at, num(row.time_created));
+ fileMap.set(file, entry);
+ }
+ }
+ if (events.length < MAX_EVENTS) {
+ events.push({
+ kind: "tool",
+ tool,
+ at: num(row.time_created),
+ status: String(part.state?.status || ""),
+ text: clampText(
+ input.command || input.description || input.file_path || input.query || "",
+ 500,
+ ),
+ });
+ }
+ } else if (type === "step-start" && !toolDefsSeen) {
+ // nothing; tool defs are taken from the message snapshot above
+ }
+ }
+
+ // Distinguish user vs assistant text: message roles from the message table.
+ const msgRows = db
+ .prepare("select data, time_created from message where session_id = ? order by time_created asc")
+ .all(sessionId);
+ const assistantMsgCount = msgRows.filter((m) => {
+ try {
+ return JSON.parse(m.data).role === "assistant";
+ } catch {
+ return false;
+ }
+ }).length;
+
+ const turnCount = msgRows.filter((m) => {
+ try {
+ const d = JSON.parse(m.data);
+ return d.role === "user" && !d.synthetic && d.visibility !== "hidden";
+ } catch {
+ return false;
+ }
+ }).length;
+
+ // Move roughly half of text tokens to the assistant bucket (heuristic).
+ const half = Math.round(composition.userMessages.tokens * 0.55);
+ composition.assistantText.tokens = half;
+ composition.assistantText.items = assistantMsgCount;
+ composition.userMessages.tokens -= half;
+ ioCounts.user = turnCount;
+
+ // --- 费用构成估算:各类 token 按最近模型牌价折算为 CNY。 ---
+ const compPartsForCost = [
+ { label: "系统提示词", tokens: composition.systemPrompt.tokens },
+ { label: "工具定义", tokens: composition.toolDefs.tokens },
+ { label: "用户消息", tokens: composition.userMessages.tokens },
+ { label: "助手回复", tokens: composition.assistantText.tokens },
+ { label: "思考", tokens: composition.reasoning.tokens },
+ { label: "工具结果", tokens: composition.toolResults.tokens },
+ ];
+ const lastModelForCost = usage.length ? String(usage[usage.length - 1].model_id || "") : "";
+ const compCostTotal = compPartsForCost.reduce(
+ (acc, g) =>
+ acc + costOfUsage({ modelId: lastModelForCost, input: g.tokens, output: 0, cacheRead: 0, cacheWrite: 0 }),
+ 0,
+ );
+ const costComposition = {
+ totalTokens: compPartsForCost.reduce((acc, g) => acc + g.tokens, 0),
+ totalCost: Math.round(compCostTotal * 100000) / 100000,
+ parts: compPartsForCost.map((g) => {
+ const c =
+ costOfUsage({ modelId: lastModelForCost, input: g.tokens, output: 0, cacheRead: 0, cacheWrite: 0 });
+ return {
+ label: g.label,
+ cost: Math.round(c * 100000) / 100000,
+ pct: compCostTotal > 0 ? c / compCostTotal : 0,
+ };
+ }),
+ };
+
+ // --- 工具调用 Top(当前会话)。 ---
+ const toolTop = [...toolCount.entries()]
+ .map(([tool, calls]) => ({ tool, calls }))
+ .sort((a, b) => b.calls - a.calls)
+ .slice(0, 10);
+
+ // --- Events: errors, compaction markers. ---
+ for (const row of usage) {
+ if (String(row.query_source || "") === "compact") {
+ events.push({
+ kind: "compact",
+ at: num(row.started_at),
+ text: "上下文压缩(compact)请求",
+ });
+ }
+ if (row.error_message) {
+ events.push({
+ kind: "error",
+ at: num(row.started_at),
+ text: clampText(row.error_message, 120),
+ });
+ }
+ }
+ events.sort((a, b) => a.at - b.at);
+
+ // --- Stats. ---
+ const totalTokens = usage.reduce((acc, r) => acc + (num(r.computed_total_tokens) || 0), 0);
+ const cost = usage.reduce(
+ (acc, r) =>
+ acc +
+ costOfUsage({
+ modelId: r.model_id,
+ input: num(r.input_tokens),
+ output: num(r.output_tokens),
+ cacheRead: num(r.cache_read_input_tokens),
+ cacheWrite: num(r.cache_creation_input_tokens),
+ }),
+ 0,
+ );
+ const lastUsage = usage[usage.length - 1] || null;
+ const lastModelId = lastUsage ? String(lastUsage.model_id) : "";
+ const window = windowOfModel(lastModelId);
+ const currentContextTokens = lastUsage ? num(lastUsage.input_tokens) : 0;
+
+ // --- 耗时分解(对齐 dsh statsTiming):模型等待=Σttft;生成时长拆思考/回复
+ // (按 reasoning/output token 占比);工具执行=相邻请求间隙(≤120s);其他=span−上述。
+ let modelWaitMs = 0;
+ let genMs = 0;
+ let reasoningTokensAll = 0;
+ let outputTokensAll = 0;
+ for (const r of usage) {
+ const ttft = Math.max(0, num(r.time_to_first_token_ms));
+ const dur = Math.max(0, num(r.duration_ms));
+ modelWaitMs += ttft;
+ genMs += Math.max(0, dur - ttft);
+ reasoningTokensAll += num(r.reasoning_tokens);
+ outputTokensAll += num(r.output_tokens);
+ }
+ const thinkShare =
+ reasoningTokensAll + outputTokensAll > 0
+ ? reasoningTokensAll / (reasoningTokensAll + outputTokensAll)
+ : 0.2;
+ const thinkMs = Math.round(genMs * thinkShare);
+ const replyMs = genMs - thinkMs;
+ let spanMs = 0;
+ {
+ let prev = null;
+ for (const r of usage) {
+ const st = num(r.started_at);
+ const en = num(r.completed_at) || st + num(r.duration_ms);
+ if (prev !== null && st > prev && st - prev <= 10 * 60 * 1000) spanMs += st - prev;
+ spanMs += num(r.duration_ms);
+ prev = en;
+ }
+ }
+ let toolMs = 0;
+ {
+ let prevEnd = null;
+ for (const r of usage) {
+ const st = num(r.started_at);
+ const en = num(r.completed_at) || st + num(r.duration_ms);
+ if (prevEnd !== null && st > prevEnd && st - prevEnd <= 600 * 1000) toolMs += st - prevEnd;
+ prevEnd = en;
+ }
+ }
+ const timing = {
+ modelWaitMs,
+ thinkMs,
+ replyMs,
+ toolMs,
+ otherMs: Math.max(0, spanMs - modelWaitMs - genMs - toolMs),
+ spanMs,
+ };
+
+ const ttft = usage
+ .map((r) => num(r.time_to_first_token_ms))
+ .filter((v) => v > 0);
+ const outputTokens = outputTokensAll;
+ const modelMs = usage.reduce((acc, r) => acc + num(r.duration_ms), 0);
+
+ const cacheReadTotal = usage.reduce((acc, r) => acc + num(r.cache_read_input_tokens), 0);
+ const cacheWriteTotal = usage.reduce((acc, r) => acc + num(r.cache_creation_input_tokens), 0);
+ const inputTotal = usage.reduce((acc, r) => acc + num(r.input_tokens), 0);
+
+ const files = [...fileMap.values()]
+ .sort((a, b) => b.read + b.write + b.edit - (a.read + a.write + a.edit))
+ .slice(0, 400)
+ .map((entry) => ({
+ ...entry,
+ file: entry.file.replace(/^.*[\\/]/, ""),
+ full: entry.file,
+ }));
+
+ return finish(
+ {
+ ok: true,
+ session: {
+ id: session.id,
+ title: session.title,
+ directory: session.directory,
+ workspace: workspaceLabel(session.directory, session.project_id),
+ startedAt: num(session.time_created),
+ lastActiveAt: num(session.time_updated),
+ additions: num(session.summary_additions),
+ deletions: num(session.summary_deletions),
+ compacted: Boolean(session.time_compacting),
+ },
+ turns: turnCount,
+ requests: usage.length,
+ steps: usage.length,
+ usage: usage.map((row) => ({
+ startedAt: num(row.started_at),
+ completedAt: num(row.completed_at),
+ durationMs: num(row.duration_ms),
+ ttftMs: num(row.time_to_first_token_ms),
+ modelId: String(row.model_id),
+ providerId: String(row.provider_id),
+ querySource: String(row.query_source || ""),
+ inputTokens: num(row.input_tokens),
+ outputTokens: num(row.output_tokens),
+ reasoningTokens: num(row.reasoning_tokens),
+ cacheReadTokens: num(row.cache_read_input_tokens),
+ toolCalls: num(row.tool_call_count),
+ retryCount: num(row.retry_count),
+ })),
+ trend,
+ composition,
+ promptPreview,
+ toolNames,
+ costComposition,
+ ioCounts,
+ toolTop,
+ events: events.slice(-MAX_EVENTS),
+ files,
+ stats: {
+ totalTokens,
+ cost: round2(cost),
+ currentContextTokens,
+ contextWindow: window,
+ cacheReadTokens: cacheReadTotal,
+ cacheWriteTokens: cacheWriteTotal,
+ cacheRate: inputTotal + cacheReadTotal + cacheWriteTotal > 0
+ ? cacheReadTotal / (inputTotal + cacheReadTotal + cacheWriteTotal)
+ : 0,
+ contextPct: window > 0 ? Math.min(1, currentContextTokens / window) : 0,
+ lastModelId,
+ modelMs,
+ timing,
+ commandStats,
+ ttftAvgMs: ttft.length ? Math.round(ttft.reduce((a, b) => a + b, 0) / ttft.length) : 0,
+ outputSpeed: modelMs > 0 ? Math.round((outputTokens / modelMs) * 1000) : 0,
+ compositionTokens: Object.values(composition).reduce((acc, g) => acc + g.tokens, 0),
+ estimate: true,
+ },
+ },
+ started,
+ );
+ } finally {
+ db.close();
+ }
+}
+
+try {
+ const result = await main();
+ process.stdout.write(JSON.stringify(result));
+} catch (error) {
+ process.stdout.write(
+ JSON.stringify({
+ ok: false,
+ error: { code: "session_failed", message: error?.message || String(error) },
+ }),
+ );
+ process.exitCode = 1;
+}
diff --git a/external_plugins/zcode-context/scripts/query-sessions.mjs b/external_plugins/zcode-context/scripts/query-sessions.mjs
new file mode 100644
index 00000000..61a296a6
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/query-sessions.mjs
@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+/**
+ * Session list for the zcode-context dashboard.
+ *
+ * Prints one JSON document: { ok, sessions: [...] } with per-session usage,
+ * sub-agent counts, and last-context usage (estimated from the most recent
+ * completed request's input tokens vs. the model window).
+ */
+import { arg, num, openDb, workspaceLabel, finish } from "./lib/common.mjs";
+import { costOfUsage, round2, windowOfModel } from "./lib/pricing.mjs";
+
+async function main() {
+ const started = Date.now();
+ const now = Date.now();
+ const start = num(arg("--start"), now - 7 * 24 * 3600 * 1000);
+ const end = num(arg("--end"), now);
+ const directory = arg("--directory") || "";
+ const order = arg("--order") || "tokens";
+ const limit = Math.min(num(arg("--limit"), 60), 120);
+
+ const opened = await openDb();
+ if (opened.error) return opened;
+ const db = opened.db;
+ try {
+ const args = [start, end];
+ let dirFilter = "";
+ if (directory) {
+ dirFilter = " and s.directory = ? ";
+ args.push(directory);
+ }
+
+ const rows = db
+ .prepare(
+ `select s.id, s.title, s.directory, s.project_id, s.parent_id,
+ s.time_created, s.time_updated,
+ count(u.id) as requests,
+ coalesce(sum(u.computed_total_tokens), 0) as tokens,
+ coalesce(sum(u.input_tokens), 0) as input_tokens,
+ coalesce(sum(u.output_tokens), 0) as output_tokens,
+ coalesce(sum(u.tool_call_count), 0) as tool_calls,
+ coalesce(sum(u.duration_ms), 0) as duration_ms,
+ coalesce(max(u.started_at), 0) as last_request_at
+ from session s
+ join model_usage u on u.session_id = s.id and u.status = 'completed'
+ and u.started_at >= ? and u.started_at < ?${dirFilter}
+ where s.parent_id is null
+ and s.id not like 'sess_subagent%'
+ and s.time_deleted is null
+ group by s.id`,
+ )
+ .all(...args);
+
+ // 每会话工具调用列表(排行点击筛选会话卡用):一次分组 SQL。
+ const toolsBySession = new Map();
+ try {
+ const toolRows = db
+ .prepare(
+ `select p.session_id, json_extract(p.data, '$.tool') as tool, count(*) as calls
+ from part p
+ join session s on s.id = p.session_id
+ where json_extract(p.data, '$.type') = 'tool'
+ and p.time_created >= ? and p.time_created < ?${dirFilter}
+ group by p.session_id, tool`,
+ )
+ .all(...args);
+ for (const r of toolRows) {
+ const sid = String(r.session_id || "");
+ if (!sid) continue;
+ const list = toolsBySession.get(sid) || [];
+ list.push({ tool: String(r.tool || "?"), calls: num(r.calls) });
+ toolsBySession.set(sid, list);
+ }
+ for (const list of toolsBySession.values()) {
+ list.sort((a, b) => b.calls - a.calls);
+ list.length = Math.min(list.length, 8);
+ }
+ } catch {
+ // tool lists stay empty; filtering by tool then yields nothing
+ }
+
+ const sessions = rows.map((row) => {
+ const last = db
+ .prepare(
+ `select model_id, input_tokens, completed_at
+ from model_usage
+ where session_id = ? and status = 'completed'
+ order by started_at desc limit 1`,
+ )
+ .get(row.id);
+ const children = db
+ .prepare("select count(*) as c from session where parent_id = ?")
+ .get(row.id).c;
+ const modelId = last ? String(last.model_id) : "";
+ const contextTokens = last ? num(last.input_tokens) : 0;
+ const window = windowOfModel(modelId);
+ return {
+ id: String(row.id),
+ title: String(row.title || row.id),
+ directory: String(row.directory || ""),
+ workspace: workspaceLabel(row.directory, row.project_id),
+ startedAt: num(row.time_created),
+ lastActiveAt: Math.max(num(row.time_updated), num(row.last_request_at)),
+ requests: num(row.requests),
+ steps: num(row.requests),
+ tools: toolsBySession.get(String(row.id)) || [],
+ tokens: num(row.tokens),
+ cost: round2(
+ costOfUsage({
+ modelId: modelId,
+ input: num(row.input_tokens),
+ output: num(row.output_tokens),
+ }),
+ ),
+ toolCalls: num(row.tool_calls),
+ activeMs: num(row.duration_ms),
+ subAgents: num(children.c ?? children),
+ contextTokens,
+ contextWindow: window,
+ contextPct: window > 0 && last ? Math.min(1, contextTokens / window) : 0,
+ lastModelId: modelId,
+ compacted: Boolean(
+ db
+ .prepare("select time_compacting from session where id = ?")
+ .get(row.id)?.time_compacting,
+ ),
+ };
+ });
+
+ sessions.sort((a, b) => {
+ if (order === "recent") return b.lastActiveAt - a.lastActiveAt;
+ if (order === "context") return b.contextPct - a.contextPct;
+ return b.tokens - a.tokens;
+ });
+
+ return finish({ ok: true, sessions: sessions.slice(0, limit) }, started);
+ } finally {
+ db.close();
+ }
+}
+
+try {
+ const result = await main();
+ process.stdout.write(JSON.stringify(result));
+} catch (error) {
+ process.stdout.write(
+ JSON.stringify({
+ ok: false,
+ error: { code: "sessions_failed", message: error?.message || String(error) },
+ }),
+ );
+ process.exitCode = 1;
+}
diff --git a/external_plugins/zcode-context/scripts/restore-backup.mjs b/external_plugins/zcode-context/scripts/restore-backup.mjs
new file mode 100644
index 00000000..3b63c1b3
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/restore-backup.mjs
@@ -0,0 +1,80 @@
+#!/usr/bin/env node
+/**
+ * Restore app.asar from the pre-patch backup created by replaceAsarWithBackup.
+ * Use this if ZCode cannot start after an injection attempt.
+ *
+ * Quit ZCode completely before running.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const RUNTIME =
+ (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) ||
+ path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "zcode-context", "runtime");
+
+function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+async function main() {
+ const patchMod = await import(pathToFileURL(path.join(HERE, "asar-patch.mjs")).href);
+ const asar = patchMod.findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR);
+ if (!asar) {
+ console.error("未找到 app.asar;可用 --asar 指定。");
+ process.exitCode = 2;
+ return;
+ }
+ const backup = patchMod.backupPathOf(asar);
+ if (!fs.existsSync(backup)) {
+ console.error(`备份不存在:${backup}`);
+ process.exitCode = 2;
+ return;
+ }
+ let check;
+ try {
+ check = patchMod.validateAsar(backup);
+ } catch (error) {
+ console.error(`备份本身校验失败,未恢复:${error.message}`);
+ process.exitCode = 1;
+ return;
+ }
+ try {
+ fs.copyFileSync(backup, asar);
+ const post = patchMod.validateAsar(asar);
+ try {
+ fs.mkdirSync(RUNTIME, { recursive: true });
+ fs.writeFileSync(
+ path.join(RUNTIME, "status.json"),
+ JSON.stringify(
+ {
+ state: "restored",
+ message: "已从备份恢复 app.asar;重启 ZCode 后生效。",
+ updatedAt: new Date().toISOString(),
+ asarPath: asar,
+ backup,
+ injected: false,
+ pending: false,
+ },
+ null,
+ 2,
+ ),
+ );
+ } catch {
+ // diagnostic only
+ }
+ console.log(JSON.stringify({ status: "restored", asar, backup, check, post }, null, 2));
+ } catch (error) {
+ console.error(
+ `恢复失败:${error.message}\n请完全退出 ZCode 后重试:\n node "${path.join(HERE, "restore-backup.mjs")}"`,
+ );
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(error.stack || error.message || String(error));
+ process.exitCode = 1;
+});
diff --git a/external_plugins/zcode-context/scripts/status.mjs b/external_plugins/zcode-context/scripts/status.mjs
new file mode 100644
index 00000000..828a5ca4
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/status.mjs
@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+/**
+ * Print the current injection status of zcode-context in a human-readable
+ * form, plus the recommended next step. Read-only.
+ *
+ * Usage: node status.mjs [--json]
+ */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const RUNTIME =
+ (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) ||
+ path.join(os.homedir(), ".zcode", "zcode-context", "runtime");
+const STATUS_PATH = path.join(RUNTIME, "status.json");
+const ASAR_HINTS = [
+ "D:\Program Files\ZCode\resources\app.asar",
+ path.join(process.env.LOCALAPPDATA || "", "Programs", "ZCode", "resources", "app.asar"),
+];
+
+const GUIDANCE = {
+ injected:
+ "✅ 已注入:侧边栏入口应已可用。若刚更新过 ZCode/插件,请完全退出并重开 ZCode。",
+ "pending-restart":
+ "⏳ 等待重启:补丁已备好,但 app.asar 被运行中的 ZCode 占用。请完全退出 ZCode(包括托盘进程)后重新打开,入口即会出现。若重开后仍没有,再重启一次,或运行 /remove-panel 后重新安装。",
+ "needs-admin":
+ "🔒 需要管理员权限:写入 app.asar 被拒绝。请以管理员身份运行一次 ZCode,或设置 ZCODE_ASAR 环境变量指向实际的 app.asar 路径。",
+ "waiting-zusage":
+ "⏳ 排队中:token 用量悬浮条正在更新 app.asar,本插件已排定在它完成后自动注入。请稍后重启 ZCode。",
+ error: "❌ 出错:详见 status.json 的 message 字段;可尝试完全重启 ZCode 后重新打开会话触发自检。",
+ unknown:
+ "❓ 未找到 app.asar:请确认 ZCode 安装位置,或设置 ZCODE_ASAR 环境变量后重开会话。",
+};
+
+function findAsarState() {
+ for (const p of ASAR_HINTS) {
+ try {
+ if (p && fs.existsSync(p)) {
+ const s = fs.readFileSync(p).toString("latin1");
+ return { p, injected: s.includes("zcode-context") };
+ }
+ } catch {
+ // ignore unreadable
+ }
+ }
+ return null;
+}
+
+async function main() {
+ let status = null;
+ try {
+ status = JSON.parse(fs.readFileSync(STATUS_PATH, "utf8"));
+ } catch {
+ console.log("尚未运行过注入自检(status.json 不存在)。请重开一个会话触发 SessionStart 自检,或重新安装插件。");
+ return;
+ }
+ const asar = findAsarState();
+ const lines = [];
+ lines.push(`状态:${status.state}`);
+ lines.push(`说明:${status.message}`);
+ lines.push(`插件版本:${status.version} 更新时间:${status.updatedAt}`);
+ if (asar) {
+ lines.push(`app.asar:${asar.p}${asar.injected ? "(包含 zcode-context 注入)" : "(尚未包含注入)"}`);
+ }
+ const g = GUIDANCE[status.state] || "请完全重启 ZCode 后再试;问题持续请运行 /remove-panel 并重新安装。";
+ lines.push(g);
+ if (process.argv.includes("--json")) {
+ console.log(JSON.stringify({ ...status, guidance: g, asar }, null, 2));
+ } else {
+ console.log(lines.join("\n"));
+ }
+}
+
+try {
+ await main();
+} catch (error) {
+ console.log(`读取状态失败:${error.message}`);
+}
diff --git a/external_plugins/zcode-context/scripts/unpatch.mjs b/external_plugins/zcode-context/scripts/unpatch.mjs
new file mode 100644
index 00000000..03c5b9e9
--- /dev/null
+++ b/external_plugins/zcode-context/scripts/unpatch.mjs
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+/**
+ * Remove only the zcode-context loader line from app.asar.
+ * Other injection lines (including the token-usage statusbar) are preserved.
+ *
+ * Run with ZCode fully quit when possible. If app.asar is locked, the command
+ * leaves a stage file and explains how to finish.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const RUNTIME =
+ (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) ||
+ path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "zcode-context", "runtime");
+
+function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+async function main() {
+ const { findAppAsar, inspectAsar, buildUnpatchedAsar, replaceAsarWithBackup } = await import(
+ pathToFileURL(path.join(HERE, "asar-patch.mjs")).href
+ );
+ const asar = findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR);
+ if (!asar) {
+ console.error("未找到 app.asar;可用 --asar 指定。");
+ process.exitCode = 2;
+ return;
+ }
+ const before = inspectAsar(asar);
+ if (!before.hasBalanceLine) {
+ console.log(JSON.stringify({ status: "already-clean", asar }, null, 2));
+ return;
+ }
+ const result = buildUnpatchedAsar({ asarPath: asar });
+ if (result.status === "already-clean") {
+ console.log(JSON.stringify({ status: "already-clean", asar }, null, 2));
+ return;
+ }
+ try {
+ const replaced = replaceAsarWithBackup(result.stagePath, asar, {
+ stageOptions: { expectBalance: false },
+ postOptions: { expectBalance: false },
+ });
+ try {
+ fs.unlinkSync(`${result.stagePath}.json`);
+ } catch {
+ // ignore
+ }
+ try {
+ fs.mkdirSync(RUNTIME, { recursive: true });
+ fs.writeFileSync(
+ path.join(RUNTIME, "status.json"),
+ JSON.stringify(
+ {
+ state: "disabled",
+ message: "上下文洞察入口已移除;重启 ZCode 后生效。",
+ updatedAt: new Date().toISOString(),
+ asarPath: asar,
+ injected: false,
+ pending: false,
+ backup: replaced.backup,
+ },
+ null,
+ 2,
+ ),
+ );
+ } catch {
+ // ignore
+ }
+ console.log(JSON.stringify({ status: "removed", asar, restartRequired: true }, null, 2));
+ } catch (error) {
+ console.error(
+ `app.asar 当前被 ZCode 占用,未能替换(${error.message})。\n` +
+ `请完全退出 ZCode 后重新运行:\n node "${path.join(HERE, "unpatch.mjs")}"\n` +
+ `临时补丁文件保留在:${result.stagePath}`,
+ );
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(error.stack || error.message || String(error));
+ process.exitCode = 1;
+});
diff --git a/marketplace.json b/marketplace.json
index 95b67706..789cc698 100644
--- a/marketplace.json
+++ b/marketplace.json
@@ -10,7 +10,10 @@
"url": "https://z.ai"
},
"unlistedPlugins": [
- { "name": "openpencil", "source": "./external_plugins/openpencil" }
+ {
+ "name": "openpencil",
+ "source": "./external_plugins/openpencil"
+ }
],
"plugins": [
{
@@ -827,7 +830,9 @@
},
{
"name": "excalidraw",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "Excalidraw",
"displayName_i18n": {
"en": "Excalidraw",
@@ -850,7 +855,9 @@
"name": "photocraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png",
"source": "./external_plugins/photocraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "PhotoCraft",
"displayName_i18n": {
"en": "PhotoCraft",
@@ -877,7 +884,9 @@
"name": "vectorcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png",
"source": "./external_plugins/vectorcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "VectorCraft",
"displayName_i18n": {
"en": "VectorCraft",
@@ -904,7 +913,9 @@
"name": "pdfcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png",
"source": "./external_plugins/pdfcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "PdfCraft",
"displayName_i18n": {
"en": "PdfCraft",
@@ -931,7 +942,9 @@
"name": "designcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png",
"source": "./external_plugins/designcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "DesignCraft",
"displayName_i18n": {
"en": "DesignCraft",
@@ -958,7 +971,9 @@
"name": "wordcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png",
"source": "./external_plugins/wordcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "WordCraft",
"displayName_i18n": {
"en": "WordCraft",
@@ -985,7 +1000,9 @@
"name": "gridcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png",
"source": "./external_plugins/gridcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "GridCraft",
"displayName_i18n": {
"en": "GridCraft",
@@ -1012,7 +1029,9 @@
"name": "deckcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png",
"source": "./external_plugins/deckcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "DeckCraft",
"displayName_i18n": {
"en": "DeckCraft",
@@ -1039,7 +1058,9 @@
"name": "cadcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png",
"source": "./external_plugins/cadcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "CADCraft",
"displayName_i18n": {
"en": "CADCraft",
@@ -1066,7 +1087,9 @@
"name": "soundcraft",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png",
"source": "./external_plugins/soundcraft",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "SoundCraft",
"displayName_i18n": {
"en": "SoundCraft",
@@ -1149,7 +1172,9 @@
},
"category": "developer-tools",
"icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png",
- "componentTypes": ["ui"],
+ "componentTypes": [
+ "ui"
+ ],
"displayName": "KiCad",
"displayName_i18n": {
"en": "KiCad",
@@ -1499,6 +1524,33 @@
"image-to-video",
"mcp"
]
+ },
+ {
+ "name": "zcode-context",
+ "source": "./external_plugins/zcode-context",
+ "description": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.",
+ "description_i18n": {
+ "en": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.",
+ "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(步进/轮次 × 总计/增量,可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。"
+ },
+ "version": "0.2.0",
+ "author": {
+ "name": "AwenJackson",
+ "url": "https://github.com/AwenJackson"
+ },
+ "category": "developer-tools",
+ "keywords": [
+ "context-window",
+ "token-usage",
+ "token-statistics",
+ "cost-estimation",
+ "cache-hit-rate",
+ "llm-observability",
+ "dashboard",
+ "agent-monitoring",
+ "context-insight",
+ "dsh-context"
+ ]
}
]
-}
\ No newline at end of file
+}