diff --git a/external_plugins/session-stats/.zcode-plugin/plugin.json b/external_plugins/session-stats/.zcode-plugin/plugin.json
new file mode 100644
index 00000000..5481f30c
--- /dev/null
+++ b/external_plugins/session-stats/.zcode-plugin/plugin.json
@@ -0,0 +1,49 @@
+{
+ "name": "session-stats",
+ "version": "0.3.0",
+ "description": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.",
+ "description_i18n": {
+ "en": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.",
+ "zh-CN": "会话统计:轮次/步数、模型/工具耗时、TTFT、TPS(会话平均+最近请求)、token 用量与缓存命中率。零第三方依赖。"
+ },
+ "author": {
+ "name": "gary.liu"
+ },
+ "keywords": [
+ "stats",
+ "usage",
+ "tokens",
+ "cache",
+ "panel",
+ "mcp-apps"
+ ],
+ "license": "MIT",
+ "mcpServers": {
+ "session-stats": {
+ "type": "stdio",
+ "command": "python",
+ "args": [
+ "${ZCODE_PLUGIN_ROOT}/server/stats_server.py"
+ ],
+ "env": {
+ "PYTHONUTF8": "1",
+ "PYTHONIOENCODING": "utf-8"
+ }
+ }
+ },
+ "ui": {
+ "surfaces": [
+ {
+ "id": "stats-panel",
+ "title": {
+ "en": "Session Stats",
+ "zh-CN": "会话统计"
+ },
+ "server": "session-stats",
+ "resourceUri": "ui://session-stats/panel.html",
+ "availability": "session"
+ }
+ ]
+ },
+ "commands": "./commands"
+}
diff --git a/external_plugins/session-stats/README.md b/external_plugins/session-stats/README.md
new file mode 100644
index 00000000..64b29c18
--- /dev/null
+++ b/external_plugins/session-stats/README.md
@@ -0,0 +1,66 @@
+# session-stats
+
+Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average +
+latest request), token usage and cache hit rate. Zero third-party dependencies.
+
+## Components
+
+- **MCP tool `get_stats`** — statistics for the current session (including subagents,
+ aggregated via `session.parent_id`) or today's aggregate across sessions.
+- **MCP App side panel** (`ui.surfaces`) — stat pills with a details popup
+ (timings, token buckets, cache hit, per-model breakdown), auto-refresh every 5 s,
+ follows session switching and the live theme.
+- **`/stats` command** — text card rendering of the same numbers.
+- **SessionStart hook** — writes the current session id into the plugin data dir so
+ the server can resolve "current session" without extra round-trips.
+
+## Data source and metric definitions
+
+Everything is read **read-only** from the local session database
+`~/.zcode/cli/db/db.sqlite` (SQLite URI `mode=ro`, `busy_timeout=5000`, WAL-safe).
+Measured against ZCode 3.17.1:
+
+| Metric | Definition |
+| --- | --- |
+| Turns | distinct `turn_id` of the main session |
+| Steps | distinct `logical_request_id` (retries excluded; failed/cancelled counted) |
+| Model time | Σ `duration_ms` |
+| Tool time | Σ `tool_usage.duration_ms` |
+| TTFT avg | AVG(`time_to_first_token_ms`) |
+| TPS | Σ output ÷ Σ(completed_at − first_token_at); "live" TPS = latest completed request |
+| Tokens | Σ input + Σ output (`input_tokens` already includes cache reads — measured) |
+| Cache hit | cache_read ÷ input |
+
+The database schema is an implementation detail of the host (not a stable API) and
+usage rows are retained for 30 days; queries degrade gracefully when columns are
+missing.
+
+## Side effects
+
+- Reads the local session database (read-only).
+- Starts a loopback-only HTTP service (`127.0.0.1:31710`) that serves the statistics
+ JSON and the panel's dynamic script to ZCode's renderer. It answers only requests
+ without a cross-origin `Origin` header (i.e. the app's own `file://` pages); other
+ origins get 403.
+- The SessionStart hook writes one small file (`current_session`) into the
+ plugin-owned data directory.
+- No network access, no credentials, no telemetry.
+
+## Install
+
+ZCode Desktop → **Plugin Marketplace** → add the marketplace that carries this plugin
+(the official `zcode-plugins` marketplace after review, or a local directory) →
+**Install session-stats** → restart or open a new session → run `/stats` or open the
+"Session Stats" side panel.
+
+## Optional composer pills (host patch, experimental)
+
+A stats strip centered under the composer input can be enabled with a patch to the
+host `app.asar` (see the upstream project's `patches/` directory). The patch is
+idempotent, backs up the host file per version and supports restore; it must be
+re-applied after ZCode updates. This is **not** part of the marketplace plugin and
+is purely optional.
+
+## License
+
+MIT
diff --git a/external_plugins/session-stats/README_CN.md b/external_plugins/session-stats/README_CN.md
new file mode 100644
index 00000000..17d568d0
--- /dev/null
+++ b/external_plugins/session-stats/README_CN.md
@@ -0,0 +1,51 @@
+# session-stats — ZCode 会话统计插件
+
+ZCode 会话统计插件:轮次/步数、模型/工具耗时、
+TTFT、TPS(会话平均 + 最近请求)、token 用量与缓存命中率。零第三方依赖。
+
+## 组件
+
+- **MCP 工具 `get_stats`**:当前会话(含子代理,经 `session.parent_id` 聚合)
+ 或今日汇总的完整统计。
+- **MCP App 侧栏面板**(`ui.surfaces`):统计卡片 + 明细弹窗(耗时、token 分桶、
+ 缓存命中、模型分布),5 秒轮询自动刷新,跟随会话切换与主题。
+- **`/stats` 命令**:同样数字的文字卡片输出。
+- **SessionStart hook**:把当前会话 id 写入插件数据目录,供服务端解析「当前会话」。
+
+## 数据源与指标口径
+
+全部**只读**读取本地会话数据库 `~/.zcode/cli/db/db.sqlite`(SQLite URI `mode=ro` +
+`busy_timeout=5000`,WAL 下并发安全)。口径对 ZCode 3.17.1 实测定案:
+
+| 指标 | 定义 |
+|---|---|
+| 轮次 | 主会话 `model_usage.turn_id` 去重数 |
+| 步数 | `logical_request_id` 去重数(重试不计步;失败/取消计步) |
+| 模型用时 | Σ `duration_ms` |
+| 工具用时 | Σ `tool_usage.duration_ms` |
+| TTFT 均值 | AVG(`time_to_first_token_ms`) |
+| TPS | Σ output ÷ Σ(completed_at − first_token_at);「实时」= 最近一次完成请求 |
+| token | Σ input + Σ output(实测 `input_tokens` 已含缓存读) |
+| 缓存命中率 | cache_read ÷ input |
+
+数据库 schema 属宿主实现细节(非稳定 API),usage 行保留 30 天;查询对缺列做了
+优雅降级。
+
+## 副作用说明
+
+- 只读读取本地会话数据库。
+- 启动仅绑定回环地址的本地服务(`127.0.0.1:31710`),向 ZCode 渲染层提供统计
+ JSON 与面板动态脚本;只响应无跨域 `Origin` 头的请求(即应用自身 `file://` 页面),
+ 其余 Origin 一律 403。
+- SessionStart hook 在插件数据目录写一个小文件(`current_session`)。
+- 无网络访问、无凭证、无遥测。
+
+## 安装
+
+ZCode Desktop → **插件市场** → 添加收录本插件的市场(审核通过后为官方
+`zcode-plugins` 市场,审核前可用本地目录)→ **安装 session-stats** → 重启或新开会话
+→ 输入 `/stats` 或打开「会话统计」侧栏面板。
+
+## 许可
+
+MIT
diff --git a/external_plugins/session-stats/commands/stats.md b/external_plugins/session-stats/commands/stats.md
new file mode 100644
index 00000000..f54bc05e
--- /dev/null
+++ b/external_plugins/session-stats/commands/stats.md
@@ -0,0 +1,26 @@
+---
+description: 显示会话统计(轮次/步数/耗时/TTFT/TPS/token/缓存命中)
+---
+
+请调用 session-stats MCP 服务器的 get_stats 工具查询会话统计。scope 取值:当参数含「今日」「today」时用 `today`,否则用 `current`。
+
+拿到 structuredContent 后,**严格按数据输出**下面的中文统计卡片(不要编造/估算任何数据里没有的数值,缺失显示「—」):
+
+```
+⏱ {turns} 轮 {steps} 步 · {tps} tok/s
+Σ {tokens.total} tok · 缓存命中 {cache_hit_pct}%
+────────────────────────
+模型用时 {model_ms 格式化为 时/分/秒}
+工具调用用时 {tool_ms}
+首 token 平均 {ttft_ms_avg}
+输出速度(TPS) {tps} tok/s
+输入(含缓存) {tokens.input}(缓存读 {tokens.cache_read} / 缓存写 {tokens.cache_write})
+输出 {tokens.output}(含推理 {tokens.reasoning})
+总计 {tokens.total}
+```
+
+- `today` 口径:开头注明「今日 {sessions} 个会话汇总」。
+- `current` 口径:结尾注明「会话 {session_id} · 含子代理 {subagent_count} 个」。
+- 若返回 isError 或 error 字段,如实转述错误并提示检查插件 server 日志。
+
+参数:$ARGUMENTS
diff --git a/external_plugins/session-stats/hooks/hooks.json b/external_plugins/session-stats/hooks/hooks.json
new file mode 100644
index 00000000..74c8481c
--- /dev/null
+++ b/external_plugins/session-stats/hooks/hooks.json
@@ -0,0 +1,18 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "startup|resume|clear|compact",
+ "hooks": [
+ {
+ "type": "process",
+ "command": "python",
+ "args": ["${ZCODE_PLUGIN_ROOT}/hooks/write_session.py"],
+ "timeoutMs": 3000,
+ "statusMessage": "session-stats: 记录当前会话 id"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/external_plugins/session-stats/hooks/write_session.py b/external_plugins/session-stats/hooks/write_session.py
new file mode 100644
index 00000000..cde024fb
--- /dev/null
+++ b/external_plugins/session-stats/hooks/write_session.py
@@ -0,0 +1,32 @@
+"""SessionStart hook:把当前 session_id 写进插件数据目录,供 MCP 服务器解析「当前会话」。
+
+输入:stdin 一行 JSON(含 session_id);输出:无(保持 stdout 干净,退出码恒 0,
+绝不阻塞会话)。
+"""
+
+import json
+import os
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ try:
+ req = json.loads(sys.stdin.read() or "{}")
+ except Exception: # noqa: BLE001
+ req = {}
+ sid = req.get("session_id") or os.environ.get("ZCODE_SESSION_ID") or ""
+ data_dir = os.environ.get("ZCODE_PLUGIN_DATA", "")
+ if not sid or not data_dir:
+ return 0 # 静默跳过,由 MCP 服务器走 env/db 兜底
+ try:
+ p = Path(data_dir)
+ p.mkdir(parents=True, exist_ok=True)
+ (p / "current_session").write_text(sid, encoding="utf-8")
+ except OSError:
+ pass
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/external_plugins/session-stats/server/pills.js b/external_plugins/session-stats/server/pills.js
new file mode 100644
index 00000000..6bcea527
--- /dev/null
+++ b/external_plugins/session-stats/server/pills.js
@@ -0,0 +1,594 @@
+/* session-stats pills v4 — 贴 chips 行(电脑操作之后)、悬浮弹层含模型分布、支持热更新 */
+;(function () {
+ "use strict";
+ if (window.__SSPILLS__) { try { window.__SSPILLS_TEARDOWN__(); } catch (e) {} }
+ window.__SSPILLS__ = true;
+
+ var ENDPOINT = "http://127.0.0.1:31710";
+ var POLL_MS = 5000;
+ var data = null, lastOk = 0, fails = 0, popupOpen = false, pillMode = "full", openedAt = 0, lastToggleAt = 0;
+ var rowEl = null, popEl = null, css = null;
+
+ function fmtDur(ms) {
+ if (ms == null) return "—";
+ if (ms < 1000) return Math.round(ms) + " 毫秒";
+ var s = ms / 1000;
+ if (s < 60) return (s < 10 ? s.toFixed(1) : Math.round(s)) + " 秒";
+ var m = Math.floor(s / 60), rs = Math.round(s % 60);
+ if (m < 60) return m + " 分 " + rs + " 秒";
+ return Math.floor(m / 60) + " 时 " + (m % 60) + " 分 " + rs + " 秒";
+ }
+ function fmtTok(n) {
+ if (n == null) return "—";
+ if (n < 1e3) return String(n);
+ if (n < 1e6) return (n / 1e3).toFixed(1) + "K";
+ if (n < 1e9) return (n / 1e6).toFixed(1) + "M";
+ return (n / 1e9).toFixed(2) + "B";
+ }
+ function esc(s) {
+ return String(s).replace(/[&<>"]/g, function (c) {
+ return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
+ });
+ }
+ function shortId(id) {
+ if (!id) return "—";
+ return id.length > 26 ? id.slice(0, 23) + "…" : id;
+ }
+
+ css = document.createElement("style");
+ css.textContent =
+ ".__sspills-row{position:absolute;left:50%;transform:translateX(-50%);bottom:0;" +
+ "max-width:calc(100% - 24px);overflow:hidden;display:inline-flex;align-items:center;gap:6px;z-index:30;}" +
+ ".__sspills-pill{display:inline-flex;align-items:center;gap:6px;height:20px;padding:1px 8px;" +
+ "border-radius:6px;background:transparent;border:0;color:rgba(232,235,241,.55);" +
+ "font:500 12px/20px 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;font-variant-numeric:tabular-nums;" +
+ "cursor:pointer;white-space:nowrap;transition:background .15s,color .15s;}" +
+ ".__sspills-pill:hover{background:rgba(255,255,255,.06);color:rgba(232,235,241,.82);}" +
+ ".__sspills-pill .ico{display:inline-flex;align-items:center;}" +
+ ".__sspills-pill .ico svg{width:14px;height:14px;display:block;}" +
+ ".__sspills-pill b{font-weight:600;color:rgba(232,235,241,.72);}" +
+ ".__sspills-pill .sep{opacity:.45;margin:0 6px;}" +
+ ".__sspills-pill .dv{opacity:.25;margin:0 5px;border-left:1px solid currentColor;height:11px;}" +
+ ".__sspills-pill .live{color:#9ece6a;font-size:8px;margin-left:4px;}" +
+ ".__sspills-row.stale .__sspills-pill{opacity:.4;}" +
+ ".__sspills-pop{position:fixed;display:none;width:400px;max-width:calc(100vw - 16px);z-index:2147483000;" +
+ "background:#1f2128;border:1px solid rgba(255,255,255,.12);border-radius:12px;" +
+ "box-shadow:0 10px 36px rgba(0,0,0,.5);padding:12px 14px;" +
+ "font:12px/1.5 'Segoe UI','Microsoft YaHei',system-ui,sans-serif;color:rgba(232,235,241,.9);}" +
+ ".__sspills-pop .h{font-weight:600;font-size:12.5px;margin-bottom:6px;}" +
+ ".__sspills-pop table{width:100%;border-collapse:collapse;table-layout:fixed;}" +
+ ".__sspills-pop td{padding:2.5px 0;vertical-align:top;}" +
+ ".__sspills-pop td.k{color:rgba(232,235,241,.52);padding-right:12px;white-space:nowrap;" +
+ "overflow:hidden;text-overflow:ellipsis;}" +
+ ".__sspills-pop td.v{font-family:Consolas,'Cascadia Mono',monospace;text-align:right;white-space:nowrap;}" +
+ ".__sspills-pop table.models td.k{width:118px;}" +
+ ".__sspills-pop table.models td.v{overflow:hidden;text-overflow:ellipsis;}" +
+ ".__sspills-pop .div{height:1px;background:rgba(255,255,255,.09);margin:7px 0;}" +
+ ".__sspills-pop .foot{color:rgba(232,235,241,.4);font-size:11px;margin-top:2px;}" +
+ "@media (prefers-color-scheme: light){" +
+ ".__sspills-pill{color:rgba(28,30,36,.55);}" +
+ ".__sspills-pill:hover{background:rgba(0,0,0,.05);color:rgba(28,30,36,.85);}" +
+ ".__sspills-pill b{color:rgba(28,30,36,.72);}" +
+ ".__sspills-pop{background:#fff;border-color:rgba(0,0,0,.14);color:rgba(28,30,36,.9);" +
+ "box-shadow:0 10px 36px rgba(0,0,0,.18);}" +
+ ".__sspills-pop td.k{color:rgba(28,30,36,.52);}" +
+ ".__sspills-pop .div{background:rgba(0,0,0,.09);}}";
+ document.documentElement.appendChild(css);
+
+ function popupHtml() {
+ var d = data, t = (d && d.tokens) || {};
+ if (!d) return '
会话统计
';
+ if (d.draft) return '会话统计
';
+ if (d.error) return '会话统计
";
+ function mk(rows) {
+ return rows.map(function (r) {
+ return '| ' + esc(r[0]) +
+ ' | ' + r[1] + " |
";
+ }).join("");
+ }
+ var lt = liveTps(d);
+ var r1 = [
+ ["模型用时", fmtDur(d.model_ms)],
+ ["工具调用用时", fmtDur(d.tool_ms)],
+ ["首 token 平均(TTFT)", fmtDur(d.ttft_ms_avg)],
+ ];
+ if (lt) {
+ r1.push(["实时 TPS", Math.round(lt.v * 10) / 10 + " tok/s" + (lt.inflight ? "(进行中)" : "(最近请求)")]);
+ }
+ r1.push(["平均 TPS(会话累计)", d.tps != null ? d.tps + " tok/s" : "—"]);
+ var r2 = [
+ ["输入(含缓存)", fmtTok(t.input) + "(读 " + fmtTok(t.cache_read) + " / 写 " + fmtTok(t.cache_write) + ")"],
+ ["输出", fmtTok(t.output) + "(含推理 " + fmtTok(t.reasoning) + ")"],
+ ["总 token", fmtTok(t.total)],
+ ["未命中缓存 token", fmtTok(Math.max(0, (t.input || 0) - (t.cache_read || 0)))],
+ ["缓存命中率", d.cache_hit_pct != null ? d.cache_hit_pct + "%" : "—"],
+ ];
+ // 最近一次完成的请求:输入/输出/缓存命中
+ var lr = null;
+ for (var ri = 0; ri < (d.recent || []).length; ri++) {
+ if (d.recent[ri].done_at != null) { lr = d.recent[ri]; break; }
+ }
+ if (lr && lr["in"] > 0) {
+ r2.push(["最近请求 输入", fmtTok(lr["in"])]);
+ r2.push(["最近请求 输出", fmtTok(lr.out)]);
+ r2.push(["最近请求 未命中缓存", fmtTok(Math.max(0, lr["in"] - (lr.cread || 0)))]);
+ r2.push(["最近请求 缓存命中", Math.round((lr.cread || 0) * 1000 / lr["in"]) / 10 + "%"]);
+ }
+ var r3 = (d.models || []).slice(0, 5).map(function (m) {
+ return [
+ m.model_id || "—",
+ (m.steps != null ? m.steps : "—") + "步 · " +
+ fmtTok((m.t_in || 0) + (m.t_out || 0)) + " tok · " +
+ (m.tps != null ? m.tps : "—") + " tok/s",
+ ];
+ });
+ var foot = "会话 " + esc(shortId(d.session_id)) +
+ (d.subagent_count ? " · 子代理 " + d.subagent_count + " 个" : "") +
+ " · 5 秒自动刷新";
+ return '会话统计
' +
+ "" +
+ '' +
+ "" +
+ (r3.length ? '模型分布
" : "") +
+ '";
+ }
+
+ /* 自愈:僵尸实例可能把 popEl 从 body 里删掉,用前先确保挂载 */
+ function ensurePopEl() {
+ if (!popEl) {
+ popEl = document.createElement("div");
+ popEl.className = "__sspills-pop";
+ }
+ if (!popEl.isConnected) document.body.appendChild(popEl);
+ }
+
+ function renderPopup() {
+ ensurePopEl();
+ if (!popEl) return;
+ popEl.innerHTML = popupHtml();
+ if (popupOpen && rowEl) {
+ var pill = rowEl.querySelector(".__sspills-pill");
+ if (pill) {
+ var r = pill.getBoundingClientRect();
+ /* 弹窗水平中心对准 pill 中心(自适应居中),越界钳制防出窗 */
+ var pw = popEl.offsetWidth;
+ var left = r.left + r.width / 2 - pw / 2;
+ left = Math.max(8, Math.min(left, window.innerWidth - pw - 8));
+ popEl.style.left = left + "px";
+ popEl.style.top = "auto";
+ popEl.style.bottom = (window.innerHeight - r.top + 8) + "px";
+ /* 上方放不下 → 翻到 pill 下方;再放不下 → 钳回视口内 */
+ var ph = popEl.offsetHeight;
+ if (r.top - ph - 8 < 8) {
+ var topBelow = r.bottom + 8;
+ if (topBelow + ph > window.innerHeight - 8) {
+ popEl.style.top = Math.max(8, window.innerHeight - ph - 8) + "px";
+ popEl.style.bottom = "auto";
+ } else {
+ popEl.style.top = topBelow + "px";
+ popEl.style.bottom = "auto";
+ }
+ }
+ }
+ }
+ }
+
+ function setPopup(open) {
+ popupOpen = open;
+ ensurePopEl();
+ if (!popEl) return;
+ popEl.style.display = open ? "block" : "none";
+ openedAt = open ? Date.now() : 0;
+ if (open) renderPopup();
+ }
+
+ /* 当前标签页的会话状态:
+ 返回正式会话 id(sess_/session_ 前缀)、"draft"(新会话未发消息)或 null。
+ 只信可见视图;可见视图里只有占位值时明确返回 draft,绝不回落到隐藏的旧会话。 */
+ function currentSessionId() {
+ var els = document.querySelectorAll("[data-session-id]");
+ var visible = [], all = [], i, v;
+ function isReal(s) {
+ return s.indexOf("sess_") === 0 || s.indexOf("session_") === 0;
+ }
+ function pick(list) {
+ for (var a = 0; a < list.length; a++) {
+ if (isReal(list[a])) return list[a];
+ }
+ for (var b = 0; b < list.length; b++) {
+ if (list[b] && list[b] !== "draft") return list[b];
+ }
+ return list.indexOf("draft") >= 0 ? "draft" : null;
+ }
+ for (i = 0; i < els.length; i++) {
+ v = els[i].getAttribute("data-session-id") || "";
+ all.push(v);
+ if (els[i].offsetParent !== null) visible.push(v);
+ }
+ var fromVisible = pick(visible);
+ if (fromVisible) return fromVisible;
+ if (visible.length) return "draft"; // 有可见会话视图但 id 是占位 → 新会话
+ return pick(all); // 视图尚未挂载完成的兜底
+ }
+
+ var lastSid = null;
+ function poll() {
+ var sid = currentSessionId();
+ lastSid = sid;
+ requestLayout(); // 每次轮询统一走调度器(宽度比对交给 ResizeObserver)
+ if (sid === "draft") {
+ // 新会话(尚未产生请求):显示空态,不去借别的会话的数据
+ data = { draft: true, session_source: "draft" };
+ lastOk = Date.now(); fails = 0;
+ if (rowEl) rowEl.style.display = "";
+ render();
+ if (popupOpen) renderPopup();
+ return;
+ }
+ var url = ENDPOINT + "/stats?scope=current" + (sid ? "&session_id=" + encodeURIComponent(sid) : "");
+ fetch(url, { cache: "no-store" })
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
+ .then(function (j) {
+ data = j; lastOk = Date.now(); fails = 0;
+ render();
+ requestLayout();
+ if (popupOpen) renderPopup();
+ })
+ .catch(function () {
+ fails++;
+ if (fails >= 3 && rowEl) rowEl.style.display = "none";
+ });
+ }
+
+ /* 实时 TPS:优先取进行中的请求(token/(now-首token)),否则取最近一次完成的请求 */
+ var MIN_DECODE_MS = 500; // decode 窗口下限:脏 fta(≈completed_at)会把分母打到毫秒级,TPS 爆表
+ function liveTps(d) {
+ if (!d || !d.recent || !d.recent.length) return null;
+ var now = d.generated_at;
+ var i, r;
+ for (i = 0; i < d.recent.length; i++) {
+ r = d.recent[i];
+ if (r.done_at == null && r.fta != null && r.out > 0 && now - r.fta >= MIN_DECODE_MS) {
+ return { v: r.out * 1000 / (now - r.fta), inflight: true };
+ }
+ }
+ for (i = 0; i < d.recent.length; i++) {
+ r = d.recent[i];
+ if (r.done_at != null && r.fta != null && r.out > 0 && r.done_at - r.fta >= MIN_DECODE_MS) {
+ return { v: r.out * 1000 / (r.done_at - r.fta), inflight: false };
+ }
+ }
+ return null;
+ }
+
+ /* 最近一次完整(已完成)请求:底部横条展示它的输入+输出 token 与缓存命中率 */
+ function latestReq(d) {
+ if (!d || !d.recent) return null;
+ for (var i = 0; i < d.recent.length; i++) {
+ var r = d.recent[i];
+ if (r.done_at != null && r["in"] > 0) return r;
+ }
+ return null;
+ }
+ function reqHitPct(r) {
+ if (!r || !(r["in"] > 0)) return null;
+ return Math.round((r.cread || 0) * 1000 / r["in"]) / 10;
+ }
+
+ function pillBody(mode) {
+ // 三档内容的内层 HTML(外层 pill 壳由 makeRow 常驻创建,切换只改 display,零重建)
+ if (!data) return "加载中…";
+ if (data.draft) return "新会话 · 暂无统计";
+ if (data.error) return "统计不可用";
+ var lt = liveTps(data);
+ var shown = lt ? Math.round(lt.v * 10) / 10 : data.tps;
+ var shownHtml = shown != null
+ ? shown + ' tok/s' + (lt && lt.inflight ? '●' : "")
+ : "—";
+ var lr = latestReq(data);
+ var hit = reqHitPct(lr);
+ // 最近请求 token 段:入 X / 出 Y(无数据时退回会话累计)
+ var reqTokHtml = lr
+ ? "输入 " + fmtTok(lr["in"]) + " · 输出 " + fmtTok(lr.out)
+ : "累计 " + fmtTok((data.tokens || {}).total) + " tok";
+ if (mode === "micro") {
+ // 最窄档:只剩实时速度(参照同款统计条的 compact 形态),保证窄输入框也放得下
+ return shownHtml;
+ }
+ if (mode === "mini") {
+ return shownHtml + " " + reqTokHtml;
+ }
+ if (mode === "mid") {
+ return "" + (data.steps != null ? data.steps : 0) + " 步" +
+ '·' + shownHtml +
+ " " + reqTokHtml +
+ '·' +
+ "缓存命中 " + (hit != null ? hit : "—") + "%";
+ }
+ return "" + (data.turns != null ? data.turns : "—") + " 轮 " + (data.steps != null ? data.steps : 0) + " 步" +
+ '·' + shownHtml +
+ '' +
+ reqTokHtml +
+ '·' +
+ "缓存命中 " + (hit != null ? hit : "—") + "%";
+ }
+
+ /* 三档内容常驻为三个子 span,档位切换只改 display(无 innerHTML 重建 → 无闪现土壤) */
+ var modeSpans = null; // { full, mid, mini }
+ var lastBody = {}; // 每档上次渲染的内容缓存
+ function ensureModeSpans() {
+ if (modeSpans && modeSpans.full && modeSpans.full.isConnected) return;
+ var pill = rowEl.querySelector(".__sspills-pill");
+ if (!pill) return;
+ modeSpans = {};
+ var modes = ["full", "mid", "mini", "micro"];
+ for (var i = 0; i < modes.length; i++) {
+ var s = pill.querySelector(".__sspills-mode[data-mode='" + modes[i] + "']");
+ if (!s) { // 不存在才创建,防热更新边界下重复追加
+ s = document.createElement("span");
+ s.className = "__sspills-mode";
+ s.setAttribute("data-mode", modes[i]);
+ s.style.display = "none";
+ pill.appendChild(s);
+ }
+ modeSpans[modes[i]] = s;
+ }
+ }
+ function render() {
+ if (!rowEl) return;
+ var stale = Date.now() - lastOk > 9e5;
+ rowEl.classList.toggle("stale", stale);
+ ensureModeSpans();
+ if (!modeSpans) return;
+ var modes = ["full", "mid", "mini", "micro"];
+ for (var i = 0; i < modes.length; i++) {
+ var m = modes[i];
+ var body = pillBody(m);
+ if (body !== lastBody[m]) { modeSpans[m].innerHTML = body; lastBody[m] = body; }
+ }
+ if (rowEl.style.display !== "none") setModeSpans(pillMode);
+ }
+ function setModeSpans(mode) {
+ if (!modeSpans) return;
+ var modes = ["full", "mid", "mini", "micro"];
+ for (var i = 0; i < modes.length; i++) {
+ modeSpans[modes[i]].style.display = modes[i] === mode ? "" : "none";
+ }
+ }
+
+ function makeRow() {
+ var el = document.createElement("div");
+ el.className = "__sspills-row";
+ el.style.cursor = "pointer";
+ el.addEventListener("click", function (ev) {
+ // 点击 pill 任意位置都开合弹窗;250ms 去抖防宿主合成重复点击(关了又开的闪烁)
+ if (Date.now() - lastToggleAt < 250) { ev.stopPropagation(); return; }
+ lastToggleAt = Date.now();
+ setPopup(!popupOpen);
+ ev.stopPropagation();
+ });
+ // pill 外壳(含图标):三档内容 span 由 ensureModeSpans 挂进来
+ var pill = document.createElement("span");
+ pill.className = "__sspills-pill";
+ pill.setAttribute("data-act", "detail");
+ var ico = document.createElement("span");
+ ico.className = "ico";
+ // 仪表盘图标(gauge-outline,16 viewBox / 1px 描边),与应用内统计图标同源
+ ico.innerHTML =
+ '";
+ pill.appendChild(ico);
+ el.appendChild(pill);
+ return el;
+ }
+
+ /* 只取「可见」的输入条容器——多标签页时隐藏标签页的 dock 也在 DOM 里。
+ 不能用 offsetParent 判断(fixed 定位/特殊容器下恒为 null,可见也会误判为隐藏),
+ 改用实测宽度 > 0。 */
+ function visibleDock() {
+ var docks = document.querySelectorAll("[data-v4-composer-dock-content]");
+ for (var i = 0; i < docks.length; i++) {
+ var r = docks[i].getBoundingClientRect();
+ if (r.width > 0 && r.height > 0) return docks[i];
+ }
+ return docks[0] || null;
+ }
+
+ /* 布局调度器:所有触发点只置脏标记,界面稳定后才执行一次完整布局。
+ - 尾随防抖 180ms:滚轮滑动/侧栏动画等过渡帧全部跳过,只在静止后布局一次
+ - 统计行挂在输入条下方(fixed),宽度只取决于输入条宽度:按「宽→窄」挑第一个放得下的档位 */
+ var dead = false; // teardown 后置 true:一切异步回调早退,死实例不碰 DOM
+ var layoutTimer = 0;
+ var lastScrollAt = 0; // 滚动豁免:滚动后 700ms 内不布局(滚动中会话顶栏/输入条有过渡动画)
+
+ function requestLayout() {
+ if (dead) return;
+ if (layoutTimer) clearTimeout(layoutTimer);
+ layoutTimer = setTimeout(function () {
+ layoutTimer = 0;
+ if (dead) return;
+ var quiet = Date.now() - lastScrollAt;
+ if (quiet < 700) {
+ // 滚动豁免期内:推迟到豁免期结束再布局
+ layoutTimer = setTimeout(function () {
+ layoutTimer = 0;
+ if (dead) return;
+ layout();
+ }, 700 - quiet + 20);
+ return;
+ }
+ layout();
+ }, 180);
+ }
+
+ /* 清扫幽灵实例:React 重建 dock 会泄漏旧 rowEl(代码只追新不删旧),
+ 泄漏元素停留旧档位与新 pill 叠加闪现。每次布局前清掉一切非当前实例。 */
+ function purgeForeign() {
+ var rows = document.querySelectorAll(".__sspills-row");
+ for (var i = 0; i < rows.length; i++) {
+ if (rows[i] !== rowEl && rows[i].parentNode) rows[i].parentNode.removeChild(rows[i]);
+ }
+ var pops = document.querySelectorAll(".__sspills-pop");
+ for (var j = 0; j < pops.length; j++) {
+ if (pops[j] !== popEl && pops[j].parentNode) pops[j].parentNode.removeChild(pops[j]);
+ }
+ }
+
+ /* 输入条卡片底部原本只剩 ~7px,放不下一整行;给它补 24px 内边距,
+ 统计行就落在卡片内新增的这条底带里(宿主重渲染清掉内联样式时自动补回)。
+ 同时把 dock 设为定位上下文,统计行用 left:50% 居中——拖拽侧栏时位置由浏览器
+ 随卡片一起重算,不再依赖 JS 逐帧改坐标(那会慢一帧,看起来就是「闪一下」)。 */
+ var dockHost = null, dockOrigPad = "", dockOrigPos = "";
+ function ensureDockHost(dock) {
+ if (dockHost && dockHost !== dock) restoreDockHost();
+ if (!dockHost) {
+ dockOrigPad = dock.style.paddingBottom || "";
+ dockOrigPos = dock.style.position || "";
+ dockHost = dock;
+ }
+ if (dock.style.paddingBottom !== "24px") dock.style.paddingBottom = "24px";
+ if (dock.style.position !== "relative") dock.style.position = "relative";
+ }
+ function restoreDockHost() {
+ if (!dockHost) return;
+ try {
+ if (dockHost.style.paddingBottom === "24px") dockHost.style.paddingBottom = dockOrigPad;
+ if (dockHost.style.position === "relative") dockHost.style.position = dockOrigPos;
+ } catch (e) {}
+ dockHost = null;
+ }
+
+ function layout() {
+ if (dead) return;
+ purgeForeign();
+ var dock = visibleDock();
+ if (!dock) {
+ restoreDockHost();
+ if (rowEl) { rowEl.remove(); }
+ return;
+ }
+ ensureDockHost(dock);
+ var dockRect = dock.getBoundingClientRect();
+ if (!(dockRect.width > 0 && dockRect.height > 0)) {
+ if (rowEl) rowEl.style.display = "none";
+ return;
+ }
+ if (!rowEl || !rowEl.isConnected) {
+ if (!rowEl) rowEl = makeRow();
+ }
+ if (rowEl.parentElement !== dock) dock.appendChild(rowEl); // 挂回 dock:位置/居中全交给 CSS
+ ensureModeSpans();
+ var avail = Math.round(dockRect.width - 24); // 左右各留 12px,与输入条左缘对齐
+ var all = ["full", "mid", "mini", "micro"];
+ var chosen = null;
+ for (var ai = 0; ai < all.length; ai++) {
+ var mode = all[ai];
+ setModeSpans(mode); // 纯 display 切换,零重建
+ rowEl.style.display = "";
+ // 换档时多留 8px 余量,防止内容在临界宽度上反复升降档
+ var need = rowEl.offsetWidth + (mode === pillMode ? 0 : 8);
+ if (avail >= need) { chosen = mode; break; }
+ }
+ if (!chosen) {
+ rowEl.style.display = "none"; // 连 micro 都放不下:宁可隐藏也不压住界面
+ return;
+ }
+ pillMode = chosen;
+ setModeSpans(chosen);
+ rowEl.style.display = "";
+ }
+
+ var scheduled = false;
+ function schedule() {
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(function () {
+ scheduled = false;
+ requestLayout();
+ var sid = currentSessionId();
+ if (sid !== lastSid) poll();
+ });
+ }
+
+ document.addEventListener("click", function (ev) {
+ if (!popupOpen) return;
+ if (Date.now() - openedAt < 400) return; // 刚开启:忽略宿主合成的迟到点击,防秒关
+ if (rowEl && !rowEl.contains(ev.target) && popEl && !popEl.contains(ev.target)) {
+ setPopup(false);
+ // 关闭后短暂锁定 pill 布局,避免点击穿透触发的宿主重排让 pill 闪一下
+ lastScrollAt = Date.now();
+ }
+ }, true);
+ window.addEventListener("resize", function () { clampNow(); requestLayout(); if (popupOpen) renderPopup(); });
+ document.addEventListener("scroll", function () {
+ lastScrollAt = Date.now();
+ if (popupOpen) renderPopup();
+ clampNow(); // 统计行跟随输入条移动(fixed 坐标,滚轮时不能等防抖)
+ requestLayout(); // 记录豁免;滚动停稳后会补执行一次
+ }, true);
+
+ var mo = new MutationObserver(schedule);
+ mo.observe(document.documentElement, {
+ childList: true, subtree: true, attributes: true, attributeFilter: ["data-session-id"],
+ });
+ /* 立即贴位:输入框尺寸/位置突变(侧栏开合、滚动)时先把统计行跟到位,
+ 防抖布局随后再决定档位。坐标是视口坐标,fixed 定位直接用,没有包含块换算问题。 */
+ function clampNow() {
+ if (dead || !rowEl) return;
+ var dock = visibleDock();
+ if (!dock) return;
+ ensureDockHost(dock);
+ if (rowEl.parentElement !== dock) dock.appendChild(rowEl);
+ var dockRect = dock.getBoundingClientRect();
+ if (!(dockRect.width > 0)) return;
+ var avail = Math.round(dockRect.width - 24);
+ if (rowEl.style.display === "none" || rowEl.offsetWidth <= avail) return;
+ /* 拖拽变窄:就地降档而不是先隐藏再显示(那会闪一下) */
+ var all = ["full", "mid", "mini", "micro"];
+ var found = null;
+ for (var i = 0; i < all.length; i++) {
+ setModeSpans(all[i]);
+ rowEl.style.display = "";
+ if (rowEl.offsetWidth <= avail) { found = all[i]; break; }
+ }
+ if (!found) { rowEl.style.display = "none"; return; }
+ pillMode = found;
+ setModeSpans(found);
+ }
+
+ /* 输入框容器尺寸变化(侧栏开合/拖分隔条不触发 window resize):
+ 立即钳制防留痕 + 防抖触发完整布局 */
+ var ro = null;
+ var roTimer = 0;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(function () { clampNow(); requestLayout(); });
+ roTimer = setInterval(function () {
+ if (dead) { clearInterval(roTimer); roTimer = 0; return; }
+ var d = visibleDock();
+ if (d) { ro.observe(d); clearInterval(roTimer); roTimer = 0; }
+ }, 2000);
+ }
+ requestLayout();
+ popEl = document.createElement("div");
+ popEl.className = "__sspills-pop";
+ document.body.appendChild(popEl);
+ poll();
+ var pollTimer = setInterval(poll, POLL_MS);
+
+ window.__SSPILLS_TEARDOWN__ = function () {
+ dead = true; // 先置死:所有异步回调(延迟布局/rAF/定时器)从此早退,死实例不再碰 DOM
+ try { mo.disconnect(); } catch (e) {}
+ try { clearInterval(pollTimer); } catch (e) {}
+ try { clearInterval(roTimer); } catch (e) {}
+ try { clearTimeout(layoutTimer); } catch (e) {}
+ try { ro && ro.disconnect(); } catch (e) {}
+ try { css.remove(); } catch (e) {}
+ try { restoreDockHost(); } catch (e) {}
+ try { rowEl && rowEl.remove(); } catch (e) {}
+ try { popEl && popEl.remove(); } catch (e) {}
+ rowEl = null; popEl = null; data = null;
+ };
+})();
diff --git a/external_plugins/session-stats/server/service_standalone.py b/external_plugins/session-stats/server/service_standalone.py
new file mode 100644
index 00000000..b7432699
--- /dev/null
+++ b/external_plugins/session-stats/server/service_standalone.py
@@ -0,0 +1,26 @@
+"""session-stats 独立常驻数据服务。
+
+由补丁器部署到 %LOCALAPPDATA%\\session-stats\\ 并写入登录自启,
+与 MCP 服务器内置的 HTTP 服务共用同一套单实例仲裁(bind 失败 + 健康探测)——
+谁先占用端口谁服务,后到者静默退出。统计条因此不再随会话生灭。
+"""
+
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import stats_server as ss # noqa: E402
+
+# 扁平部署布局(本文件与 pills.js 同目录)下,把本目录的 pills.js 插到下发候选首位
+ss.PILLS_CANDIDATES.insert(0, str(Path(__file__).resolve().parent / "pills.js"))
+
+if __name__ == "__main__":
+ ss.start_http_service()
+ print("session-stats 数据服务运行中: "
+ f"http://{ss.HTTP_HOST}:{ss.HTTP_PORT}(Ctrl+C 退出)", flush=True)
+ try:
+ while True:
+ time.sleep(3600) # 保活
+ except KeyboardInterrupt:
+ pass
diff --git a/external_plugins/session-stats/server/stats_server.py b/external_plugins/session-stats/server/stats_server.py
new file mode 100644
index 00000000..3dbc01df
--- /dev/null
+++ b/external_plugins/session-stats/server/stats_server.py
@@ -0,0 +1,593 @@
+"""ZCode session-stats MCP 服务器(stdio,Python 标准库,零第三方依赖)。
+
+协议:JSON-RPC 2.0 按行读写(MCP stdio 传输),日志走 stderr。
+数据源:~/.zcode/cli/db/db.sqlite(只读 URI 打开,WAL 下并发安全)。
+
+指标口径(2026-10-31 对本机 db 实测定案):
+- 时间戳为 epoch 毫秒;TPS = Σoutput / Σ((completed_at - first_token_at)/1000)
+- input_tokens 已包含 cache_read_input_tokens(GLM 供应商实测),
+ 缓存命中 = cache_read / input;total = input + output
+- 子代理会话通过 session.parent_id 挂在主会话下,current 口径聚合主+子
+- 错误/取消的请求行同样计步计耗时
+"""
+
+import json
+import os
+import sqlite3
+import sys
+import threading
+import time
+import urllib.parse
+import urllib.request
+from datetime import datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+SERVER_NAME = "session-stats"
+SERVER_VERSION = "0.3.0"
+UI_URI = "ui://session-stats/panel.html"
+PLUGIN_ROOT = Path(__file__).resolve().parent.parent
+PANEL_FILE = PLUGIN_ROOT / "ui" / "panel.html"
+
+TOOLS = [
+ {
+ "name": "get_stats",
+ "description": (
+ "获取 ZCode 会话统计(轮次/步数、模型用时、工具用时、TTFT 均值、"
+ "TPS、token 用量、缓存命中率)。scope=current 返回当前会话(含子代理),"
+ "scope=today 返回今日全部会话汇总。"
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "description": "可选。指定主会话 id(sess_ 开头);缺省自动解析当前会话。",
+ },
+ "scope": {
+ "type": "string",
+ "enum": ["current", "today"],
+ "description": "统计口径,默认 current。",
+ },
+ },
+ },
+ "_meta": {"ui": {"visibility": ["model", "app"]}},
+ },
+ {
+ "name": "open_stats_panel",
+ "description": "打开/聚焦会话统计侧栏面板(无参数)。",
+ "inputSchema": {"type": "object", "properties": {}},
+ "_meta": {"ui": {"visibility": ["model", "app"]}},
+ },
+]
+
+RESOURCES = [
+ {"uri": UI_URI, "name": "panel", "title": "会话统计", "mimeType": "text/html"}
+]
+
+# ---------------------------------------------------------------- sqlite ----
+
+_local = threading.local()
+_db_cols: dict[str, set[str]] = {}
+
+
+def log(msg: str) -> None:
+ print(f"[session-stats] {msg}", file=sys.stderr, flush=True)
+
+
+def get_conn() -> sqlite3.Connection:
+ conn = getattr(_local, "conn", None)
+ if conn is None:
+ env_db = os.environ.get("ZCODE_SESSION_DB_PATH", "")
+ db = Path(env_db) if env_db else Path.home() / ".zcode" / "cli" / "db" / "db.sqlite"
+ if not db.exists():
+ raise RuntimeError(f"会话数据库不存在: {db}")
+ conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=5)
+ conn.execute("PRAGMA busy_timeout=5000")
+ conn.row_factory = sqlite3.Row
+ _local.conn = conn
+ return conn
+
+
+def close_local_conn() -> None:
+ conn = getattr(_local, "conn", None)
+ if conn is not None:
+ try:
+ conn.close()
+ except sqlite3.Error:
+ pass
+ _local.conn = None
+
+
+def cols(table: str) -> set[str]:
+ if table not in _db_cols:
+ rows = get_conn().execute(f"PRAGMA table_info({table})").fetchall()
+ _db_cols[table] = {r[1] for r in rows}
+ return _db_cols[table]
+
+
+# ------------------------------------------------------------- 会话解析 ----
+
+
+def read_hook_session() -> str | None:
+ """SessionStart hook 把 session_id 写进插件数据目录的 current_session 文件。"""
+ data_dir = os.environ.get("ZCODE_PLUGIN_DATA", "")
+ if not data_dir:
+ return None
+ try:
+ return (Path(data_dir) / "current_session").read_text(encoding="utf-8").strip() or None
+ except OSError:
+ return None
+
+
+def fallback_session() -> str | None:
+ """兜底:最近一个有 model_usage 的非子代理会话。"""
+ row = get_conn().execute(
+ "SELECT session_id FROM model_usage WHERE session_id NOT LIKE 'sess_subagent%'"
+ " ORDER BY started_at DESC LIMIT 1"
+ ).fetchone()
+ return row[0] if row else None
+
+
+def resolve_session_id(explicit: str | None) -> tuple[str | None, str]:
+ if explicit:
+ return explicit, "param"
+ env_sid = os.environ.get("ZCODE_SESSION_ID")
+ if env_sid:
+ return env_sid, "env"
+ hooked = read_hook_session()
+ if hooked:
+ return hooked, "hook"
+ fb = fallback_session()
+ return fb, "fallback"
+
+
+def session_family(main_id: str) -> list[str]:
+ """主会话 + 其子代理会话(session.parent_id 关联)。"""
+ try:
+ kids = [
+ r[0]
+ for r in get_conn().execute(
+ "SELECT id FROM session WHERE parent_id = ?", (main_id,)
+ ).fetchall()
+ ]
+ except sqlite3.Error:
+ kids = []
+ return [main_id] + kids
+
+
+# ------------------------------------------------------------- 聚合统计 ----
+
+# 请求行的过滤条件由调用方给(current: session_id IN (...),today: started_at >= ?)
+
+# decode 窗口下限:部分供应商(如 minimax)的 first_token_at 不可靠——要么 NULL,
+# 要么与 completed_at 几乎相等(实测 26ms 出 627 token → 2.4 万 tok/s)。
+# 窗口 <500ms 的行不参与任何 TPS 计算,宁可少算也不让分母趋零爆表。
+MIN_DECODE_MS = 500
+
+_DECODE_GUARD = (
+ "output_tokens > 0 AND first_token_at IS NOT NULL "
+ "AND completed_at IS NOT NULL AND completed_at > first_token_at "
+ f"AND completed_at - first_token_at >= {MIN_DECODE_MS}"
+)
+
+
+def agg_model(cur, cond: str, params: list) -> dict:
+ r = cur.execute(
+ f"""
+ SELECT
+ COUNT(DISTINCT logical_request_id) AS steps,
+ COALESCE(SUM(duration_ms), 0) AS model_ms,
+ AVG(time_to_first_token_ms) AS ttft_avg,
+ COALESCE(SUM(CASE WHEN time_to_first_token_ms IS NOT NULL
+ THEN 1 ELSE 0 END), 0) AS ttft_steps,
+ COALESCE(SUM(input_tokens), 0) AS t_in,
+ COALESCE(SUM(output_tokens), 0) AS t_out,
+ COALESCE(SUM(reasoning_tokens), 0) AS t_reason,
+ COALESCE(SUM(cache_read_input_tokens), 0) AS t_cread,
+ COALESCE(SUM(cache_creation_input_tokens), 0) AS t_cwrite,
+ COALESCE(SUM(CASE WHEN {_DECODE_GUARD}
+ THEN output_tokens END), 0) AS decode_tokens,
+ COALESCE(SUM(CASE WHEN {_DECODE_GUARD}
+ THEN completed_at - first_token_at END), 0) AS decode_ms
+ FROM model_usage WHERE {cond}
+ """,
+ params,
+ ).fetchone()
+ return dict(r)
+
+
+def agg_tools(cur, cond: str, params: list) -> dict:
+ r = cur.execute(
+ "SELECT COALESCE(COUNT(*), 0) AS calls, COALESCE(SUM(duration_ms), 0) AS tool_ms"
+ f" FROM tool_usage WHERE {cond}",
+ params,
+ ).fetchone()
+ return dict(r)
+
+
+def models_breakdown(cur, cond: str, params: list) -> list[dict]:
+ rows = cur.execute(
+ f"""
+ SELECT model_id,
+ COUNT(DISTINCT logical_request_id) AS steps,
+ COALESCE(SUM(input_tokens), 0) AS t_in,
+ COALESCE(SUM(output_tokens), 0) AS t_out,
+ COALESCE(SUM(cache_read_input_tokens), 0) AS t_cread,
+ COALESCE(SUM(cache_creation_input_tokens), 0) AS t_cwrite,
+ COALESCE(SUM(CASE WHEN {_DECODE_GUARD}
+ THEN output_tokens END), 0) AS decode_tokens,
+ COALESCE(SUM(CASE WHEN {_DECODE_GUARD}
+ THEN completed_at - first_token_at END), 0) AS decode_ms
+ FROM model_usage WHERE {cond}
+ GROUP BY model_id ORDER BY t_in + t_out DESC
+ """,
+ params,
+ ).fetchall()
+ out = []
+ for r in rows:
+ d = dict(r)
+ d["tps"] = round(d["decode_tokens"] * 1000.0 / d["decode_ms"], 1) if d["decode_ms"] > 0 else None
+ out.append(d)
+ return out
+
+
+def recent_requests(cur, cond: str, params: list, limit: int = 6) -> list[dict]:
+ """最近几条请求的原始计时(供 pill 算实时 TPS)。started_at 倒序。"""
+ rows = cur.execute(
+ f"""
+ SELECT logical_request_id, output_tokens, first_token_at, completed_at,
+ started_at, model_id, input_tokens, cache_read_input_tokens
+ FROM model_usage WHERE {cond}
+ ORDER BY started_at DESC LIMIT {int(limit)}
+ """,
+ params,
+ ).fetchall()
+ return [
+ {
+ "rid": r[0], "out": r[1], "fta": r[2], "done_at": r[3],
+ "started": r[4], "model": r[5],
+ "in": r[6], "cread": r[7],
+ }
+ for r in rows
+ ]
+
+
+def build_stats(scope: str, session_id: str | None, session_source: str) -> dict:
+ cur = get_conn().cursor()
+ payload = {
+ "scope": scope,
+ "session_id": session_id,
+ "session_source": session_source,
+ "generated_at": int(time.time() * 1000),
+ "turns": None,
+ "steps": 0,
+ "model_ms": 0,
+ "tool_ms": 0,
+ "tool_calls": 0,
+ "ttft_ms_avg": None,
+ "ttft_steps": 0,
+ "tps": None,
+ "decode_tokens": 0,
+ "decode_ms": 0,
+ "tokens": {
+ "input": 0, "output": 0, "reasoning": 0,
+ "cache_read": 0, "cache_write": 0, "total": 0,
+ },
+ "cache_hit_pct": None,
+ "models": [],
+ "subagent_count": 0,
+ }
+
+ if scope == "today":
+ # 本地零点(epoch ms)
+ midnight = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000)
+ cond = "started_at >= ?"
+ params: list = [midnight]
+ n = cur.execute(
+ "SELECT COUNT(DISTINCT CASE WHEN session_id NOT LIKE 'sess_subagent%'"
+ " THEN session_id END) FROM model_usage WHERE started_at >= ?",
+ params,
+ ).fetchone()
+ payload["sessions"] = n[0]
+ else:
+ if not session_id:
+ payload["error"] = "无法解析当前会话 id(hook/env/db 兜底均未命中)"
+ return payload
+ ids = session_family(session_id)
+ payload["subagent_count"] = len(ids) - 1
+ cond = "session_id IN (" + ",".join("?" * len(ids)) + ")"
+ params = list(ids)
+ payload["turns"] = cur.execute(
+ "SELECT COUNT(DISTINCT turn_id) FROM model_usage WHERE session_id = ?",
+ (session_id,),
+ ).fetchone()[0]
+
+ m = agg_model(cur, cond, params)
+ t = agg_tools(cur, cond, params)
+ payload["steps"] = m["steps"]
+ payload["model_ms"] = m["model_ms"]
+ payload["ttft_ms_avg"] = round(m["ttft_avg"]) if m["ttft_avg"] is not None else None
+ payload["ttft_steps"] = m["ttft_steps"]
+ payload["decode_tokens"] = m["decode_tokens"]
+ payload["decode_ms"] = m["decode_ms"]
+ payload["tps"] = round(m["decode_tokens"] * 1000.0 / m["decode_ms"], 1) if m["decode_ms"] > 0 else None
+ payload["tool_calls"] = t["calls"]
+ payload["tool_ms"] = t["tool_ms"]
+ payload["tokens"] = {
+ "input": m["t_in"],
+ "output": m["t_out"],
+ "reasoning": m["t_reason"],
+ "cache_read": m["t_cread"],
+ "cache_write": m["t_cwrite"],
+ "total": m["t_in"] + m["t_out"],
+ }
+ # input 已含 cache_read(spike 实测);其他供应商若口径不同会 >100,钳制并让 UI 展示明细
+ hit = m["t_cread"] * 100.0 / m["t_in"] if m["t_in"] > 0 else None
+ payload["cache_hit_pct"] = round(min(hit, 100.0), 1) if hit is not None else None
+ payload["models"] = models_breakdown(cur, cond, params)
+ payload["recent"] = recent_requests(cur, cond, params)
+ # 最近一次完成请求的 TPS(面板/弹窗「实时 TPS」行)。
+ # 复用 _DECODE_GUARD 的窗口下限:脏 fta 行直接跳过,取最近一条窗口可信的请求。
+ live = None
+ for r in payload["recent"]:
+ if (
+ r["done_at"] is not None and r["fta"] is not None and (r["out"] or 0) > 0
+ and r["done_at"] - r["fta"] >= MIN_DECODE_MS
+ ):
+ live = round(r["out"] * 1000.0 / (r["done_at"] - r["fta"]), 1)
+ break
+ payload["live_tps"] = live
+ return payload
+
+
+# ------------------------------------------------------------- MCP 协议 ----
+
+
+def tool_get_stats(args: dict) -> dict:
+ scope = args.get("scope") or "current"
+ if scope not in ("current", "today"):
+ scope = "current"
+ if scope == "today":
+ sid, source = None, "n/a"
+ else:
+ sid, source = resolve_session_id(args.get("session_id"))
+ payload = build_stats(scope, sid, source)
+ text = json.dumps(payload, ensure_ascii=False, indent=1)
+ return {"content": [{"type": "text", "text": text}], "structuredContent": payload}
+
+
+def tool_open_panel(_args: dict) -> dict:
+ return {
+ "content": [{"type": "text", "text": "会话统计面板已打开(stats-panel)。"}],
+ "_meta": {"ui": {"surface": "stats-panel"}},
+ }
+
+
+def read_panel_html() -> str:
+ try:
+ return PANEL_FILE.read_text(encoding="utf-8")
+ except OSError as e:
+ log(f"panel.html 读取失败: {e}")
+ return (
+ ""
+ f"panel.html 缺失:{PANEL_FILE}"
+ )
+
+
+def reply(rid, result: dict) -> None:
+ send({"jsonrpc": "2.0", "id": rid, "result": result})
+
+
+def send_err(rid, code: int, message: str) -> None:
+ send({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}})
+
+
+def send(msg: dict) -> None:
+ sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
+ sys.stdout.flush()
+
+
+def handle(req: dict) -> None:
+ method = req.get("method")
+ rid = req.get("id")
+
+ if method == "initialize":
+ client_pv = (req.get("params") or {}).get("protocolVersion") or "2025-06-18"
+ reply(rid, {
+ "protocolVersion": client_pv,
+ "capabilities": {"tools": {}, "resources": {}},
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
+ "instructions": "session-stats: 只读查询 ZCode 本地会话统计。",
+ })
+ return
+
+ if rid is None:
+ return # notification(notifications/initialized 等)一律不回
+
+ if method == "ping":
+ reply(rid, {})
+ elif method == "tools/list":
+ reply(rid, {"tools": TOOLS})
+ elif method == "tools/call":
+ params = req.get("params") or {}
+ name = params.get("name", "")
+ args = params.get("arguments") or {}
+ try:
+ if name == "get_stats":
+ result = tool_get_stats(args)
+ elif name == "open_stats_panel":
+ result = tool_open_panel(args)
+ else:
+ send_err(rid, -32602, f"unknown tool: {name}")
+ return
+ except Exception as e: # noqa: BLE001
+ log(f"tools/call {name} 失败: {e!r}")
+ reply(rid, {"content": [{"type": "text", "text": f"统计失败: {e}"}], "isError": True})
+ return
+ reply(rid, result)
+ elif method == "resources/list":
+ reply(rid, {"resources": RESOURCES})
+ elif method == "resources/read":
+ uri = (req.get("params") or {}).get("uri")
+ if uri != UI_URI:
+ send_err(rid, -32602, f"unknown resource: {uri}")
+ return
+ reply(rid, {"contents": [{"uri": UI_URI, "mimeType": "text/html", "text": read_panel_html()}]})
+ else:
+ send_err(rid, -32601, f"method not found: {method}")
+
+
+# ------------------------------------------------------- 本地数据服务 ----
+# 渲染层注入 pill 的数据源:http://127.0.0.1:31710。
+# 生命周期跟随 MCP 服务器进程(会话存活期间);多实例靠端口单例 + 健康探测,
+# 谁先绑定谁服务,后到的发现健康实例就静默退出。仅响应 file:// 页面
+# (Origin: null/缺失),其余 Origin 一律 403。
+
+HTTP_HOST = "127.0.0.1"
+HTTP_PORT = int(os.environ.get("ZCODE_STATS_HTTP_PORT", "31710"))
+# pill 代码下发路径解析顺序:环境变量(开发者热更新用)→ 插件内置副本(共享默认)→ 开发目录
+PILLS_CANDIDATES = [
+ os.environ.get("ZCODE_STATS_PILLS_PATH", ""),
+ str(PLUGIN_ROOT / "pills.js"),
+ str(PLUGIN_ROOT / "server" / "pills.js"),
+ str(PLUGIN_ROOT.parents[1] / "patches" / "pills.js"),
+]
+
+
+def read_pills_js() -> str | None:
+ for p in PILLS_CANDIDATES:
+ if p:
+ try:
+ return Path(p).read_text(encoding="utf-8")
+ except OSError:
+ continue
+ return None
+
+
+def _health_check() -> bool:
+ try:
+ with urllib.request.urlopen(
+ f"http://{HTTP_HOST}:{HTTP_PORT}/health", timeout=1
+ ) as r:
+ return r.status == 200
+ except Exception: # noqa: BLE001
+ return False
+
+
+class _StatsHandler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ allowed = False
+ try:
+ origin = self.headers.get("Origin")
+ allowed = origin in (None, "", "null")
+ parsed = urllib.parse.urlparse(self.path)
+ if not allowed:
+ self._send(403, {"error": "origin rejected"}, cors=False)
+ elif parsed.path == "/health":
+ self._send(200, {"ok": True, "version": SERVER_VERSION})
+ elif parsed.path == "/pills.js":
+ code = read_pills_js()
+ if code is None:
+ self._send(404, {"error": "pills.js not configured"})
+ else:
+ body = code.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/javascript; charset=utf-8")
+ self.send_header("Access-Control-Allow-Origin", "null")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+ elif parsed.path == "/stats":
+ qs = urllib.parse.parse_qs(parsed.query)
+ scope = qs.get("scope", ["current"])[0]
+ if scope not in ("current", "today"):
+ scope = "current"
+ if scope == "today":
+ payload = build_stats("today", None, "n/a")
+ else:
+ sid_param = (qs.get("session_id") or [None])[0]
+ if sid_param is not None and not str(sid_param).startswith(("sess_", "session_")):
+ sid_param = None # 格式守卫,防宿主 DOM 传来 draft 等占位值
+ sid, source = resolve_session_id(sid_param)
+ payload = build_stats("current", sid, source)
+ self._send(200, payload)
+ else:
+ self._send(404, {"error": "not found"})
+ except Exception as e: # noqa: BLE001
+ log(f"HTTP 处理异常: {e!r}")
+ try:
+ self._send(500, {"error": str(e)}, cors=allowed)
+ except Exception: # noqa: BLE001
+ pass
+ finally:
+ close_local_conn()
+
+ def _send(self, code: int, obj: dict, cors: bool = True) -> None:
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ if cors:
+ self.send_header("Access-Control-Allow-Origin", "null")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, fmt, *args) -> None: # noqa: N802
+ pass # 静默访问日志
+
+
+def start_http_service() -> bool:
+ """在本进程内启动 HTTP 数据服务。
+
+ 返回 True=本进程已开始服务;False=端口已有健康实例(或启动失败)。
+ 常驻服务进程据此退出,避免自动重打补丁时反复拉起空转实例。
+ """
+ class _Server(ThreadingHTTPServer):
+ # Windows 上 SO_REUSEADDR 会允许重复绑定活跃端口,关闭以靠异常探测单例
+ allow_reuse_address = False
+ daemon_threads = True
+
+ for _ in range(3):
+ try:
+ srv = _Server((HTTP_HOST, HTTP_PORT), _StatsHandler)
+ except OSError:
+ if _health_check():
+ log(f"端口 {HTTP_PORT} 已有健康实例,跳过启动")
+ return False
+ time.sleep(0.5)
+ continue
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ log(f"数据服务已启动 http://{HTTP_HOST}:{HTTP_PORT}")
+ return True
+ log(f"数据服务启动失败(端口 {HTTP_PORT} 被占用且无健康实例)")
+ return False
+
+
+def main() -> None:
+ try:
+ sys.stdout.reconfigure(newline="\n", encoding="utf-8")
+ sys.stdin.reconfigure(encoding="utf-8")
+ except Exception: # noqa: BLE001
+ pass
+ log(f"started; plugin_root={PLUGIN_ROOT}")
+ start_http_service()
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ req = json.loads(line)
+ except json.JSONDecodeError as e:
+ log(f"非法 JSON 行: {e}; line={line[:120]!r}")
+ continue
+ try:
+ handle(req)
+ except Exception as e: # noqa: BLE001
+ log(f"handle 异常: {e!r}")
+ if req.get("id") is not None:
+ send_err(req["id"], -32603, f"internal error: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/external_plugins/session-stats/ui/panel.html b/external_plugins/session-stats/ui/panel.html
new file mode 100644
index 00000000..df07dd26
--- /dev/null
+++ b/external_plugins/session-stats/ui/panel.html
@@ -0,0 +1,232 @@
+
+
+
+
+
+会话统计
+
+
+
+
+
+
+
diff --git a/marketplace.json b/marketplace.json
index c7f09a06..4a5d469f 100644
--- a/marketplace.json
+++ b/marketplace.json
@@ -13,6 +13,41 @@
{ "name": "openpencil", "source": "./external_plugins/openpencil" }
],
"plugins": [
+ {
+ "name": "session-stats",
+ "componentTypes": [
+ "ui"
+ ],
+ "displayName": "Session Stats",
+ "displayName_i18n": {
+ "en": "Session Stats",
+ "zh-CN": "会话统计"
+ },
+ "source": "./external_plugins/session-stats",
+ "description": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.",
+ "description_i18n": {
+ "en": "Session statistics for ZCode: turns/steps, model vs tool time, TTFT, TPS (average + latest request), token usage and cache hit rate. Zero third-party dependencies.",
+ "zh-CN": "会话统计:轮次/步数、模型/工具耗时、TTFT、TPS(会话平均+最近请求)、token 用量与缓存命中率。零第三方依赖。"
+ },
+ "version": "0.3.0",
+ "author": {
+ "name": "gary.liu",
+ "url": "https://github.com/huantest2024"
+ },
+ "homepage": "https://github.com/huantest2024/zcode-session-stats",
+ "repository": "https://github.com/huantest2024/zcode-session-stats",
+ "license": "MIT",
+ "keywords": [
+ "stats",
+ "usage",
+ "tokens",
+ "cache",
+ "ttft",
+ "tps",
+ "panel"
+ ],
+ "category": "developer-tools"
+ },
{
"name": "cloudbase-skills",
"source": "./plugins/cloudbase-skills",