minecodex 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,7 @@ MineCodex 给 Codex Desktop 增加一组轻量、本地优先的扩展功能。
14
14
  | **Notes** | 跨会话管理待办事项、常用提示词与文件,可随时从 Codex 工具栏快速复用 |
15
15
  | **Images** | 集中浏览生成的图片、Prompt 和来源任务,轻松保存原图或快速返回原会话 |
16
16
  | **Hide Upsell Banner** | 隐藏输入框上方的用量提示横幅,仅优化界面展示,不影响正常的用量统计 |
17
+ | **Session Status** | 在输入框下方查看当前任务的步骤、观测速度、Token 用量与上下文明细 |
17
18
 
18
19
  ## 安装
19
20
 
@@ -53,7 +54,7 @@ mcx open
53
54
 
54
55
  ## 工作原理
55
56
 
56
- MineCodex 统一管理 Codex 内的菜单入口、主题风格与界面适配。每个插件(Model Slider、Notes、Images)独立管理各自的数据与操作页面;Hide Upsell Banner 专注于界面展示的优化,不影响实际的用量计算。所有功能按需开启,关闭后即停止运行。
57
+ MineCodex 统一管理 Codex 内的菜单入口、主题风格与界面适配。Model Slider、Notes、Images 各自管理数据与操作页面;Session Status 只读汇总本机 Codex 会话事件,Hide Upsell Banner 优化界面展示。所有功能按需开启,关闭后即停止运行。
57
58
 
58
59
  ## 开发
59
60
 
@@ -67,4 +68,4 @@ npm run pack:audit
67
68
 
68
69
  ## 许可
69
70
 
70
- MineCodex 是非官方独立扩展项目,与 OpenAI 无关联。项目自有代码以 MIT License 授权;第三方图标、品牌和兼容资产遵循各自的权利说明,见 [`features/model-slider/THIRD_PARTY_NOTICES.md`](features/model-slider/THIRD_PARTY_NOTICES.md)。
71
+ MineCodex 是非官方独立扩展项目,与 OpenAI 无关联。项目自有代码以 MIT License 授权;第三方图标、品牌和兼容资产遵循各自的权利说明,见 [`features/model-slider/THIRD_PARTY_NOTICES.md`](features/model-slider/THIRD_PARTY_NOTICES.md) 与 [`features/session-status/vendor/lucide-LICENSE`](features/session-status/vendor/lucide-LICENSE)。
@@ -0,0 +1,31 @@
1
+ # Session Status 设计约定
2
+
3
+ ## 意图
4
+
5
+ 状态行紧随 Codex 原生输入框,帮助用户在长任务中快速判断回合、模型步骤、输出速度和 token 用量。视觉上作为输入框的附属信息,不争夺发送按钮与模型选择器的层级。
6
+
7
+ ## Token 与布局
8
+
9
+ | 角色 | Codex token | 用途 |
10
+ | --- | --- | --- |
11
+ | 次要文字 | `--color-text-tertiary` | 状态行文字与分隔符 |
12
+ | 主要文字 | `--color-text-primary` | 弹层标题和数值 |
13
+ | 弹层表面 | `--color-token-dropdown-background` | 点击后的明细 |
14
+ | 边框 | `--color-border-subtle` | 弹层与分割线 |
15
+ | 悬停 | `--color-token-list-hover-background` | 状态按钮反馈 |
16
+ | 焦点描边 | `--input-outline-border-color-focus` | 键盘焦点反馈 |
17
+ | 分类进度 | Codex `--app-block-accent-purple`、`--app-block-accent-blue`、`--app-block-accent-green`,以及 `--color-token-border-default` | 各弹层的分类比例与未分类段 |
18
+ | 字体 | `--font-sans-default`、`--text-xs` | 与原生辅助信息一致 |
19
+ | 圆角与阴影 | `--radius-xl`、`--shadow-lg` | 明细弹层 |
20
+
21
+ 状态行和输入框共用内容宽度,单行高度为 24px,顶部留 2px 内边距;每个状态按钮高 22px,内边距为上下 3px、左右 7px。宽度不足时自然换行,不覆盖输入框或悬浮控件。统计图标采用 Lucide Gauge,用量图标采用 Phosphor Database 单路径版本,两者以 14px 绘制;上下文使用与 Codex 原生相同参数的 12px 百分环。弹层由按钮锚定,键盘可聚焦,Escape 和点击外部关闭。遵守系统减少动态效果设置,不添加持续动画。
22
+
23
+ ## 数据呈现
24
+
25
+ 界面文案跟随 Codex 的 `html[lang]`:中文语言使用简体中文,其他语言使用英文;运行中切换语言时同步更新状态行和已打开的明细浮窗。MineCodex Web 使用同样的中英文规则提供本功能开关。
26
+
27
+ TPS 根据 Codex 会话事件计算,分母扣除工具执行区间;缺少有效计时样本时显示 `—`。累计 token 与缓存比例来自 Codex 用量记录。上下文使用原生 Codex 桌面版的口径:最近请求 `last.totalTokens / modelContextWindow`,状态栏显示百分环和数字,弹层显示用量、窗口容量和压缩次数。二级浮窗的 token 数按十进制换算为 K/M,省略 `tok` 后缀;TPS 仍保留 `tok/s`。日志可见历史只列有数据的指令、消息、工具交互三个短标签,估算值以 `~` 开头。6px 进度条使用 Codex 图标选择器的紫、蓝、绿配色,标签圆点保持同色;当前页面未暴露对应变量时使用 Codex 亮/暗主题原值。剩余的原生占用以浅灰表示;若估算合计超过原生占用,按比例缩放彩色段,让进度条终点始终对应原生总占用。不显示无法取得的工具定义,也不放解释性长文案。
28
+
29
+ 上下文弹层按视觉归属排列:进度条下紧接三个可见历史分段,不加分段副标题;分隔线之后列原生占用合计、窗口容量和压缩次数。合计取原生最近请求用量,不是三个估算分段的求和。
30
+
31
+ 会话统计浮窗只显示回合、步骤、首字延迟和 TPS。Token 用量浮窗以 Total 为主项,紧接比例条和输出(紫)、其他输入(蓝)、缓存输入(绿)三项,顺序与上下文弹层一致;分隔线后才显示缓存命中率。其他输入为总输入减缓存输入,包含缓存写入。三项使用同一分母。
@@ -0,0 +1,29 @@
1
+ # Session Status / 会话状态
2
+
3
+ 在 Codex 输入框下方显示当前任务的回合、模型响应步骤、观测 TPS、累计 token 和缓存命中。点击状态行可查看统计、用量及上下文详情。
4
+
5
+ 数据由 Runtime Host 只读解析本机 `~/.codex/sessions` 中的 Codex 会话事件,再通过已有 CDP 连接向注入脚本发送数值快照;功能不发送对话内容,也不修改 Codex 会话日志。远程任务或缺少本机日志时隐藏状态行。
6
+
7
+ ## 口径
8
+
9
+ - 回合:唯一 `task_started` / `turn_context` 的 `turn_id`。
10
+ - 步骤:有 `token_usage_record` 的唯一 `response_id`,即已记录的模型响应次数。
11
+ - Token:优先采用逐响应记录的 `thread_token_usage`,旧日志缺少该记录时回退到 `token_count.total_token_usage`;缓存命中为 `cached_input_tokens / input_tokens`。未缓存输入为总输入减缓存读与缓存写。
12
+ - TPS:仅对可配对模型输出条目起点与逐次用量记录终点的响应,计算 `Σ output_tokens / Σ(观测时长 − 其中工具执行时长)`;重叠工具区间只扣一次。缺少有效计时样本时显示 `—`。
13
+ - Token 构成:Total 下的比例条按输出(紫)、其他输入(蓝)、缓存输入(绿)显示三项在会话总用量中的占比;缓存命中率独立放在末尾,仍以总输入为分母。其他输入为总输入减缓存输入,包含缓存写入。
14
+ - 上下文:与当前 Codex 桌面版原生百分环相同,采用最近请求的 `last.total_tokens / model_context_window`,裁切至 0–100%。即使关闭原生百分环,本功能仍从会话事件独立读取。二级浮窗在进度条下显示日志可见历史的指令、消息、工具交互本地估算,随后显示原生占用合计、窗口容量和压缩次数;`~` 表示估算,合计并非估算分段之和。进度条采用 Codex 原生配色,以紫、蓝、绿分别表示三类,未分类的占用为浅灰;彩色段按需缩放,整条终点对应原生总占用。工具定义因未写入日志而不显示。
15
+ - 压缩次数:读取会话日志中的 `compacted` 记录及其 `window_number`,兼容旧版 `context_compacted` 和 `ContextCompaction` 完成事件;同一次压缩的多种记录只计一次。若 `compacted` 含 `replacement_history`,分段估算以它替换压缩前历史,再累计后续记录。
16
+
17
+ 统计图标使用 Lucide Static v1.31.0 的 Gauge;用量图标使用 Phosphor Icons 的 Database,单路径填充避免半透明色在路径交叠处加深。许可见 [`vendor/lucide-LICENSE`](vendor/lucide-LICENSE) 和 [`vendor/phosphor-LICENSE`](vendor/phosphor-LICENSE)。
18
+
19
+ ## 上下文分类的可行性
20
+
21
+ 当前桌面版原生百分环只需要 `last.totalTokens` 和 `modelContextWindow`。Codex 的会话用量事件还提供输入、缓存、输出和推理输出,但没有系统提示词、工具定义、对话消息各自的 token 数。日志中的 `session_meta.base_instructions`、`response_item` 角色与类型、`compacted.replacement_history` 可以形成有边界的活跃历史分段:基础与开发者指令、消息(包含压缩摘要)、工具调用与结果。参考 Codex 自身的粗估口径,将 UTF-8 可见文本按约 4 字节/token 计,图片按固定大小估算;全程本地运算,不调用模型。加密摘要参考 Codex 对编码长度的换算。
22
+
23
+ 这些分段与准确的最近请求用量是两套口径:日志缺少完整工具定义和实际请求封装,且最新追加的条目可能尚未进入下一次请求,因此分段值不强求与总占用相加相等。旧日志没有 `replacement_history` 时只估算压缩后的可见后缀,不把压缩前历史继续累加。弹层保持简短,通过分组标题和 `~` 标记区分估算。二级浮窗所有 token 数按十进制(1K = 1,000,1M = 1,000,000)显示数量级,不附加 `tok`;速度继续使用 `tok/s`。
24
+
25
+ 若将来 Codex 的事件或 App Server 增加分类用量,可用原生数据替换本地估算。
26
+
27
+ 相关实现调研:[`picoSols/codex-usage-statusline`](https://github.com/picoSols/codex-usage-statusline) 也用 Codex `token_count` 计算总占用;[`auditt98/context-viewer`](https://github.com/auditt98/context-viewer) 用 `tiktoken` 给可见日志文本估算分类,但其 Codex 解析不能还原每次实际请求中的完整提示词和工具定义。[`HarnessDesk` 的 DSH 接入](https://github.com/HarnessDesk/HarnessDesk/blob/main/docs/context-usage.md) 从 DSH 自己的 token meter 接收分类投影,其 Codex 接入没有这项投影。[Codex App Server 用量协议](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/schema/json/v2/ThreadTokenUsageUpdatedNotification.json) 只提供输入、缓存、输出、推理等用量种类和上下文窗口,没有按提示词、工具和消息拆分的字段。
28
+
29
+ 会话 JSONL 和 Codex DOM 均属版本相关接口,升级 Codex 后需用隔离窗口重新验证。关闭本功能会移除注入节点和观察器。
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "session-status",
4
+ "version": "0.1.0",
5
+ "label": { "en": "Session Status", "zh-CN": "会话状态" },
6
+ "description": {
7
+ "en": "Shows conversation steps, observed output speed, token usage and context details below the composer.",
8
+ "zh-CN": "在输入框下方显示对话步骤、观测输出速度、Token 用量与上下文明细。"
9
+ },
10
+ "entry": { "kind": "page-script", "script": "src/session-status.mjs" },
11
+ "icon": {
12
+ "viewBox": "0 0 24 24",
13
+ "markup": "<path d=\"m12 14 4-4\"></path><path d=\"M3.34 19a10 10 0 1 1 17.32 0\"></path>"
14
+ },
15
+ "placement": { "order": 50 }
16
+ }
@@ -0,0 +1,326 @@
1
+ // page-script:数据由 Runtime Host 单向传入,页面只负责展示。
2
+ const MARKER = "data-minecodex-session-status";
3
+ const STYLE_ID = "minecodex-session-status-style";
4
+ const copyByLocale = { "zh-CN": {
5
+ turns: "回合", steps: "步", speed: "TPS", cache: "缓存", cacheDetail: "缓存命中",
6
+ statistics: "会话统计", usage: "Token 用量", context: "上下文详情", total: "总量",
7
+ firstToken: "平均首字延迟", input: "输入", cached: "缓存输入", output: "输出",
8
+ sum: "合计", capacity: "窗口容量", contextShare: "上下文占用", compactions: "压缩次数",
9
+ instructions: "指令", messages: "消息", toolHistory: "工具交互", other: "未分类",
10
+ }, en: {
11
+ turns: "turns", steps: "steps", speed: "TPS", cache: "Cache", cacheDetail: "Cache hit",
12
+ statistics: "Session stats", usage: "Token usage", context: "Context", total: "Total",
13
+ firstToken: "Avg first token", input: "Input", cached: "Cached input", output: "Output",
14
+ sum: "Sum", capacity: "Window size", contextShare: "Context used", compactions: "Compactions",
15
+ instructions: "Instructions", messages: "Messages", toolHistory: "Tool activity", other: "Unclassified",
16
+ } };
17
+ function selectedCopy() {
18
+ const language = document.documentElement.lang || navigator.languages?.[0] || navigator.language || "en";
19
+ return String(language).toLowerCase().startsWith("zh") ? copyByLocale["zh-CN"] : copyByLocale.en;
20
+ }
21
+ let copy = selectedCopy();
22
+
23
+ // 图标来自 Lucide Static 与 Phosphor Icons;许可见 vendor/。
24
+ const icons = {
25
+ statistics: '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>',
26
+ usage: '<svg aria-hidden="true" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>',
27
+ };
28
+
29
+ document.getElementById(STYLE_ID)?.remove();
30
+ const style = document.createElement("style");
31
+ style.id = STYLE_ID;
32
+ style.textContent = `
33
+ [${MARKER}] { box-sizing:border-box; display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:0 12px; margin:-2px auto -6px; padding-top:2px; max-width:100%; min-height:24px; min-width:0; font-family:var(--font-sans-default,inherit); font-size:var(--text-xs,12px); line-height:16px; font-variant-numeric:tabular-nums; color:var(--color-text-tertiary); }
34
+ [${MARKER}][hidden] { display:none; }
35
+ [${MARKER}] button { box-sizing:border-box; display:inline-flex; align-items:center; gap:5px; height:22px; color:inherit; font:inherit; border:0; border-radius:var(--radius-xl,12px); background:transparent; padding:3px 7px; cursor:pointer; white-space:nowrap; }
36
+ [${MARKER}] svg, [data-minecodex-session-status-panel] h3 svg { width:14px; height:14px; flex:none; stroke-width:1.75; }
37
+ [${MARKER}] [data-kind="context"] svg, [data-minecodex-session-status-panel] h3 .context-ring { width:12px; height:12px; stroke-width:2; }
38
+ [${MARKER}] button:hover, [${MARKER}] button[aria-expanded="true"] { background:var(--color-token-list-hover-background); }
39
+ [${MARKER}] button:focus-visible { outline:2px solid var(--input-outline-border-color-focus); outline-offset:1px; }
40
+ [data-minecodex-session-status-panel] { --mcx-purple:var(--app-block-accent-purple,light-dark(#8952ee,#7849d1)); --mcx-blue:var(--app-block-accent-blue,light-dark(#3a83f7,#2c67c5)); --mcx-green:var(--app-block-accent-green,light-dark(#53b559,#48a04c)); box-sizing:border-box; position:fixed; z-index:2147483000; min-width:260px; max-width:min(360px,calc(100vw - 24px)); padding:16px; border:1px solid var(--color-border-subtle); border-radius:var(--radius-xl,14px); background:var(--color-token-dropdown-background); color:var(--color-text-primary); font-family:var(--font-sans-default,inherit); font-size:13px; box-shadow:var(--shadow-lg,0 8px 28px rgba(0,0,0,.12)); }
41
+ [data-minecodex-session-status-panel] h3 { display:flex; align-items:center; gap:8px; margin:0 0 12px; padding:0 0 10px; border-bottom:1px solid var(--color-border-subtle); font-size:14px; font-weight:600; }
42
+ [data-minecodex-session-status-panel] dl { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px 20px; margin:0; }
43
+ [data-minecodex-session-status-panel] dt { color:var(--color-text-tertiary); }
44
+ [data-minecodex-session-status-panel] dd { margin:0; text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
45
+ [data-minecodex-session-status-panel] .context-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:8px; }
46
+ [data-minecodex-session-status-panel] .context-head span:first-child { color:var(--color-text-tertiary); }
47
+ [data-minecodex-session-status-panel] .context-bar, [data-minecodex-session-status-panel] .mix-bar { display:flex; height:6px; overflow:hidden; border-radius:var(--radius-xl); background:var(--color-token-border-default); }
48
+ [data-minecodex-session-status-panel] .context-bar { margin-bottom:14px; }
49
+ [data-minecodex-session-status-panel] .mix-bar { grid-column:1 / -1; margin:0 0 3px; }
50
+ [data-minecodex-session-status-panel] .context-bar span, [data-minecodex-session-status-panel] .mix-bar span { display:block; flex:none; height:100%; }
51
+ [data-minecodex-session-status-panel] .context-bar [data-segment="instructions"] { background:var(--mcx-purple); }
52
+ [data-minecodex-session-status-panel] .context-bar [data-segment="messages"], [data-minecodex-session-status-panel] .context-bar [data-segment="overall"] { background:var(--mcx-blue); }
53
+ [data-minecodex-session-status-panel] .context-bar [data-segment="tools"] { background:var(--mcx-green); }
54
+ [data-minecodex-session-status-panel] .context-bar [data-segment="other"] { background:var(--color-text-tertiary); opacity:.4; }
55
+ [data-minecodex-session-status-panel] .mix-bar [data-tone="purple"], [data-minecodex-session-status-panel] dt[data-tone="purple"]::before { background:var(--mcx-purple); }
56
+ [data-minecodex-session-status-panel] .mix-bar [data-tone="blue"], [data-minecodex-session-status-panel] dt[data-tone="blue"]::before { background:var(--mcx-blue); }
57
+ [data-minecodex-session-status-panel] .mix-bar [data-tone="green"], [data-minecodex-session-status-panel] dt[data-tone="green"]::before { background:var(--mcx-green); }
58
+ [data-minecodex-session-status-panel] .detail-divider { grid-column:1 / -1; margin-top:4px; border-top:1px solid var(--color-border-subtle); }
59
+ [data-minecodex-session-status-panel] dt[data-tone] { display:flex; align-items:center; gap:7px; }
60
+ [data-minecodex-session-status-panel] dt[data-tone]::before { content:""; flex:none; width:7px; height:7px; border-radius:2px; }
61
+ `;
62
+ document.head.append(style);
63
+
64
+ const root = document.createElement("div");
65
+ root.setAttribute(MARKER, "");
66
+ root.hidden = true;
67
+ function createContextRing() {
68
+ const namespace = "http://www.w3.org/2000/svg";
69
+ const svg = document.createElementNS(namespace, "svg");
70
+ svg.setAttribute("viewBox", "0 0 12 12");
71
+ svg.setAttribute("aria-hidden", "true");
72
+ svg.classList.add("context-ring");
73
+ for (const opacity of ["0.16", "1"]) {
74
+ const circle = document.createElementNS(namespace, "circle");
75
+ for (const [name, value] of Object.entries({ cx: "6", cy: "6", r: "5", fill: "none", stroke: "currentColor", "stroke-width": "2", opacity })) {
76
+ circle.setAttribute(name, value);
77
+ }
78
+ if (opacity === "1") {
79
+ circle.setAttribute("pathLength", "100");
80
+ circle.setAttribute("stroke-dasharray", "100");
81
+ circle.setAttribute("stroke-dashoffset", "100");
82
+ circle.setAttribute("stroke-linecap", "round");
83
+ circle.setAttribute("transform", "rotate(-90 6 6)");
84
+ circle.dataset.contextProgress = "";
85
+ }
86
+ svg.append(circle);
87
+ }
88
+ return svg;
89
+ }
90
+ const buttons = ["statistics", "usage", "context"].map((kind) => {
91
+ const button = document.createElement("button");
92
+ button.type = "button";
93
+ button.dataset.kind = kind;
94
+ button.setAttribute("aria-haspopup", "dialog");
95
+ button.setAttribute("aria-expanded", "false");
96
+ if (kind === "context") button.append(createContextRing());
97
+ else button.insertAdjacentHTML("beforeend", icons[kind]);
98
+ const label = document.createElement("span");
99
+ label.className = "status-label";
100
+ button.append(label);
101
+ root.append(button);
102
+ return button;
103
+ });
104
+ let snapshot = null;
105
+ let panel = null;
106
+ let openKind = null;
107
+ let rescanQueued = false;
108
+
109
+ function number(value) { return value == null ? "—" : new Intl.NumberFormat().format(value); }
110
+ function compact(value) {
111
+ if (value == null) return "—";
112
+ if (value < 1000) return number(value);
113
+ const millions = value >= 999_950;
114
+ return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value / (millions ? 1e6 : 1e3))}${millions ? "M" : "K"}`;
115
+ }
116
+ function tokenValue(value) { return value == null ? "—" : compact(value); }
117
+ function sectionValue(value) { return `~${compact(value)}`; }
118
+ function contextBarSegments(value) {
119
+ const windowSize = value.contextWindow;
120
+ const used = Math.min(windowSize, Math.max(0, value.contextUsedTokens ?? 0));
121
+ if (!(windowSize > 0) || used === 0) return [];
122
+ if (!value.contextSections) return [["overall", used]];
123
+ const known = [
124
+ ["instructions", Math.max(0, value.contextSections.instructions ?? 0)],
125
+ ["messages", Math.max(0, value.contextSections.messages ?? 0)],
126
+ ["tools", Math.max(0, value.contextSections.toolHistory ?? 0)],
127
+ ];
128
+ const estimatedTotal = known.reduce((sum, [, amount]) => sum + amount, 0);
129
+ const scale = estimatedTotal > used ? used / estimatedTotal : 1;
130
+ const segments = known.map(([kind, amount]) => [kind, amount * scale]);
131
+ if (estimatedTotal < used) segments.push(["other", used - estimatedTotal]);
132
+ return segments.filter(([, amount]) => amount > 0);
133
+ }
134
+ function duration(ms) {
135
+ if (ms == null) return "—";
136
+ if (ms < 1000) return `${Math.round(ms)}ms`;
137
+ const seconds = Math.round(ms / 1000);
138
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
139
+ }
140
+ function row(dl, label, value) {
141
+ const dt = document.createElement("dt");
142
+ dt.textContent = label;
143
+ const dd = document.createElement("dd");
144
+ dd.textContent = value;
145
+ dl.append(dt, dd);
146
+ return dt;
147
+ }
148
+ function divider(dl) {
149
+ const line = document.createElement("div");
150
+ line.className = "detail-divider";
151
+ dl.append(line);
152
+ }
153
+ function appendMixBar(dl, title, parts) {
154
+ const visible = parts.filter(([, , amount]) => Number.isFinite(amount) && amount > 0);
155
+ const total = visible.reduce((sum, [, , amount]) => sum + amount, 0);
156
+ if (total === 0) return;
157
+ const bar = document.createElement("div");
158
+ bar.className = "mix-bar";
159
+ bar.setAttribute("role", "img");
160
+ const percent = (amount) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(amount / total * 100);
161
+ bar.setAttribute("aria-label", `${title}: ${visible.map(([, name, amount]) => `${name} ${percent(amount)}%`).join(", ")}`);
162
+ for (const [tone, name, amount] of visible) {
163
+ const segment = document.createElement("span");
164
+ segment.dataset.tone = tone;
165
+ segment.style.width = `${amount / total * 100}%`;
166
+ segment.title = `${name} ${percent(amount)}%`;
167
+ bar.append(segment);
168
+ }
169
+ dl.append(bar);
170
+ }
171
+ function closePanel() {
172
+ panel?.remove();
173
+ panel = null;
174
+ openKind = null;
175
+ for (const button of buttons) button.setAttribute("aria-expanded", "false");
176
+ }
177
+ function showPanel(kind) {
178
+ closePanel();
179
+ if (!snapshot) return;
180
+ const anchor = buttons.find((button) => button.dataset.kind === kind);
181
+ if (!anchor) return;
182
+ openKind = kind;
183
+ anchor.setAttribute("aria-expanded", "true");
184
+ panel = document.createElement("div");
185
+ panel.dataset.minecodexSessionStatusPanel = kind;
186
+ panel.setAttribute("role", "dialog");
187
+ panel.setAttribute("aria-label", copy[kind]);
188
+ const heading = document.createElement("h3");
189
+ heading.textContent = copy[kind];
190
+ heading.prepend(anchor.querySelector("svg").cloneNode(true));
191
+ const dl = document.createElement("dl");
192
+ panel.append(heading, dl);
193
+ if (kind === "statistics") {
194
+ row(dl, copy.turns, number(snapshot.turns));
195
+ row(dl, copy.steps, number(snapshot.steps));
196
+ row(dl, copy.firstToken, duration(snapshot.averageFirstTokenMs));
197
+ row(dl, copy.speed, snapshot.observedTps == null ? "—" : `${number(snapshot.observedTps)} tok/s`);
198
+ } else if (kind === "usage") {
199
+ row(dl, copy.total, tokenValue(snapshot.totalTokens));
200
+ const otherInput = snapshot.inputTokens == null || snapshot.cachedInputTokens == null
201
+ ? null : Math.max(0, snapshot.inputTokens - snapshot.cachedInputTokens);
202
+ appendMixBar(dl, copy.total, [
203
+ ["purple", copy.output, snapshot.outputTokens],
204
+ ["blue", copy.input, otherInput],
205
+ ["green", copy.cached, snapshot.cachedInputTokens],
206
+ ]);
207
+ row(dl, copy.output, tokenValue(snapshot.outputTokens)).dataset.tone = "purple";
208
+ row(dl, copy.input, tokenValue(otherInput)).dataset.tone = "blue";
209
+ row(dl, copy.cached, tokenValue(snapshot.cachedInputTokens)).dataset.tone = "green";
210
+ divider(dl);
211
+ row(dl, copy.cacheDetail, snapshot.cacheHitPercent == null ? "—" : `${snapshot.cacheHitPercent}%`);
212
+ } else {
213
+ if (snapshot.contextPercent != null) {
214
+ const head = document.createElement("div");
215
+ head.className = "context-head";
216
+ const label = document.createElement("span");
217
+ label.textContent = copy.contextShare;
218
+ const value = document.createElement("span");
219
+ value.textContent = `${snapshot.contextPercent}%`;
220
+ head.append(label, value);
221
+ const bar = document.createElement("div");
222
+ bar.className = "context-bar";
223
+ bar.setAttribute("role", "img");
224
+ bar.setAttribute("aria-label", `${copy.contextShare} ${snapshot.contextPercent}%`);
225
+ for (const [kind, amount] of contextBarSegments(snapshot)) {
226
+ const segment = document.createElement("span");
227
+ segment.dataset.segment = kind;
228
+ segment.style.width = `${amount / snapshot.contextWindow * 100}%`;
229
+ segment.title = copy[kind === "tools" ? "toolHistory" : kind];
230
+ bar.append(segment);
231
+ }
232
+ panel.insertBefore(head, dl);
233
+ panel.insertBefore(bar, dl);
234
+ }
235
+ if (snapshot.contextSections) {
236
+ const sections = [
237
+ ["instructions", copy.instructions, snapshot.contextSections.instructions],
238
+ ["messages", copy.messages, snapshot.contextSections.messages],
239
+ ["tools", copy.toolHistory, snapshot.contextSections.toolHistory],
240
+ ].filter(([, , value]) => value > 0);
241
+ if (sections.length) {
242
+ for (const [section, label, value] of sections) {
243
+ row(dl, label, sectionValue(value)).dataset.tone = section === "instructions" ? "purple" : section === "tools" ? "green" : "blue";
244
+ }
245
+ divider(dl);
246
+ }
247
+ }
248
+ row(dl, copy.sum, tokenValue(snapshot.contextUsedTokens));
249
+ row(dl, copy.capacity, tokenValue(snapshot.contextWindow));
250
+ row(dl, copy.compactions, number(snapshot.compactions));
251
+ }
252
+ document.body.append(panel);
253
+ const anchorRect = anchor.getBoundingClientRect();
254
+ const width = panel.getBoundingClientRect().width;
255
+ const height = panel.getBoundingClientRect().height;
256
+ panel.style.left = `${Math.max(12, Math.min(innerWidth - width - 12, anchorRect.left + anchorRect.width / 2 - width / 2))}px`;
257
+ panel.style.top = `${Math.max(12, anchorRect.top - height - 8)}px`;
258
+ }
259
+
260
+ function findComposer() {
261
+ return document.querySelector('[data-codex-composer="true"][contenteditable="true"]')
262
+ ?? document.querySelector('[contenteditable="true"][role="textbox"]');
263
+ }
264
+ function ensurePosition() {
265
+ const composer = findComposer();
266
+ if (!composer) { root.remove(); closePanel(); return; }
267
+ const form = composer.closest("form");
268
+ const card = composer.closest("[data-codex-composer-root]") ?? form ?? composer.parentElement?.parentElement;
269
+ if (!card?.parentElement) return;
270
+ if (root.previousElementSibling !== card || root.parentElement !== card.parentElement) {
271
+ card.after(root);
272
+ closePanel();
273
+ }
274
+ }
275
+ function render() {
276
+ root.hidden = !snapshot || (snapshot.turns === 0 && snapshot.totalTokens == null);
277
+ if (root.hidden) { closePanel(); return; }
278
+ buttons[0].querySelector(".status-label").textContent = `${number(snapshot.turns)} ${copy.turns} ${number(snapshot.steps)} ${copy.steps} · ${snapshot.observedTps == null ? "—" : number(snapshot.observedTps)} tok/s`;
279
+ buttons[1].querySelector(".status-label").textContent = `${compact(snapshot.totalTokens)} · ${copy.cache} ${snapshot.cacheHitPercent == null ? "—" : `${snapshot.cacheHitPercent}%`}`;
280
+ buttons[2].querySelector(".status-label").textContent = snapshot.contextPercent == null ? "—" : `${snapshot.contextPercent}%`;
281
+ buttons[2].querySelector("[data-context-progress]").setAttribute("stroke-dashoffset", String(100 - (snapshot.contextPercent ?? 0)));
282
+ buttons[2].setAttribute("aria-label", `${copy.context}: ${snapshot.contextPercent == null ? "—" : `${snapshot.contextPercent}%`}`);
283
+ if (openKind) showPanel(openKind);
284
+ }
285
+ for (const button of buttons) button.addEventListener("click", () => {
286
+ const kind = button.dataset.kind;
287
+ if (openKind === kind) closePanel();
288
+ else showPanel(kind);
289
+ }, { signal });
290
+ document.addEventListener("pointerdown", (event) => {
291
+ if (panel && !panel.contains(event.target) && !root.contains(event.target)) closePanel();
292
+ }, { signal });
293
+ document.addEventListener("keydown", (event) => {
294
+ if (event.key === "Escape") closePanel();
295
+ }, { signal });
296
+ const observer = new MutationObserver(() => {
297
+ if (rescanQueued) return;
298
+ rescanQueued = true;
299
+ queueMicrotask(() => { rescanQueued = false; ensurePosition(); });
300
+ });
301
+ observer.observe(document.documentElement, { childList: true, subtree: true });
302
+ const localeObserver = new MutationObserver(() => {
303
+ const nextCopy = selectedCopy();
304
+ if (nextCopy === copy) return;
305
+ copy = nextCopy;
306
+ render();
307
+ });
308
+ localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
309
+ ensurePosition();
310
+ const previous = window.__minecodexSessionStatus;
311
+ const receiver = {
312
+ update(value) {
313
+ snapshot = value;
314
+ ensurePosition();
315
+ render();
316
+ },
317
+ };
318
+ window.__minecodexSessionStatus = receiver;
319
+ return () => {
320
+ observer.disconnect();
321
+ localeObserver.disconnect();
322
+ closePanel();
323
+ root.remove();
324
+ style.remove();
325
+ if (window.__minecodexSessionStatus === receiver) window.__minecodexSessionStatus = previous;
326
+ };
@@ -0,0 +1,43 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Lucide Icons and Contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ The following Lucide icons are derived from the Feather project:
20
+
21
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
22
+
23
+ The MIT License (MIT) (for the icons listed above)
24
+
25
+ Copyright (c) 2013-present Cole Bemis
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Phosphor Icons
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,12 @@
31
31
  "features/images/codex-feature.json",
32
32
  "features/hide-upsell-banner/src",
33
33
  "features/hide-upsell-banner/codex-feature.json",
34
+ "features/session-status/src",
35
+ "features/session-status/codex-feature.json",
36
+ "features/session-status/DESIGN.md",
37
+ "features/session-status/README.md",
38
+ "features/session-status/vendor/lucide-LICENSE",
39
+ "features/session-status/vendor/phosphor-LICENSE",
34
40
  "apps/dashboard/app.js",
35
41
  "apps/dashboard/favicon.svg",
36
42
  "apps/dashboard/index.html",
@@ -42,7 +48,7 @@
42
48
  },
43
49
  "scripts": {
44
50
  "start": "node packages/cli/bin/mcx.mjs serve",
45
- "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs apps/dashboard/test/*.test.mjs",
51
+ "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs features/session-status/test/*.test.mjs apps/dashboard/test/*.test.mjs",
46
52
  "test:cli": "node --test packages/cli/test/*.test.mjs",
47
53
  "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
48
54
  "check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner"]);
5
+ export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner", "session-status"]);
6
6
  export const DEFAULT_CONFIG = Object.freeze({
7
7
  schemaVersion: 1,
8
8
  features: Object.freeze({
@@ -10,6 +10,7 @@ export const DEFAULT_CONFIG = Object.freeze({
10
10
  notes: true,
11
11
  images: true,
12
12
  "hide-upsell-banner": true,
13
+ "session-status": true,
13
14
  }),
14
15
  });
15
16
 
@@ -197,14 +197,7 @@ async function iconResponse(id, paths, response) {
197
197
  iconPath = path.join(paths.featuresRoot, id, "assets", "ui", "sparkles.svg");
198
198
  } else if (id === "notes") {
199
199
  iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "sticky-note.svg");
200
- } else if (id === "images") {
201
- const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
202
- const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
203
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
204
- response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
205
- response.end(svg);
206
- return;
207
- } else if (id === "hide-upsell-banner") {
200
+ } else if (["images", "hide-upsell-banner", "session-status"].includes(id)) {
208
201
  const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
209
202
  const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
210
203
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
@@ -31,6 +31,7 @@ import {
31
31
  attachFilesToComposer,
32
32
  createImageEditThread,
33
33
  } from "./host-actions.mjs";
34
+ import { SessionStatusReader, threadIdFromLocation } from "./session-status-reader.mjs";
34
35
 
35
36
 
36
37
  // 保持公共入口兼容:历史 import 从本文件继续可用。
@@ -103,6 +104,8 @@ export class CodexRuntime {
103
104
  this.pidChangeQueue = null;
104
105
  this.rendererDiscovered = false;
105
106
  this.lastRendererStatus = null;
107
+ this.sessionStatusReader = features.some((feature) => feature.id === "session-status")
108
+ ? new SessionStatusReader() : null;
106
109
  }
107
110
 
108
111
  async start({ launchCodex = false } = {}) {
@@ -673,9 +676,34 @@ export class CodexRuntime {
673
676
  this.notifyStatusChange();
674
677
  }
675
678
  }
679
+ if (this.sessionStatusReader) await this.publishSessionStatus();
676
680
  this.notifyStatusChange();
677
681
  }
678
682
 
683
+ async publishSessionStatus() {
684
+ for (const [targetId, client] of this.clients) {
685
+ if (!this.injectedTargets.has(targetId)) continue;
686
+ try {
687
+ const response = await client.send("Runtime.evaluate", {
688
+ expression: `(() => {
689
+ const selected = document.querySelector('[data-app-action-sidebar-thread-selected="true"]');
690
+ return selected?.getAttribute('data-app-action-sidebar-thread-id')
691
+ ?? location.pathname + location.search;
692
+ })()`,
693
+ returnByValue: true,
694
+ });
695
+ const threadId = threadIdFromLocation(response.result?.value);
696
+ const snapshot = threadId ? await this.sessionStatusReader.read(threadId) : null;
697
+ await client.send("Runtime.evaluate", {
698
+ expression: `window.__minecodexSessionStatus?.update(${JSON.stringify(snapshot)})`,
699
+ returnByValue: true,
700
+ });
701
+ } catch (error) {
702
+ this.logger.warn?.("Session Status update failed", error.message);
703
+ }
704
+ }
705
+ }
706
+
679
707
  async stop() {
680
708
  if (this.stopPromise) return this.stopPromise;
681
709
  this.stopPromise = (async () => {
@@ -712,4 +740,3 @@ export class CodexRuntime {
712
740
  return this.stopPromise;
713
741
  }
714
742
  }
715
-
@@ -0,0 +1,299 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const MODEL_OUTPUT_ITEMS = new Set(["Reasoning", "AgentMessage"]);
8
+ const TOOL_ITEMS = new Set(["CommandExecution", "McpToolCall", "DynamicToolCall"]);
9
+ const IMAGE_TOKENS_ESTIMATE = 1844;
10
+
11
+ function estimatedTextTokens(value) {
12
+ return typeof value === "string" ? Math.ceil(Buffer.byteLength(value, "utf8") / 4) : 0;
13
+ }
14
+
15
+ function estimatedContentTokens(content) {
16
+ if (!Array.isArray(content)) return 0;
17
+ return content.reduce((total, item) => {
18
+ if (item?.type === "input_image" || item?.type === "output_image") return total + IMAGE_TOKENS_ESTIMATE;
19
+ return total + estimatedTextTokens(item?.text);
20
+ }, 0);
21
+ }
22
+
23
+ function estimatedEncryptedTokens(value) {
24
+ if (typeof value !== "string") return 0;
25
+ // 参考 Codex 的历史粗估:先去掉加密封装,再按约 4 字节/token 换算。
26
+ return Math.ceil(Math.max(0, Math.floor(value.length * 3 / 4) - 650) / 4);
27
+ }
28
+
29
+ function estimatedSections(item) {
30
+ const type = item?.type;
31
+ if (type === "message") {
32
+ const tokens = estimatedContentTokens(item.content);
33
+ return item.role === "developer" || item.role === "system"
34
+ ? { instructions: tokens, messages: 0, tools: 0 }
35
+ : { instructions: 0, messages: tokens, tools: 0 };
36
+ }
37
+ if (type === "function_call" || type === "custom_tool_call") {
38
+ return { instructions: 0, messages: 0, tools: estimatedTextTokens(item.arguments ?? item.input) };
39
+ }
40
+ if (type === "function_call_output" || type === "custom_tool_call_output") {
41
+ return { instructions: 0, messages: 0, tools: estimatedContentTokens(item.output) };
42
+ }
43
+ if (type === "reasoning" || type === "compaction") {
44
+ return { instructions: 0, messages: estimatedContentTokens(item.summary)
45
+ + estimatedEncryptedTokens(item.encrypted_content), tools: 0 };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function addSections(target, sections) {
51
+ if (!sections) return;
52
+ for (const key of ["instructions", "messages", "tools"]) target[key] += sections[key];
53
+ }
54
+
55
+ export function threadIdFromLocation(value) {
56
+ return String(value ?? "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? null;
57
+ }
58
+
59
+ function emptyState(filePath) {
60
+ return {
61
+ filePath,
62
+ offset: 0,
63
+ pending: "",
64
+ turns: new Set(),
65
+ responses: new Set(),
66
+ hasResponseUsage: false,
67
+ total: null,
68
+ last: null,
69
+ contextWindow: null,
70
+ firstOutputAt: null,
71
+ pendingToolIntervals: [],
72
+ decodeTokens: 0,
73
+ decodeMs: 0,
74
+ speedSamples: 0,
75
+ ttftTotalMs: 0,
76
+ ttftSamples: 0,
77
+ toolMs: 0,
78
+ compactionRecords: 0,
79
+ compactionCompleted: 0,
80
+ compactionEvents: 0,
81
+ compactionWindow: 0,
82
+ baseInstructionTokens: 0,
83
+ visibleSections: { instructions: 0, messages: 0, tools: 0 },
84
+ hasVisibleHistory: false,
85
+ updatedAt: null,
86
+ };
87
+ }
88
+
89
+ function validUsage(value) {
90
+ return value && ["input_tokens", "cached_input_tokens", "output_tokens", "total_tokens"]
91
+ .every((key) => Number.isFinite(value[key]) && value[key] >= 0);
92
+ }
93
+
94
+ function overlappingToolMs(intervals, start, end) {
95
+ const clipped = intervals
96
+ .map(([toolStart, toolEnd]) => [Math.max(start, toolStart), Math.min(end, toolEnd)])
97
+ .filter(([left, right]) => right > left)
98
+ .sort((left, right) => left[0] - right[0]);
99
+ let elapsed = 0;
100
+ let rightEdge = start;
101
+ for (const [left, right] of clipped) {
102
+ elapsed += Math.max(0, right - Math.max(left, rightEdge));
103
+ rightEdge = Math.max(rightEdge, right);
104
+ }
105
+ return elapsed;
106
+ }
107
+
108
+ export function applySessionStatusEvent(state, event) {
109
+ const payload = event?.payload ?? {};
110
+ if (event?.type === "compacted") {
111
+ state.compactionRecords += 1;
112
+ if (Number.isSafeInteger(payload.window_number) && payload.window_number >= 0) {
113
+ state.compactionWindow = Math.max(state.compactionWindow, payload.window_number);
114
+ }
115
+ // 压缩会替换活跃历史;不能继续累计压缩前的消息与工具记录。
116
+ state.visibleSections = { instructions: 0, messages: 0, tools: 0 };
117
+ state.hasVisibleHistory = false;
118
+ if (Array.isArray(payload.replacement_history)) {
119
+ for (const item of payload.replacement_history) addSections(state.visibleSections, estimatedSections(item));
120
+ state.hasVisibleHistory = true;
121
+ }
122
+ }
123
+ if (event?.type === "response_item") {
124
+ const sections = estimatedSections(payload);
125
+ if (sections) {
126
+ addSections(state.visibleSections, sections);
127
+ state.hasVisibleHistory = true;
128
+ }
129
+ }
130
+ if (event?.type === "turn_context" && payload.turn_id) state.turns.add(payload.turn_id);
131
+ if (event?.type === "session_meta") {
132
+ if (Number.isFinite(payload.context_window)) state.contextWindow = payload.context_window;
133
+ state.baseInstructionTokens = estimatedTextTokens(payload.base_instructions?.text);
134
+ }
135
+ if (event?.type === "event_msg") {
136
+ if (payload.type === "context_compacted") state.compactionEvents += 1;
137
+ if (payload.type === "task_started" && payload.turn_id) state.turns.add(payload.turn_id);
138
+ if (payload.type === "task_complete" && Number.isFinite(payload.time_to_first_token_ms)) {
139
+ state.ttftTotalMs += payload.time_to_first_token_ms;
140
+ state.ttftSamples += 1;
141
+ }
142
+ if (payload.type === "item_completed") {
143
+ const itemType = payload.item?.type;
144
+ if (itemType === "ContextCompaction") state.compactionCompleted += 1;
145
+ const start = payload.started_at_ms;
146
+ const end = payload.completed_at_ms;
147
+ if (MODEL_OUTPUT_ITEMS.has(itemType) && Number.isFinite(start)) {
148
+ state.firstOutputAt = Math.min(state.firstOutputAt ?? start, start);
149
+ }
150
+ if (TOOL_ITEMS.has(itemType) && Number.isFinite(start) && Number.isFinite(end)) {
151
+ state.toolMs += Math.max(0, end - start);
152
+ state.pendingToolIntervals.push([start, end]);
153
+ }
154
+ }
155
+ if (payload.type === "token_count") {
156
+ if (!state.hasResponseUsage && validUsage(payload.info?.total_token_usage)) {
157
+ state.total = payload.info.total_token_usage;
158
+ }
159
+ if (!state.hasResponseUsage && validUsage(payload.info?.last_token_usage)) {
160
+ state.last = payload.info.last_token_usage;
161
+ }
162
+ if (Number.isFinite(payload.info?.model_context_window)) {
163
+ state.contextWindow = payload.info.model_context_window;
164
+ }
165
+ }
166
+ }
167
+ if (event?.type === "token_usage_record") {
168
+ const responseId = payload.response_id;
169
+ if (responseId && !state.responses.has(responseId)) {
170
+ state.responses.add(responseId);
171
+ const responseEnd = Date.parse(event.timestamp);
172
+ const elapsed = state.firstOutputAt == null ? null : responseEnd - state.firstOutputAt
173
+ - overlappingToolMs(state.pendingToolIntervals, state.firstOutputAt, responseEnd);
174
+ const output = payload.usage?.output_tokens;
175
+ // 会话事件只给出首个可见条目的时间,故 TPS 是观测估算。
176
+ if (Number.isFinite(output) && output > 0 && state.firstOutputAt != null
177
+ && Number.isFinite(elapsed) && elapsed > 0 && elapsed < 600_000) {
178
+ state.decodeTokens += output;
179
+ state.decodeMs += elapsed;
180
+ state.speedSamples += 1;
181
+ }
182
+ }
183
+ state.firstOutputAt = null;
184
+ state.pendingToolIntervals = [];
185
+ if (validUsage(payload.thread_token_usage)) {
186
+ state.total = payload.thread_token_usage;
187
+ state.hasResponseUsage = true;
188
+ }
189
+ if (validUsage(payload.usage)) state.last = payload.usage;
190
+ }
191
+ if (state.total || state.turns.size) state.updatedAt = event.timestamp ?? state.updatedAt;
192
+ }
193
+
194
+ export function sessionStatusSnapshot(state, threadId) {
195
+ const total = state.total;
196
+ const input = total?.input_tokens ?? 0;
197
+ const cached = total?.cached_input_tokens ?? 0;
198
+ const cacheWrite = total?.cache_write_input_tokens ?? 0;
199
+ const contextUsed = state.last?.total_tokens ?? null;
200
+ const contextWindow = state.contextWindow > 0 ? state.contextWindow : null;
201
+ return {
202
+ threadId,
203
+ turns: state.turns.size,
204
+ steps: state.responses.size,
205
+ totalTokens: total?.total_tokens ?? null,
206
+ inputTokens: total?.input_tokens ?? null,
207
+ cachedInputTokens: total?.cached_input_tokens ?? null,
208
+ cacheWriteInputTokens: total?.cache_write_input_tokens ?? null,
209
+ uncachedInputTokens: total ? Math.max(0, input - cached - cacheWrite) : null,
210
+ outputTokens: total?.output_tokens ?? null,
211
+ cacheHitPercent: input > 0 ? Math.round(cached / input * 100) : null,
212
+ observedTps: state.decodeMs > 0 ? Math.round(state.decodeTokens / (state.decodeMs / 1000)) : null,
213
+ speedSamples: state.speedSamples,
214
+ observedModelMs: state.decodeMs,
215
+ toolMs: state.toolMs,
216
+ averageFirstTokenMs: state.ttftSamples > 0 ? Math.round(state.ttftTotalMs / state.ttftSamples) : null,
217
+ contextUsedTokens: contextUsed,
218
+ contextWindow,
219
+ contextPercent: contextUsed != null && contextWindow != null
220
+ ? Math.min(100, Math.round(contextUsed / contextWindow * 100)) : null,
221
+ compactions: Math.max(state.compactionWindow, state.compactionRecords,
222
+ state.compactionCompleted, state.compactionEvents),
223
+ contextSections: state.hasVisibleHistory ? {
224
+ instructions: state.baseInstructionTokens + state.visibleSections.instructions,
225
+ messages: state.visibleSections.messages,
226
+ toolHistory: state.visibleSections.tools,
227
+ } : null,
228
+ updatedAt: state.updatedAt,
229
+ };
230
+ }
231
+
232
+ async function findLog(directory, threadId) {
233
+ let entries;
234
+ try { entries = await readdir(directory, { withFileTypes: true }); }
235
+ catch (error) {
236
+ if (error.code === "ENOENT") return null;
237
+ throw error;
238
+ }
239
+ for (const entry of entries) {
240
+ const filePath = path.join(directory, entry.name);
241
+ if (entry.isFile() && entry.name.endsWith(`-${threadId}.jsonl`)) return filePath;
242
+ if (entry.isDirectory()) {
243
+ const found = await findLog(filePath, threadId);
244
+ if (found) return found;
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ export class SessionStatusReader {
251
+ constructor({ sessionsDir = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sessions") } = {}) {
252
+ this.sessionsDir = sessionsDir;
253
+ this.states = new Map();
254
+ this.missingCheckedAt = new Map();
255
+ }
256
+
257
+ async read(threadId) {
258
+ if (!THREAD_ID.test(threadId ?? "")) return null;
259
+ let state = this.states.get(threadId);
260
+ if (!state) {
261
+ const lastCheck = this.missingCheckedAt.get(threadId) ?? 0;
262
+ if (Date.now() - lastCheck < 10_000) return null;
263
+ this.missingCheckedAt.set(threadId, Date.now());
264
+ const filePath = await findLog(this.sessionsDir, threadId);
265
+ if (!filePath) return null;
266
+ state = emptyState(filePath);
267
+ this.states.set(threadId, state);
268
+ this.missingCheckedAt.delete(threadId);
269
+ }
270
+ let info;
271
+ try { info = await stat(state.filePath); }
272
+ catch (error) {
273
+ if (error.code !== "ENOENT") throw error;
274
+ this.states.delete(threadId);
275
+ return null;
276
+ }
277
+ if (info.size < state.offset) state = emptyState(state.filePath);
278
+ if (info.size > state.offset) {
279
+ const stream = createReadStream(state.filePath, {
280
+ start: state.offset,
281
+ end: info.size - 1,
282
+ encoding: "utf8",
283
+ });
284
+ for await (const chunk of stream) {
285
+ const lines = `${state.pending}${chunk}`.split("\n");
286
+ state.pending = lines.pop() ?? "";
287
+ for (const line of lines) {
288
+ if (!line.includes('"token_usage_record"') && !line.includes('"event_msg"')
289
+ && !line.includes('"turn_context"') && !line.includes('"session_meta"')
290
+ && !line.includes('"response_item"') && !line.includes('"compacted"')) continue;
291
+ try { applySessionStatusEvent(state, JSON.parse(line)); } catch { /* 忽略损坏的单行 */ }
292
+ }
293
+ }
294
+ state.offset = info.size;
295
+ this.states.set(threadId, state);
296
+ }
297
+ return sessionStatusSnapshot(state, threadId);
298
+ }
299
+ }