diff --git a/README.md b/README.md index 75e94eb..bab3cdf 100644 --- a/README.md +++ b/README.md @@ -4,104 +4,56 @@ # CC Buddy -### Coding CLI Buddy +**Manage and review completed Coding Agent CLI sessions.** -**Point Claude Code at any Anthropic-compatible provider — one click, all local.** +[![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-5b6cff?style=flat-square)](#installation) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-3b82f6?style=flat-square)](./LICENSE) -[![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-5b6cff?style=flat-square)](#-installation) -[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) -[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-3b82f6?style=flat-square)](./LICENSE) - -[Installation](#-installation) · [Quick Start](#-quick-start) · [How it works](#-how-it-works) - -**English** · [简体中文](./README.zh-CN.md) +[Download](https://github.com/ccbud/ccbud/releases) · **English** · [简体中文](./README.zh-CN.md) --- -**CC Buddy** (`CC` coding CLI + `Buddy`) is a cross-platform desktop app that runs a tiny **local gateway** between Claude Code and any Anthropic-compatible provider — Kimi, DeepSeek, GLM, MiMo and more. Add your providers once, switch with a single click, and let CC Buddy wire up Claude Code for you. **You never touch an environment variable.** - -
- CC Buddy — services view -
- -- **One-click Connect** — CC Buddy writes `~/.claude/settings.json` for you, and restores it exactly when you disconnect. -- **Switch in a click** — keep many providers side by side; tap a card to switch instantly. -- **Automatic model mapping** — Claude's default model names are routed to the active provider's models for you. -- **Stays on your machine** — the gateway binds to `127.0.0.1`; nothing leaves your computer. +**CC Buddy** is a cross-platform desktop app for managing and reviewing local Coding Agent CLI sessions. It does not run the agent for you; it turns histories already written by your CLIs into readable, searchable timelines. After a task finishes, you can trace its goal, decisions, tool calls, subagents, changes, failures, and final outcome. An optional local gateway is included for model API conversion. -## 📥 Installation +```text +CLI session histories ──▶ CC Buddy ──▶ browse · search · trace · export · review +``` -### Download (recommended) +## Session review -Grab the latest build for your platform from the **[Releases page](https://github.com/ccbud/ccbud/releases)**: +Reads local histories from **Claude Code, Codex CLI, Qoder CLI, Grok Build CLI, GitHub Copilot CLI, and Antigravity CLI**. -| Platform | File | -| :-- | :-- | -| **macOS** (Apple Silicon & Intel) | `.dmg` | -| **Windows** | `.exe` installer | -| **Linux** | `.AppImage` / `.deb` | +- **Reconstruct the run** — render Markdown, thinking, tool calls and results, patches, images, recorded model/token metadata, and main/subagent threads. +- **Find the moment** — auto-discover histories, group by source and project, and search across sessions or inside one conversation. +- **Manage the archive** — rename, tag, filter, recycle, add custom history roots, follow active sessions, and import compatible JSONL/ZIP transcripts. +- **Continue the review** — export raw session files/bundles (JSONL, ZIP, or DB) or portable HTML; on macOS, open the main and subagent transcripts in Claude or ChatGPT for analysis. -> **macOS Gatekeeper:** if the first launch is blocked, right-click the app → **Open**, or run -> `xattr -dr com.apple.quarantine "/Applications/CC Buddy.app"`. +## Included: local API gateway -### Homebrew (macOS) +As a companion feature, the gateway accepts **Anthropic Messages**, **OpenAI Chat Completions**, and **OpenAI Responses** on both client and provider sides, passing through matching protocols or translating between them. It configures **Claude Code and Codex** with one click; other compatible clients can use the local endpoint manually. Preset, custom, and plugin-backed providers support switching and model mapping. -```bash -brew install --cask ccbud/tap/ccbud # first install -brew upgrade --cask ccbud # later upgrades -``` +The gateway binds to `127.0.0.1`; inference requests still go to the provider you select. -### Staying up to date +## Installation -CC Buddy checks for new releases on launch (and daily) and surfaces them under **Settings → About & Updates**. App updates are delivered through the Tauri updater when available; Homebrew users can also run `brew upgrade --cask ccbud`. +Download the latest build for macOS, Windows, or Linux from [Releases](https://github.com/ccbud/ccbud/releases). -### Build from source +Homebrew (macOS): ```bash -git clone https://github.com/ccbud/ccbud.git -cd ccbud -npm install -npm start # run the Tauri app in development - -# package a distributable for your OS: -npm run dist:mac # or: dist:win · dist:linux +brew install --cask ccbud/tap/ccbud ``` -## 🚀 Quick Start - -**1 · Add a provider** -Open CC Buddy, click **`+`**, pick a preset (GLM · DeepSeek · MiMo · Kimi …) or enter a custom base URL, and paste your API key. - -
Add and switch providers
- -**2 · Connect** -Hit the big **Connect** button. CC Buddy points Claude Code at the local gateway by writing `env.ANTHROPIC_BASE_URL` and `env.ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json` — backing up whatever was there before. - -**3 · Use Claude Code** -Start a new Claude Code session and you're on your chosen provider. Switch anytime by clicking another card; hit **Disconnect** to restore your original settings, untouched. Keep CC Buddy running while you work — closing the window tucks it into the menu bar / tray. +## Development -## 🔧 How it works +With Node.js and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) installed: -```text -Claude Code ──(ANTHROPIC_BASE_URL = 127.0.0.1:port)──▶ CC Buddy gateway ──▶ active provider - │ - · swaps in the upstream's real token - · routes / maps model names - · rewrites the model name in the response - back to the name the client asked for +```bash +git clone https://github.com/ccbud/ccbud.git && cd ccbud +npm install && npm start ``` -CC Buddy never edits your providers' own configs — it just runs a forwarding server on your machine. When you use a model alias, the gateway rewrites the `model` field in the response (including the streaming `message_start`) back to the alias, so Claude Code always sees the name it requested. If you don't set `ANTHROPIC_MODEL`, Claude's default `claude-*` model names are mapped to the active provider's main model (with `haiku` → its small model). - -## 📸 A look inside - -| | | -| :--: | :--: | -|
Switch providers and map models in a click |
Watch every request flow through, live | -|
Usage at a glance — even from the menu bar |
Redact sensitive data before it ever leaves | - -## 📄 License +## License -Released under the [GPL-3.0](./LICENSE) license. +Released under [GPL-3.0](./LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index a970e8e..9d7e129 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,106 +1,59 @@
-CCBuddy +CC Buddy -# CCBuddy +# CC Buddy -### 编码 CLI 好搭子 +**集中管理与复盘已完成的 Coding Agent CLI 会话。** -**让 Claude Code 接到任意 Anthropic 兼容服务商 —— 一键接入,全程本地。** +[![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-5b6cff?style=flat-square)](#安装) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-3b82f6?style=flat-square)](./LICENSE) -[![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-5b6cff?style=flat-square)](#-安装) -[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/) -[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-3b82f6?style=flat-square)](./LICENSE) - -[安装](#-安装) · [快速开始](#-快速开始) · [工作原理](#-工作原理) - -[English](./README.md) · **简体中文** +[下载](https://github.com/ccbud/ccbud/releases) · [English](./README.md) · **简体中文**
--- -**CCBuddy**(`CC` coding CLI + `Buddy` 伙伴)是一个跨平台桌面应用,在 Claude Code 和任意 Anthropic 兼容服务商(Kimi、DeepSeek、GLM、MiMo 等)之间起一个**本地网关**。服务商添加一次,点一下卡片就切换,Claude Code 的配置由 CCBuddy 自动写好 —— **你不用碰任何环境变量。** - -
- CCBuddy — 服务商 -
- -- **一键接入** —— CCBuddy 自动写好 `~/.claude/settings.json`,断开时再原样还原。 -- **即时切换** —— 多家服务并存,点一下卡片立刻换。 -- **自动模型映射** —— Claude 的默认模型名自动路由到当前服务商的模型。 -- **全程本地** —— 网关只绑定 `127.0.0.1`,数据不出本机。 +**CC Buddy** 是一个跨平台桌面应用,核心是管理和复盘本地 Coding Agent CLI 会话。它不替代 Agent 执行任务,而是把各 CLI 已写入本机的历史记录还原成可读、可搜索的时间线。任务结束后,你可以追踪目标、决策、工具调用、子代理、文件改动、失败点与最终结果。此外还附带可选的本地网关,用于转换模型 API。 -## 📥 安装 +```text +CLI 会话记录 ──▶ CC Buddy ──▶ 浏览 · 搜索 · 追踪 · 导出 · 复盘 +``` -### 下载(推荐) +## 会话复盘 -从 **[Releases 页面](https://github.com/ccbud/ccbud/releases)** 下载对应平台的安装包: +支持读取 **Claude Code、Codex CLI、Qoder CLI、Grok Build CLI、GitHub Copilot CLI 和 Antigravity CLI** 的本地会话。 -| 平台 | 文件 | -| :-- | :-- | -| **macOS**(Apple 芯片 & Intel) | `.dmg` | -| **Windows** | `.exe` 安装包 | -| **Linux** | `.AppImage` / `.deb` | +- **还原执行过程** —— 统一展示 Markdown、思考、工具调用与结果、补丁、图片、记录中包含的模型与 token 信息,以及主会话和子代理线程。 +- **快速定位问题** —— 自动发现记录,按来源和项目归类,支持跨会话全文搜索与会话内搜索。 +- **管理会话档案** —— 重命名、标签、筛选、回收站、自定义历史目录、活跃会话跟随,以及兼容的 JSONL/ZIP 导入。 +- **继续发起复盘** —— 导出原始会话文件或打包记录(JSONL、ZIP 或 DB),也可导出独立 HTML;在 macOS 上可将主会话和子代理记录交给 Claude 或 ChatGPT 分析。 -> **macOS 首次打开被拦截:** 右键点应用 → **打开**,或执行 -> `xattr -dr com.apple.quarantine "/Applications/CC Buddy.app"`。 +## 附带能力:本地 API 网关 -### Homebrew(macOS) +作为附带能力,网关的客户端和上游均支持 **Anthropic Messages、OpenAI Chat Completions 和 OpenAI Responses**:协议相同则直通,不同则相互转换。它能一键配置 **Claude Code 和 Codex**;其他兼容客户端可手动使用本地端点。预设、自定义和插件服务均可切换并映射模型。 -```bash -brew install --cask ccbud/tap/ccbud # 首次安装 -brew upgrade --cask ccbud # 后续升级 -``` +网关只监听 `127.0.0.1`;推理请求仍会发送给所选服务商。 -### 保持最新(应用内更新) +## 安装 -CCBuddy 会在启动时(以及每天)检查新版本,并在 **设置 → 关于与更新** 中提示。可用时应用内通过 Tauri updater 更新;Homebrew 用户也可以执行 `brew upgrade --cask ccbud`。 +前往 [Releases](https://github.com/ccbud/ccbud/releases) 下载 macOS、Windows 或 Linux 最新版本。 -### 从源码构建 +Homebrew(仅 macOS): ```bash -git clone https://github.com/ccbud/ccbud.git -cd ccbud -npm install -npm start # 开发模式运行 Tauri 应用 - -# 打包当前系统的发行版: -npm run dist:mac # 或:dist:win · dist:linux +brew install --cask ccbud/tap/ccbud ``` -## 🚀 快速开始 - -**1 · 添加服务商** -打开 CCBuddy,点 **`+`**,选预设(GLM · DeepSeek · MiMo · Kimi …)或填自定义 base URL,粘贴 API Key。 - -
添加并切换服务商
- -**2 · 接入** -点首页大按钮 **「接入」**。CCBuddy 会把网关地址写进 `~/.claude/settings.json` 的 `env.ANTHROPIC_BASE_URL` / `env.ANTHROPIC_AUTH_TOKEN`,接入前的配置会自动备份。 - -**3 · 用 Claude Code** -新开一个 Claude Code 会话即生效,已经在你选的服务商上。想换服务点另一张卡片即可;点 **「断开接入」** 会把你原来的配置原样还原。使用期间保持 CCBuddy 运行 —— 关闭窗口会缩到菜单栏 / 托盘继续工作。 +## 开发 -## 🔧 工作原理 +安装 Node.js 和 [Tauri 开发环境](https://v2.tauri.app/start/prerequisites/) 后: -```text -Claude Code ──(ANTHROPIC_BASE_URL = 127.0.0.1:port)──▶ CCBuddy 网关 ──▶ 激活的服务商 - │ - · 替换为上游真实 token - · 模型名路由 / 映射 - · 把响应里的 model 名改回客户端期望的值 +```bash +git clone https://github.com/ccbud/ccbud.git && cd ccbud +npm install && npm start ``` -CCBuddy **不修改任何第三方服务的配置**,只在本机起一个转发 server。用「别名」时,网关会把响应里的 `model` 字段(含流式 `message_start`)改回别名,保证客户端看到的就是它请求的名字。未配置 `ANTHROPIC_MODEL` 时,Claude Code 发来的 `claude-*` 默认模型名会被映射到激活服务商的主模型(含 `haiku` → 小模型)。 - -## 📸 走进来看看 - -| | | -| :--: | :--: | -|
一键切换服务商、映射模型 |
实时看着每个请求流过 | -|
用量一目了然 —— 菜单栏也能看 |
敏感信息出门前先抹掉 | - -## 📄 许可证 +## 许可证 基于 [GPL-3.0](./LICENSE) 协议开源。 diff --git a/package.json b/package.json index 964a377..ac3ec06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccbud", - "version": "1.3.7", + "version": "1.3.8", "description": "CC Buddy — Coding CLI Buddy. A cross-platform desktop app that proxies Claude Code to any Anthropic-compatible provider (one-click switching, model mapping) and browses your Claude Code & Codex session history.", "author": { "name": "loadchange", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ef567f4..1f41abd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,7 +89,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "1.3.7" +version = "1.3.8" dependencies = [ "arboard", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74010e7..d8ccb2d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.3.7" +version = "1.3.8" description = "CCBuddy — Coding CLI Buddy" authors = ["loadchange "] license = "GPL-3.0-only" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 6433f4d..9e4f076 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -4,5 +4,7 @@ CFBundleDisplayName CC Buddy + NSAppDataUsageDescription + CC Buddy reads local Qoder and other coding CLI session history to display it in the app. diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 0d33608..0ab17c1 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -6,8 +6,9 @@ // A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, // response_item, event_msg, compacted}. Conversation content lives in response_item payloads // (message / reasoning / function_call / function_call_output / local_shell_call / -// custom_tool_call / web_search_call); event_msg mostly duplicates that content (ignored) -// but its token_count records carry per-turn usage (harvested). Very old Codex builds wrote +// custom_tool_call / web_search_call); event_msg mostly duplicates that content, but token_count +// carries usage and user_message supplies a bounded title fallback for image-heavy first turns. +// Very old Codex builds wrote // payload objects directly per line (no envelope) — handled by treating such a line as its // own payload. // @@ -22,8 +23,10 @@ #![allow(dead_code)] use crate::history::{image_block, Norm}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; use serde_json::{json, Value}; use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; /// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time @@ -51,6 +54,84 @@ pub fn root_exists() -> bool { sessions_root().is_dir() } +fn codex_home_for_rollout(file: &Path) -> Option { + file.ancestors() + .find(|dir| { + matches!( + dir.file_name().and_then(|name| name.to_str()), + Some("sessions") | Some("archived_sessions") + ) + }) + .and_then(Path::parent) + .map(Path::to_path_buf) +} + +fn resolve_sqlite_home_path(raw: &str, codex_home: &Path) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if raw == "~" { + return Some(home()); + } + if let Some(rest) = raw.strip_prefix("~/") { + return Some(home().join(rest)); + } + let path = PathBuf::from(raw); + Some(if path.is_absolute() { path } else { codex_home.join(path) }) +} + +fn configured_sqlite_home(codex_home: &Path) -> Option { + let raw = fs::read_to_string(codex_home.join("config.toml")).ok()?; + let doc = raw.parse::().ok()?; + resolve_sqlite_home_path(doc.get("sqlite_home")?.as_str()?, codex_home) +} + +/// Codex treats the completed state DB's rollout_path as authoritative for a canonical thread id. +/// This is intentionally queried only when ccbud has found duplicate physical candidates, so the +/// normal list walk never opens SQLite per row. A missing/stale/incomplete DB simply means callers +/// fall back to validated metadata + mtime, just as Codex does during scan-and-repair. +pub fn preferred_rollout_path(file: &Path, thread_id: &str) -> Option { + let codex_home = codex_home_for_rollout(file)?; + let sqlite_home = configured_sqlite_home(&codex_home) + .or_else(|| { + std::env::var("CODEX_SQLITE_HOME") + .ok() + .and_then(|value| resolve_sqlite_home_path(&value, &codex_home)) + }) + .unwrap_or(codex_home); + let db = sqlite_home.join("state_5.sqlite"); + if !db.is_file() { + return None; + } + let conn = Connection::open_with_flags( + db, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let status = conn + .query_row( + "SELECT status FROM backfill_state WHERE id = 1", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + if status != "complete" { + return None; + } + let rollout = conn + .query_row( + "SELECT rollout_path FROM threads WHERE id = ?1 AND archived = 0", + [thread_id], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + let rollout = PathBuf::from(rollout); + rollout.is_file().then_some(rollout) +} + /// Walk every rollout .jsonl under a sessions tree (date-sharded YYYY/MM/DD, but walked /// generically so a layout change doesn't lose sessions). Depth-capped against cycles. pub fn walk_sessions(root: &Path, mut cb: F) { @@ -106,6 +187,94 @@ fn split_line(rec: &Value) -> (&str, &Value, Option<&str>) { } } +#[derive(Default)] +struct CanonicalThreadMeta { + thread_id: Option, + root_session_id: Option, + parent_thread_id: Option, + forked_from_id: Option, + is_subagent: bool, + agent_path: Option, + agent_nickname: Option, + agent_role: Option, + agent_depth: Option, +} + +// The first SessionMeta is canonical for the physical rollout. Subagent/fork rollouts can copy +// ancestor SessionMeta records behind it, and every thread in that tree intentionally shares the +// same session_id. The unique thread key is the first meta's id. +fn canonical_thread_meta(payload: &Value) -> CanonicalThreadMeta { + let subagent = payload + .get("source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + .or_else(|| { + payload + .get("thread_source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + }); + let detail = subagent.and_then(|source| { + ["thread_spawn", "review", "compact", "other"] + .iter() + .find_map(|key| source.get(*key).filter(|value| value.is_object())) + .or_else(|| source.as_object().and_then(|object| object.values().find(|value| value.is_object()))) + }); + let string = |value: Option<&Value>| { + value + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }; + let thread_id = string( + payload + .get("id") + .or_else(|| payload.get("thread_id")), + ); + let root_session_id = string(payload.get("session_id")).or_else(|| thread_id.clone()); + let parent_thread_id = string( + payload + .get("parent_thread_id") + .or_else(|| detail.and_then(|value| value.get("parent_thread_id"))), + ); + let is_subagent = subagent.is_some() + || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") + || payload + .get("agent_path") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || payload + .get("agent_nickname") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || (parent_thread_id.is_some() && thread_id != root_session_id); + CanonicalThreadMeta { + thread_id, + root_session_id, + parent_thread_id, + forked_from_id: string(payload.get("forked_from_id")), + is_subagent, + // Current Codex stores the canonical Agent identity on SessionMeta itself. Older + // rollouts only carried it inside source.subagent., so keep that as a fallback. + agent_path: string( + payload + .get("agent_path") + .or_else(|| detail.and_then(|value| value.get("agent_path"))), + ), + agent_nickname: string( + payload + .get("agent_nickname") + .or_else(|| detail.and_then(|value| value.get("agent_nickname"))), + ), + agent_role: string( + payload + .get("agent_role") + .or_else(|| payload.get("agent_type")) + .or_else(|| detail.and_then(|value| value.get("agent_role"))) + .or_else(|| detail.and_then(|value| value.get("agent_type"))), + ), + agent_depth: detail.and_then(|value| value.get("depth")).and_then(|value| value.as_i64()), + } +} + /// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't /// human prose — hidden from the timeline, exactly like Claude's isMeta records. fn is_meta_user_text(t: &str) -> bool { @@ -115,6 +284,44 @@ fn is_meta_user_text(t: &str) -> bool { .any(|p| t.starts_with(p)) } +// The workspace AGENTS bootstrap is user-role transport data. Keep it visible in the transcript +// (the renderer formats it as Markdown), but mark it as metadata so it cannot become the title. +fn is_agents_bootstrap(t: &str) -> bool { + let source = t.trim_start(); + let Some(heading) = source.strip_prefix('#') else { return false; }; + let heading = heading.trim_start().to_ascii_lowercase(); + heading.starts_with("agents.md instructions for ") + && heading.contains("") +} + +// Codex serializes a loaded Skill as a synthetic user turn. Keep the snapshot embedded in the +// rollout rather than reading the current SKILL.md from disk: historical sessions must show the +// exact instructions that were loaded at the time. Anchoring the whole envelope leaves quoted +// markup in normal user prose untouched. +fn skill_load_block(t: &str) -> Option { + static SKILL_ENVELOPE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = SKILL_ENVELOPE_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*\s*(.*?)\s*(.*?)(.*)\s*$", + ) + .unwrap() + }); + let captures = re.captures(t)?; + let name = captures.get(1)?.as_str().trim(); + let path = captures.get(2)?.as_str().trim(); + if name.is_empty() || path.is_empty() { + return None; + } + let snapshot = captures.get(3)?.as_str(); + Some(json!({ + "type": "skill_load", + "name": name, + "path": path, + "snapshot": snapshot, + })) +} + fn joined_text(content: &Value, kinds: &[&str]) -> String { let arr = match content.as_array() { Some(a) => a, @@ -127,6 +334,159 @@ fn joined_text(content: &Value, kinds: &[&str]) -> String { .join("\n") } +// Codex surrounds each real input_image with text-only transport tags. Replace the opening tag +// with its safe display name (`[Image #1]`) and drop the closing tag; image_block still carries +// the actual bitmap to the renderer. +fn image_transport_label(text: &str) -> Option { + let source = text.trim(); + let lower = source.to_ascii_lowercase(); + if !lower.starts_with("') { + return None; + } + let boundary = source.as_bytes().get(6).copied(); + if !matches!(boundary, Some(b'>')) && !boundary.map(|b| b.is_ascii_whitespace()).unwrap_or(false) { + return None; + } + + let mut label = None; + if let Some(pos) = lower.find("name") { + let rest = source[pos + 4..].trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim_start(); + label = if let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') { + value[quote.len_utf8()..] + .find(quote) + .map(|end| value[quote.len_utf8()..quote.len_utf8() + end].to_string()) + } else if value.starts_with('[') { + value.find(']').map(|end| value[..=end].to_string()) + } else { + Some(value.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or("").to_string()) + }; + } + } + Some(label.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[Image]".to_string())) +} + +fn joined_user_text(content: &Value) -> String { + let arr = match content.as_array() { + Some(a) => a, + None => return content.as_str().unwrap_or("").to_string(), + }; + let has_image = arr + .iter() + .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")); + arr.iter() + .filter(|b| matches!(b.get("type").and_then(|t| t.as_str()), Some("input_text") | Some("text"))) + .filter_map(|b| { + let text = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if has_image { + if text.trim().eq_ignore_ascii_case("") { + return None; + } + if let Some(label) = image_transport_label(text) { + return Some(label); + } + } + if text.is_empty() { None } else { Some(text.to_string()) } + }) + .collect::>() + .join("\n") +} + +fn event_user_display_text(payload: &Value) -> String { + let message = payload.get("message").and_then(|v| v.as_str()).unwrap_or("").trim(); + let image_count = payload.get("images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0) + + payload.get("local_images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0); + let labels = (1..=image_count) + .map(|i| format!("[Image #{}]", i)) + .collect::>() + .join(" "); + format!("{}{}{}", labels, if !labels.is_empty() && !message.is_empty() { " " } else { "" }, message) + .trim() + .to_string() +} + +fn event_user_title_from_record(rec: &Value) -> String { + let (ty, payload, _) = split_line(rec); + if ty != "event_msg" || payload.get("type").and_then(|v| v.as_str()) != Some("user_message") { + return String::new(); + } + let text = event_user_display_text(payload); + if text.is_empty() { + String::new() + } else { + crate::history::first_user_text(&[json!({ + "role": "user", + "content": [{ "type": "text", "text": text }], + })]) + } +} + +fn first_event_user_title(recs: &[Value]) -> String { + recs.iter() + .map(event_user_title_from_record) + .find(|title| !title.is_empty()) + .unwrap_or_default() +} + +fn append_scan_segment(line: &mut Vec, dropping: &mut bool, segment: &[u8], max_line: usize) { + if *dropping || segment.is_empty() { + return; + } + if line.len().saturating_add(segment.len()) > max_line { + line.clear(); + *dropping = true; + } else { + line.extend_from_slice(segment); + } +} + +fn event_user_title_from_line(line: &[u8]) -> String { + let line = line.strip_suffix(b"\r").unwrap_or(line); + serde_json::from_slice::(line) + .map(|rec| event_user_title_from_record(&rec)) + .unwrap_or_default() +} + +// List metadata normally parses only the first 128 KiB. If an image-first response_item is a +// larger single JSON line, stream past it and read the following compact user_message event. +fn scan_event_user_title(file: &Path) -> String { + const MAX_SCAN: usize = 64 * 1024 * 1024; + const MAX_LINE: usize = 256 * 1024; + let input = match fs::File::open(file) { + Ok(file) => file, + Err(_) => return String::new(), + }; + let mut reader = BufReader::new(input); + let mut line: Vec = vec![]; + let mut dropping = false; + let mut scanned = 0usize; + while scanned < MAX_SCAN { + let available = match reader.fill_buf() { + Ok(buf) if !buf.is_empty() => buf, + Ok(_) | Err(_) => break, + }; + let take = available.len().min(MAX_SCAN - scanned); + let mut start = 0usize; + for i in 0..take { + if available[i] == b'\n' { + append_scan_segment(&mut line, &mut dropping, &available[start..i], MAX_LINE); + let title = if dropping { String::new() } else { event_user_title_from_line(&line) }; + line.clear(); + dropping = false; + if !title.is_empty() { + return title; + } + start = i + 1; + } + } + append_scan_segment(&mut line, &mut dropping, &available[start..take], MAX_LINE); + reader.consume(take); + scanned = scanned.saturating_add(take); + } + if dropping { String::new() } else { event_user_title_from_line(&line) } +} + /// argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. fn join_argv(cmd: &Value) -> String { if let Some(s) = cmd.as_str() { @@ -447,6 +807,15 @@ pub fn normalize(recs: &[Value]) -> Norm { let mut model: Option = None; let mut cwd: Option = None; let mut session_id: Option = None; + let mut thread_id: Option = None; + let mut parent_thread_id: Option = None; + let mut forked_from_id: Option = None; + let mut is_subagent = false; + let mut agent_path: Option = None; + let mut agent_nickname: Option = None; + let mut agent_role: Option = None; + let mut agent_depth: Option = None; + let mut saw_session_meta = false; let mut git_branch: Option = None; let mut version: Option = None; @@ -460,6 +829,19 @@ pub fn normalize(recs: &[Value]) -> Norm { }; match ty { "session_meta" => { + if !saw_session_meta { + saw_session_meta = true; + let identity = canonical_thread_meta(p); + thread_id = identity.thread_id; + session_id = identity.root_session_id; + parent_thread_id = identity.parent_thread_id; + forked_from_id = identity.forked_from_id; + is_subagent = identity.is_subagent; + agent_path = identity.agent_path; + agent_nickname = identity.agent_nickname; + agent_role = identity.agent_role; + agent_depth = identity.agent_depth; + } let sid = p .get("session_id") .or_else(|| p.get("id")) @@ -549,7 +931,15 @@ pub fn normalize(recs: &[Value]) -> Norm { messages.push(with_ts(m)); } } else if role == "user" { - let text = joined_text(&content, &["input_text", "text"]); + let text = joined_user_text(&content); + if let Some(skill) = skill_load_block(&text) { + messages.push(with_ts(json!({ + "role": "user", + "_meta": true, + "content": [skill], + }))); + continue; + } if is_meta_user_text(&text) { continue; } @@ -571,7 +961,11 @@ pub fn normalize(recs: &[Value]) -> Norm { } } if !blocks.is_empty() { - messages.push(with_ts(json!({ "role": "user", "content": blocks }))); + let mut message = json!({ "role": "user", "content": blocks }); + if is_agents_bootstrap(&text) { + message["_meta"] = json!(true); + } + messages.push(with_ts(message)); } } // system / developer turns: harness plumbing, not conversation } @@ -744,20 +1138,30 @@ pub fn normalize(recs: &[Value]) -> Norm { last_ts, cwd, session_id, + thread_id, + parent_thread_id, + forked_from_id, + is_subagent, + agent_path, + agent_nickname, + agent_role, + agent_depth, git_branch, version, } } -/// (cwd, session_id) from a codex head — used by the import path to lay out the store copy. +/// (cwd, canonical thread id) from a Codex head — used to name an imported store copy. pub fn head_ids(recs: &[Value]) -> (Option, Option) { for rec in recs { let (ty, p, _) = split_line(rec); if ty == "session_meta" { let cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); let sid = p - .get("session_id") - .or_else(|| p.get("id")) + // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the + // unique rollout key; using session_id makes sibling imports collide. + .get("id") + .or_else(|| p.get("thread_id")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); return (cwd, sid); @@ -798,6 +1202,41 @@ pub fn remove_meta(file: &str) { // ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- +fn subagent_title(n: &Norm) -> String { + if !n.is_subagent { + return String::new(); + } + let path = n + .agent_path + .as_deref() + .unwrap_or("") + .trim_start_matches('/') + .strip_prefix("root/") + .unwrap_or_else(|| n.agent_path.as_deref().unwrap_or("").trim_start_matches('/')); + let mut parts = Vec::new(); + if let Some(nickname) = n.agent_nickname.as_deref().filter(|value| !value.trim().is_empty()) { + parts.push(nickname.trim()); + } + if !path.is_empty() { + parts.push(path); + } + if parts.is_empty() { + "Codex subagent".to_string() + } else { + parts.join(" · ") + } +} + +fn is_canonical_thread_id(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8usize, 13, 18, 23].into_iter().all(|index| bytes[index] == b'-') + && bytes + .iter() + .enumerate() + .all(|(index, byte)| [8usize, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit()) +} + /// List-row meta from already-parsed head records. `dir_id` is `__codex__` for the live tree /// or `__imported__` for snapshots copied into the app store. pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { @@ -812,7 +1251,15 @@ pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: & } else { crate::history::read_ccbud(recs) }; - let auto_title = crate::history::first_user_text(&n.messages); + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + if transcript_title.is_empty() && meta.len() > 131072 { + transcript_title = scan_event_user_title(file); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; let stem = stem_of(file); let mt = meta .modified() @@ -821,12 +1268,19 @@ pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: & .map(|d| d.as_millis() as f64) .unwrap_or(0.0); Some(json!({ - "id": format!("codex:{}", stem), + // Row ids are UI identities, so include the configured store. A live rollout and an + // imported snapshot can legitimately share the same filename/thread id. + "id": format!("codex:{}:{}", dir_id, stem), "file": file.to_string_lossy(), "source": "codex", "dirId": dir_id, "dirLabel": dir_label, - "sessionId": n.session_id.clone().unwrap_or_else(|| stem.clone()), + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), "cwd": n.cwd.clone(), "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), "gitBranch": n.git_branch.clone(), @@ -834,7 +1288,11 @@ pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: & "autoTitle": auto_title, "tags": cc_tags, "model": n.model, - "isSubagent": false, + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, "imported": dir_id == "__imported__", "deleted": cc_deleted, "createdAt": crate::history::record_created_ms(recs, file), @@ -854,7 +1312,12 @@ pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { } else { crate::history::read_ccbud(recs) }; - let auto_title = crate::history::first_user_text(&n.messages); + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; let stem = stem_of(path); json!({ "meta": { @@ -866,12 +1329,21 @@ pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { "autoTitle": auto_title, "tags": cc_tags, "summary": Value::Null, - "sessionId": n.session_id.clone().unwrap_or_else(|| stem.clone()), + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), "cwd": n.cwd.clone(), "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), "gitBranch": n.git_branch.clone(), "version": n.version.clone(), - "isSubagent": false, + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, "deleted": cc_deleted, "imported": import_meta.is_some(), "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), diff --git a/src-tauri/src/exporthtml.rs b/src-tauri/src/exporthtml.rs index 2c2dbb7..5f8144d 100644 --- a/src-tauri/src/exporthtml.rs +++ b/src-tauri/src/exporthtml.rs @@ -21,6 +21,7 @@ const CAP_THINKING: usize = 16000; const CAP_RESULT: usize = 24000; const CAP_PROMPT: usize = 9000; const CAP_CONTENT: usize = 14000; +const CAP_SKILL_SNAPSHOT: usize = 131072; fn cap(s: &str, n: usize) -> String { if s.chars().count() > n { @@ -32,25 +33,47 @@ fn cap(s: &str, n: usize) -> String { } } -fn parse_jsonl(file: &Path) -> Vec { - let raw = match fs::read_to_string(file) { - Ok(s) => s, - Err(_) => return vec![], - }; - raw.split('\n') +fn parse_jsonl_result(file: &Path) -> std::io::Result> { + let qoder = crate::qoder::looks_qoder_path(file); + let raw = if qoder { crate::qoder::read_text(file) } else { fs::read_to_string(file) }?; + let records: Vec = raw + .split('\n') .map(|l| l.trim()) .filter(|l| !l.is_empty()) .filter_map(|l| serde_json::from_str::(l).ok()) - .collect() + .collect(); + Ok(if qoder { + crate::qoder::normalize_records(&records) + } else { + records + }) +} + +/// Skip-on-error variant for subagent sidecars — one broken agent file must not sink the export. +/// The MAIN transcript goes through parse_jsonl_result so a read failure surfaces to the caller +/// instead of exporting an empty page. +fn parse_jsonl(file: &Path) -> Vec { + parse_jsonl_result(file).unwrap_or_default() } fn usage_of(u: &Value) -> Value { - json!({ + let mut usage = json!({ "in": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "out": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }) + }); + let object = usage.as_object_mut().unwrap(); + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage } fn cap_content(content: &Value) -> Value { @@ -68,6 +91,16 @@ fn cap_content(content: &Value) -> Value { match ty { "text" => json!({ "type": "text", "text": cap(b.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_TEXT) }), "thinking" => json!({ "type": "thinking", "thinking": cap(b.get("thinking").and_then(|t| t.as_str()).unwrap_or(""), CAP_THINKING) }), + "skill_load" => json!({ + "type": "skill_load", + "name": b.get("name").cloned().unwrap_or(Value::Null), + "path": b.get("path").cloned().unwrap_or(Value::Null), + "snapshot": b + .get("snapshot") + .and_then(|v| v.as_str()) + .map(|v| json!(cap(v, CAP_SKILL_SNAPSHOT))) + .unwrap_or(Value::Null), + }), "tool_use" => { let mut input = b.get("input").cloned().unwrap_or(json!({})); if let Some(obj) = input.as_object_mut() { @@ -138,9 +171,18 @@ fn line_to_msg(rec: &Value) -> Option { }); if ty == "assistant" { let o = out.as_object_mut().unwrap(); - o.insert("model".into(), m.get("model").cloned().unwrap_or(Value::Null)); - o.insert("usage".into(), m.get("usage").map(usage_of).unwrap_or(Value::Null)); - o.insert("stop".into(), m.get("stop_reason").cloned().unwrap_or(Value::Null)); + o.insert( + "model".into(), + m.get("model").cloned().unwrap_or(Value::Null), + ); + o.insert( + "usage".into(), + m.get("usage").map(usage_of).unwrap_or(Value::Null), + ); + o.insert( + "stop".into(), + m.get("stop_reason").cloned().unwrap_or(Value::Null), + ); } Some(out) } @@ -178,7 +220,9 @@ fn command_label(raw: &str) -> String { fn first_user_text(messages: &[Value]) -> String { let mut fallback = String::new(); for m in messages { - if m.get("role").and_then(|r| r.as_str()) != Some("user") || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) { + if m.get("role").and_then(|r| r.as_str()) != Some("user") + || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) + { continue; } let raw = content_text(m.get("content").unwrap_or(&Value::Null)); @@ -201,19 +245,28 @@ fn first_user_text(messages: &[Value]) -> String { fallback.chars().take(100).collect() } fn base_name(p: &str) -> String { - p.split('/').filter(|s| !s.is_empty()).last().unwrap_or(p).to_string() + p.split('/') + .filter(|s| !s.is_empty()) + .last() + .unwrap_or(p) + .to_string() } struct Shaped { messages: Vec, model: Option, totals: (i64, i64, i64, i64), + cache_creation: i64, + credits: Option, + token_usage_available: bool, first_ts: Option, last_ts: Option, } fn shape_session(recs: &[Value]) -> Shaped { let mut messages = vec![]; - let (mut tin, mut tout, mut tcr, mut turns) = (0i64, 0i64, 0i64, 0i64); + let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; let mut model = None; let mut first_ts = None; let mut last_ts = None; @@ -238,17 +291,33 @@ fn shape_session(recs: &[Value]) -> Shaped { tin += u.get("in").and_then(|v| v.as_i64()).unwrap_or(0); tout += u.get("out").and_then(|v| v.as_i64()).unwrap_or(0); tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); + tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } turns += 1; } messages.push(lm); } - Shaped { messages, model, totals: (tin, tout, tcr, turns), first_ts, last_ts } + Shaped { + messages, + model, + totals: (tin, tout, tcr, turns), + cache_creation: tcc, + credits: has_credits.then_some(credits), + token_usage_available: !(has_credits && tin == 0 && tout == 0 && tcr == 0 && tcc == 0), + first_ts, + last_ts, + } } fn read_subagents(file: &Path) -> Value { - let dir = file - .parent() - .map(|p| p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")).join("subagents")); + let qoder = crate::qoder::looks_qoder_path(file); + let dir = file.parent().map(|p| { + p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")) + .join("subagents") + }); let dir = match dir { Some(d) => d, None => return json!({}), @@ -257,18 +326,39 @@ fn read_subagents(file: &Path) -> Value { Ok(e) => e, Err(_) => return json!({}), }; - let mut by_tool = serde_json::Map::new(); - for ent in entries.flatten() { - let name = ent.file_name().to_string_lossy().into_owned(); - if !(name.starts_with("agent-") && name.ends_with(".jsonl")) { - continue; + let mut agent_names: Vec = entries + .flatten() + .map(|ent| ent.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("agent-") && name.ends_with(".jsonl")) + .collect(); + agent_names.sort(); + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch. + if qoder { + let mut warm: Vec = vec![]; + for name in &agent_names { + warm.push(dir.join(name)); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); } - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); - let meta: Value = fs::read_to_string(dir.join(format!("agent-{}.meta.json", agent_id))) + crate::qoder::prefetch(&warm); + } + let mut by_tool = serde_json::Map::new(); + for name in agent_names { + let agent_id = name + .trim_start_matches("agent-") + .trim_end_matches(".jsonl") + .to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(json!({})); - let recs = parse_jsonl(&ent.path()); + let recs = parse_jsonl(&dir.join(&name)); let shaped = shape_session(&recs); let key = meta .get("toolUseId") @@ -283,7 +373,15 @@ fn read_subagents(file: &Path) -> Value { "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), "skill": crate::history::skill_from_recs(&recs), "count": shaped.messages.len(), - "totals": { "in": shaped.totals.0, "out": shaped.totals.1, "cacheRead": shaped.totals.2, "turns": shaped.totals.3 }, + "totals": { + "in": shaped.totals.0, + "out": shaped.totals.1, + "cacheRead": shaped.totals.2, + "cacheCreation": shaped.cache_creation, + "turns": shaped.totals.3, + "credits": shaped.credits, + "tokenUsageAvailable": shaped.token_usage_available, + }, "messages": shaped.messages, }), ); @@ -306,21 +404,32 @@ fn build_from_session(sess: Value, assistant: &str) -> Value { "role": msg.get("role").cloned().unwrap_or(Value::Null), "content": cap_content(msg.get("content").unwrap_or(&Value::Null)), "ts": msg.get("ts").cloned().unwrap_or(Value::Null), - "meta": false, + "meta": msg + .get("meta") + .or_else(|| msg.get("_meta")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), }); let o = out.as_object_mut().unwrap(); if let Some(md) = msg.get("modelActual") { o.insert("model".into(), md.clone()); } if let Some(u) = msg.get("usage") { + let mut usage = json!({ + "in": u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0), + "out": u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheRead": u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheCreation": u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0), + }); + let usage_object = usage.as_object_mut().unwrap(); + for field in ["credits", "originalCredits", "contextUsageRatio"] { + if let Some(value) = u.get(field).filter(|value| value.is_number()) { + usage_object.insert(field.to_string(), value.clone()); + } + } o.insert( "usage".into(), - json!({ - "in": u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "out": u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0), - }), + usage, ); } out @@ -329,7 +438,11 @@ fn build_from_session(sess: Value, assistant: &str) -> Value { }) .unwrap_or_default(); let t = m.get("totals").cloned().unwrap_or_else(|| json!({})); - let title = m.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = m + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); json!({ "meta": { "title": if title.is_empty() { "(conversation)".to_string() } else { title }, @@ -345,6 +458,8 @@ fn build_from_session(sess: Value, assistant: &str) -> Value { "inTok": t.get("in").cloned().unwrap_or(json!(0)), "outTok": t.get("out").cloned().unwrap_or(json!(0)), "cacheTok": t.get("cacheRead").cloned().unwrap_or(json!(0)), + "credits": t.get("credits").cloned().unwrap_or(Value::Null), + "tokenUsageAvailable": t.get("tokenUsageAvailable").cloned().unwrap_or(json!(true)), "subagentCount": 0, "firstTs": m.get("firstTs").cloned().unwrap_or(Value::Null), "lastTs": m.get("lastTs").cloned().unwrap_or(Value::Null), @@ -356,31 +471,62 @@ fn build_from_session(sess: Value, assistant: &str) -> Value { pub fn build_data(file: &str) -> Value { let path = Path::new(file); - // Foreign sources first — container-shape routing (one of them is SQLite, not jsonl). + let qoder = crate::qoder::looks_qoder_path(path); + // Antigravity first — it's SQLite and its shaper opens the DB itself. Every other source + // reads the transcript here, and a failed MAIN read returns the structured error (the export + // command surfaces it) instead of silently exporting an empty page. + if matches!(crate::history::foreign_kind(path), Some(crate::history::Foreign::Antigravity)) { + return build_from_session(crate::antigravity::session_from(file), "Antigravity"); + } + let recs = match parse_jsonl_result(path) { + Ok(recs) => recs, + Err(error) => return crate::history::session_read_error(path, &error), + }; match crate::history::foreign_kind(path) { Some(crate::history::Foreign::Grok) => { - let recs = parse_jsonl(path); return build_from_session(crate::grok::session_from_recs(file, &recs), "Grok"); } Some(crate::history::Foreign::Copilot) => { - let recs = parse_jsonl(path); return build_from_session(crate::copilot::session_from_recs(file, &recs), "Copilot"); } - Some(crate::history::Foreign::Antigravity) => { - return build_from_session(crate::antigravity::session_from(file), "Antigravity"); - } - None => {} + _ => {} } - let recs = parse_jsonl(path); if crate::codex::looks_codex(&recs) { return build_from_session(crate::codex::session_from_recs(file, &recs), "Codex"); } - let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + // Qoder sessions are Claude-format (the shaping below applies as-is) — brand the exported + // page and prefer qoder's own stored title over first-user-text. + let meta_rec = recs + .iter() + .find(|r| r.get("cwd").is_some()) + .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); let s = shape_session(&recs); - let cwd = meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let cwd = if qoder { + crate::qoder::working_dir_from(&recs).or(top_level_cwd) + } else { + top_level_cwd + }; let title = { - let t = first_user_text(&s.messages); - if t.is_empty() { "(conversation)".to_string() } else { t } + let t = (if qoder { + crate::qoder::session_title_from(&recs) + } else { + None + }) + .unwrap_or_else(|| first_user_text(&s.messages)); + if t.is_empty() { + "(conversation)".to_string() + } else { + t + } + }; + let model = if qoder { + crate::qoder::model_from(&recs).or(s.model.clone()) + } else { + s.model.clone() }; let stem = path.file_stem().and_then(|x| x.to_str()).unwrap_or(""); let mut subagents = read_subagents(path); @@ -391,8 +537,10 @@ pub fn build_data(file: &str) -> Value { json!({ "meta": { "title": title, - "model": s.model, - "project": cwd.map(base_name), + // The viewer runtime labels turns `meta.assistant || 'Claude'`. + "assistant": if qoder { json!("Qoder") } else { Value::Null }, + "model": model, + "project": cwd.as_deref().map(base_name), "cwd": cwd, "branch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(stem), @@ -400,6 +548,8 @@ pub fn build_data(file: &str) -> Value { "count": s.messages.len(), "turns": s.totals.3, "inTok": s.totals.0, "outTok": s.totals.1, "cacheTok": s.totals.2, + "credits": s.credits, + "tokenUsageAvailable": s.token_usage_available, "subagentCount": subagents.as_object().map(|o| o.len()).unwrap_or(0), "firstTs": s.first_ts, "lastTs": s.last_ts, }, @@ -409,7 +559,9 @@ pub fn build_data(file: &str) -> Value { } pub fn html_from_data(data: &Value) -> String { - let json = serde_json::to_string(data).unwrap_or_default().replace('<', "\\u003c"); + let json = serde_json::to_string(data) + .unwrap_or_default() + .replace('<', "\\u003c"); // Tab title uses the project name (already public via the export's filename), NOT the // conversation title: Clarity reports document.title as page metadata that masking can't // reach, and the conversation title is first-message text. The full title still renders @@ -467,8 +619,10 @@ fn sanitize_name(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut prev_underscore = false; for ch in s.chars() { - let bad = matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r') - || ch.is_whitespace(); + let bad = matches!( + ch, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r' + ) || ch.is_whitespace(); if bad { if !prev_underscore { out.push('_'); @@ -479,15 +633,20 @@ fn sanitize_name(s: &str) -> String { prev_underscore = false; } } - out.trim_matches(|c| c == '_' || c == '.' || c == '-').chars().take(60).collect() + out.trim_matches(|c| c == '_' || c == '.' || c == '-') + .chars() + .take(60) + .collect() } // Parse an ISO-8601 `ts` and render it as YYMMDDHHmm in local time (matches `new Date(ts)` + the // Date's local getters used by the original). fn fmt_ts_local(ts: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(ts) - .ok() - .map(|dt| dt.with_timezone(&chrono::Local).format("%y%m%d%H%M").to_string()) + chrono::DateTime::parse_from_rfc3339(ts).ok().map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%y%m%d%H%M") + .to_string() + }) } // Derive the base name from already-built export `data` (avoids re-parsing for the HTML path). @@ -512,3 +671,282 @@ pub fn export_base_name_from_data(data: &Value) -> String { pub fn export_base_name(file: &str) -> String { export_base_name_from_data(&build_data(file)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn qoder_fixture(name: &str) -> (PathBuf, PathBuf) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccbud-exporthtml-{name}-{}-{nonce}", + std::process::id() + )); + let file = root + .join(".qoder") + .join("projects") + .join("-work-project") + .join("session.jsonl"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + (root, file) + } + + fn write_jsonl(path: &Path, records: &[Value]) { + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n"); + fs::write(path, format!("{raw}\n")).unwrap(); + } + + fn streamed_assistant_records() -> Vec { + vec![ + json!({ + "type": "assistant", + "uuid": "wrap-1", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "", + "content": [{ "type": "thinking", "thinking": "plan" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-2", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "redacted_thinking", "data": "opaque" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-3", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ + "type": "tool_use", + "id": "tool-1", + "name": "Read", + "input": { "file_path": "/work/a" }, + }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, + "stop_reason": "tool_use", + }, + }), + ] + } + + #[test] + fn qoder_jsonl_normalizes_streamed_assistant_records() { + let (root, file) = qoder_fixture("normalize"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].get("uuid").and_then(Value::as_str), + Some("wrap-1") + ); + let message = records[0].get("message").unwrap(); + assert_eq!( + message.get("model").and_then(Value::as_str), + Some("ultimate") + ); + assert_eq!( + message.get("stop_reason").and_then(Value::as_str), + Some("tool_use") + ); + assert_eq!( + message + .get("usage") + .and_then(|usage| usage.get("input_tokens")) + .and_then(Value::as_i64), + Some(7) + ); + let content = message.get("content").and_then(Value::as_array).unwrap(); + assert_eq!(content.len(), 2); + assert_eq!( + content[0].get("type").and_then(Value::as_str), + Some("thinking") + ); + assert_eq!( + content[1].get("type").and_then(Value::as_str), + Some("tool_use") + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ordinary_jsonl_keeps_streamed_records_unchanged() { + let (root, _) = qoder_fixture("ordinary"); + let file = root.join("ordinary.jsonl"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 3); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn qoder_export_uses_record_metadata_and_reads_subagent_meta() { + let (root, file) = qoder_fixture("metadata"); + write_jsonl( + &file, + &[ + json!({ + "type": "workspace-directories", + "sessionId": "session", + "directories": ["/work/project"], + }), + json!({ + "type": "runtime-config", + "sessionId": "session", + "model": "ultimate", + }), + json!({ + "type": "ai-title", + "sessionId": "session", + "aiTitle": "Inline Qoder title", + }), + json!({ + "type": "user", + "uuid": "user-1", + "timestamp": "2026-08-04T08:00:00Z", + "message": { "role": "user", "content": "User fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "timestamp": "2026-08-04T08:01:00Z", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "message-model", + "content": [{ "type": "text", "text": "Done" }], + "usage": { "input_tokens": 2, "output_tokens": 1 }, + "stop_reason": "end_turn", + }, + }), + ], + ); + fs::write( + file.parent().unwrap().join("session-session.json"), + r#"{"title":"stale companion title","working_dir":"/stale/path"}"#, + ) + .unwrap(); + + let subagent_dir = file.parent().unwrap().join("session").join("subagents"); + fs::create_dir_all(&subagent_dir).unwrap(); + write_jsonl( + &subagent_dir.join("agent-a.jsonl"), + &[ + json!({ + "type": "user", + "uuid": "sub-user", + "message": { "role": "user", "content": "Investigate" }, + }), + json!({ + "type": "assistant", + "uuid": "sub-assistant", + "message": { + "id": "sub-answer", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "text", "text": "Found it" }], + }, + }), + ], + ); + fs::write( + subagent_dir.join("agent-a.meta.json"), + r#"{"toolUseId":"tool-a","agentType":"Explore","description":"trace"}"#, + ) + .unwrap(); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!(meta.get("assistant").and_then(Value::as_str), Some("Qoder")); + assert_eq!( + meta.get("title").and_then(Value::as_str), + Some("Inline Qoder title") + ); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/work/project") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("project")); + assert_eq!(meta.get("model").and_then(Value::as_str), Some("ultimate")); + assert_eq!(meta.get("subagentCount").and_then(Value::as_u64), Some(1)); + let subagent = data + .get("subagents") + .and_then(|subagents| subagents.get("tool-a")) + .unwrap(); + assert_eq!( + subagent.get("type").and_then(Value::as_str), + Some("Explore") + ); + assert_eq!( + subagent.get("description").and_then(Value::as_str), + Some("trace") + ); + assert_eq!(subagent.get("count").and_then(Value::as_u64), Some(2)); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn qoder_export_falls_back_to_top_level_cwd_and_assistant_model() { + let (root, file) = qoder_fixture("metadata-fallback"); + write_jsonl( + &file, + &[ + json!({ + "type": "user", + "uuid": "user-1", + "sessionId": "session", + "cwd": "/legacy/work", + "message": { "role": "user", "content": "Fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "legacy-model", + "content": [{ "type": "text", "text": "Done" }], + }, + }), + ], + ); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/legacy/work") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("work")); + assert_eq!( + meta.get("model").and_then(Value::as_str), + Some("legacy-model") + ); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 450dded..f6aacec 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -68,25 +68,99 @@ pub(crate) fn parse_lines(text: &str) -> Vec { out } +fn read_head_result(file: &Path, max: usize) -> std::io::Result { + use std::io::{BufRead, BufReader, Read}; + // Qoder data can be protected as "Other Application Data" on macOS. Its reader first + // attempts the normal filesystem path and uses the installed Qoder CLI only for EPERM; + // keep the same bounded-head contract used by list metadata after that read succeeds. + if crate::qoder::looks_qoder_path(file) { + let mut bytes = crate::qoder::read_bytes(file)?; + bytes.truncate(max); + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + let mut file = fs::File::open(file)?; + let mut buf = vec![0u8; max]; + let read = file.read(&mut buf)?; + buf.truncate(read); + // SessionMeta may exceed the ordinary list window because it can embed base instructions and + // dynamic tools. Extend ONLY when the first record itself has no newline yet; a later partial + // record can be ignored, avoiding an accidental multi-megabyte image/tool-result read. + let prefix_len = buf.len().min(4096); + let compact_prefix: String = String::from_utf8_lossy(&buf[..prefix_len]) + .chars() + .filter(|value| !value.is_ascii_whitespace()) + .collect(); + let codex_session_meta = compact_prefix + .find("\"type\":\"session_meta\"") + .is_some_and(|position| position < 512); + if codex_session_meta && read == max && !buf.contains(&b'\n') { + let mut reader = BufReader::new(file); + let _ = reader.read_until(b'\n', &mut buf)?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + pub(crate) fn read_head(file: &Path, max: usize) -> String { - use std::io::Read; - let mut f = match fs::File::open(file) { - Ok(f) => f, - Err(_) => return String::new(), + read_head_result(file, max).unwrap_or_default() +} + +fn read_session_text(file: &Path) -> std::io::Result { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(file) + } else { + fs::read_to_string(file) + } +} + +fn read_session_bytes(file: &Path) -> std::io::Result> { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_bytes(file) + } else { + fs::read(file) + } +} + +/// Verbatim bytes for raw export. Qoder sessions may require the guarded Qoder CLI fallback on +/// macOS; all other sources retain the ordinary filesystem read used before Qoder support. +pub(crate) fn raw_session_bytes(file: &str) -> std::io::Result> { + read_session_bytes(Path::new(file)) +} + +pub(crate) fn session_read_error(file: &Path, error: &std::io::Error) -> Value { + let kind = match error.kind() { + std::io::ErrorKind::NotFound => "notFound", + std::io::ErrorKind::PermissionDenied => "permissionDenied", + _ => "readFailed", }; - let mut buf = vec![0u8; max]; - let n = f.read(&mut buf).unwrap_or(0); - buf.truncate(n); - String::from_utf8_lossy(&buf).into_owned() + json!({ + "error": { + "kind": kind, + "file": file.to_string_lossy(), + "message": error.to_string(), + } + }) } fn usage_of(u: &Value) -> Value { - json!({ + let mut usage = json!({ "inputTokens": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "outputTokens": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }) + }); + let object = usage.as_object_mut().unwrap(); + // Qoder supplies billing/context facts alongside its zeroed token counters. Keep them on the + // per-turn usage object without adding empty fields to ordinary Claude Code messages. + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage } fn content_text(content: &Value) -> String { @@ -199,13 +273,14 @@ pub(crate) fn foreign_kind(file: &Path) -> Option { } /// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's -/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / foreign source, -/// whose flag lives in a sidecar and can flip WITHOUT touching the file — so only the format -/// verdict is cached, never the flag). +/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / Qoder session / +/// foreign source, whose flag lives in a sidecar and can flip WITHOUT touching the file — so only +/// the format verdict is cached, never the flag). #[derive(Clone, Copy)] enum DelKind { Claude(bool), Codex, + Qoder, Foreign(Foreign), } @@ -229,9 +304,12 @@ fn is_session_deleted(file: &Path) -> bool { .ok() .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); let kind = cached.unwrap_or_else(|| { - // Foreign sources are recognized by path shape alone — no read needed. + // Foreign sources are recognized by path shape alone — no read needed. Qoder is + // Claude-FORMAT but another tool's file, so its flag lives in the sidecar too. let kind = if let Some(fk) = foreign_kind(file) { DelKind::Foreign(fk) + } else if crate::qoder::looks_qoder_path(file) { + DelKind::Qoder } else { // Read the same window session_meta uses: a Codex rollout's first (session_meta) line // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields @@ -253,6 +331,7 @@ fn is_session_deleted(file: &Path) -> bool { match kind { DelKind::Claude(del) => del, DelKind::Codex => crate::codex::is_deleted(file), + DelKind::Qoder => crate::qoder::is_deleted(file), DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), @@ -301,6 +380,14 @@ pub struct Norm { pub last_ts: Option, pub cwd: Option, pub session_id: Option, + pub thread_id: Option, + pub parent_thread_id: Option, + pub forked_from_id: Option, + pub is_subagent: bool, + pub agent_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, + pub agent_depth: Option, pub git_branch: Option, pub version: Option, } @@ -315,6 +402,14 @@ impl Default for Norm { last_ts: None, cwd: None, session_id: None, + thread_id: None, + parent_thread_id: None, + forked_from_id: None, + is_subagent: false, + agent_path: None, + agent_nickname: None, + agent_role: None, + agent_depth: None, git_branch: None, version: None, } @@ -331,6 +426,8 @@ pub(crate) fn image_block(url: &str) -> Option { fn shape_messages(recs: &[Value]) -> Shaped { let mut messages = vec![]; let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; let mut model: Option = None; let mut first_ts: Option = None; let mut last_ts: Option = None; @@ -369,6 +466,10 @@ fn shape_messages(recs: &[Value]) -> Shaped { tout += u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } turns += 1; } if let Some(sr) = lm.get("_stopReason").and_then(|v| v.as_str()) { @@ -377,9 +478,19 @@ fn shape_messages(recs: &[Value]) -> Shaped { } messages.push(msg); } + let mut totals = json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }); + if has_credits { + let totals = totals.as_object_mut().unwrap(); + totals.insert("credits".into(), json!(credits)); + // Qoder's source log may omit usable token accounting while still providing real credits. + // Flag that state so the UI does not misrepresent unavailable token counts as zero usage. + if tin == 0 && tout == 0 && tcr == 0 && tcc == 0 { + totals.insert("tokenUsageAvailable".into(), json!(false)); + } + } Shaped { messages, - totals: json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }), + totals, model, first_ts, last_ts, @@ -434,10 +545,10 @@ fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { dirs } /// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for -/// EVERY layout (Claude Code writes `/projects/…`, Codex and Grok `/sessions/…`, -/// Copilot `/session-state/…`, Antigravity `/conversations/*.db`), so `~/.codex`, -/// `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli` are just configured dirs rather than -/// special cases. +/// EVERY layout (Claude Code AND Qoder write `/projects/…`, Codex and Grok +/// `/sessions/…`, Copilot `/session-state/…`, Antigravity `/conversations/*.db`), +/// so `~/.codex`, `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli`, `~/.qoder` are just +/// configured dirs rather than special cases. fn sibling_dir(projects_dir: &Path, name: &str) -> Option { projects_dir.parent().map(|b| b.join(name)) } @@ -543,8 +654,20 @@ fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> O } } let built = build_session_meta(file, dir_name, dir_id, dir_label)?; - if let Ok(mut cache) = meta_cache().lock() { - cache.insert(file.to_path_buf(), (mt, size, built.clone())); + // A permission failure is recoverable without changing the transcript's mtime/size (for + // example after the user grants macOS App Data access) — never memoize that placeholder row, + // otherwise it would stay "(conversation)" until the process restarts. Every OTHER read + // error is memoized like a normal row: the mtime/size key already invalidates it when the + // file changes, and skipping the memo would re-read a broken transcript on every refresh. + let awaiting_grant = built + .get("readError") + .and_then(|e| e.get("kind")) + .and_then(|k| k.as_str()) + == Some("permissionDenied"); + if !awaiting_grant { + if let Ok(mut cache) = meta_cache().lock() { + cache.insert(file.to_path_buf(), (mt, size, built.clone())); + } } Some(built) } @@ -563,21 +686,62 @@ fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str } let meta = fs::metadata(file).ok()?; let size = meta.len(); - let head = read_head(file, 131072); - let recs = parse_lines(&head); + let qoder = crate::qoder::looks_qoder_path(file); + // Qoder stores title/workspace/runtime records throughout the transcript, and its first JSON + // line can itself exceed the ordinary 128 KiB list window. Read the full file once (the row + // cache makes this a one-time cost per mtime/size); other formats retain the bounded head. + let raw = if qoder { + crate::qoder::read_text(file) + } else { + read_head_result(file, 131072) + }; + let (parsed_recs, read_error) = match raw { + Ok(raw) => (parse_lines(&raw), Value::Null), + Err(error) => ( + vec![], + session_read_error(file, &error) + .get("error") + .cloned() + .unwrap_or(Value::Null), + ), + }; + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list // through the codex shaper — the record format shares nothing with Claude's. if crate::codex::looks_codex(&recs) { return crate::codex::session_meta_from(file, &recs, dir_id, dir_label); } + // Qoder sessions use Claude-like user/assistant envelopes plus inline title/workspace/runtime + // records and atomic assistant content wrappers. normalize_records makes the message stream + // Claude-shaped; Qoder-specific metadata remains app-sidecar + inline JSONL data. let meta_rec = recs .iter() .find(|r| r.get("cwd").is_some()) .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); let msgs: Vec = recs.iter().filter_map(line_to_message).collect(); - let (cc_title, cc_tags, cc_deleted) = read_ccbud(&recs); - let auto_title = first_user_text(&msgs); + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(file) } else { read_ccbud(&recs) }; + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&msgs)); let mut model: Option = None; for r in &recs { if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { @@ -586,18 +750,26 @@ fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str } } } + if qoder_model.is_some() { + model = qoder_model; + } let subagent = agent_rec.is_some(); - let cwd = meta_rec + let top_level_cwd = meta_rec .and_then(|r| r.get("cwd")) .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| decode_dir_name(dir_name)); + .map(|s| s.to_string()); + let cwd = (if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }) + .or_else(|| decode_dir_name(dir_name)); let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); let mt = mtime_ms(file); Some(json!({ - "id": format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }), + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, "file": file.to_string_lossy(), - "source": "disk", + "source": if qoder { "qoder" } else { "disk" }, "dirId": dir_id, "dirLabel": dir_label, "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(&stem), @@ -611,41 +783,241 @@ fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str "isSubagent": subagent, "imported": dir_id == "__imported__", "deleted": cc_deleted, + "readError": read_error, "createdAt": record_created_ms(&recs, file), "lastActivity": mt, "sizeKB": (size as f64 / 1024.0).round() as i64, })) } +fn canonical_codex_key(session: &Value) -> Option { + if session.get("source").and_then(Value::as_str) != Some("codex") { + return None; + } + if session.get("canonicalThreadIdValid").and_then(Value::as_bool) != Some(true) { + return None; + } + let thread_id = session.get("threadId").and_then(Value::as_str)?; + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + Some(format!("{dir_id}\0{thread_id}")) +} + +fn codex_canonical_filename(session: &Value) -> bool { + let Some(thread_id) = session.get("threadId").and_then(Value::as_str) else { + return false; + }; + let Some(file) = session.get("file").and_then(Value::as_str) else { + return false; + }; + let stem = Path::new(file).file_stem().and_then(|value| value.to_str()).unwrap_or(""); + stem == thread_id || stem.strip_suffix(thread_id).is_some_and(|prefix| prefix.ends_with('-')) +} + +fn codex_candidate_preferred(candidate: &Value, current: &Value) -> bool { + let candidate_file = candidate.get("file").and_then(Value::as_str).unwrap_or(""); + let current_file = current.get("file").and_then(Value::as_str).unwrap_or(""); + let thread_id = candidate + .get("threadId") + .and_then(Value::as_str) + .or_else(|| current.get("threadId").and_then(Value::as_str)) + .unwrap_or(""); + + // Codex's completed state DB is authoritative when its rollout_path still exists. Both + // candidates already passed ccbud's first-SessionMeta parse, so a matching path also verifies + // that the DB row belongs to this canonical id. + let preferred_path = [candidate_file, current_file] + .into_iter() + .filter(|file| !file.is_empty()) + .find_map(|file| crate::codex::preferred_rollout_path(Path::new(file), thread_id)); + if let Some(preferred) = preferred_path { + let candidate_matches = Path::new(candidate_file) == preferred.as_path(); + let current_matches = Path::new(current_file) == preferred.as_path(); + if candidate_matches != current_matches { + return candidate_matches; + } + } + + let imported = |value: &Value| value.get("imported").and_then(Value::as_bool).unwrap_or(false); + if imported(candidate) != imported(current) { + return !imported(candidate); + } + let archived = |file: &str| { + Path::new(file) + .components() + .any(|part| part.as_os_str().to_str() == Some("archived_sessions")) + }; + if archived(candidate_file) != archived(current_file) { + return !archived(candidate_file); + } + let number = |value: &Value, field: &str| value.get(field).and_then(Value::as_f64).unwrap_or(0.0); + for field in ["lastActivity", "createdAt"] { + let candidate_value = number(candidate, field); + let current_value = number(current, field); + if candidate_value != current_value { + return candidate_value > current_value; + } + } + if codex_canonical_filename(candidate) != codex_canonical_filename(current) { + return codex_canonical_filename(candidate); + } + let candidate_size = number(candidate, "sizeKB"); + let current_size = number(current, "sizeKB"); + if candidate_size != current_size { + return candidate_size > current_size; + } + candidate_file > current_file +} + +fn dedupe_canonical_codex_sessions(sessions: Vec) -> Vec { + let mut out = Vec::with_capacity(sessions.len()); + let mut positions = std::collections::HashMap::::new(); + for session in sessions { + let Some(key) = canonical_codex_key(&session) else { + out.push(session); + continue; + }; + if let Some(index) = positions.get(&key).copied() { + if codex_candidate_preferred(&session, &out[index]) { + out[index] = session; + } + } else { + positions.insert(key, out.len()); + out.push(session); + } + } + out +} + +fn limit_with_codex_ancestors(sessions: Vec, limit: usize) -> Vec { + if sessions.len() <= limit { + return sessions; + } + let mut positions = std::collections::HashMap::::new(); + for (index, session) in sessions.iter().enumerate() { + if let Some(key) = canonical_codex_key(session) { + positions.insert(key, index); + } + } + let mut included: std::collections::HashSet = (0..limit).collect(); + let mut queue: Vec = (0..limit).collect(); + let mut cursor = 0usize; + while cursor < queue.len() { + let index = queue[cursor]; + cursor += 1; + let session = &sessions[index]; + if canonical_codex_key(session).is_none() { + continue; + } + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + let direct_parent = session.get("parentThreadId").and_then(Value::as_str); + let root_parent = session + .get("isSubagent") + .and_then(Value::as_bool) + .unwrap_or(false) + .then(|| session.get("rootSessionId").and_then(Value::as_str)) + .flatten(); + let parent_index = [direct_parent, root_parent] + .into_iter() + .flatten() + .find_map(|parent_id| positions.get(&format!("{dir_id}\0{parent_id}")).copied()); + let Some(parent_index) = parent_index else { + continue; + }; + if included.insert(parent_index) { + queue.push(parent_index); + } + } + sessions + .into_iter() + .enumerate() + .filter_map(|(index, session)| included.contains(&index).then_some(session)) + .collect() +} + pub fn list_sessions(config: &Value, active: &str, limit: usize) -> Vec { // The recycle bin spans every dir and shows only soft-deleted sessions; every other view // is scoped to its dir and hides them. let trash = active == TRASH_ID; - // Read (memoized) metas for EVERY candidate, then select + order on the content-derived - // createdAt — both the sort AND the limit cut key on the session's own first-record time, - // so a title/tag rewrite (which resets fs times) can neither reshuffle nor evict a row. - // The meta cache turns the full walk into stats for unchanged files. + // Read (memoized) metas for EVERY candidate, then dedupe/order before the limit cut. Most + // formats use content-derived CreatedAt so title/tag rewrites cannot reshuffle rows; Codex + // uses rollout UpdatedAt because its custom metadata is sidecar-only and Codex defines latest + // that way. The meta cache turns the full walk into stats for unchanged files. let mut live: std::collections::HashSet = std::collections::HashSet::new(); - let mut out: Vec = Vec::new(); + let mut candidates: Vec<(PathBuf, String, String, String)> = Vec::new(); each_session_file(config, |file, dir_name, id, label| { live.insert(file.clone()); if !trash && active != "all" && id != active { return; } - if let Some(m) = session_meta(&file, &dir_name, id, label) { - if m.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) == trash { - out.push(m); - } - } + candidates.push((file, dir_name, id.to_string(), label.to_string())); }); + // Warm the qoder helper cache in ONE batch before the per-row reads — on a macOS install with + // protected app data, every stale row would otherwise spawn its own helper process. + let qoder_files: Vec = candidates + .iter() + .map(|(file, _, _, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); + let mut out: Vec = Vec::new(); + for (file, dir_name, id, label) in &candidates { + if let Some(m) = session_meta(file, dir_name, id, label) { + out.push(m); + } + } // Drop memo entries for files that no longer exist, so removed dirs don't pin stale rows. if let Ok(mut cache) = meta_cache().lock() { cache.retain(|k, _| live.contains(k)); } - let key = |v: &Value| v.get("createdAt").and_then(|x| x.as_f64()).unwrap_or(0.0); - out.sort_by(|a, b| key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal)); - out.truncate(limit); - out + // Collapse only true physical duplicates (same dir + canonical thread id) BEFORE limit. + // Threads that merely share rootSessionId are distinct root/subagent nodes and remain intact. + let mut out = dedupe_canonical_codex_sessions(out); + // Apply recycle-bin visibility to the selected logical representative, not to each physical + // candidate. Otherwise deleting the authoritative copy could make a stale duplicate reappear + // in the normal list while the same logical thread also sits in the recycle bin. + out.retain(|session| { + session.get("deleted").and_then(Value::as_bool).unwrap_or(false) == trash + }); + let key = |v: &Value| { + // Codex defines "latest" as UpdatedAt (rollout mtime); its title/tags live in a sidecar, + // so this timestamp is not dirtied by ccbud edits. Keep the stable CreatedAt policy for + // formats whose transcript itself is rewritten when metadata changes. + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; + out.sort_by(|a, b| { + key(b) + .partial_cmp(&key(a)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let updated = |value: &Value| { + value.get("lastActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + updated(b) + .partial_cmp(&updated(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| { + let a_id = a + .get("threadId") + .or_else(|| a.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + let b_id = b + .get("threadId") + .or_else(|| b.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + b_id.cmp(a_id) + }) + }); + // Soft-cap Codex trees: include the parent/root chain of every selected child so a busy tree + // cannot show orphan subagents merely because its older root fell just below the limit. + limit_with_codex_ancestors(out, limit) } pub fn list_projects(config: &Value, active: &str) -> Vec { @@ -656,9 +1028,10 @@ pub fn list_projects(config: &Value, active: &str) -> Vec { let cwd = s.get("cwd").and_then(|v| v.as_str()).unwrap_or("(unknown)").to_string(); let la = s.get("lastActivity").and_then(|v| v.as_f64()).unwrap_or(0.0); let ct = s.get("createdAt").and_then(|v| v.as_f64()).unwrap_or(la); + let sk = if s.get("source").and_then(Value::as_str) == Some("codex") { la } else { ct }; let g = groups.entry(cwd.clone()).or_insert_with(|| { order.push(cwd.clone()); - json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0 }) + json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0, "sortActivity": 0.0 }) }); g["sessions"].as_array_mut().unwrap().push(s.clone()); if la > g["lastActivity"].as_f64().unwrap_or(0.0) { @@ -667,31 +1040,50 @@ pub fn list_projects(config: &Value, active: &str) -> Vec { if ct > g["createdAt"].as_f64().unwrap_or(0.0) { g["createdAt"] = json!(ct); } + if sk > g["sortActivity"].as_f64().unwrap_or(0.0) { + g["sortActivity"] = json!(sk); + } } - // Sort sessions + groups by creation time (stable across tag/title edits), newest first. - let sort_key = |v: &Value| v.get("createdAt").and_then(|x| x.as_f64()).unwrap_or(0.0); + // Codex's latest semantic is rollout UpdatedAt; other formats retain CreatedAt so in-file + // title/tag edits cannot reorder them. Apply the same source-aware rule to rows and projects. + let sort_key = |v: &Value| { + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; let mut arr: Vec = order.into_iter().filter_map(|k| groups.remove(&k)).collect(); for g in &mut arr { g["sessions"].as_array_mut().unwrap().sort_by(|a, b| { sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal) }); } - arr.sort_by(|a, b| sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal)); + arr.sort_by(|a, b| { + let key = |value: &Value| { + value.get("sortActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) + }); + for group in &mut arr { + if let Some(object) = group.as_object_mut() { + object.remove("sortActivity"); + } + } arr } pub fn dir_stats(config: &Value) -> Vec { // Per-dir counts exclude soft-deleted sessions (they're hidden from those views); the deleted - // ones are tallied separately into the synthetic recycle-bin bucket. + // ones are tallied separately into the synthetic recycle-bin bucket. Reuse list_sessions so + // counts reflect canonical logical rows rather than duplicate physical rollout files. let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - let mut trash = 0i64; - each_session_file(config, |file, _dn, id, _label| { - if is_session_deleted(&file) { - trash += 1; - } else { - *counts.entry(id.to_string()).or_insert(0) += 1; - } - }); + for session in list_sessions(config, "all", usize::MAX) { + let id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + *counts.entry(id.to_string()).or_insert(0) += 1; + } + let trash = list_sessions(config, TRASH_ID, usize::MAX).len() as i64; let mut out: Vec = all_dirs(config) .into_iter() .map(|(id, label, pd)| { @@ -784,6 +1176,7 @@ pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json:: /// keyed by the spawning tool_use id so the renderer can nest them. {} when none. (history.js readSubagents) fn read_subagents(file: &str) -> serde_json::Map { let p = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(p); let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let dir = match p.parent() { Some(d) => d.join(stem).join("subagents"), @@ -794,24 +1187,49 @@ fn read_subagents(file: &str) -> serde_json::Map { Ok(e) => e, Err(_) => return by_tool, }; + let mut agent_files: Vec<(String, PathBuf)> = vec![]; for ent in entries.flatten() { let name = ent.file_name().to_string_lossy().to_string(); - if !(name.starts_with("agent-") && name.ends_with(".jsonl")) { - continue; + if name.starts_with("agent-") && name.ends_with(".jsonl") { + agent_files.push((name, ent.path())); } + } + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch + // instead of two spawns per agent. + if qoder { + let mut warm: Vec = vec![]; + for (name, path) in &agent_files { + warm.push(path.clone()); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); + } + crate::qoder::prefetch(&warm); + } + for (name, transcript_path) in agent_files { let agent_id = name .trim_start_matches("agent-") .trim_end_matches(".jsonl") .to_string(); - let meta: Value = fs::read_to_string(dir.join(format!("agent-{}.meta.json", agent_id))) + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(|| json!({})); - let raw = match fs::read_to_string(ent.path()) { + let raw = match read_session_text(&transcript_path) { Ok(s) => s, Err(_) => continue, }; - let recs = parse_lines(&raw); + let parsed = parse_lines(&raw); + let recs = if qoder { + crate::qoder::normalize_records(&parsed) + } else { + parsed + }; let shaped = shape_messages(&recs); let key = meta .get("toolUseId") @@ -827,7 +1245,7 @@ fn read_subagents(file: &str) -> serde_json::Map { key, json!({ "agentId": agent_id, - "file": ent.path().to_string_lossy(), + "file": transcript_path.to_string_lossy(), "type": agent_type, "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), "skill": skill_from_recs(&recs), @@ -853,6 +1271,7 @@ fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { Some(d) => d, None => return vec![], }; + let qoder = crate::qoder::looks_qoder_path(file); let mut out = vec![]; if let Ok(entries) = fs::read_dir(&dir) { for ent in entries.flatten() { @@ -863,7 +1282,12 @@ fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { let name = ent.file_name().to_string_lossy().into_owned(); let lower = name.to_lowercase(); if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - if let Ok(bytes) = fs::read(&p) { + let bytes = if qoder { + crate::qoder::read_bytes(&p) + } else { + fs::read(&p) + }; + if let Ok(bytes) = bytes { out.push((name, bytes)); } } @@ -883,7 +1307,7 @@ pub fn session_has_subagents(file: &str) -> bool { /// (a plain .jsonl export otherwise). Round-trips through import_zip / splitBundle. pub fn export_bundle(file: &str) -> std::io::Result> { let path = Path::new(file); - let main = fs::read(path)?; + let main = read_session_bytes(path)?; let main_name = path .file_name() .and_then(|n| n.to_str()) @@ -933,14 +1357,15 @@ pub(crate) fn read_import_meta(file: &str) -> Option { pub fn get_session(file: &str) -> Value { let path = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(path); // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. match foreign_kind(path) { Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), Some(fk) => { - let raw = match fs::read_to_string(path) { + let raw = match read_session_text(path) { Ok(s) => s, - Err(_) => return Value::Null, + Err(error) => return session_read_error(path, &error), }; let recs = parse_lines(&raw); return match fk { @@ -950,14 +1375,34 @@ pub fn get_session(file: &str) -> Value { } None => {} } - let raw = match fs::read_to_string(path) { + let raw = match read_session_text(path) { Ok(s) => s, - Err(_) => return Value::Null, + Err(error) => return session_read_error(path, &error), }; - let recs = parse_lines(&raw); - if crate::codex::looks_codex(&recs) { - return crate::codex::session_from_recs(file, &recs); + let parsed_recs = parse_lines(&raw); + if crate::codex::looks_codex(&parsed_recs) { + return crate::codex::session_from_recs(file, &parsed_recs); } + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; let meta_rec = recs .iter() .find(|r| r.get("cwd").is_some()) @@ -971,11 +1416,22 @@ pub fn get_session(file: &str) -> Value { .iter() .find(|r| r.get("type").and_then(|v| v.as_str()) == Some("summary") && r.get("summary").is_some()) .and_then(|r| r.get("summary").cloned()); - let (cc_title, cc_tags, cc_deleted) = read_ccbud(&recs); + // Qoder detail mirrors build_session_meta: normalized atomic wrappers, inline metadata, and + // app-owned title/tags/delete overrides without rewriting another CLI's transcript. + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(path) } else { read_ccbud(&recs) }; let shaped = shape_messages(&recs); - let auto_title = first_user_text(&shaped.messages); + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&shaped.messages)); let subagent = agent_rec.is_some(); - let cwd = meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()).map(|s| s.to_string()); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let cwd = if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }; let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); let base_id = meta_rec .and_then(|r| r.get("sessionId")) @@ -989,13 +1445,16 @@ pub fn get_session(file: &str) -> Value { }; let mut subs = if subagent { serde_json::Map::new() } else { read_subagents(file) }; apply_skill_names(&shaped.messages, &mut subs); - let import_meta = read_import_meta(file); + // Live Qoder files are never imported snapshots; avoid probing a protected sibling sidecar. + let import_meta = if qoder { None } else { read_import_meta(file) }; json!({ "meta": { - "id": format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }), + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, "file": file, - "source": "disk", + "source": if qoder { "qoder" } else { "disk" }, + // Renderer falls back to Claude when null (the app's home turf carries no label). + "assistant": if qoder { json!("Qoder") } else { Value::Null }, "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), "autoTitle": auto_title, "tags": cc_tags, @@ -1012,7 +1471,7 @@ pub fn get_session(file: &str) -> Value { "imported": import_meta.is_some(), "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), - "model": shaped.model, + "model": qoder_model.or(shaped.model), "totals": shaped.totals, "messages": shaped.messages.len(), "subagentCount": subs.len(), @@ -1077,17 +1536,141 @@ fn icount(hay: &str, needle: &str) -> usize { c } -/// Strip harness-injected blocks from user prose — mirrors the renderer's stripInjected, so what +/// Mirror of the renderer's formatCodexBootstrap (conversations.js / runtime.js): Codex records +/// its initial AGENTS.md instructions + environment snapshot as one XML-ish user text block, and +/// the panel renders it as compact Markdown — search must index that same Markdown, not the raw +/// transport shape. None = not a bootstrap message (ordinary prose passes through untouched). +fn format_codex_bootstrap(source: &str) -> Option { + static AGENTS_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ENV_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ROOT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let agents_re = AGENTS_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*#\s+AGENTS\.md instructions for ([^\r\n]+).*?]*>(.*?)", + ) + .unwrap() + }); + let env_re = ENV_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>(.*?)").unwrap() + }); + let root_re = + ROOT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); + let agents = agents_re.captures(source)?; + + // Dynamic per-name regexes are fine here: at most one bootstrap message exists per session. + let tag = |block: &str, name: &str| -> String { + regex::Regex::new(&format!(r"(?is)<{name}\b[^>]*>(.*?)")) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let attr = |block: &str, name: &str, attribute: &str| -> String { + regex::Regex::new(&format!(r#"(?i)<{name}\b[^>]*\b{attribute}=["']([^"']+)["']"#)) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let code = |value: &str| -> String { + if value.is_empty() { String::new() } else { format!("`{}`", value) } + }; + + let mut parts: Vec = + vec![format!("# AGENTS.md instructions for {}", agents.get(1).map(|m| m.as_str().trim()).unwrap_or(""))]; + let instructions = agents.get(2).map(|m| m.as_str().trim()).unwrap_or(""); + if !instructions.is_empty() { + let lines: Vec<&str> = instructions.lines().filter(|line| !line.trim().is_empty()).collect(); + parts.push(if lines.len() == 1 { + format!("**INSTRUCTIONS:** {}", lines[0].trim()) + } else { + format!("**INSTRUCTIONS:**\n\n{}", instructions) + }); + } + + let env = env_re.captures(source); + if let Some(env) = &env { + let block = env.get(1).map(|m| m.as_str()).unwrap_or(""); + let roots: Vec = root_re + .captures_iter(block) + .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_string())) + .filter(|r| !r.is_empty()) + .map(|r| code(&r)) + .collect(); + let fields: Vec<(&str, String)> = vec![ + ("environment_context", code(&tag(block, "cwd"))), + ("shell", tag(block, "shell")), + ("current_date", tag(block, "current_date")), + ("timezone", tag(block, "timezone")), + ("workspace_roots", roots.join(", ")), + ("permission_profile", attr(block, "permission_profile", "type")), + ("file_system", attr(block, "file_system", "type")), + ] + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .collect(); + if !fields.is_empty() { + parts.push( + fields + .iter() + .map(|(name, value)| format!("**{}:** {}", name, value)) + .collect::>() + .join(" \n"), + ); + } + } + + let mut rest = source.replacen(agents.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + if let Some(env) = &env { + rest = rest.replacen(env.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + } + let rest = rest.trim(); + if !rest.is_empty() { + parts.push(rest.to_string()); + } + Some(parts.join("\n\n").trim().to_string()) +} + +/// Strip harness-injected blocks from user prose — MUST stay rule-for-rule in sync with the +/// renderer's stripInjected (conversations.js) and the export viewer's copy (runtime.js), so what /// the big search matches is exactly what the in-conversation search (and the panel) will show. +/// The Codex AGENTS bootstrap reformats to the same Markdown the panel renders; task-notification +/// envelopes keep their human-facing body; the transport metadata (ids, status, summary) +/// is dropped and must therefore never be searchable. fn strip_injected(s: &str) -> String { + static SKILL_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static TASK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static RESULT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + // Same rule order as the JS: the Codex AGENTS bootstrap is reformatted FIRST, then the + // envelope rules run over the (possibly rewritten) text. + let bootstrap = format_codex_bootstrap(s); + let s: &str = bootstrap.as_deref().unwrap_or(s); + // A turn that is nothing but a envelope is Codex's recorded skill-instruction + // injection — runtime context the panel suppresses wholesale, so search must too. Prose that + // merely quotes markup alongside other text stays searchable. + let skill_re = SKILL_RE + .get_or_init(|| regex::Regex::new(r"(?is)^\s*]*>.*\s*$").unwrap()); + if skill_re.is_match(s) { + return String::new(); + } + let task_re = TASK_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>.*?").unwrap() + }); + let result_re = + RESULT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); let re = RE.get_or_init(|| { regex::Regex::new( r"(?s).*?|.*?|.*?", ) .unwrap() }); - re.replace_all(s, "").trim().to_string() + let unwrapped = task_re.replace_all(s, |caps: ®ex::Captures| { + result_re + .captures(caps.get(0).map(|m| m.as_str()).unwrap_or("")) + .and_then(|c| c.get(1)) + .map(|m| format!("\n{}\n", m.as_str().trim())) + .unwrap_or_default() + }); + re.replace_all(&unwrapped, "").trim().to_string() } fn tool_result_search_text(c: &Value) -> String { @@ -1144,6 +1727,11 @@ fn extract_search_text(messages: &[Value]) -> String { } } "thinking" => push(b.get("thinking").and_then(|v| v.as_str()).unwrap_or("")), + "skill_load" => { + push(b.get("name").and_then(Value::as_str).unwrap_or("")); + push(b.get("path").and_then(Value::as_str).unwrap_or("")); + push(b.get("snapshot").and_then(Value::as_str).unwrap_or("")); + } "tool_use" => { let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); let input = b.get("input").map(|i| i.to_string()).unwrap_or_default(); @@ -1192,11 +1780,16 @@ fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc crate::grok::normalize(&recs, None).messages, Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, @@ -1268,7 +1861,13 @@ fn scan_session(file: &Path, q: &str, raw_safe: bool) -> Option { for name in names { if let Some((text, pos)) = thread_scan(&dir.join(&name), q, raw_safe) { let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); - let meta: Value = fs::read_to_string(dir.join(format!("agent-{}.meta.json", agent_id))) + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(|| json!({})); @@ -1310,20 +1909,28 @@ pub fn search_sessions(config: &Value, active: &str, query: &str, limit: usize) // Python's json.dumps default) escape it as \uXXXX, which a byte scan would miss; those // queries always take the parse+extract path (cached, so paid once per file version). let raw_safe = q.bytes().all(|b| b.is_ascii() && b != b'"' && b != b'\\' && b >= 0x20); - let mut files: Vec<(PathBuf, f64)> = vec![]; - each_session_file(config, |file, dir_name, id, label| { - if !trash && active != "all" && id != active { - return; - } - // Same content-derived key the list orders by (memoized), so the candidate cap below - // keeps exactly the sessions the list can show. - let ct = session_meta(&file, &dir_name, id, label) - .and_then(|m| m.get("createdAt").and_then(|v| v.as_f64())) - .unwrap_or_else(|| created_ms(&file)); - files.push((file, ct)); - }); - files.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - files.truncate(600); // the list view's own cap — search what the list can show + // Reuse the list's pre-limit canonical-thread dedupe, directory/trash scope, and ordering. + // Otherwise duplicate physical rollouts could consume the 600-file search window even though + // the sidebar shows only their selected representative. + let files: Vec<(PathBuf, f64)> = list_sessions(config, active, 600) + .into_iter() + .filter_map(|session| { + let file = PathBuf::from(session.get("file")?.as_str()?); + let created = session + .get("createdAt") + .and_then(Value::as_f64) + .unwrap_or_else(|| created_ms(&file)); + Some((file, created)) + }) + .collect(); + // One batch helper call instead of a spawn per protected qoder file inside the worker loop + // (repeat scans of unchanged files are then served by the extraction + helper caches). + let qoder_files: Vec = files + .iter() + .map(|(file, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); let hits = std::sync::Mutex::new(Vec::<(f64, Value)>::new()); let next = std::sync::atomic::AtomicUsize::new(0); let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8); @@ -1376,6 +1983,18 @@ pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { } return r; } + // Qoder sessions are Claude-format but another tool's live files: title/tags/delete go to + // the shared sidecar (keyed qoder:) instead of an in-file rewrite. The sidecar edit + // doesn't touch the file (no mtime bump), so the list-meta memo is dropped by hand. + if crate::qoder::looks_qoder_path(target) { + let r = crate::qoder::set_meta(file, patch); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } let raw = match fs::read_to_string(file) { Ok(s) => s, Err(_) => return json!({ "ok": false, "reason": "read" }), @@ -1495,12 +2114,12 @@ pub fn delete_session_file(file: &str, config: &Value) -> Value { if !target.is_file() { return json!({ "ok": false, "reason": "missing" }); } - // A LIVE Codex rollout or foreign-CLI session is another tool's file — the app only ever - // soft-deletes those via the sidecar and never rewrites them (see set_ccbud), so "delete - // forever" must not rm the source either. Imported codex COPIES (marked by an .import.json) - // are our own snapshots and stay hard-deletable, like Claude sessions the app manages in - // the configured dirs. - if foreign_kind(target).is_some() { + // A LIVE Codex rollout, Qoder session, or foreign-CLI session is another tool's file — the + // app only ever soft-deletes those via the sidecar and never rewrites them (see set_ccbud), + // so "delete forever" must not rm the source either. Imported codex COPIES (marked by an + // .import.json) are our own snapshots and stay hard-deletable, like Claude sessions the app + // manages in the configured dirs. + if foreign_kind(target).is_some() || crate::qoder::looks_qoder_path(target) { return json!({ "ok": false, "reason": "foreign" }); } let head = parse_lines(&read_head(target, 131072)); @@ -1653,6 +2272,37 @@ fn now_ms() -> i64 { .unwrap_or(0) } +fn records_to_jsonl(records: &[Value]) -> String { + let mut text = records + .iter() + .map(|r| serde_json::to_string(r).unwrap_or_default()) + .collect::>() + .join("\n"); + text.push('\n'); + text +} + +/// A qoder transcript imported into the app store loses its container path and would otherwise +/// re-parse as raw Claude records: queued commands vanish, redacted duplicates render, atomic +/// wrappers stay fragmented, and qoder's own title is lost. Sniffed by CONTENT (import copies and +/// bundle zips carry no .qoder path), the copy is rewritten up front — normalized records, with +/// the qoder title carried onto the first line's __ccbud__ so the import keeps its name. +fn qoder_import_raw(recs: &[Value]) -> Option<(String, Vec)> { + if !crate::qoder::looks_qoder_records(recs) { + return None; + } + let mut normalized = crate::qoder::normalize_records(recs); + if let Some(title) = crate::qoder::session_title_from(recs) { + if let Some(first) = normalized.iter_mut().find(|r| r.is_object()) { + let obj = first.as_object_mut().unwrap(); + let mut cc = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); + cc.entry("title".to_string()).or_insert_with(|| json!(title)); + obj.insert("__ccbud__".into(), Value::Object(cc)); + } + } + Some((records_to_jsonl(&normalized), normalized)) +} + /// Snapshot a transcript (already read into `raw`) plus its subagent sidecars into the import store, /// laid out like a native projects/ tree + a provenance sidecar. `subagents`: (filename, bytes) to /// drop under `/subagents/` — names are basename-reduced and pattern-checked so a crafted @@ -1661,6 +2311,13 @@ fn now_ms() -> i64 { fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents: &[(String, Vec)]) -> i32 { let recs = parse_lines(raw); let is_codex = crate::codex::looks_codex(&recs); + // Qoder content is rewritten to Claude shape before storing — the has_msg gate below then + // sees the materialized queued-command user turns too. + let (qoder_text, recs) = match qoder_import_raw(&recs) { + Some((text, normalized)) => (Some(text), normalized), + None => (None, recs), + }; + let raw = qoder_text.as_deref().unwrap_or(raw); let has_msg = recs.iter().any(|r| { let t = r.get("type").and_then(|v| v.as_str()); (t == Some("user") || t == Some("assistant")) && r.get("message").is_some() @@ -1701,6 +2358,15 @@ fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents let safe = Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(""); let lower = safe.to_lowercase(); if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + // A qoder session's subagent transcripts carry the same atomic wrappers — + // the parent's sniff decides, so the whole stored copy is Claude-shaped. + if qoder_text.is_some() && lower.ends_with(".jsonl") { + if let Ok(text) = std::str::from_utf8(bytes) { + let normalized = crate::qoder::normalize_records(&parse_lines(text)); + let _ = fs::write(sub_dir.join(safe), records_to_jsonl(&normalized)); + continue; + } + } let _ = fs::write(sub_dir.join(safe), bytes); } } @@ -1729,7 +2395,7 @@ fn import_one(src: &str) -> i32 { if foreign_kind(src_path).is_some() { return 0; } - let raw = match fs::read_to_string(src) { + let raw = match read_session_text(src_path) { Ok(s) => s, Err(_) => return 0, }; @@ -2013,6 +2679,226 @@ mod tests { let _ = fs::remove_dir_all(&base); } + // Qoder writes Claude-like atomic event wrappers plus inline metadata into its own tree. + // Rows and detail must use that metadata, merge one assistant response's content blocks, + // retain queued commands as user turns, nest subagents, and remain searchable/exportable. + #[test] + fn qoder_sessions_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-qoder-route-test"); + let _ = fs::remove_dir_all(&base); + let root = base.join(".qoder"); + let proj = root.join("projects").join("-tmp-qproj"); + fs::create_dir_all(&proj).unwrap(); + let uuid = "11111111-1111-4111-8111-111111111111"; + let sess = proj.join(format!("{}.jsonl", uuid)); + let records = vec![ + json!({ "type": "agent-setting", "agentSetting": "triage", "entrypoint": "sdk-cli", "sessionId": uuid }), + json!({ "type": "last-prompt", "sessionId": uuid, "lastPrompt": "last prompt fallback" }), + json!({ "type": "ai-title", "sessionId": uuid, "aiTitle": "Qoder 会话" }), + json!({ "type": "workspace-directories", "sessionId": uuid, "directories": ["/tmp/qproj"] }), + json!({ "type": "runtime-config", "sessionId": uuid, "model": "ultimate", "reasoningEffort": "high" }), + json!({ + "type": "user", "uuid": "u1", "timestamp": "2026-06-04T09:47:27.966Z", + "message": { "role": "user", "content": "qoder needle axolotl" }, + "sessionId": uuid, "version": "1.1.13" + }), + json!({ + "type": "assistant", "uuid": "a1", "parentUuid": "u1", "timestamp": "2026-06-04T09:47:32.116Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "model": "wire-model", "content": [ + { "type": "redacted_thinking", "data": "must not render" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a2", "parentUuid": "a1", "timestamp": "2026-06-04T09:47:32.216Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "thinking", "thinking": "considering" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a3", "parentUuid": "a2", "timestamp": "2026-06-04T09:47:32.316Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "text", "text": "done" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a4", "parentUuid": "a3", "timestamp": "2026-06-04T09:47:32.416Z", + "message": { + "id": "msg_1", "type": "message", "role": "assistant", "stop_reason": "end_turn", + "usage": { "input_tokens": 100, "cache_creation_input_tokens": 7, "cache_read_input_tokens": 50, "output_tokens": 30 }, + "content": [{ "type": "tool_use", "id": "tu1", "name": "Task", "input": {} }] + }, "sessionId": uuid + }), + json!({ + "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued narwhal follow-up", "commandMode": false }, + "uuid": "u2", "parentUuid": "a4", "timestamp": "2026-06-04T09:47:35.000Z", "sessionId": uuid + }), + ]; + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n") + + "\n"; + fs::write(&sess, raw).unwrap(); + let sub = proj.join(uuid).join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("agent-q1.jsonl"), + format!("{{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"q1\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"sub quetzal done\"}}]}},\"sessionId\":\"{}\",\"timestamp\":\"2026-06-04T09:47:40.000Z\"}}\n", uuid), + ) + .unwrap(); + fs::write( + sub.join("agent-q1.meta.json"), + "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu1\"}", + ) + .unwrap(); + + let config = json!({ "historyDirs": [ root.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + assert_eq!(rows.len(), 1, "rows: {:?}", rows); + let r = &rows[0]; + assert_eq!(r["source"], "qoder"); + assert_eq!(r["id"], format!("qoder:{}", uuid)); + assert_eq!(r["title"], "Qoder 会话"); + assert_eq!(r["autoTitle"], "Qoder 会话"); + assert_eq!(r["cwd"], "/tmp/qproj"); + assert_eq!(r["model"], "ultimate"); + assert_eq!(r["deleted"], false); + + let file = r["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], "Qoder"); + assert_eq!(d["meta"]["source"], "qoder"); + assert_eq!(d["meta"]["id"], format!("qoder:{}", uuid)); + assert_eq!(d["meta"]["title"], "Qoder 会话"); + assert_eq!(d["meta"]["model"], "ultimate"); + assert_eq!(d["meta"]["subagentCount"], 1); + assert_eq!(d["messages"].as_array().unwrap().len(), 3); + assert_eq!(d["messages"][0]["content"], "qoder needle axolotl"); // string-content user turn + let assistant_blocks = d["messages"][1]["content"].as_array().unwrap(); + assert_eq!( + assistant_blocks + .iter() + .filter_map(|block| block.get("type").and_then(Value::as_str)) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + assert_eq!(d["messages"][1]["usage"]["inputTokens"], 100); + assert_eq!(d["messages"][1]["stopReason"], "end_turn"); + assert_eq!(d["messages"][2]["role"], "user"); + assert_eq!(d["messages"][2]["content"], "queued narwhal follow-up"); + assert_eq!(d["subagents"]["tu1"]["messages"][0]["content"][0]["text"], "sub quetzal done"); + + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + + // content search reaches the main thread and the subagent transcript + let hits = search_sessions(&config, "all", "axolotl", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "narwhal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "quetzal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "tu1"); + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn session_read_errors_are_structured() { + let base = std::env::temp_dir().join(format!( + "ccbud-session-read-error-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + let project = base.join(".qoder").join("projects").join("-tmp-error"); + fs::create_dir_all(&project).unwrap(); + + let missing = project.join("missing.jsonl"); + let detail = get_session(&missing.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "notFound"); + + let invalid = project.join("invalid.jsonl"); + fs::write(&invalid, [0xff, 0xfe]).unwrap(); + let detail = get_session(&invalid.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "readFailed"); + + let denied = session_read_error( + &invalid, + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + ); + assert_eq!(denied["error"]["kind"], "permissionDenied"); + + let _ = fs::remove_dir_all(&base); + } + + // The Rust search extractor and the renderer's stripInjected must agree: a task-notification + // envelope surfaces only its body — transport metadata must never be searchable. + #[test] + fn strip_injected_unwraps_task_notifications() { + let s = strip_injected( + "before \ncompleted\ntransport-noise\n\nDone **ok**\n\n after", + ); + assert!(s.contains("before") && s.contains("after")); + assert!(s.contains("Done **ok**")); + assert!(!s.contains("transport-noise")); + assert!(!s.contains("task-notification")); + // an envelope without a vanishes wholesale, like a system-reminder + let gone = strip_injected("x running y"); + assert!(gone.contains('x') && gone.contains('y') && !gone.contains("running")); + // the pre-existing rules still apply after the unwrap + assert_eq!(strip_injected("himeta"), "hi"); + // a standalone Codex injection vanishes; quoting one alongside prose does not + assert_eq!(strip_injected(" skill-body\n"), ""); + assert!(strip_injected("see quoted here").contains("quoted")); + } + + // The Codex AGENTS bootstrap must index as the SAME compact Markdown the panel renders + // (formatCodexBootstrap parity) — not as the raw XML-ish transport shape. + #[test] + fn strip_injected_formats_codex_bootstrap_like_the_panel() { + let raw = "# AGENTS.md instructions for /work/proj\n\n\nAlways run tests.\n\n\n\n /work/proj\n zsh\n /work/proj\n \n"; + let s = strip_injected(raw); + assert!(s.starts_with("# AGENTS.md instructions for /work/proj"), "{s}"); + assert!(s.contains("**INSTRUCTIONS:** Always run tests."), "{s}"); + assert!(s.contains("**environment_context:** `/work/proj`"), "{s}"); + assert!(s.contains("**shell:** zsh"), "{s}"); + assert!(s.contains("**workspace_roots:** `/work/proj`"), "{s}"); + assert!(s.contains("**permission_profile:** workspace-write"), "{s}"); + assert!(!s.contains("\na\nb\n"); + assert!(multi.contains("**INSTRUCTIONS:**\n\na\nb"), "{multi}"); + // ordinary prose is untouched + assert_eq!(strip_injected("ordinary prose"), "ordinary prose"); + } + + // Imported qoder content is rewritten to Claude shape (wrappers merged, queued commands + // materialized) with qoder's own title carried onto __ccbud__; Claude content passes through. + #[test] + fn qoder_imports_are_normalized_with_title() { + let recs = vec![ + json!({ "type": "ai-title", "aiTitle": "Qoder 导入标题" }), + json!({ "type": "assistant", "uuid": "w1", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "thinking", "thinking": "t" }] } }), + json!({ "type": "assistant", "uuid": "w2", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "text", "text": "done" }] } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued prompt" } }), + ]; + let (text, normalized) = qoder_import_raw(&recs).expect("sniffs as qoder"); + let assistants: Vec<&Value> = normalized.iter().filter(|r| r["type"] == "assistant").collect(); + assert_eq!(assistants.len(), 1, "wrappers merged: {:?}", normalized); + assert_eq!(assistants[0]["message"]["content"].as_array().unwrap().len(), 2); + assert!(normalized + .iter() + .any(|r| r["type"] == "user" && r["message"]["content"] == "queued prompt")); + let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(first["__ccbud__"]["title"], "Qoder 导入标题"); + assert!(qoder_import_raw(&[json!({ "type": "user", "message": { "content": "hi" } })]).is_none()); + } + // A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted // by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on // disk. A Claude session in the same dir's projects/ tree is still deletable. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa6128c..3567870 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ mod grok; mod history; mod plugin; mod protocol; +mod qoder; mod sidecar; mod store; mod usage; @@ -1167,7 +1168,7 @@ async fn history_export_raw(file: String) -> Result { None => Ok(json!({ "canceled": true })), } } else { - let data = std::fs::read_to_string(&file).map_err(|e| e.to_string())?; + let data = history::raw_session_bytes(&file).map_err(|e| e.to_string())?; match rfd::AsyncFileDialog::new() .add_filter("JSONL", &["jsonl"]) .set_file_name(format!("{}.jsonl", base)) @@ -1193,6 +1194,15 @@ async fn history_export_html(payload: Value) -> Result { .to_string(); // Build the export data once, then reuse it for both the HTML body and the filename. let data = exporthtml::build_data(&file); + // An unreadable main transcript surfaces as a command error (renderer reports it) instead of + // silently saving an empty viewer page. + if let Some(error) = data.get("error") { + return Err(error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("session read failed") + .to_string()); + } let html = exporthtml::html_from_data(&data); let base = exporthtml::export_base_name_from_data(&data); match rfd::AsyncFileDialog::new().set_file_name(format!("{}.html", base)).save_file().await { @@ -1823,13 +1833,15 @@ pub fn run() { )?; } // One-time migrations: detected installs of the other coding CLIs (Codex, Grok - // Build, Copilot CLI, Antigravity CLI) and an XDG Claude tree join historyDirs as - // regular work dirs. Runs BEFORE the history watcher so their trees get watched. + // Build, Copilot CLI, Antigravity CLI, Qoder) and an XDG Claude tree join + // historyDirs as regular work dirs. Runs BEFORE the history watcher so their trees + // get watched. store::ensure_codex_dir(); store::ensure_xdg_claude_dir(); store::ensure_grok_dir(); store::ensure_copilot_dir(); store::ensure_antigravity_dir(); + store::ensure_qoder_dir(); // Start the localhost gateway on the configured port (proxy.js parity). let gw = gateway::GatewayState::new(app.handle().clone()); diff --git a/src-tauri/src/qoder.rs b/src-tauri/src/qoder.rs new file mode 100644 index 0000000..27955d7 --- /dev/null +++ b/src-tauri/src/qoder.rs @@ -0,0 +1,1200 @@ +// Qoder CLI session support — Qoder writes Claude-like transcripts into its own trees +// (`~/.qoder/projects//.jsonl` and the same layout under `~/.qoderwork`, +// subagents in `/subagents/agent-*.jsonl`). Qoder streams assistant content as atomic +// wrappers and stores title/workspace/runtime metadata inline, so this module provides the small +// normalization layer needed by the normal Claude pipeline, plus root discovery, safe reads, +// path routing, and the shared foreign-CLI sidecar. The source files belong to another tool and +// are never rewritten, which also means hard-delete refuses them (history.rs). + +#![allow(dead_code)] + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::io; +#[cfg(target_os = "macos")] +use std::io::Read; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex, OnceLock}; +#[cfg(target_os = "macos")] +use std::time::{Duration, Instant}; + +/// Keep the privileged helper path bounded even if the on-disk file changes while it is read. +/// This is deliberately much larger than normal transcripts, while still preventing an +/// accidental/untrusted child process from filling the app's memory with stdout. +const MAX_READ_BYTES: usize = 256 * 1024 * 1024; + +/// Hard deadline for one helper invocation — generous for a MAX_READ_BYTES read, but bounded so +/// a wedged helper can never pin a sync command thread (and with it the renderer's coalesced +/// request slot for that session) until app restart. Batches get longer since they serve many +/// files in one spawn. +#[cfg(target_os = "macos")] +const HELPER_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "macos")] +const HELPER_BATCH_TIMEOUT: Duration = Duration::from_secs(45); + +/// Byte budget for the helper-read cache (see helper_cache) — cleared wholesale when exceeded, +/// mirroring the search cache's crude-but-safe policy. +const HELPER_CACHE_BUDGET: usize = 256 * 1024 * 1024; + +const QODER_READ_SCRIPT: &str = + "const fs=require(\"fs\");process.stdout.write(fs.readFileSync(process.argv[1]))"; + +/// Batch counterpart: one line of JSON per argv file — content as base64 (`b64`) or a per-file +/// error (`err`) that must not abort the rest of the batch. +#[cfg(target_os = "macos")] +const QODER_BATCH_READ_SCRIPT: &str = "const fs=require(\"fs\");for(const p of process.argv.slice(1)){let line;try{line=JSON.stringify({p,b64:fs.readFileSync(p).toString(\"base64\")})}catch(e){line=JSON.stringify({p,err:String(e&&e.code||e)})}process.stdout.write(line+\"\\n\")}"; + +fn home() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Qoder's two known data roots (both observed in the wild): each is a history-dir entry +/// candidate for the auto-add migration; browsing itself walks every configured dir's +/// `projects/` tree, so these only seed historyDirs. +pub fn default_root() -> PathBuf { + home().join(".qoder") +} + +pub fn work_root() -> PathBuf { + home().join(".qoderwork") +} + +/// A qoder install exists at `root` when its projects tree is on disk. +pub fn root_exists(root: &Path) -> bool { + root.join("projects").is_dir() +} + +fn denied(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message.into()) +} + +fn too_large() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Qoder data file exceeds the {} MiB read limit", + MAX_READ_BYTES / 1024 / 1024 + ), + ) +} + +/// Canonicalize a prospective helper target and prove that it is a JSON/JSONL data file in a +/// `projects` directory directly below a `.qoder` or `.qoderwork` root. Canonicalizing the root, +/// projects directory, and target separately prevents a symlink below `projects` from escaping +/// into an arbitrary part of the filesystem. +fn validated_qoder_data_path(path: &Path) -> io::Result<(PathBuf, PathBuf)> { + let projects = path + .ancestors() + .find(|ancestor| { + ancestor + .file_name() + .map(|name| name == "projects") + .unwrap_or(false) + && ancestor + .parent() + .and_then(Path::file_name) + .map(|name| name == ".qoder" || name == ".qoderwork") + .unwrap_or(false) + }) + .ok_or_else(|| denied("helper reads are limited to .qoder/.qoderwork projects trees"))?; + let root = projects + .parent() + .ok_or_else(|| denied("Qoder projects directory has no data root"))?; + + let canonical_root = fs::canonicalize(root)?; + let canonical_projects = fs::canonicalize(projects)?; + if canonical_projects.parent() != Some(canonical_root.as_path()) { + return Err(denied("Qoder projects directory escapes its data root")); + } + + let canonical_path = fs::canonicalize(path)?; + let relative = canonical_path + .strip_prefix(&canonical_projects) + .map_err(|_| denied("Qoder data path escapes its projects directory"))?; + if relative.as_os_str().is_empty() { + return Err(denied("Qoder data path must name a file below projects")); + } + + let is_data_file = matches!( + canonical_path + .extension() + .and_then(|extension| extension.to_str()), + Some("json") | Some("jsonl") + ); + if !is_data_file { + return Err(denied("Qoder helper only reads JSON and JSONL data files")); + } + + let metadata = fs::metadata(&canonical_path)?; + if !metadata.is_file() { + return Err(denied("Qoder data path is not a regular file")); + } + if metadata.len() > MAX_READ_BYTES as u64 { + return Err(too_large()); + } + + Ok((canonical_path, canonical_root)) +} + +#[cfg(target_os = "macos")] +fn helper_from_install_dir(install_dir: &Path) -> io::Result { + let canonical_dir = fs::canonicalize(install_dir)?; + if !fs::metadata(&canonical_dir)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Qoder CLI helper install directory is not a directory", + )); + } + + let version_path = fs::canonicalize(canonical_dir.join("version.txt"))?; + if version_path.parent() != Some(canonical_dir.as_path()) { + return Err(denied( + "Qoder CLI version file escapes its install directory", + )); + } + let version = fs::read_to_string(version_path)?; + let version = version.trim(); + if version.is_empty() + || version.len() > 64 + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Qoder CLI version.txt contains an invalid version", + )); + } + + let helper = fs::canonicalize(canonical_dir.join(format!("qodercli-{version}")))?; + if helper.parent() != Some(canonical_dir.as_path()) || !fs::metadata(&helper)?.is_file() { + return Err(denied("Qoder CLI helper escapes its install directory")); + } + Ok(helper) +} + +#[cfg(target_os = "macos")] +fn installed_qoder_helper(current_root: &Path) -> io::Result { + let current_install = current_root.join("bin").join("qodercli"); + let home_install = default_root().join("bin").join("qodercli"); + + let mut last_error = None; + for install_dir in [¤t_install, &home_install] { + match helper_from_install_dir(install_dir) { + Ok(helper) => return Ok(helper), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "installed Qoder CLI helper was not found", + ) + })) +} + +/// The helper lives in a user-writable tree, so before executing it must prove it wasn't planted: +/// owned by the same uid as $HOME, neither it nor its directory group/world-writable, a valid +/// strict codesign signature, and a real TeamIdentifier (rejects ad-hoc-signed payloads). Same-uid +/// malware can defeat any same-uid check by definition — the goal is to stop weaker writers and +/// unsigned binaries, and to guarantee a signing-identity trail for anything that does run. +/// codesign hashes the whole (large) binary, so the verdict is memoized per (path, mtime, size). +#[cfg(target_os = "macos")] +fn verify_helper_trust(helper: &Path) -> io::Result<()> { + static VERDICTS: OnceLock>> = OnceLock::new(); + let meta = fs::metadata(helper)?; + let (mt, size) = (mtime_ms_of(&meta), meta.len()); + let cache = VERDICTS.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some((cmt, csz, ok)) = guard.get(helper) { + if *cmt == mt && *csz == size { + return if *ok { + Ok(()) + } else { + Err(denied("Qoder CLI helper previously failed trust verification")) + }; + } + } + } + let verdict = helper_trust_checks(helper, &meta); + if let Ok(mut guard) = cache.lock() { + guard.insert(helper.to_path_buf(), (mt, size, verdict.is_ok())); + } + verdict +} + +#[cfg(target_os = "macos")] +fn helper_trust_checks(helper: &Path, meta: &fs::Metadata) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + let home_uid = fs::metadata(home())?.uid(); + let dir_meta = match helper.parent() { + Some(dir) => fs::metadata(dir)?, + None => return Err(denied("Qoder CLI helper has no install directory")), + }; + for (what, m) in [("helper", meta), ("helper directory", &dir_meta)] { + if m.uid() != home_uid { + return Err(denied(format!("Qoder CLI {what} is not owned by the current user"))); + } + if m.mode() & 0o022 != 0 { + return Err(denied(format!("Qoder CLI {what} is group/world writable"))); + } + } + // codesign is Apple's own bounded tool — a plain blocking call is fine here. + let valid = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !valid.success() { + return Err(denied("Qoder CLI helper has no valid code signature")); + } + let display = Command::new("/usr/bin/codesign") + .args(["-d", "--verbose=2", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output()?; // codesign -d prints its details on stderr + let info = String::from_utf8_lossy(&display.stderr); + let has_team = info.lines().any(|line| { + let line = line.trim_start(); + line.starts_with("TeamIdentifier=") && line != "TeamIdentifier=not set" + }); + if !has_team { + return Err(denied("Qoder CLI helper is not signed with a developer Team ID")); + } + Ok(()) +} + +/// Poll the child for exit until `deadline`; None = still running when time ran out. +#[cfg(target_os = "macos")] +fn wait_deadline( + child: &mut std::process::Child, + deadline: Instant, +) -> io::Result> { + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Run a prepared helper command with a hard deadline. stdout drains on its own thread (bounded +/// at `stdout_cap`), stderr on another (an 8 KiB diagnostic tail, then discarded so a chatty +/// child can't deadlock on a full pipe), the child is killed at the deadline or on an oversized +/// stream, and exit is polled — a helper that closes stdout but never exits still can't pin the +/// calling thread. +#[cfg(target_os = "macos")] +fn run_helper_bounded( + mut cmd: Command, + stdout_cap: usize, + timeout: Duration, + expected_len: usize, +) -> io::Result> { + use std::sync::mpsc; + let timed_out = || io::Error::new(io::ErrorKind::TimedOut, "Qoder CLI helper timed out"); + let mut child = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let deadline = Instant::now() + timeout; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Qoder CLI stdout was not captured"))?; + let (tx, rx) = mpsc::channel(); + // Pre-allocate for the expected payload, capped — metadata could claim MAX_READ_BYTES and an + // upfront 256 MiB allocation per read is needless; Vec growth covers honest large files. + let prealloc = expected_len.min(8 * 1024 * 1024); + let cap = stdout_cap as u64; + let reader = std::thread::spawn(move || { + let mut bytes = Vec::with_capacity(prealloc); + let result = stdout.by_ref().take(cap + 1).read_to_end(&mut bytes); + let _ = tx.send((result, bytes)); + }); + let (etx, erx) = mpsc::channel(); + let stderr_reader = child.stderr.take().map(|mut pipe| { + std::thread::spawn(move || { + let mut tail = Vec::with_capacity(1024); + let _ = pipe.by_ref().take(8192).read_to_end(&mut tail); + let _ = io::copy(&mut pipe, &mut io::sink()); + let _ = etx.send(tail); + }) + }); + let kill = |child: &mut std::process::Child| { + let _ = child.kill(); + let _ = child.wait(); + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let (read_result, bytes) = match rx.recv_timeout(remaining) { + Ok(outcome) => outcome, + Err(_) => { + kill(&mut child); // pipe closes → reader threads unblock and exit + let _ = reader.join(); + return Err(timed_out()); + } + }; + let _ = reader.join(); + if let Err(error) = read_result { + kill(&mut child); + return Err(error); + } + if bytes.len() > stdout_cap { + kill(&mut child); + return Err(too_large()); + } + let status = match wait_deadline(&mut child, deadline)? { + Some(status) => status, + None => { + kill(&mut child); + return Err(timed_out()); + } + }; + let stderr_tail = stderr_reader + .and_then(|_| erx.recv_timeout(Duration::from_millis(200)).ok()) + .unwrap_or_default(); + if !status.success() { + let detail = String::from_utf8_lossy(&stderr_tail); + let detail = detail.trim(); + return Err(io::Error::new( + io::ErrorKind::Other, + if detail.is_empty() { + format!("Qoder CLI helper exited with status {status}") + } else { + format!("Qoder CLI helper exited with status {status}: {detail}") + }, + )); + } + Ok(bytes) +} + +#[cfg(target_os = "macos")] +fn read_with_qoder_helper(path: &Path) -> io::Result> { + let (path, root) = validated_qoder_data_path(path)?; + let helper = installed_qoder_helper(&root)?; + verify_helper_trust(&helper)?; + let expected_len = fs::metadata(&path)?.len().min(MAX_READ_BYTES as u64) as usize; + + // Qoder CLI is a Bun executable. Passing the fixed program and target as distinct argv + // entries is important: never interpolate a path into JavaScript or a shell command. + let mut cmd = Command::new(helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_READ_SCRIPT).arg(&path); + run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_TIMEOUT, expected_len) +} + +/// Read a local history file. The normal filesystem path is always attempted first; macOS may +/// fall back to Qoder's already-installed CLI only for a permission denial, and only after the +/// helper target passes the strict projects-tree validation above. +pub(crate) fn read_bytes(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) if bytes.len() <= MAX_READ_BYTES => Ok(bytes), + Ok(_) => Err(too_large()), + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + #[cfg(target_os = "macos")] + { + // Serve repeat reads of the same file version from the helper cache — stat still + // works on content-protected files, so (mtime, size) is a valid freshness key. + let stamp = file_stamp(path).ok(); + if let Some((mt, size)) = stamp { + if let Some(hit) = helper_cache_get(path, mt, size) { + return Ok(hit); + } + } + read_with_qoder_helper(path) + .map(|bytes| { + if let Some((mt, size)) = stamp { + helper_cache_put(path, mt, size, Arc::new(bytes.clone())); + } + bytes + }) + .map_err(|helper_error| { + if matches!( + helper_error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) { + // The original file read was a permission failure. A missing helper + // must not turn that into NotFound, which callers interpret as a + // moved file. + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Qoder data is not directly readable and its CLI helper is unavailable: {helper_error}" + ), + ) + } else { + // Size/encoding failures, timeouts, and abnormal helper exits retain + // their distinct classification so the UI reports a read failure, + // not an auth hint. + helper_error + } + }) + } + #[cfg(not(target_os = "macos"))] + { + Err(error) + } + } + Err(error) => Err(error), + } +} + +/// UTF-8 text counterpart to [`read_bytes`]. Qoder's JSON/JSONL data is defined as UTF-8, so +/// malformed data is reported rather than silently replacing bytes and corrupting records. +pub(crate) fn read_text(path: &Path) -> io::Result { + String::from_utf8(read_bytes(path)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn mtime_ms_of(meta: &fs::Metadata) -> f64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +fn file_stamp(path: &Path) -> io::Result<(f64, u64)> { + let meta = fs::metadata(path)?; + Ok((mtime_ms_of(&meta), meta.len())) +} + +/// Bytes fetched through the macOS helper, memoized by (mtime, size) — the list/search/detail +/// paths and the 4s live-follow tick otherwise each pay a bun startup for the SAME file version. +/// Stat keeps working on content-protected files (discovery depends on it), so the stamp is the +/// same freshness signal the list-meta memo uses. +struct HelperCache { + map: HashMap>)>, + bytes: usize, +} + +fn helper_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HelperCache { map: HashMap::new(), bytes: 0 })) +} + +fn helper_cache_get(path: &Path, mt: f64, size: u64) -> Option> { + let cache = helper_cache().lock().ok()?; + let (cmt, csz, bytes) = cache.map.get(path)?; + (*cmt == mt && *csz == size).then(|| bytes.as_ref().clone()) +} + +fn helper_cache_put(path: &Path, mt: f64, size: u64, bytes: Arc>) { + if let Ok(mut cache) = helper_cache().lock() { + if cache.bytes + bytes.len() > HELPER_CACHE_BUDGET { + cache.map.clear(); + cache.bytes = 0; + } + let len = bytes.len(); + if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, size, bytes)) { + cache.bytes = cache.bytes.saturating_sub(old.len()); + } + cache.bytes += len; + } +} + +/// Minimal standard-alphabet base64 decoder for the batch helper's output (node/bun emit padded +/// base64 without line breaks; stray CR/LF are tolerated anyway). +fn b64_decode(s: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some((b - b'A') as u32), + b'a'..=b'z' => Some((b - b'a' + 26) as u32), + b'0'..=b'9' => Some((b - b'0' + 52) as u32), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut chunk = [0u32; 4]; + let mut n = 0usize; + let mut pad = 0usize; + for &b in bytes { + if b == b'\r' || b == b'\n' { + continue; + } + if b == b'=' { + pad += 1; + chunk[n] = 0; + } else { + if pad > 0 { + return None; // data after padding + } + chunk[n] = val(b)?; + } + n += 1; + if n == 4 { + let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; + out.push((v >> 16) as u8); + if pad < 2 { + out.push((v >> 8) as u8); + } + if pad < 1 { + out.push(v as u8); + } + n = 0; + if pad > 0 { + break; + } + } + } + (n == 0).then_some(out) +} + +/// Warm the helper cache for many qoder files with ONE helper invocation per data root — the +/// list, search, usage, and subagent scans otherwise pay one bun startup per file on a protected +/// macOS install (the measured stall is seconds for a first refresh). Directly-readable and +/// fresh-cached files are skipped; per-file failures fall back to the on-demand single read. +/// No-op off macOS. +pub(crate) fn prefetch(paths: &[PathBuf]) { + #[cfg(target_os = "macos")] + prefetch_macos(paths); + #[cfg(not(target_os = "macos"))] + let _ = paths; +} + +#[cfg(target_os = "macos")] +fn prefetch_macos(paths: &[PathBuf]) { + // (canonical helper target, original cache key, stamp) + let mut by_root: HashMap> = HashMap::new(); + for path in paths { + let Ok((mt, size)) = file_stamp(path) else { continue }; + if size > MAX_READ_BYTES as u64 || helper_cache_get(path, mt, size).is_some() { + continue; + } + match fs::File::open(path) { + Ok(_) => continue, // direct reads work — the ordinary path is cheap + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} + Err(_) => continue, + } + let Ok((canonical, root)) = validated_qoder_data_path(path) else { continue }; + by_root.entry(root).or_default().push((canonical, path.clone(), mt, size)); + } + for (root, files) in by_root { + let Ok(helper) = installed_qoder_helper(&root) else { continue }; + if verify_helper_trust(&helper).is_err() { + continue; + } + // Small argv chunks keep each spawn's total output within the shared byte cap and far + // below ARG_MAX; a lost chunk (timeout/oversize) degrades to per-file reads, not failure. + for chunk in files.chunks(32) { + let mut cmd = Command::new(&helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_BATCH_READ_SCRIPT); + for (canonical, _, _, _) in chunk { + cmd.arg(canonical); + } + let Ok(out) = run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_BATCH_TIMEOUT, 0) else { + continue; + }; + let by_canonical: HashMap<&Path, (&PathBuf, f64, u64)> = chunk + .iter() + .map(|(canonical, original, mt, size)| (canonical.as_path(), (original, *mt, *size))) + .collect(); + for line in out.split(|b| *b == b'\n') { + let Ok(row) = serde_json::from_slice::(line) else { continue }; + let Some(p) = row.get("p").and_then(Value::as_str) else { continue }; + let Some(&(original, mt, size)) = by_canonical.get(Path::new(p)) else { continue }; + let Some(bytes) = row.get("b64").and_then(Value::as_str).and_then(b64_decode) else { + continue; // per-file err rows fall back to the single-read path on demand + }; + helper_cache_put(original, mt, size, Arc::new(bytes)); + } + } + } +} + +/// Container-shape test for routing: a .jsonl anywhere under a `.qoder/projects/` or +/// `.qoderwork/projects/` tree (main sessions AND `/subagents/agent-*.jsonl`). +pub fn looks_qoder_path(file: &Path) -> bool { + if file.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return false; + } + let mut child: Option<&std::ffi::OsStr> = None; + for anc in file.ancestors().skip(1) { + let name = match anc.file_name() { + Some(n) => n, + None => break, + }; + if (name == ".qoder" || name == ".qoderwork") + && child.map(|c| c == "projects").unwrap_or(false) + { + return true; + } + child = Some(name); + } + false +} + +/// The session uuid (its file stem) — sidecar key and renderer id both build on it. +fn session_stem(file: &Path) -> String { + file.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("qoder:{}", session_stem(file)) +} + +fn trimmed_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn latest_inline_string(records: &[Value], record_type: &str, field: &str) -> Option { + records.iter().rev().find_map(|record| { + (record.get("type").and_then(Value::as_str) == Some(record_type)) + .then(|| trimmed_string(record.get(field))) + .flatten() + }) +} + +fn text_content(value: &Value) -> Option { + if let Some(text) = value.as_str() { + let text = text.trim(); + return (!text.is_empty()).then(|| text.to_owned()); + } + if let Some(blocks) = value.as_array() { + let parts: Vec<&str> = blocks + .iter() + .filter_map(|block| { + if let Some(text) = block.as_str() { + return Some(text); + } + let kind = block.get("type").and_then(Value::as_str).unwrap_or(""); + matches!(kind, "text" | "input_text") + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + if !parts.is_empty() { + return Some(parts.join("\n")); + } + } + value + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +fn summary_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("summary") { + return None; + } + trimmed_string(record.get("summary")) + .or_else(|| record.get("content").and_then(text_content)) + .or_else(|| { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + }) + }) +} + +fn first_user_text_from(records: &[Value]) -> Option { + records + .iter() + .find_map(|record| match record.get("type").and_then(Value::as_str) { + Some("user") + if record.get("isMeta").and_then(Value::as_bool) != Some(true) + && record.get("isCompactSummary").and_then(Value::as_bool) != Some(true) => + { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + } + Some("attachment") + if record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") => + { + trimmed_string( + record + .get("attachment") + .and_then(|attachment| attachment.get("prompt")), + ) + } + _ => None, + }) +} + +/// Qoder's inline title, in the same precedence used by its own conversation list. Repeated +/// metadata records are append-only updates, so the last non-empty value wins within each tier. +pub(crate) fn session_title_from(records: &[Value]) -> Option { + latest_inline_string(records, "custom-title", "customTitle") + .or_else(|| latest_inline_string(records, "ai-title", "aiTitle")) + .or_else(|| latest_inline_string(records, "last-prompt", "lastPrompt")) + .or_else(|| summary_from(records)) + .or_else(|| first_user_text_from(records)) +} + +/// Primary workspace from Qoder's latest inline `workspace-directories` record. +pub(crate) fn working_dir_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("workspace-directories") { + return None; + } + record + .get("directories") + .and_then(Value::as_array) + .and_then(|directories| { + directories + .iter() + .find_map(|value| trimmed_string(Some(value))) + }) + }) +} + +/// Effective model from Qoder's latest inline `runtime-config` update. +pub(crate) fn model_from(records: &[Value]) -> Option { + latest_inline_string(records, "runtime-config", "model") +} + +fn has_value(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(value) => !value.trim().is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + +fn without_redacted_thinking(record: &Value) -> Value { + let mut record = record.clone(); + if let Some(content) = record + .get_mut("message") + .and_then(|message| message.get_mut("content")) + .and_then(Value::as_array_mut) + { + content + .retain(|block| block.get("type").and_then(Value::as_str) != Some("redacted_thinking")); + } + record +} + +fn merge_assistant_wrapper(target: &mut Value, wrapper: &Value) { + let Some(source_message) = wrapper.get("message").and_then(Value::as_object) else { + return; + }; + let Some(target_message) = target.get_mut("message").and_then(Value::as_object_mut) else { + return; + }; + + if let Some(source_content) = source_message.get("content").and_then(Value::as_array) { + match target_message.get_mut("content") { + Some(Value::Array(target_content)) => { + target_content.extend(source_content.iter().cloned()) + } + Some(Value::Null) | None => { + target_message.insert("content".to_string(), Value::Array(source_content.clone())); + } + Some(_) => {} + } + } + + for field in ["model", "usage", "stop_reason"] { + if let Some(value) = source_message.get(field).filter(|value| has_value(value)) { + target_message.insert(field.to_string(), value.clone()); + } + } +} + +fn queued_command_as_user(record: &Value) -> Option { + let attachment = record.get("attachment")?; + if attachment.get("type").and_then(Value::as_str) != Some("queued_command") { + return None; + } + let prompt = attachment + .get("prompt") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let mut normalized = record.clone(); + let object = normalized.as_object_mut()?; + object.insert("type".to_string(), Value::String("user".to_string())); + object.insert( + "message".to_string(), + json!({ "role": "user", "content": prompt }), + ); + object.remove("attachment"); + Some(normalized) +} + +/// Content sniff for qoder transcripts that lost their container path (import copies, bundle +/// zips): the inline metadata / queued-command record types are qoder-only vocabulary that no +/// Claude Code or Codex transcript produces. +pub(crate) fn looks_qoder_records(records: &[Value]) -> bool { + records.iter().any(|record| match record.get("type").and_then(Value::as_str) { + Some("agent-setting") | Some("ai-title") | Some("custom-title") | Some("last-prompt") + | Some("workspace-directories") | Some("runtime-config") => true, + Some("attachment") => { + record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") + } + _ => false, + }) +} + +/// Convert Qoder's append-only wire records into the Claude-like records expected by the shared +/// history shaper. Atomic assistant wrappers with the same `message.id` collapse at their first +/// position, queued prompts become user messages, and opaque duplicate thinking blocks are +/// discarded in favor of the corresponding ordinary `thinking` block. +pub(crate) fn normalize_records(records: &[Value]) -> Vec { + let mut normalized = Vec::with_capacity(records.len()); + let mut assistant_by_message_id: HashMap = HashMap::new(); + + for record in records { + if let Some(user) = queued_command_as_user(record) { + normalized.push(user); + continue; + } + if record.get("type").and_then(Value::as_str) != Some("assistant") { + normalized.push(record.clone()); + continue; + } + + let wrapper = without_redacted_thinking(record); + let message_id = wrapper + .get("message") + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned); + if let Some(message_id) = message_id { + if let Some(index) = assistant_by_message_id.get(&message_id).copied() { + merge_assistant_wrapper(&mut normalized[index], &wrapper); + } else { + assistant_by_message_id.insert(message_id, normalized.len()); + normalized.push(wrapper); + } + } else { + normalized.push(wrapper); + } + } + + normalized +} + +/// (custom title, tags, deleted) from the shared agent sidecar (~/.ccbud/agent-meta.json). +pub fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "qoder:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("ccbud-qoder-{name}-{}", std::process::id())) + } + + #[test] + fn detects_qoder_paths() { + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-Users-a-p/1111-uuid.jsonl" + ))); + assert!(looks_qoder_path(Path::new( + "/h/.qoderwork/projects/-Users-a-p/1111-uuid.jsonl" + ))); + // subagent transcripts under the session's own dir route too (search scans them) + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/1111-uuid/subagents/agent-x.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.claude/projects/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/session-state.json" + ))); + // projects/ must be DIRECTLY under the qoder root + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/sessions/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/qoder/projects/-enc/1111-uuid.jsonl" + ))); + } + + #[test] + fn extracts_inline_title_workspace_and_runtime_metadata() { + let records = vec![ + json!({ "type": "user", "isMeta": true, "message": { "content": "hidden setup" } }), + json!({ "type": "user", "message": { "content": [{ "type": "tool_result", "content": "not a title" }] } }), + json!({ "type": "user", "message": { "content": " First real prompt " } }), + json!({ "type": "summary", "summary": " Summary fallback " }), + json!({ "type": "last-prompt", "lastPrompt": " Older prompt " }), + json!({ "type": "last-prompt", "lastPrompt": " Latest prompt " }), + json!({ "type": "ai-title", "aiTitle": " Generated title " }), + json!({ "type": "custom-title", "customTitle": " " }), + json!({ "type": "custom-title", "customTitle": " Chosen title " }), + json!({ "type": "workspace-directories", "directories": ["/old/workspace"] }), + json!({ "type": "workspace-directories", "directories": [" ", "/work/project", "/work/secondary"] }), + json!({ "type": "runtime-config", "model": "basic" }), + json!({ "type": "runtime-config", "model": " " }), + json!({ "type": "runtime-config", "model": "ultimate" }), + ]; + + assert_eq!( + session_title_from(&records).as_deref(), + Some("Chosen title") + ); + assert_eq!(working_dir_from(&records).as_deref(), Some("/work/project")); + assert_eq!(model_from(&records).as_deref(), Some("ultimate")); + + assert_eq!( + session_title_from(&records[..8]).as_deref(), + Some("Generated title") + ); + assert_eq!( + session_title_from(&records[..6]).as_deref(), + Some("Latest prompt") + ); + assert_eq!( + session_title_from(&records[..4]).as_deref(), + Some("Summary fallback") + ); + assert_eq!( + session_title_from(&records[..3]).as_deref(), + Some("First real prompt") + ); + } + + #[test] + fn normalizes_atomic_assistant_wrappers_and_queued_commands() { + let records = vec![ + json!({ "type": "runtime-config", "model": "ultimate" }), + json!({ + "type": "assistant", "uuid": "wrapper-1", "timestamp": "2026-01-01T00:00:00Z", + "message": { + "id": "message-1", "role": "assistant", "model": "draft", + "content": [ + { "type": "thinking", "thinking": "plan" }, + { "type": "redacted_thinking", "data": "opaque duplicate" } + ], + "usage": { "input_tokens": 1 }, "stop_reason": null + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-2", "timestamp": "2026-01-01T00:00:01Z", + "message": { + "id": "message-1", "role": "assistant", "model": "ultimate", + "content": [{ "type": "text", "text": "checking" }], + "usage": null, "stop_reason": "tool_use" + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-3", "timestamp": "2026-01-01T00:00:02Z", + "message": { + "id": "message-1", "role": "assistant", "model": " ", + "content": [{ "type": "tool_use", "id": "tool-1", "name": "Read", "input": { "file_path": "/work/file" } }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, "stop_reason": null + } + }), + json!({ + "type": "attachment", "uuid": "queued-1", "cwd": "/work/project", + "attachment": { "type": "queued_command", "prompt": "follow up", "commandMode": "agent" } + }), + json!({ + "type": "assistant", "uuid": "wrapper-without-id", + "message": { "role": "assistant", "content": [ + { "type": "redacted_thinking", "data": "drop me" }, + { "type": "text", "text": "kept" } + ] } + }), + ]; + + let normalized = normalize_records(&records); + assert_eq!(normalized.len(), 4); + assert_eq!(normalized[0]["type"], "runtime-config"); + + let assistant = &normalized[1]; + assert_eq!(assistant["uuid"], "wrapper-1"); + assert_eq!(assistant["timestamp"], "2026-01-01T00:00:00Z"); + assert_eq!(assistant["message"]["model"], "ultimate"); + assert_eq!( + assistant["message"]["usage"], + json!({ "input_tokens": 7, "output_tokens": 3 }) + ); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); + assert_eq!( + assistant["message"]["content"] + .as_array() + .unwrap() + .iter() + .map(|block| block["type"].as_str().unwrap()) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + + let queued = &normalized[2]; + assert_eq!(queued["type"], "user"); + assert_eq!(queued["uuid"], "queued-1"); + assert_eq!(queued["cwd"], "/work/project"); + assert_eq!( + queued["message"], + json!({ "role": "user", "content": "follow up" }) + ); + assert!(queued.get("attachment").is_none()); + + assert_eq!( + normalized[3]["message"]["content"], + json!([{ "type": "text", "text": "kept" }]) + ); + // The caller's parsed records remain untouched. + assert_eq!( + records[1]["message"]["content"].as_array().unwrap().len(), + 2 + ); + } + + #[test] + fn decodes_batch_helper_base64() { + assert_eq!(b64_decode("").unwrap(), b""); + assert_eq!(b64_decode("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(b64_decode("aGVsbG8h").unwrap(), b"hello!"); + assert_eq!(b64_decode("aA==").unwrap(), b"h"); + assert_eq!(b64_decode("5Lit5paH").unwrap(), "中文".as_bytes()); + assert!(b64_decode("not base64!").is_none()); + assert!(b64_decode("aGVsbG8").is_none()); // truncated group + } + + #[test] + fn helper_cache_serves_only_fresh_stamps() { + let path = test_dir("cache").join("t.jsonl"); + assert!(helper_cache_get(&path, 1.0, 10).is_none()); + helper_cache_put(&path, 1.0, 10, Arc::new(b"v1".to_vec())); + assert_eq!(helper_cache_get(&path, 1.0, 10).unwrap(), b"v1"); + // a changed mtime or size means a new file version — the stale entry must not serve + assert!(helper_cache_get(&path, 2.0, 10).is_none()); + assert!(helper_cache_get(&path, 1.0, 11).is_none()); + helper_cache_put(&path, 2.0, 10, Arc::new(b"v2".to_vec())); + assert_eq!(helper_cache_get(&path, 2.0, 10).unwrap(), b"v2"); + } + + #[test] + fn sniffs_qoder_records_by_inline_vocabulary() { + assert!(looks_qoder_records(&[json!({ "type": "ai-title", "aiTitle": "t" })])); + assert!(looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "p" } }), + ])); + // plain Claude / Codex shapes must not sniff as qoder + assert!(!looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" }, "cwd": "/x" }), + json!({ "type": "assistant", "message": { "role": "assistant", "content": [] } }), + json!({ "type": "attachment", "attachment": { "type": "file" } }), + json!({ "type": "session_meta", "payload": {} }), + ])); + } + + #[test] + fn ordinary_reads_do_not_require_a_qoder_path() { + let dir = test_dir("ordinary-read"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ordinary.txt"); + fs::write(&file, "local UTF-8 文本").unwrap(); + + assert_eq!(read_bytes(&file).unwrap(), "local UTF-8 文本".as_bytes()); + assert_eq!(read_text(&file).unwrap(), "local UTF-8 文本"); + + fs::write(&file, [0xff, 0xfe]).unwrap(); + assert_eq!( + read_text(&file).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn helper_target_is_limited_to_canonical_qoder_project_data() { + let dir = test_dir("path-validation"); + let _ = fs::remove_dir_all(&dir); + let projects = dir.join(".qoder").join("projects"); + let session = projects.join("-encoded-cwd").join("session-id"); + fs::create_dir_all(&session).unwrap(); + + let transcript = projects.join("-encoded-cwd").join("session-id.jsonl"); + let state = session.join("state.json"); + let metadata = session.join("agent-worker.meta.json"); + fs::write(&transcript, "{}\n").unwrap(); + fs::write(&state, "{}").unwrap(); + fs::write(&metadata, "{}").unwrap(); + + for file in [&transcript, &state, &metadata] { + let (validated, root) = validated_qoder_data_path(file).unwrap(); + assert_eq!(validated, fs::canonicalize(file).unwrap()); + assert_eq!(root, fs::canonicalize(dir.join(".qoder")).unwrap()); + } + + let arbitrary = session.join("secret.txt"); + fs::write(&arbitrary, "not helper-readable").unwrap(); + assert_eq!( + validated_qoder_data_path(&arbitrary).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + let outside = dir.join("outside.jsonl"); + fs::write(&outside, "{}\n").unwrap(); + assert_eq!( + validated_qoder_data_path(&outside).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let escaped = session.join("escaped.jsonl"); + symlink(&outside, &escaped).unwrap(); + assert_eq!( + validated_qoder_data_path(&escaped).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + } + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 01a360b..81b288f 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -427,6 +427,18 @@ pub fn ensure_antigravity_dir() -> bool { ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) } +/// Qoder writes Claude-format sessions under two known roots (~/.qoder and ~/.qoderwork) — +/// each detected root joins historyDirs once, under its own run-once flag. +pub fn ensure_qoder_dir() -> bool { + let mut changed = false; + for (flag, root) in + [("qoderDirAutoAdded", crate::qoder::default_root()), ("qoderworkDirAutoAdded", crate::qoder::work_root())] + { + changed |= ensure_history_dir(flag, crate::qoder::root_exists(&root), collapse_home(&root.to_string_lossy())); + } + changed +} + /// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG /// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, /// add it to historyDirs so its sessions count toward conversations and usage. Same run-once diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index c4c536a..87e28d4 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -494,7 +494,27 @@ fn build_data(config: &Value, active: &str) -> HashMap { let mut files = vec![]; collect_jsonl(&root.join("projects"), 0, &mut files); files.sort(); + // Qoder projects/ trees can be macOS-protected: route those files through the guarded + // reader (helper fallback + cache, warmed in one batch) so 用量 counts the same sessions + // the 对话 view can browse. Wrapper records that repeat a message id are handled by the + // usual dedup (max-total wins) and partial snapshots lack output_tokens, so nothing + // double-counts. + let qoder_files: Vec = files + .iter() + .filter(|f| crate::qoder::looks_qoder_path(f)) + .cloned() + .collect(); + crate::qoder::prefetch(&qoder_files); for file in files { + if crate::qoder::looks_qoder_path(&file) { + let Ok(bytes) = crate::qoder::read_bytes(&file) else { continue }; + for line in String::from_utf8_lossy(&bytes).lines() { + if let Some(rec) = parse_claude_line(line.trim()) { + claude_recs.push(rec); + } + } + continue; + } let Some(mut lines) = LossyLines::open(&file) else { continue }; while let Some(line) = lines.next_line() { if let Some(rec) = parse_claude_line(line.trim()) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fb944b6..4865036 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "CCBuddy", - "version": "1.3.7", + "version": "1.3.8", "identifier": "dev.ccbud.gateway", "build": { "frontendDist": "../src/renderer", diff --git a/src/main/codex.js b/src/main/codex.js index 1af79c3..a6c04a3 100644 --- a/src/main/codex.js +++ b/src/main/codex.js @@ -9,7 +9,8 @@ * * A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, * response_item, event_msg, compacted}. Conversation content lives in response_item payloads; - * event_msg mostly duplicates it (ignored) but its token_count records carry per-turn usage. + * event_msg mostly duplicates it; token_count carries usage, and user_message is a bounded title + * fallback when an image-heavy response_item is too large for the list view's head read. * Very old Codex builds wrote payload objects directly per line (no envelope) — handled by * treating such a line as its own payload. * @@ -91,6 +92,55 @@ function splitLine(rec) { return { t, p: rec, ts }; } +// The FIRST SessionMeta belongs to this physical rollout. A forked/subagent rollout may copy +// ancestor SessionMeta records after it, so later ones must never replace the canonical thread +// identity. `session_id` is shared by the whole multi-agent tree; `id` is the unique thread key. +function canonicalThreadMeta(payload) { + payload = payload && typeof payload === 'object' ? payload : {}; + const source = payload.source && typeof payload.source === 'object' ? payload.source : {}; + const threadSource = payload.thread_source && typeof payload.thread_source === 'object' ? payload.thread_source : {}; + const subagent = source.subagent || source.sub_agent || threadSource.subagent || threadSource.sub_agent || null; + let detail = null; + if (subagent && typeof subagent === 'object') { + detail = subagent.thread_spawn || subagent.review || subagent.compact || subagent.other || null; + if (!detail || typeof detail !== 'object') { + detail = Object.values(subagent).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || null; + } + } + detail = detail && typeof detail === 'object' ? detail : {}; + // `id` is mandatory in a valid Codex SessionMeta. Never promote the tree-shared session_id to + // canonical status; malformed/legacy records can still fall back to the filename for display. + const threadId = payload.id || payload.thread_id || null; + const rootSessionId = payload.session_id || threadId; + const parentThreadId = payload.parent_thread_id || detail.parent_thread_id || null; + const threadSourceKind = typeof payload.thread_source === 'string' ? payload.thread_source.toLowerCase() : ''; + return { + threadId, + rootSessionId, + parentThreadId, + forkedFromId: payload.forked_from_id || null, + isSubagent: !!subagent || threadSourceKind === 'subagent' + || !!payload.agent_path || !!payload.agent_nickname + || (!!parentThreadId && threadId !== rootSessionId), + // Current Codex writes these canonical fields at SessionMeta top level. Older multi-agent + // rollouts kept them only inside source.subagent., so retain that as a fallback. + agentPath: payload.agent_path || detail.agent_path || null, + agentNickname: payload.agent_nickname || detail.agent_nickname || null, + agentRole: payload.agent_role || payload.agent_type || detail.agent_role || detail.agent_type || null, + agentDepth: Number.isFinite(detail.depth) ? detail.depth : null, + }; +} + +function subagentTitle(n) { + if (!n || !n.isSubagent) return ''; + const pathLabel = String(n.agentPath || '').replace(/^\/?root\/?/, ''); + return [n.agentNickname, pathLabel].filter(Boolean).join(' · ') || 'Codex subagent'; +} + +function isCanonicalThreadId(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '')); +} + // Harness-injected user turns (environment/permissions/instructions wrappers) that aren't // human prose — hidden from the timeline, exactly like Claude's isMeta records. function isMetaUserText(t) { @@ -99,6 +149,28 @@ function isMetaUserText(t) { .some((p) => t.startsWith(p)); } +// Codex injects the workspace AGENTS instructions as a user-role transport message. Keep it in +// the transcript (the renderer turns it into readable Markdown), but mark it as metadata so it +// never becomes the conversation title or a user navigation point. +function isAgentsBootstrap(t) { + const source = String(t || '').trimStart(); + return /^#\s+AGENTS\.md instructions for [^\r\n]+/i.test(source) + && /]*>[\s\S]*?<\/INSTRUCTIONS>/i.test(source); +} + +// Codex records a loaded Skill as a synthetic user turn. Preserve the embedded snapshot instead +// of re-reading SKILL.md: it is the exact version that influenced this historical turn. Only an +// entire, well-formed envelope is recognized so quoted markup remains ordinary prose. +function skillLoadBlock(t) { + const source = String(t || ''); + const match = /^\s*\s*([\s\S]*?)<\/name>\s*([\s\S]*?)<\/path>([\s\S]*)<\/skill>\s*$/i.exec(source); + if (!match) return null; + const name = match[1].trim(); + const path = match[2].trim(); + if (!name || !path) return null; + return { type: 'skill_load', name, path, snapshot: match[3] }; +} + function joinedText(content, kinds) { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; @@ -108,6 +180,117 @@ function joinedText(content, kinds) { .join('\n'); } +// A Codex image prompt is serialized as three transport blocks around the actual prose: +// , input_image, +// Surface only the safe/readable name while preserving the real input_image block separately. +function imageTransportLabel(text) { + const source = String(text || '').trim(); + if (!/^]*>$/i.test(source)) return null; + const match = /\bname\s*=\s*(?:["']([^"']+)["']|(\[[^\]]+\])|([^\s>]+))/i.exec(source); + return match ? (match[1] || match[2] || match[3] || '').trim() : '[Image]'; +} + +function joinedUserText(content) { + if (!Array.isArray(content)) return typeof content === 'string' ? content : ''; + const hasImage = content.some((b) => b && b.type === 'input_image'); + return content + .filter((b) => b && (b.type === 'input_text' || b.type === 'text')) + .map((b) => { + const text = b.text || ''; + if (!hasImage) return text; + if (/^\s*<\/image>\s*$/i.test(text)) return ''; + const label = imageTransportLabel(text); + return label == null ? text : label; + }) + .filter(Boolean) + .join('\n'); +} + +function eventUserDisplayText(payload) { + const message = String((payload && payload.message) || '').trim(); + const imageCount = (Array.isArray(payload && payload.images) ? payload.images.length : 0) + + (Array.isArray(payload && payload.local_images) ? payload.local_images.length : 0); + const labels = []; + for (let i = 0; i < imageCount; i++) labels.push('[Image #' + (i + 1) + ']'); + return (labels.join(' ') + (labels.length && message ? ' ' : '') + message).trim(); +} + +function eventUserTitleFromRecord(rec) { + if (!rec || typeof rec !== 'object') return ''; + const { t, p } = splitLine(rec); + if (t !== 'event_msg' || !p || p.type !== 'user_message') return ''; + const text = eventUserDisplayText(p); + return text ? firstUserText([{ role: 'user', content: [{ type: 'text', text }] }]) : ''; +} + +function firstEventUserTitle(recs) { + for (const rec of recs || []) { + const title = eventUserTitleFromRecord(rec); + if (title) return title; + } + return ''; +} + +// List rows normally shape only the first 128 KiB. An image-first response_item can be one much +// larger JSON line, so the bounded read drops it and everything after it. If no title was found, +// scan forward without retaining oversized lines and use Codex's following user_message event. +function scanEventUserTitle(file) { + const CHUNK = 64 * 1024; + const MAX_SCAN = 64 * 1024 * 1024; + const MAX_LINE = 256 * 1024; + let fd = null; + try { + fd = fs.openSync(file, 'r'); + const buf = Buffer.allocUnsafe(CHUNK); + let scanned = 0; + let parts = []; + let lineBytes = 0; + let dropping = false; + + const append = (piece) => { + if (dropping || !piece.length) return; + if (lineBytes + piece.length > MAX_LINE) { + dropping = true; + parts = []; + lineBytes = 0; + return; + } + parts.push(Buffer.from(piece)); + lineBytes += piece.length; + }; + const finishLine = () => { + let title = ''; + if (!dropping && lineBytes) { + try { title = eventUserTitleFromRecord(JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8').trim())); } catch (_) {} + } + parts = []; + lineBytes = 0; + dropping = false; + return title; + }; + + while (scanned < MAX_SCAN) { + const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, MAX_SCAN - scanned), null); + if (!n) break; + scanned += n; + let start = 0; + for (let i = 0; i < n; i++) { + if (buf[i] !== 10) continue; + append(buf.subarray(start, i)); + const title = finishLine(); + if (title) return title; + start = i + 1; + } + append(buf.subarray(start, n)); + } + return finishLine(); + } catch (_) { + return ''; + } finally { + if (fd != null) { try { fs.closeSync(fd); } catch (_) {} } + } +} + /** argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. */ function joinArgv(cmd) { if (typeof cmd === 'string') return cmd; @@ -182,13 +365,28 @@ function imageBlock(url) { function normalize(recs) { const messages = []; const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; - let model = null, cwd = null, sessionId = null, gitBranch = null, version = null; + let model = null, cwd = null, sessionId = null, threadId = null, parentThreadId = null; + let forkedFromId = null, isSubagent = false, agentPath = null, agentNickname = null; + let agentRole = null, agentDepth = null, gitBranch = null, version = null, sawSessionMeta = false; for (const rec of recs || []) { if (!rec || typeof rec !== 'object') continue; const { t, p, ts } = splitLine(rec); const withTs = (m) => { if (ts) m.ts = ts; return m; }; if (t === 'session_meta') { + if (!sawSessionMeta) { + sawSessionMeta = true; + const identity = canonicalThreadMeta(p); + threadId = identity.threadId; + sessionId = identity.rootSessionId; + parentThreadId = identity.parentThreadId; + forkedFromId = identity.forkedFromId; + isSubagent = identity.isSubagent; + agentPath = identity.agentPath; + agentNickname = identity.agentNickname; + agentRole = identity.agentRole; + agentDepth = identity.agentDepth; + } if (!sessionId) sessionId = p.session_id || p.id || null; if (!cwd) cwd = p.cwd || null; if (!version) version = p.cli_version || null; @@ -233,7 +431,12 @@ function normalize(recs) { messages.push(withTs(m)); } } else if (p.role === 'user') { - const text = joinedText(content, ['input_text', 'text']); + const text = joinedUserText(content); + const skill = skillLoadBlock(text); + if (skill) { + messages.push(withTs({ role: 'user', _meta: true, content: [skill] })); + continue; + } if (isMetaUserText(text)) continue; const blocks = []; if (text.trim()) blocks.push({ type: 'text', text }); @@ -242,7 +445,11 @@ function normalize(recs) { if (b && b.type === 'input_image') { const img = imageBlock(b.image_url); if (img) blocks.push(img); } } } - if (blocks.length) messages.push(withTs({ role: 'user', content: blocks })); + if (blocks.length) { + const message = { role: 'user', content: blocks }; + if (isAgentsBootstrap(text)) message._meta = true; + messages.push(withTs(message)); + } } // system / developer turns: harness plumbing, not conversation } else if (it === 'reasoning') { let txt = joinedText(p.summary, ['summary_text', 'text']); @@ -291,17 +498,26 @@ function normalize(recs) { const firstTs = (messages.find((m) => m.ts) || {}).ts || null; let lastTs = null; for (let i = messages.length - 1; i >= 0; i--) if (messages[i].ts) { lastTs = messages[i].ts; break; } - return { messages, totals, model, firstTs, lastTs, cwd, sessionId, gitBranch, version }; + return { + messages, totals, model, firstTs, lastTs, cwd, sessionId, threadId, parentThreadId, + forkedFromId, isSubagent, agentPath, agentNickname, agentRole, agentDepth, gitBranch, version, + }; } -/** (cwd, sessionId) from a codex head — used by the import path to lay out the store copy. */ +/** (cwd, canonical threadId) from a Codex head — used to name an imported store copy. */ function headIds(recs) { for (const rec of recs || []) { if (!rec || typeof rec !== 'object') continue; const { t, p } = splitLine(rec); - if (t === 'session_meta') return { cwd: p.cwd || null, sessionId: p.session_id || p.id || null }; + // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the only safe + // import key; using session_id makes sibling rollouts overwrite/skip one another. + if (t === 'session_meta') return { + cwd: p.cwd || null, + sessionId: p.id || p.thread_id || null, + rootSessionId: p.session_id || p.id || null, + }; } - return { cwd: null, sessionId: null }; + return { cwd: null, sessionId: null, rootSessionId: null }; } /* ---------- sidecar customization (~/.ccbud/codex-meta.json): { "": {title?, tagList?, delete?} } ---------- */ @@ -403,15 +619,23 @@ function sessionMetaFrom(file, recs, dm, st) { // Live rollouts customize via the sidecar (never rewrite another tool's files); imported // COPIES are our own files, where the in-file __ccbud__ applies. const cc = hasImportSidecar(file) ? fileCcbud(recs) : sidecarMeta(file); - const autoTitle = firstUserText(n.messages); + let transcriptTitle = firstUserText(n.messages) || firstEventUserTitle(recs); + if (!transcriptTitle && (!st || st.size > 131072)) transcriptTitle = scanEventUserTitle(file); + const autoTitle = subagentTitle(n) || transcriptTitle; const stem = stemOf(file); + const rowScope = (dm && dm.id) || ''; return { - id: 'codex:' + stem, + id: 'codex:' + rowScope + ':' + stem, file, source: 'codex', dirId: dm ? dm.id : null, dirLabel: dm ? dm.label : null, - sessionId: n.sessionId || stem, + sessionId: n.threadId || n.sessionId || stem, + threadId: n.threadId || n.sessionId || stem, + canonicalThreadIdValid: isCanonicalThreadId(n.threadId), + rootSessionId: n.sessionId || n.threadId || stem, + parentThreadId: n.parentThreadId, + forkedFromId: n.forkedFromId, cwd: n.cwd, project: baseName(n.cwd), gitBranch: n.gitBranch, @@ -419,7 +643,11 @@ function sessionMetaFrom(file, recs, dm, st) { autoTitle, tags: cc.tags, model: n.model, - isSubagent: false, + isSubagent: n.isSubagent, + agentPath: n.agentPath, + agentNickname: n.agentNickname, + agentRole: n.agentRole, + agentDepth: n.agentDepth, imported: !!(dm && dm.imported), deleted: cc.deleted || false, lastActivity: st ? st.mtimeMs : 0, @@ -434,7 +662,7 @@ function sessionFromRecs(file, recs) { try { imported = JSON.parse(fs.readFileSync(String(file).replace(/\.jsonl$/, '.import.json'), 'utf8')); } catch (_) {} // Same sidecar-vs-in-file split as sessionMetaFrom. const cc = imported ? fileCcbud(recs) : sidecarMeta(file); - const autoTitle = firstUserText(n.messages); + const autoTitle = subagentTitle(n) || firstUserText(n.messages) || firstEventUserTitle(recs); const stem = stemOf(file); return { meta: { @@ -446,12 +674,21 @@ function sessionFromRecs(file, recs) { autoTitle, tags: cc.tags, summary: null, - sessionId: n.sessionId || stem, + sessionId: n.threadId || n.sessionId || stem, + threadId: n.threadId || n.sessionId || stem, + canonicalThreadIdValid: isCanonicalThreadId(n.threadId), + rootSessionId: n.sessionId || n.threadId || stem, + parentThreadId: n.parentThreadId, + forkedFromId: n.forkedFromId, cwd: n.cwd, project: baseName(n.cwd), gitBranch: n.gitBranch, version: n.version, - isSubagent: false, + isSubagent: n.isSubagent, + agentPath: n.agentPath, + agentNickname: n.agentNickname, + agentRole: n.agentRole, + agentDepth: n.agentDepth, deleted: cc.deleted || false, imported: !!imported, importedFrom: imported ? imported.originalPath : null, diff --git a/src/main/export-assets/runtime.js b/src/main/export-assets/runtime.js index 9f1b494..959ade9 100644 --- a/src/main/export-assets/runtime.js +++ b/src/main/export-assets/runtime.js @@ -70,6 +70,7 @@ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function md(t) { try { return marked ? marked.parse(String(t || '')) : '

' + esc(t) + '

'; } catch (e) { return '

' + esc(t) + '

'; } } function fmtTok(n) { n = n || 0; if (n < 1000) return '' + n; if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; } + function fmtCredits(n) { n = Number(n); if (!isFinite(n)) return '—'; var a = Math.abs(n), s = a >= 1000 ? (n / 1000).toFixed(a < 10000 ? 1 : 0) : a >= 100 ? n.toFixed(1) : a >= 1 ? n.toFixed(2) : n.toFixed(3); s = s.replace(/(\.\d*?[1-9])0+$|\.0+$/, '$1'); return a >= 1000 ? s + 'K' : s; } function size(s) { var b = s ? s.length : 0; if (!b) return ''; return b < 1024 ? b + ' B' : (b / 1024).toFixed(1) + ' KB'; } function trunc(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + '…' : s; } function shortPath(p) { if (!p) return ''; var a = String(p).split('/'); return a.length > 3 ? '…/' + a.slice(-2).join('/') : p; } @@ -108,6 +109,19 @@ function diff(o, n) { o = String(o || '').split('\n'); n = String(n || '').split('\n'); return '
' + o.map(function (l) { return '
- ' + esc(l) + '
'; }).join('') + n.map(function (l) { return '
+ ' + esc(l) + '
'; }).join('') + '
'; } function todos(list) { return '
' + (list || []).map(function (t) { var m = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; return '
' + m + '' + esc(t.content || t.activeForm || '') + '
'; }).join('') + '
'; } + function renderSkillLoad(b) { + var name = b.name || 'Skill'; + var path = b.path || ''; + var snapshot = typeof b.snapshot === 'string' ? b.snapshot : ''; + var detail = ''; + if (b.snapshot != null) { + detail = '
查看加载时快照' + (snapshot ? '' + esc(size(snapshot)) + '' : '') + '' + + '
来源路径
' + esc(path || '未记录') + '
' + + '
Markdown 源码
' + esc(snapshot) + '
'; + } + return '
🧩Skill 已加载' + esc(name) + '' + esc(shortPath(path)) + '
' + detail + '
'; + } + function renderTool(b) { var name = b.name || 'tool', inp = (b.input && typeof b.input === 'object') ? b.input : {}; var tm = toolMeta(name), ico = tm[0], cls = tm[1]; @@ -136,29 +150,108 @@ } function renderSubagent(sub) { var subName = (sub.type || 'agent') + (sub.skill ? ':' + sub.skill : ''); // skill-spawned agents carry the invoking skill - return '
🤖子代理 · ' + esc(subName) + '' + esc(sub.description || '') + '' + (sub.count || 0) + ' 条 · ' + fmtTok((sub.totals && sub.totals.out) || 0) + '↓
' + renderThread(sub.messages || []) + '
'; + var totals = sub.totals || {}, usage = []; + if (totals.tokenUsageAvailable !== false) usage.push(fmtTok(totals.out || 0) + '↓'); + if (totals.credits != null) usage.push(fmtCredits(totals.credits) + ' Credits'); + return '
🤖子代理 · ' + esc(subName) + '' + esc(sub.description || '') + '' + (sub.count || 0) + ' 条 · ' + esc(usage.join(' · ') || '—') + '
' + renderThread(sub.messages || []) + '
'; } - function isReminder(t) { return /<(system-reminder|command-name|local-command)/.test(t); } - function renderBlocks(content) { + function formatCodexBootstrap(text) { + var source = String(text || ''); + var agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); + if (!agents) return null; + + var env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); + var parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; + var instructions = agents[2].trim(); + if (instructions) { + var lines = instructions.split(/\r?\n/).filter(function (line) { return line.trim(); }); + parts.push(lines.length === 1 + ? '**INSTRUCTIONS:** ' + lines[0].trim() + : '**INSTRUCTIONS:**\n\n' + instructions); + } + + if (env) { + var block = env[1]; + var tag = function (name) { + var match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); + return match ? match[1].trim() : ''; + }; + var attr = function (name, attribute) { + var match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); + return match ? match[1].trim() : ''; + }; + var code = function (value) { + var tick = String.fromCharCode(96); + return value ? tick + value + tick : ''; + }; + var roots = []; + var rootRe = /]*>([\s\S]*?)<\/root>/gi; + var root; + while ((root = rootRe.exec(block)) !== null) { + if (root[1].trim()) roots.push(code(root[1].trim())); + } + var fields = [ + ['environment_context', code(tag('cwd'))], + ['shell', tag('shell')], + ['current_date', tag('current_date')], + ['timezone', tag('timezone')], + ['workspace_roots', roots.join(', ')], + ['permission_profile', attr('permission_profile', 'type')], + ['file_system', attr('file_system', 'type')], + ].filter(function (field) { return field[1]; }); + if (fields.length) { + parts.push(fields.map(function (field) { return '**' + field[0] + ':** ' + field[1]; }).join(' \n')); + } + } + + var rest = source.replace(agents[0], ''); + if (env) rest = rest.replace(env[0], ''); + rest = rest.trim(); + if (rest) parts.push(rest); + return parts.join('\n\n').trim(); + } + function stripInjected(text) { + var source = String(text || ''); + var bootstrap = formatCodexBootstrap(source); + if (bootstrap != null) source = bootstrap; + if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; + return source + .replace(/]*>[\s\S]*?<\/task-notification>/gi, function (block) { + var result = /]*>([\s\S]*?)<\/result>/i.exec(block); + return result ? '\n' + result[1].trim() + '\n' : ''; + }) + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') + .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') + .trim(); + } + function renderBlocks(content, cleanUserText) { var blocks = Array.isArray(content) ? content : (typeof content === 'string' ? [{ type: 'text', text: content }] : []); var out = ''; blocks.forEach(function (b) { if (!b) return; - if (b.type === 'text') { if (b.text && b.text.trim()) out += '
' + md(b.text) + '
'; } + if (b.type === 'text') { var text = cleanUserText ? stripInjected(b.text) : b.text; if (text && text.trim()) out += '
' + md(text) + '
'; } else if (b.type === 'thinking') { if (b.thinking && b.thinking.trim()) { var first = b.thinking.split('\n').filter(function (x) { return x.trim(); })[0] || ''; out += '
💭 思考 · ' + esc(trunc(first, 64)) + '
' + md(b.thinking) + '
'; } } + else if (b.type === 'skill_load') out += renderSkillLoad(b); else if (b.type === 'tool_use') out += renderTool(b); else if (b.type === 'image') { var s = b.source || {}; out += s.data ? '' : '
🖼 image' + (s.oversized ? ' (large, omitted)' : '') + '
'; } }); return out; } function turnMeta(m) { - var bits = []; if (m.model) bits.push(esc(m.model)); if (m.usage) bits.push(fmtTok(m.usage.in) + '↑ ' + fmtTok(m.usage.out) + '↓'); if (m.usage && m.usage.cacheRead) bits.push(fmtTok(m.usage.cacheRead) + ' cache'); + var bits = []; if (m.model) bits.push(esc(m.model)); + if (m.usage) { + var tokenTotal = (m.usage.in || 0) + (m.usage.out || 0) + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); + if (m.usage.credits == null || tokenTotal > 0) bits.push(fmtTok(m.usage.in) + '↑ ' + fmtTok(m.usage.out) + '↓'); + if (m.usage.credits != null) bits.push(fmtCredits(m.usage.credits) + ' Credits'); + } + if (m.usage && m.usage.cacheRead) bits.push(fmtTok(m.usage.cacheRead) + ' cache'); return bits.length ? '
' + bits.map(function (b) { return '' + b + ''; }).join('') + '
' : ''; } function msgVisible(m) { var bl = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); - if (m.role === 'user') { var vis = bl.filter(function (b) { return b && (b.type === 'text' || b.type === 'image'); }); if (!vis.length) return false; var txt = vis.map(function (b) { return b.text || ''; }).join(''); return !isReminder(txt); } + if (m.role === 'user') { var vis = bl.filter(function (b) { return b && (b.type === 'text' || b.type === 'image'); }); if (!vis.length) return false; var hasImage = vis.some(function (b) { return b.type === 'image'; }); var txt = vis.map(function (b) { return b.type === 'text' ? stripInjected(b.text) : ''; }).filter(Boolean).join('\n'); return hasImage || !!txt; } return bl.some(function (b) { return b && (b.type === 'text' || b.type === 'thinking' || b.type === 'tool_use' || b.type === 'image'); }); } // tags for sidebar/filtering @@ -170,8 +263,13 @@ function renderThread(msgs) { var out = ''; (msgs || []).forEach(function (m) { - if (!msgVisible(m)) return; - var body = renderBlocks(m.content); if (!body) return; + var blocks = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); + var skillBlocks = blocks.filter(function (b) { return b && b.type === 'skill_load'; }); + if (skillBlocks.length) out += '
' + skillBlocks.map(renderSkillLoad).join('') + '
'; + var regularBlocks = blocks.filter(function (b) { return !b || b.type !== 'skill_load'; }); + var regular = Object.assign({}, m, { content: regularBlocks }); + if (!msgVisible(regular)) return; + var body = renderBlocks(regularBlocks, m.role === 'user'); if (!body) return; if (m.role === 'user') out += '
👤 你
' + body + '
'; else { var tg = msgTags(m); out += '
' + esc(AST) + '
' + body + turnMeta(m) + '
'; } }); @@ -186,7 +284,8 @@ if (meta.model) p.push('' + esc(meta.model) + ''); if (meta.project) p.push(esc(meta.project)); if (meta.turns) p.push(meta.turns + ' 轮'); - if (meta.inTok != null) p.push(fmtTok(meta.inTok) + '↑ ' + fmtTok(meta.outTok) + '↓'); + if (meta.inTok != null && meta.tokenUsageAvailable !== false) p.push(fmtTok(meta.inTok) + '↑ ' + fmtTok(meta.outTok) + '↓'); + if (meta.credits != null) p.push(fmtCredits(meta.credits) + ' Credits'); if (meta.cacheTok) p.push(fmtTok(meta.cacheTok) + ' 缓存'); if (meta.subagentCount) p.push(meta.subagentCount + ' 子代理'); return p.join(' · '); diff --git a/src/main/export-assets/skin.css b/src/main/export-assets/skin.css index 2b108b1..4ef62d4 100644 --- a/src/main/export-assets/skin.css +++ b/src/main/export-assets/skin.css @@ -98,7 +98,7 @@ body { .msg-name .dot { width: 14px; height: 14px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; } .msg.assistant .msg-name .dot { background: var(--accent); color: #fff; } -.bubble { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 13px 18px; box-shadow: var(--shadow-sm); } +.bubble { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 12px; box-shadow: var(--shadow-sm); } .msg.assistant .body { color: var(--text); } /* markdown */ @@ -158,6 +158,24 @@ pre.code code, .prose pre code { color: var(--code-fg); } .tool-todo { border-left-color: #6b73c9; } .tool-mcp { border-left-color: #3fa090; } .tool-ask { border-left-color: #c9a13f; } .tool-default { border-left-color: var(--border-strong); } +/* Skill envelopes are timeline metadata, so they use a neutral tool card without a speaker. */ +.skill-load-event { scroll-margin-top: 70px; } +.tool-skill { border-left-color: #7d78b5; margin: 0; } +.skill-load-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; font-size: 12.5px; } +.skill-loaded-label { color: var(--text-dim); font-weight: 600; flex-shrink: 0; } +.skill-load-name { color: var(--text); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; flex-shrink: 0; } +.skill-snapshot { border-top: 1px solid var(--border); } +.skill-snapshot > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 7px; padding: 8px 12px; color: var(--text-dim); font-size: 11.5px; font-weight: 600; } +.skill-snapshot > summary::-webkit-details-marker { display: none; } +.skill-snapshot > summary:hover { background: var(--surface-2); color: var(--text); } +.skill-caret { color: var(--text-faint); font-size: 16px; line-height: 1; transform-origin: center; transition: transform .14s ease; } +.skill-snapshot[open] .skill-caret { transform: rotate(90deg); } +.skill-size { margin-left: auto; color: var(--text-faint); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; font-weight: 500; } +.skill-snapshot-body { border-top: 1px solid var(--border); padding: 2px 12px 12px; } +.skill-snapshot-body .lbl { font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-faint); margin: 9px 0 4px; } +.skill-source { padding: 7px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--text-dim); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.skill-snapshot-code { max-height: 620px; margin: 0; overflow: auto; white-space: pre; } + /* diff */ .diff { font-family: ui-monospace, Menlo, monospace; font-size: 12px; border-radius: 7px; overflow: hidden; border: 1px solid var(--border); } .diff .d-del { background: var(--red-soft); color: var(--red); padding: 1px 10px; white-space: pre-wrap; } diff --git a/src/main/exportHtml.js b/src/main/exportHtml.js index d53fc17..0e20570 100644 --- a/src/main/exportHtml.js +++ b/src/main/exportHtml.js @@ -16,7 +16,7 @@ const fs = require('fs'); const path = require('path'); -const CAP = { text: 24000, thinking: 16000, result: 24000, prompt: 9000, content: 14000 }; +const CAP = { text: 24000, thinking: 16000, result: 24000, prompt: 9000, content: 14000, skillSnapshot: 131072 }; function cap(s, n) { s = s == null ? '' : String(s); return s.length > n ? s.slice(0, n) + '\n…[truncated ' + (s.length - n) + ' chars]' : s; @@ -36,7 +36,11 @@ function parseJsonl(file) { function usageOf(u) { if (!u) return null; - return { in: u.input_tokens || 0, out: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheCreation: u.cache_creation_input_tokens || 0 }; + const usage = { in: u.input_tokens || 0, out: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheCreation: u.cache_creation_input_tokens || 0 }; + if (typeof u.credits === 'number' && Number.isFinite(u.credits)) usage.credits = u.credits; + if (typeof u.original_credits === 'number' && Number.isFinite(u.original_credits)) usage.originalCredits = u.original_credits; + if (typeof u.context_usage_ratio === 'number' && Number.isFinite(u.context_usage_ratio)) usage.contextUsageRatio = u.context_usage_ratio; + return usage; } // Cap the heavy fields inside a content array so the embedded JSON stays bounded. @@ -47,6 +51,14 @@ function capContent(content) { if (!b || typeof b !== 'object') return b; if (b.type === 'text') return { type: 'text', text: cap(b.text, CAP.text) }; if (b.type === 'thinking') return { type: 'thinking', thinking: cap(b.thinking, CAP.thinking) }; + if (b.type === 'skill_load') { + return { + type: 'skill_load', + name: b.name == null ? null : b.name, + path: b.path == null ? null : b.path, + snapshot: b.snapshot == null ? null : cap(b.snapshot, CAP.skillSnapshot), + }; + } if (b.type === 'tool_use') { const input = Object.assign({}, b.input || {}); if (typeof input.prompt === 'string') input.prompt = cap(input.prompt, CAP.prompt); @@ -115,16 +127,28 @@ const baseName = (p) => (p ? String(p).split('/').filter(Boolean).pop() : null); function shapeSession(recs) { const metaRec = recs.find((r) => r.cwd) || recs.find((r) => r.sessionId) || {}; const messages = []; - const totals = { in: 0, out: 0, cacheRead: 0, turns: 0 }; + const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; + let credits = 0, hasCredits = false; let model = null, firstTs = null, lastTs = null; for (const r of recs) { const lm = lineToMsg(r); if (!lm || lm.meta) continue; if (lm.ts) { if (!firstTs) firstTs = lm.ts; lastTs = lm.ts; } if (lm.model) model = lm.model; - if (lm.usage) { totals.in += lm.usage.in; totals.out += lm.usage.out; totals.cacheRead += lm.usage.cacheRead; totals.turns += 1; } + if (lm.usage) { + totals.in += lm.usage.in; totals.out += lm.usage.out; + totals.cacheRead += lm.usage.cacheRead; totals.cacheCreation += lm.usage.cacheCreation; + if (typeof lm.usage.credits === 'number' && Number.isFinite(lm.usage.credits)) { + credits += lm.usage.credits; hasCredits = true; + } + totals.turns += 1; + } messages.push(lm); } + if (hasCredits) { + totals.credits = credits; + if (totals.in === 0 && totals.out === 0 && totals.cacheRead === 0 && totals.cacheCreation === 0) totals.tokenUsageAvailable = false; + } return { messages, model, totals, firstTs, lastTs, metaRec }; } @@ -164,7 +188,7 @@ function buildCodexData(file, recs) { const sess = require('./codex').sessionFromRecs(file, recs); const m = sess.meta || {}; const messages = (sess.messages || []).map((msg) => { - const out = { role: msg.role, content: capContent(msg.content), ts: msg.ts || null, meta: false }; + const out = { role: msg.role, content: capContent(msg.content), ts: msg.ts || null, meta: !!(msg.meta || msg._meta) }; if (msg.modelActual) out.model = msg.modelActual; if (msg.usage) { out.usage = { @@ -173,6 +197,9 @@ function buildCodexData(file, recs) { cacheRead: msg.usage.cacheRead || 0, cacheCreation: msg.usage.cacheCreation || 0, }; + if (typeof msg.usage.credits === 'number') out.usage.credits = msg.usage.credits; + if (typeof msg.usage.originalCredits === 'number') out.usage.originalCredits = msg.usage.originalCredits; + if (typeof msg.usage.contextUsageRatio === 'number') out.usage.contextUsageRatio = msg.usage.contextUsageRatio; } return out; }); @@ -192,6 +219,8 @@ function buildCodexData(file, recs) { inTok: t.in || 0, outTok: t.out || 0, cacheTok: t.cacheRead || 0, + credits: t.credits == null ? null : t.credits, + tokenUsageAvailable: t.tokenUsageAvailable !== false, subagentCount: 0, firstTs: m.firstTs || null, lastTs: m.lastTs || null, @@ -223,6 +252,8 @@ function buildData(file) { inTok: s.totals.in, outTok: s.totals.out, cacheTok: s.totals.cacheRead, + credits: s.totals.credits == null ? null : s.totals.credits, + tokenUsageAvailable: s.totals.tokenUsageAvailable !== false, subagentCount: Object.keys(subagents).length, firstTs: s.firstTs, lastTs: s.lastTs, diff --git a/src/main/history.js b/src/main/history.js index 9a2dee4..e6b1900 100644 --- a/src/main/history.js +++ b/src/main/history.js @@ -40,12 +40,18 @@ function baseName(p) { function usageOf(u) { if (!u) return null; - return { + const usage = { inputTokens: u.input_tokens || 0, outputTokens: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheCreation: u.cache_creation_input_tokens || 0, }; + // Qoder records billing credits even when its transcript exposes no token counts. Keep these + // fields optional so the normalized shape for Claude Code sessions remains unchanged. + if (typeof u.credits === 'number' && Number.isFinite(u.credits)) usage.credits = u.credits; + if (typeof u.original_credits === 'number' && Number.isFinite(u.original_credits)) usage.originalCredits = u.original_credits; + if (typeof u.context_usage_ratio === 'number' && Number.isFinite(u.context_usage_ratio)) usage.contextUsageRatio = u.context_usage_ratio; + return usage; } function lineToMessage(rec) { @@ -127,14 +133,40 @@ function parseLines(buf) { } function readChunk(file, size, max) { + let fd = null; try { - const fd = fs.openSync(file, 'r'); + fd = fs.openSync(file, 'r'); const len = Math.min(size, max); - const b = Buffer.alloc(len); - fs.readSync(fd, b, 0, len, 0); - fs.closeSync(fd); - return b.toString('utf8'); + const chunks = []; + const first = Buffer.alloc(len); + const firstRead = fs.readSync(fd, first, 0, len, 0); + chunks.push(first.subarray(0, firstRead)); + let offset = firstRead; + const firstPrefix = first.subarray(0, Math.min(firstRead, 4096)).toString('utf8'); + const codexMetaMatch = /"type"\s*:\s*"session_meta"/.exec(firstPrefix); + const codexSessionMeta = !!codexMetaMatch && codexMetaMatch.index < 512; + // A Codex SessionMeta can itself exceed the ordinary list window (base instructions and + // dynamic tools live on that line). Complete that first record instead of handing the parser + // a truncated JSON object; this mirrors Codex's own line-oriented metadata reader. + // Only extend when the FIRST record itself exceeds the window. If the window already contains + // a newline, its final partial record can be ignored by parseLines; chasing that line could + // otherwise pull a multi-megabyte image/tool result into every list refresh. + while (codexSessionMeta && offset < size && first.subarray(0, firstRead).indexOf(10) < 0) { + const next = Buffer.alloc(Math.min(65536, size - offset)); + const n = fs.readSync(fd, next, 0, next.length, offset); + if (!n) break; + const part = next.subarray(0, n); + const newline = part.indexOf(10); + if (newline >= 0) { + chunks.push(part.subarray(0, newline + 1)); + break; + } + chunks.push(part); + offset += n; + } + return Buffer.concat(chunks).toString('utf8'); } catch (_) { return ''; } + finally { if (fd != null) try { fs.closeSync(fd); } catch (_) {} } } // Shape parsed records into the renderer's message model (+ rollup totals / model / span). Shared @@ -142,6 +174,7 @@ function readChunk(file, size, max) { function shapeMessages(recs) { const messages = []; const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; + let credits = 0, hasCredits = false; let model = null, firstTs = null, lastTs = null; for (const r of recs) { const lm = lineToMessage(r); @@ -158,11 +191,23 @@ function shapeMessages(recs) { if (u) { totals.in += u.inputTokens; totals.out += u.outputTokens; totals.cacheRead += u.cacheRead; totals.cacheCreation += u.cacheCreation; + if (typeof u.credits === 'number' && Number.isFinite(u.credits)) { + credits += u.credits; + hasCredits = true; + } totals.turns += 1; } } messages.push(msg); } + if (hasCredits) { + totals.credits = credits; + // Qoder currently writes real credit usage but zero for all four token counters. Expose that + // distinction explicitly so callers can render an unavailable value instead of a fake zero. + if (totals.in === 0 && totals.out === 0 && totals.cacheRead === 0 && totals.cacheCreation === 0) { + totals.tokenUsageAvailable = false; + } + } return { messages, totals, model, firstTs, lastTs }; } @@ -390,8 +435,80 @@ function createHistoryWatcher(opts) { for (const f of [...metaCache.keys()]) { if (!liveFiles.has(f)) metaCache.delete(f); } - files.sort((a, b) => b.mtime - a.mtime); - return files.slice(0, limit || 400).map((s) => sessionMeta(s.file, s.dirName, s.isSub, s.dm)).filter(Boolean); + files.sort((a, b) => b.mtime - a.mtime || String(b.file).localeCompare(String(a.file))); + // Materialize before applying the limit: true duplicate Codex rollout paths must not consume + // slots and evict distinct threads. `session_id` is intentionally NOT part of this key because + // root + subagents share it; the canonical first SessionMeta.id is exposed as threadId. + const sessions = files.map((s) => sessionMeta(s.file, s.dirName, s.isSub, s.dm)).filter(Boolean); + return limitWithCodexAncestors(dedupeCanonicalCodexSessions(sessions), limit || 400); + } + + function dedupeCanonicalCodexSessions(sessions) { + const out = []; + const positions = new Map(); + const canonicalFile = (session) => { + const threadId = String(session.threadId || ''); + const stem = path.basename(String(session.file || ''), '.jsonl'); + return !!threadId && (stem === threadId || stem.endsWith('-' + threadId)); + }; + const prefer = (candidate, current) => { + // Keep separate configured roots/import stores independent even when they contain the same + // logical transcript. Without Codex's SQLite index in this legacy backend, UpdatedAt + // (rollout mtime) is the closest official fallback; filename/size/path only break ties. + const activityDelta = (candidate.lastActivity || 0) - (current.lastActivity || 0); + if (activityDelta) return activityDelta > 0; + const createdDelta = (candidate.createdAt || 0) - (current.createdAt || 0); + if (createdDelta) return createdDelta > 0; + const canonicalDelta = Number(canonicalFile(candidate)) - Number(canonicalFile(current)); + if (canonicalDelta) return canonicalDelta > 0; + const sizeDelta = (candidate.sizeKB || 0) - (current.sizeKB || 0); + if (sizeDelta) return sizeDelta > 0; + return String(candidate.file || '') > String(current.file || ''); + }; + (sessions || []).forEach((session) => { + if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid || !session.threadId) { + out.push(session); + return; + } + const key = `${session.dirId || ''}\u0000${session.threadId}`; + const existing = positions.get(key); + if (existing == null) { + positions.set(key, out.length); + out.push(session); + } else if (prefer(session, out[existing])) { + out[existing] = session; + } + }); + return out.sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) + || String(b.threadId || b.id || '').localeCompare(String(a.threadId || a.id || ''))); + } + + // The limit is a soft cap for Codex trees: when a recent child makes the cut, include its parent + // chain (and root) even if those older rollout files rank below the cap. This prevents an orphan + // child from appearing without the conversation that spawned it. + function limitWithCodexAncestors(sessions, limit) { + if (sessions.length <= limit) return sessions; + const positions = new Map(); + sessions.forEach((session, index) => { + if (session && session.source === 'codex' && session.canonicalThreadIdValid && session.threadId) { + positions.set(`${session.dirId || ''}\u0000${session.threadId}`, index); + } + }); + const included = new Set(sessions.slice(0, limit).map((_, index) => index)); + const queue = [...included]; + for (let cursor = 0; cursor < queue.length; cursor++) { + const session = sessions[queue[cursor]]; + if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid) continue; + const parentIds = [session.parentThreadId, session.isSubagent ? session.rootSessionId : null] + .filter((id, index, ids) => id != null && ids.indexOf(id) === index); + let parent = null; + for (const parentId of parentIds) { + const found = positions.get(`${session.dirId || ''}\u0000${parentId}`); + if (found != null) { parent = found; break; } + } + if (parent != null && !included.has(parent)) { included.add(parent); queue.push(parent); } + } + return sessions.filter((_, index) => included.has(index)); } function listProjects(activeId, limit) { @@ -413,7 +530,13 @@ function createHistoryWatcher(opts) { /** Per-directory session counts (for the settings list + directory switcher). */ function dirStats() { const counts = {}; - eachSessionFile((file, dirName, isSub, dm) => { const id = dm ? dm.id : 'default'; counts[id] = (counts[id] || 0) + 1; }); + // Count the same canonical rows the sidebar can show, not raw rollout files. Distinct Codex + // subagents still count; only true same-thread physical duplicates are collapsed. + for (const session of listSessions('all', Number.MAX_SAFE_INTEGER)) { + if (!session || session.deleted) continue; + const id = session.dirId || 'default'; + counts[id] = (counts[id] || 0) + 1; + } return dirs().map((dm) => { // A dir "exists" when EITHER data tree is on disk — ~/.codex has only sessions/. let exists = false; diff --git a/src/renderer/conversations.js b/src/renderer/conversations.js index 60b6451..eac10f5 100644 --- a/src/renderer/conversations.js +++ b/src/renderer/conversations.js @@ -15,8 +15,14 @@ const localeTag = () => (window.I18n ? window.I18n.localeTag : 'en-US'); // Non-Claude session sources (meta.source): list-row chip label + assistant display name. // Claude ('disk') deliberately has no chip — it's the app's home turf. - const SOURCE_NAMES = { codex: 'Codex', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity' }; + const SOURCE_NAMES = { codex: 'Codex', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity', qoder: 'Qoder' }; const isForeignSource = (s) => !!SOURCE_NAMES[s]; + // conv.permissionDenied walks the user through macOS System Settings — that guidance only fits + // macOS (the helper-backed Qoder read path); other platforms show the generic read-failure copy. + const IS_MAC = /mac/i.test(navigator.platform || ''); + const readErrorKey = (kind) => (kind === 'permissionDenied' && IS_MAC + ? 'conv.permissionDenied' + : kind === 'notFound' ? 'conv.notFound' : 'conv.readFailed'); let projects = []; // [{ cwd, name, sessions:[...], lastActivity }] let openId = null; @@ -35,6 +41,13 @@ let collapsed = new Set(); // collapsed project cwds let lastRender = { file: null, count: -1 }; let currentDetail = null; // last-loaded session detail (for export) + let detailRequestSeq = 0; // drops a late historyGet result after another session/request took over + let detailRequest = null; // latest in-flight { seq, file }; also prevents timer requests piling up + // Failed detail reads retry via the safety-net timer: { file, attempts, nextAt }. + // permissionDenied probes steadily (granting macOS access emits no event we could watch); + // other read/IPC failures back off exponentially so a permanently broken transcript isn't + // re-read — and on macOS re-spawned through the helper — every 4 seconds forever. + let detailRetry = null; // Which session occupies the main panel: 'main' (the root thread) or a subagent key (its tool_use // id in detail.subagents). Each subagent is an independent session, so it gets the WHOLE panel — // switched via the agent list in the right nav, not nested inline. Reset to 'main' on open. @@ -96,6 +109,17 @@ if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; } + // Qoder Credits are billing units, not tokens or currency. Keep their fractional precision for + // individual turns, while abbreviating only large conversation totals. + function fmtCredits(n) { + n = Number(n); + if (!Number.isFinite(n)) return '—'; + const trim = (s) => s.replace(/(\.\d*?[1-9])0+$|\.0+$/, '$1'); + if (Math.abs(n) >= 1000) return trim((n / 1000).toFixed(Math.abs(n) < 10000 ? 1 : 0)) + 'K'; + if (Math.abs(n) >= 100) return trim(n.toFixed(1)); + if (Math.abs(n) >= 1) return trim(n.toFixed(2)); + return trim(n.toFixed(3)); + } function truncate(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + L('conv.charsMore', { n: s.length - n }) : s; } // Size shown in KB until it's large enough to read better as MB / GB. function fmtSizeKB(kb) { @@ -109,12 +133,82 @@ if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : []; return Array.isArray(c) ? c : []; } - // Strip the harness-injected blocks Claude Code appends to user turns — environment - // s, slash-command expansions (), and local-command output - // () — leaving the human prose. Returns '' when a turn was nothing but - // injected content, so a pure slash-command / lone reminder turn still renders as nothing. + // Codex records its initial AGENTS instructions and environment snapshot as two text blocks in + // one user message. Turn that XML-ish transport shape into compact Markdown for the transcript. + function formatCodexBootstrap(text) { + const source = String(text || ''); + const agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); + if (!agents) return null; + + const env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); + const parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; + const instructions = agents[2].trim(); + if (instructions) { + const lines = instructions.split(/\r?\n/).filter((line) => line.trim()); + parts.push(lines.length === 1 + ? '**INSTRUCTIONS:** ' + lines[0].trim() + : '**INSTRUCTIONS:**\n\n' + instructions); + } + + if (env) { + const block = env[1]; + const tag = (name) => { + const match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); + return match ? match[1].trim() : ''; + }; + const attr = (name, attribute) => { + const match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); + return match ? match[1].trim() : ''; + }; + const code = (value) => { + const tick = String.fromCharCode(96); + return value ? tick + value + tick : ''; + }; + const roots = []; + const rootRe = /]*>([\s\S]*?)<\/root>/gi; + let root; + while ((root = rootRe.exec(block)) !== null) { + if (root[1].trim()) roots.push(code(root[1].trim())); + } + const fields = [ + ['environment_context', code(tag('cwd'))], + ['shell', tag('shell')], + ['current_date', tag('current_date')], + ['timezone', tag('timezone')], + ['workspace_roots', roots.join(', ')], + ['permission_profile', attr('permission_profile', 'type')], + ['file_system', attr('file_system', 'type')], + ].filter((field) => field[1]); + if (fields.length) { + parts.push(fields.map((field) => '**' + field[0] + ':** ' + field[1]).join(' \n')); + } + } + + let rest = source.replace(agents[0], ''); + if (env) rest = rest.replace(env[0], ''); + rest = rest.trim(); + if (rest) parts.push(rest); + return parts.join('\n\n').trim(); + } + // Strip harness-injected blocks from user turns while keeping their human-facing content. + // A task notification is an XML envelope whose is the actual Markdown response; + // its IDs, status, summary, usage, and other transport metadata are not useful in the thread. + // Codex normalization turns a standalone envelope into a neutral `skill_load` card. + // Keep this raw-envelope suppression only as a legacy fallback, so it can never leak into a + // user bubble when older/imported data bypasses that normalizer. + // Returns '' when a turn contains injected metadata only. function stripInjected(text) { - return String(text || '') + let source = String(text || ''); + const bootstrap = formatCodexBootstrap(source); + if (bootstrap != null) source = bootstrap; + // Only suppress the standalone Codex injection. Keep ordinary prose intact when a user is + // discussing or quoting markup alongside their own text. + if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; + return source + .replace(/]*>[\s\S]*?<\/task-notification>/gi, (block) => { + const result = /]*>([\s\S]*?)<\/result>/i.exec(block); + return result ? `\n${result[1].trim()}\n` : ''; + }) .replace(/[\s\S]*?<\/system-reminder>/g, '') .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') @@ -209,6 +303,10 @@ // card — not the user turn that carries them). Keeps search matches aligned with rendered messages. function messagePlainText(m, results) { const blocks = normContent(m.content); + const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); + if (skillLoads.length) { + return skillLoads.map((b) => [b.name, b.path, b.snapshot].filter(Boolean).join('\n')).join('\n'); + } if (m.role === 'user') { const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); if (!vis.length) return ''; @@ -317,6 +415,13 @@ refreshWindowHighlights(); const host = $('convDetail'); const el = host && host.querySelector(`[data-mi="${mi}"]`); + const skillSnapshot = el && el.querySelector('.skill-snapshot'); + const skillBody = skillSnapshot && skillSnapshot.querySelector('.skill-snapshot-body'); + if (skillSnapshot && skillBody && searchQuery + && String(skillBody.textContent || '').toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase())) { + skillSnapshot.open = true; + refreshWindowHighlights(); + } if (el && hasHighlightAPI() && searchQuery) { let re; try { re = new RegExp(escapeRegExp(searchQuery), 'gi'); } catch (_) { re = null; } let curRange = null; @@ -453,7 +558,7 @@ } el.innerHTML = fbar + list.map((p) => { const isCol = collapsed.has(p.cwd || p.name) && !search; - const items = isCol ? '' : `
${p.sessions.map(sessionItem).join('')}
`; + const items = isCol ? '' : `
${orderSessionRows(p.sessions).map(sessionItem).join('')}
`; return `
${isCol ? '▸' : '▾'} @@ -464,6 +569,60 @@ }).join(''); } + // Codex assigns one session_id to the whole root/subagent tree. Keep that tree together in the + // list, but key each node by its canonical first SessionMeta.id. The first bucket encounter is + // already the newest activity in the backend order; within it, root precedes recursively nested + // children so parallel agents no longer look like duplicate top-level conversations. + function orderSessionRows(sessions) { + const buckets = new Map(); + (sessions || []).forEach((session, index) => { + const grouped = session.source === 'codex' && session.canonicalThreadIdValid && session.rootSessionId; + // Keep live/configured/imported stores independent. A copied snapshot may share both root + // and thread ids with a live rollout, but must never replace it merely because its copy mtime + // is newer. + const key = grouped + ? `codex:${session.dirId || ''}:${session.rootSessionId}` + : `row:${session.id || ''}:${session.file || index}`; + if (!buckets.has(key)) buckets.set(key, { index, activity: 0, sessions: [] }); + const bucket = buckets.get(key); + bucket.activity = Math.max(bucket.activity, session.lastActivity || 0); + bucket.sessions.push(session); + }); + const ordered = []; + [...buckets.values()].sort((a, b) => b.activity - a.activity || a.index - b.index).forEach((bucket) => { + if (bucket.sessions.length === 1 || bucket.sessions[0].source !== 'codex') { + ordered.push(...bucket.sessions); + return; + } + const byParent = new Map(); + bucket.sessions.forEach((session) => { + const parent = session.parentThreadId || ''; + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent).push(session); + }); + const newest = (a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) + || (b.createdAt || 0) - (a.createdAt || 0) + || String(a.threadId || a.id || '').localeCompare(String(b.threadId || b.id || '')) + || String(a.file || '').localeCompare(String(b.file || '')); + byParent.forEach((children) => children.sort(newest)); + const seen = new Set(); + const append = (session) => { + const id = session.canonicalThreadIdValid + ? (session.threadId || session.sessionId || session.id) + : `${session.id || ''}:${session.file || ''}`; + if (seen.has(id)) return; + seen.add(id); ordered.push(session); + (byParent.get(id) || []).forEach(append); + }; + bucket.sessions + .filter((session) => !session.isSubagent || session.threadId === session.rootSessionId) + .sort(newest) + .forEach(append); + bucket.sessions.sort((a, b) => (a.agentDepth || 0) - (b.agentDepth || 0) || newest(a, b)).forEach(append); + }); + return ordered; + } + // Two timestamps: the session's start (createdAt, the sort key) and — only when it meaningfully // differs — the last-updated time, so an edited/active session shows both without redundancy. function metaTimes(c) { @@ -476,17 +635,22 @@ } function sessionItem(c) { const live = isLive(c.lastActivity) ? '' : ''; - const sub = c.isSubagent ? `${esc(L('conv.subagent'))}` : ''; + const subLabel = [L('conv.subagent'), c.agentNickname].filter(Boolean).join(' · '); + const sub = c.isSubagent ? `${esc(subLabel)}` : ''; const imp = c.imported ? `${ICN.download || ''}${esc(L('conv.imported'))}` : ''; // Non-Claude sources carry a small origin chip so a mixed project group stays readable. const srcName = SOURCE_NAMES[c.source]; const srcBadge = srcName ? `${esc(srcName)}` : ''; + // A row whose transcript couldn't be read explains itself on hover instead of sitting as a + // silent untitled entry (the reason only became visible after clicking before). + const rerr = c.readError; + const errBadge = rerr ? `` : ''; // Recycle bin rows swap the import-remove affordance for restore + delete-forever; everywhere else // imported copies (which live only in the app store) keep their remove affordance. const inTrash = activeDir === '__trash__'; - // A LIVE session of another CLI (codex/grok/copilot/antigravity, not an imported copy) is - // that tool's file — it can be restored but NEVER permanently deleted, since the app must - // not rm another tool's data. + // A LIVE session of another CLI (codex/grok/copilot/antigravity/qoder, not an imported + // copy) is that tool's file — it can be restored but NEVER permanently deleted, since the + // app must not rm another tool's data. const foreign = isForeignSource(c.source) && !c.imported; const restoreBtn = ``; const deleteForeverBtn = ``; @@ -508,9 +672,12 @@ // Full title on hover; when a custom title overrides the auto one, also surface the original first line. const fullTitle = c.title || L('conv.untitled'); const tip = (c.autoTitle && c.title && c.autoTitle !== c.title) ? (fullTitle + ' · ' + c.autoTitle) : fullTitle; - return `
-
${live}${esc(fullTitle)}${rm}
-
${model}${srcBadge}${sub}${imp}
+ const treeDepth = c.source === 'codex' && c.isSubagent ? Math.max(1, Math.min(Number(c.agentDepth) || 1, 5)) : 0; + const treeIndent = 22 + treeDepth * 13; + const treeMark = treeDepth ? '' : ''; + return `
+
${treeMark}${live}${esc(fullTitle)}${rm}
+
${model}${srcBadge}${errBadge}${sub}${imp}
${snipRow} ${tagsRow}
${metaTimes(c)}${c.sizeKB ? '' + fmtSizeKB(c.sizeKB) + '' : ''}
@@ -525,6 +692,19 @@ const rs = document.querySelector('.conv-resizer-right'); if (nav) nav.classList.toggle('hidden', !openFile); if (rs) rs.classList.toggle('hidden', !openFile); + if (!openFile) detailRetry = null; + } + + // Drop all transcript-derived UI while a different session loads or the current read fails. + // openFile/openId deliberately stay untouched, so the selected row and navigation rail remain + // present and a later retry can recover in place without showing data from the previous session. + function clearLoadedDetail() { + currentDetail = null; + searchDocs = null; + subIndex = null; + renderAgentTabs(null); + const stats = $('convStats'); if (stats) stats.innerHTML = ''; + const toc = $('convToc'); if (toc) toc.innerHTML = ''; } async function openConversation(id, file) { @@ -534,7 +714,9 @@ openId = id; openFile = file || null; syncConvNav(); activeAgent = 'main'; // new conversation always opens on its main thread - searchDocs = null; vStart = 0; vEnd = 0; // reset the render window for the new conversation + detailRetry = null; + clearLoadedDetail(); + vStart = 0; vEnd = 0; // reset the render window for the new conversation lastRender = { file: null, count: -1 }; const eb = $('convExportBtn'); if (eb) eb.disabled = !openFile; const cp = $('convCopyPathBtn'); if (cp) cp.disabled = !openFile; @@ -559,11 +741,48 @@ async function rerenderDetail(force) { if (!openFile) return; + const requestedFile = openFile; + // The live/error retry timer can fire while a slower helper-backed Qoder read is still running. + // One request for the currently-selected file is enough; a genuinely different selection may + // start immediately and its newer sequence invalidates this result. + if (detailRequest && detailRequest.file === requestedFile) { + if (force) detailRequest.force = true; // preserve a language-change/re-open forced paint + return; + } + const request = { seq: ++detailRequestSeq, file: requestedFile, force: !!force }; + detailRequest = request; let detail = null; - try { detail = await api.historyGet(openFile); } catch (_) {} + let ipcReadFailed = false; + try { detail = await api.historyGet(requestedFile); } catch (_) { ipcReadFailed = true; } + if (detailRequest === request) detailRequest = null; + // A→B (or A→B→A) can leave older IPC calls in flight. Never let their success/error overwrite + // the latest selection, even when the path happens to match again after an intervening click. + if (openFile !== requestedFile || request.seq !== detailRequestSeq) return; + force = request.force; const host = $('convDetail'); if (!host) return; - if (!detail) { host.innerHTML = `
${esc(L('conv.notFound'))}
`; lastRender = { file: null, count: -1 }; return; } + // A failed read is distinct from a missing/moved transcript. Keep openFile intact so the + // selected row + navigation rail remain open and a later retry (for example after granting + // macOS access to Qoder's data) can recover in place. + const loadError = ipcReadFailed ? { kind: 'readFailed' } : (detail && detail.error); + if (loadError || !detail) { + const kind = loadError && loadError.kind; + const key = !loadError ? 'conv.notFound' : readErrorKey(kind); + host.innerHTML = `
${esc(L(key))}
`; + clearLoadedDetail(); + // A missing/moved path is not expected to recover in place. Permission failures re-probe at + // the timer's steady 4s; other read/IPC failures back off (4s → 60s cap) per attempt. + if (key === 'conv.notFound') { + detailRetry = null; + } else { + const attempts = (detailRetry && detailRetry.file === requestedFile ? detailRetry.attempts : 0) + 1; + const delay = kind === 'permissionDenied' ? 0 : Math.min(4000 * 2 ** (attempts - 1), 60000); + detailRetry = { file: requestedFile, attempts, nextAt: Date.now() + delay }; + } + lastRender = { file: null, count: -1 }; + return; + } + detailRetry = null; currentDetail = detail; subIndex = null; // call-site map is rebuilt lazily against the freshly-loaded subagents @@ -666,12 +885,18 @@ messages.forEach((m) => normContent(m.content).forEach((b) => { if (b.type === 'tool_result') results[b.tool_use_id] = b; })); return results; } - // Returns the HTML for one message, or '' for a pure tool_result / meta user turn. + // Returns the HTML for one message, or '' for a pure tool_result / hidden meta user turn. + // Structured metadata such as a loaded Skill is rendered before the role branches so it reads + // as an event in the timeline, rather than being mislabeled as either the user or the assistant. // inSub: rendered inside a nested subagent block — suppress the per-turn "subagent" badge // (the surrounding block already labels it) so the nested thread stays clean. function renderMessage(m, results, idx, inSub) { const mid = idx == null ? '' : ` id="m${idx}" data-mi="${idx}"`; const blocks = normContent(m.content); + const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); + if (skillLoads.length) { + return `
${skillLoads.map(renderSkillLoad).join('')}
`; + } if (m.role === 'user') { const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); if (!vis.length) return ''; @@ -682,7 +907,7 @@ .map((b) => (b.type === 'text' ? { type: 'text', text: stripInjected(b.text) } : b)) .filter((b) => b.type === 'image' || b.text); if (!clean.length) return ''; - return `
👤 ${esc(L('conv.you'))}
${clean.map(renderUserBlock).join('')}
`; + return `
👤 ${esc(L('conv.you'))}
${clean.map(renderUserBlock).join('')}
`; } let body = ''; blocks.forEach((b) => { @@ -786,10 +1011,36 @@ const first = t.split('\n').find((x) => x.trim()) || L('conv.thinking'); return `
💭 ${esc(L('conv.thinking'))} · ${esc(first.slice(0, 60))}
${md(t)}
`; } + // A Skill envelope is an automatic Codex context-load event. Its recorded body is the exact + // snapshot used for that turn, so keep it collapsed by default but make the full source available + // for later workflow/debug reviews. It deliberately carries no user/assistant role label. + function renderSkillLoad(b) { + const name = String(b.name || '').trim() || 'Skill'; + const path = String(b.path || '').trim(); + const snapshot = String(b.snapshot || ''); + const target = path ? shortPath(path) : ''; + const size = resultSummary(snapshot); + const source = path + ? `
${esc(L('conv.skillSource'))}${esc(path)}
` + : ''; + const disclosure = snapshot + ? `
${esc(L('conv.skillSnapshot'))}${size ? `${esc(size)}` : ''}
${source}${codeBlock(snapshot, 'markdown')}
` + : `
${esc(L('conv.skillNoSnapshot'))}
`; + return `
🧩${esc(L('conv.skillLoaded'))}${esc(name)}${target ? `${esc(target)}` : ''}
${disclosure}
`; + } function turnMeta(m) { const bits = []; if (m.modelActual) bits.push(esc(m.modelActual)); - if (m.usage) bits.push(`${fmtTok(m.usage.inputTokens)}↑ ${fmtTok(m.usage.outputTokens)}↓`); + if (m.usage) { + const tokenTotal = (m.usage.inputTokens || 0) + (m.usage.outputTokens || 0) + + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); + // A credit-bearing, all-zero Qoder usage object means token accounting was not recorded. + // Do not turn that absence into a misleading "0↑ 0↓" badge. + if (m.usage.credits == null || tokenTotal > 0) { + bits.push(`${fmtTok(m.usage.inputTokens)}↑ ${fmtTok(m.usage.outputTokens)}↓`); + } + if (m.usage.credits != null) bits.push(`${fmtCredits(m.usage.credits)} ${esc(L('conv.credits'))}`); + } if (m.usage && m.usage.cacheRead) bits.push(`${fmtTok(m.usage.cacheRead)} ${esc(L('conv.cache'))}`); if (m.stopReason && m.stopReason !== 'end_turn' && m.stopReason !== 'tool_use') bits.push(esc(m.stopReason)); return bits.length ? `
${bits.map((b) => `${b}`).join('')}
` : ''; @@ -928,6 +1179,13 @@ // Display name of a subagent: its agent type, suffixed with the skill that invoked it // (`type:skill`) when the backend attributed one (Skill tool_use / transcript sentinel). function subName(s) { return (s.type || 'agent') + (s.skill ? ':' + s.skill : ''); } + function subUsageSummary(s) { + const totals = (s && s.totals) || {}; + const bits = []; + if (totals.tokenUsageAvailable !== false) bits.push(`${fmtTok(totals.out || 0)}↓`); + if (totals.credits != null) bits.push(`${fmtCredits(totals.credits)} ${L('conv.credits')}`); + return bits.join(' · ') || '—'; + } // A subagent dialogue is keyed by the tool_use id that spawned it (history.readSubagents). We render it // as a lazily-filled disclosure directly under that call — at any nesting depth, since a subagent's own // tool cards run through this same path. Body stays empty until opened (see fillSubBody) to bound the DOM. @@ -936,10 +1194,9 @@ const s = id && subs[id]; if (!s) return ''; const cnt = s.count != null ? s.count : ((s.messages || []).length); - const out = (s.totals && s.totals.out) || 0; - const meta = `${esc(L('conv.subagentMsgs', { n: cnt }))} · ${fmtTok(out)}↓`; + const meta = `${esc(L('conv.subagentMsgs', { n: cnt }))} · ${esc(subUsageSummary(s))}`; const desc = s.description ? ` · ${esc(s.description)}` : ''; - return `
🤖 ${esc(L('conv.subagent'))} · ${esc(subName(s))}${desc}${meta}
`; + return `
🤖 ${esc(L('conv.subagent'))} · ${esc(subName(s))}${desc}${meta}
`; } // Render one subagent's whole thread (recursively wiring its own inline subagents via renderMessage → // renderToolCard). idx=null so nested turns carry no data-mi (they're outside main-window navigation). @@ -977,14 +1234,13 @@ const ddLabel = activeSub ? `🤖 ${esc(subName(activeSub))}` : `🤖 ${esc(L('conv.stat.subagents'))} (${keys.length})`; const items = keys.map((k) => { const s = subs[k] || {}; - const out = (s.totals && s.totals.out) || 0; const cnt = s.count != null ? s.count : ((s.messages || []).length); const active = activeAgent === k; const desc = s.description ? `
${esc(s.description)}
` : ''; return ``; }).join(''); const menu = `
${items}
`; @@ -1080,10 +1336,13 @@ function renderSidePanels(detail) { const m = detail.meta || {}; - const t = m.totals || {}; // Invoking skill of the session in the panel: the active subagent's when one is selected, // else the session's own (a standalone subagent transcript). Absent → row filtered out. const panelSub = activeAgent !== 'main' && detail.subagents ? detail.subagents[activeAgent] : null; + const t = (panelSub && panelSub.totals) || m.totals || {}; + const messageCount = panelSub + ? (panelSub.count != null ? panelSub.count : (panelSub.messages || []).length) + : m.messages; const skill = panelSub ? panelSub.skill : m.skill; const rows = [ [L('conv.stat.title'), m.title], @@ -1094,10 +1353,15 @@ [L('conv.stat.project'), m.cwd ? projName(m.cwd) : m.project], [L('conv.stat.branch'), m.gitBranch], [L('conv.stat.session'), m.sessionId ? String(m.sessionId).slice(0, 8) : null], - [L('conv.stat.messages'), m.messages], + [L('conv.stat.rootSession'), m.rootSessionId && m.rootSessionId !== m.sessionId ? String(m.rootSessionId).slice(0, 8) : null], + [L('conv.stat.parentThread'), m.parentThreadId ? String(m.parentThreadId).slice(0, 8) : null], + [L('conv.stat.agent'), m.agentNickname], + [L('conv.stat.agentPath'), m.agentPath], + [L('conv.stat.messages'), messageCount], [L('conv.stat.turns'), t.turns], - [L('conv.stat.input'), t.in != null ? fmtTok(t.in) : null], - [L('conv.stat.output'), t.out != null ? fmtTok(t.out) : null], + [L('conv.stat.input'), t.tokenUsageAvailable === false ? '—' : (t.in != null ? fmtTok(t.in) : null)], + [L('conv.stat.output'), t.tokenUsageAvailable === false ? '—' : (t.out != null ? fmtTok(t.out) : null)], + [L('conv.stat.credits'), t.credits != null ? fmtCredits(t.credits) : null], [L('conv.stat.cacheRead'), t.cacheRead ? fmtTok(t.cacheRead) : null], [L('conv.stat.tool'), m.assistant || 'Claude Code'], [L('conv.stat.version'), m.version], @@ -1110,10 +1374,10 @@ const messages = activeMessages(); // TOC follows the session shown in the main panel const toc = []; messages.forEach((m, i) => { - if (m.role !== 'user') return; + if (m.role !== 'user' || m._meta || m.meta) return; const vis = normContent(m.content).filter((b) => b.type === 'text'); - const tv = vis.map((b) => b.text || '').join(' ').trim(); - if (!tv || tv.includes('') || tv.includes('') || tv.includes(' stripInjected(b.text)).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (!tv) return; toc.push(`
👤 ${esc(tv.slice(0, 32) || '…')}
`); }); $('convToc').innerHTML = toc.join(''); @@ -1710,10 +1974,14 @@ await applyImportResult(r); }); - // Safety-net auto-refresh: while an in-progress (live) session is open, re-read it on a timer in - // case a file-watch event is missed. rerenderDetail's skip-guard makes this a no-op when nothing - // changed and only repaints when the view is pinned to the bottom, so it never disrupts reading. - setInterval(() => { if (openFile && openSessionLive()) rerenderDetail(false); }, 4000); + // Unified safety-net: live sessions still refresh when a file-watch event is missed, while a + // failed read retries on its own schedule (steady probe for permission errors, backoff for + // the rest). rerenderDetail coalesces ticks while a helper-backed read is already in flight. + setInterval(() => { + if (!openFile) return; + const retryDue = detailRetry && detailRetry.file === openFile && Date.now() >= detailRetry.nextAt; + if (retryDue || openSessionLive()) rerenderDetail(false); + }, 4000); } window.ccbudConversations = { diff --git a/src/renderer/input.css b/src/renderer/input.css index f55264f..8c94e15 100644 --- a/src/renderer/input.css +++ b/src/renderer/input.css @@ -1371,6 +1371,29 @@ body { /* result-size chip pushed to the right edge of the result summary */ .tool-res-size { margin-left: auto; font-family: ui-monospace, monospace; font-size: 9.5px; font-weight: 600; color: var(--caption); background: var(--chip-bg); padding: 1px 6px; border-radius: 5px; } +/* Codex Skill context-load event: tool-card visual language, neutral timeline semantics. */ +.skill-name { flex-shrink: 0; font-family: var(--mono); font-size: calc(10.5px * var(--conv-fs, 1)); color: var(--fg); } +.skill-snapshot > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + list-style: none; + color: var(--muted); + font-size: calc(10.5px * var(--conv-fs, 1)); + font-weight: 600; +} +.skill-snapshot > summary::-webkit-details-marker { display: none; } +.skill-snapshot[open] > summary { border-bottom: 1px solid var(--border); } +.skill-caret { display: inline-block; transition: transform 0.14s var(--ease); } +.skill-snapshot[open] > summary .skill-caret { transform: rotate(90deg); } +.skill-snapshot-body { padding: 8px 10px 10px; } +.skill-snapshot-body pre { margin: 0; } +.skill-source { display: flex; align-items: baseline; gap: 7px; min-width: 0; margin-bottom: 7px; color: var(--caption); font-size: calc(10px * var(--conv-fs, 1)); } +.skill-source code { overflow: hidden; color: var(--muted); font-family: var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.skill-no-snapshot { padding: 6px 10px; color: var(--caption); font-size: calc(10.5px * var(--conv-fs, 1)); } + /* Collapsed session sidebar: only the expand button may occupy the 34px strip. Without this the import (+) button stays in the flex row, overflows the narrow strip, and justify-center shoves the expand button left under the primary nav — leaving an empty, unclickable strip. !important beats `flex`. */ @@ -1939,9 +1962,10 @@ body { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 11px; - padding: 12px 16px; + padding: 12px; box-shadow: var(--card-shadow); } +.msg.user .msg-body > .blk-text:first-child > :first-child { margin-top: 0; } .msg.assistant .msg-body { padding: 2px 0 2px 12px; @@ -2129,6 +2153,12 @@ pre.cb-has-gutter > code { flex: 1 1 auto; min-width: 0; overflow-x: auto; paddi border-left: 2.5px solid var(--brand); scroll-margin-top: 12px; } +.subagent-inline-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 16px; +} .subagent-inline[open] > summary .sub-caret { transform: rotate(90deg); } /* Landing flash when jumped to from the subagent picker — a bright ring that fades, hard to miss. */ .subagent-inline.sub-flash { border-radius: 4px; animation: subFlash 2.2s var(--ease); } @@ -2791,4 +2821,3 @@ pre.cb-has-gutter > code { flex: 1 1 auto; min-width: 0; overflow-x: auto; paddi (transform), and the markup also carries scale utilities (the `scale` property) → they double. Kill the utility scale only (translate is left alone, so card hover-lifts still work). */ .nav-item, .btn, .hero-action, .fab { scale: none !important; } - diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 930c3af..30c3bf4 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -4241,6 +4241,63 @@ body { padding: 1px 6px; border-radius: 5px; } +.skill-name { + flex-shrink: 0; + font-family: var(--mono); + font-size: calc(10.5px * var(--conv-fs, 1)); + color: var(--fg); +} +.skill-snapshot > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + list-style: none; + color: var(--muted); + font-size: calc(10.5px * var(--conv-fs, 1)); + font-weight: 600; +} +.skill-snapshot > summary::-webkit-details-marker { + display: none; +} +.skill-snapshot[open] > summary { + border-bottom: 1px solid var(--border); +} +.skill-caret { + display: inline-block; + transition: transform 0.14s var(--ease); +} +.skill-snapshot[open] > summary .skill-caret { + transform: rotate(90deg); +} +.skill-snapshot-body { + padding: 8px 10px 10px; +} +.skill-snapshot-body pre { + margin: 0; +} +.skill-source { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; + margin-bottom: 7px; + color: var(--caption); + font-size: calc(10px * var(--conv-fs, 1)); +} +.skill-source code { + overflow: hidden; + color: var(--muted); + font-family: var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.skill-no-snapshot { + padding: 6px 10px; + color: var(--caption); + font-size: calc(10.5px * var(--conv-fs, 1)); +} .conv-sidebar.collapsed .conv-search > :not(#btnCollapseConvList) { display: none !important; } @@ -4909,9 +4966,12 @@ body { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 11px; - padding: 12px 16px; + padding: 12px; box-shadow: var(--card-shadow); } +.msg.user .msg-body > .blk-text:first-child > :first-child { + margin-top: 0; +} .msg.assistant .msg-body { padding: 2px 0 2px 12px; border-left: 2px solid var(--border-strong); @@ -5164,6 +5224,12 @@ pre.cb-has-gutter > code { border-left: 2.5px solid var(--brand); scroll-margin-top: 12px; } +.subagent-inline-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 16px; +} .subagent-inline[open] > summary .sub-caret { transform: rotate(90deg); } diff --git a/src/shared/i18n-dict.js b/src/shared/i18n-dict.js index dcf1b20..b621e2d 100644 --- a/src/shared/i18n-dict.js +++ b/src/shared/i18n-dict.js @@ -285,6 +285,8 @@ 'conv.searching': 'Searching message content…', 'conv.noLocal': 'No local session history', 'conv.notFound': 'Session not found or moved.', + 'conv.permissionDenied': 'CC Buddy can’t read this Qoder or other app’s session data. Allow access in macOS System Settings → Privacy & Security, then try again.', + 'conv.readFailed': 'Failed to read this session. Please try again.', 'conv.emptyConv': '(empty conversation)', 'conv.loadEarlier': '↑ Load earlier messages ({n} more above)', 'conv.loading': 'Loading…', @@ -300,6 +302,11 @@ 'conv.noResult': 'no result recorded', 'conv.noEdits': '(no edits)', 'conv.cache': 'cache', + 'conv.credits': 'Credits', + 'conv.skillLoaded': 'Skill loaded', + 'conv.skillSnapshot': 'View the loaded snapshot', + 'conv.skillSource': 'Source', + 'conv.skillNoSnapshot': 'No Skill snapshot was recorded', 'conv.charsMore': '\n… (+{n} chars)', 'conv.subagentSession': 'Subagent session', 'conv.import': 'Import a .jsonl / .zip transcript', @@ -343,10 +350,15 @@ 'conv.stat.project': 'Project', 'conv.stat.branch': 'Branch', 'conv.stat.session': 'Session', + 'conv.stat.rootSession': 'Root session', + 'conv.stat.parentThread': 'Parent thread', + 'conv.stat.agent': 'Agent', + 'conv.stat.agentPath': 'Agent path', 'conv.stat.messages': 'Messages', 'conv.stat.turns': 'Turns', 'conv.stat.input': 'Input', 'conv.stat.output': 'Output', + 'conv.stat.credits': 'Credits', 'conv.stat.cacheRead': 'Cache read', 'conv.stat.version': 'Version', 'conv.stat.tool': 'Tool', @@ -723,6 +735,8 @@ 'conv.searching': '正在搜索消息内容…', 'conv.noLocal': '未找到本地会话历史', 'conv.notFound': '会话不存在或已被移动。', + 'conv.permissionDenied': '无法读取 Qoder 或其他应用的会话数据。请在 macOS「系统设置」→「隐私与安全性」中授予 CC Buddy 访问权限,然后重试。', + 'conv.readFailed': '读取会话失败,请重试。', 'conv.emptyConv': '(空对话)', 'conv.loadEarlier': '↑ 加载更早的消息(上面还有 {n} 条)', 'conv.loading': '加载中…', @@ -738,6 +752,11 @@ 'conv.noResult': '无结果记录', 'conv.noEdits': '(无编辑内容)', 'conv.cache': '缓存', + 'conv.credits': '积分', + 'conv.skillLoaded': 'Skill 已加载', + 'conv.skillSnapshot': '查看加载时快照', + 'conv.skillSource': '来源', + 'conv.skillNoSnapshot': '未记录 Skill 快照', 'conv.charsMore': '\n… (+{n} 字符)', 'conv.subagentSession': '子代理会话', 'conv.import': '导入 .jsonl / .zip 记录', @@ -781,10 +800,15 @@ 'conv.stat.project': '项目', 'conv.stat.branch': '分支', 'conv.stat.session': '会话', + 'conv.stat.rootSession': '根会话', + 'conv.stat.parentThread': '父线程', + 'conv.stat.agent': 'Agent', + 'conv.stat.agentPath': 'Agent 路径', 'conv.stat.messages': '消息', 'conv.stat.turns': '轮次', 'conv.stat.input': '输入', 'conv.stat.output': '输出', + 'conv.stat.credits': '积分', 'conv.stat.cacheRead': '缓存读', 'conv.stat.version': '版本', 'conv.stat.tool': '工具', @@ -1152,6 +1176,8 @@ 'conv.searching': '正在搜尋訊息內容…', 'conv.noLocal': '找不到本機工作階段歷史', 'conv.notFound': '工作階段不存在或已被移動。', + 'conv.permissionDenied': '無法讀取 Qoder 或其他應用程式的工作階段資料。請在 macOS「系統設定」→「隱私權與安全性」中授予 CC Buddy 存取權限,然後再試一次。', + 'conv.readFailed': '讀取工作階段失敗,請再試一次。', 'conv.emptyConv': '(空對話)', 'conv.loadEarlier': '↑ 載入更早的訊息(上面還有 {n} 則)', 'conv.loading': '載入中…', @@ -1167,6 +1193,11 @@ 'conv.noResult': '無結果記錄', 'conv.noEdits': '(無編輯內容)', 'conv.cache': '快取', + 'conv.credits': '點數', + 'conv.skillLoaded': 'Skill 已載入', + 'conv.skillSnapshot': '檢視載入時快照', + 'conv.skillSource': '來源', + 'conv.skillNoSnapshot': '未記錄 Skill 快照', 'conv.charsMore': '\n… (+{n} 字元)', 'conv.subagentSession': '子代理工作階段', 'conv.import': '匯入 .jsonl / .zip 記錄', @@ -1210,10 +1241,15 @@ 'conv.stat.project': '專案', 'conv.stat.branch': '分支', 'conv.stat.session': '工作階段', + 'conv.stat.rootSession': '根工作階段', + 'conv.stat.parentThread': '父執行緒', + 'conv.stat.agent': 'Agent', + 'conv.stat.agentPath': 'Agent 路徑', 'conv.stat.messages': '訊息', 'conv.stat.turns': '輪次', 'conv.stat.input': '輸入', 'conv.stat.output': '輸出', + 'conv.stat.credits': '點數', 'conv.stat.cacheRead': '快取讀取', 'conv.stat.version': '版本', 'conv.stat.tool': '工具', @@ -1590,6 +1626,8 @@ 'conv.searching': 'メッセージ内容を検索中…', 'conv.noLocal': 'ローカルのセッション履歴が見つかりません', 'conv.notFound': 'セッションが存在しないか移動されました。', + 'conv.permissionDenied': 'Qoder またはほかのアプリのセッションデータを読み取れません。macOSの「システム設定」→「プライバシーとセキュリティ」で CC Buddy にアクセスを許可してから、もう一度お試しください。', + 'conv.readFailed': 'セッションの読み込みに失敗しました。もう一度お試しください。', 'conv.emptyConv': '(空の会話)', 'conv.loadEarlier': '↑ 以前のメッセージを読み込む(上にあと {n} 件)', 'conv.loading': '読み込み中…', @@ -1605,6 +1643,11 @@ 'conv.noResult': '結果の記録なし', 'conv.noEdits': '(編集内容なし)', 'conv.cache': 'キャッシュ', + 'conv.credits': 'クレジット', + 'conv.skillLoaded': 'Skill を読み込み済み', + 'conv.skillSnapshot': '読み込み時のスナップショットを表示', + 'conv.skillSource': '読み込み元', + 'conv.skillNoSnapshot': 'Skill スナップショットの記録なし', 'conv.charsMore': '\n… (+{n} 文字)', 'conv.subagentSession': 'サブエージェントセッション', 'conv.import': '.jsonl / .zip をインポート', @@ -1648,10 +1691,15 @@ 'conv.stat.project': 'プロジェクト', 'conv.stat.branch': 'ブランチ', 'conv.stat.session': 'セッション', + 'conv.stat.rootSession': 'ルートセッション', + 'conv.stat.parentThread': '親スレッド', + 'conv.stat.agent': 'Agent', + 'conv.stat.agentPath': 'Agent パス', 'conv.stat.messages': 'メッセージ', 'conv.stat.turns': 'ターン', 'conv.stat.input': '入力', 'conv.stat.output': '出力', + 'conv.stat.credits': 'クレジット', 'conv.stat.cacheRead': 'キャッシュ読取', 'conv.stat.version': 'バージョン', 'conv.stat.tool': 'ツール', @@ -2028,6 +2076,8 @@ 'conv.searching': '메시지 내용 검색 중…', 'conv.noLocal': '로컬 세션 기록을 찾을 수 없음', 'conv.notFound': '세션이 없거나 이동되었습니다.', + 'conv.permissionDenied': 'Qoder 또는 다른 앱의 세션 데이터를 읽을 수 없습니다. macOS ‘시스템 설정’ → ‘개인정보 보호 및 보안’에서 CC Buddy의 접근을 허용한 후 다시 시도하세요.', + 'conv.readFailed': '세션을 읽지 못했습니다. 다시 시도하세요.', 'conv.emptyConv': '(빈 대화)', 'conv.loadEarlier': '↑ 이전 메시지 불러오기 (위에 {n}개 더)', 'conv.loading': '불러오는 중…', @@ -2043,6 +2093,11 @@ 'conv.noResult': '결과 기록 없음', 'conv.noEdits': '(편집 내용 없음)', 'conv.cache': '캐시', + 'conv.credits': '크레딧', + 'conv.skillLoaded': 'Skill 불러옴', + 'conv.skillSnapshot': '불러올 당시 스냅샷 보기', + 'conv.skillSource': '출처', + 'conv.skillNoSnapshot': 'Skill 스냅샷 기록 없음', 'conv.charsMore': '\n… (+{n}자)', 'conv.subagentSession': '서브에이전트 세션', 'conv.import': '.jsonl / .zip 가져오기', @@ -2086,10 +2141,15 @@ 'conv.stat.project': '프로젝트', 'conv.stat.branch': '브랜치', 'conv.stat.session': '세션', + 'conv.stat.rootSession': '루트 세션', + 'conv.stat.parentThread': '부모 스레드', + 'conv.stat.agent': 'Agent', + 'conv.stat.agentPath': 'Agent 경로', 'conv.stat.messages': '메시지', 'conv.stat.turns': '턴', 'conv.stat.input': '입력', 'conv.stat.output': '출력', + 'conv.stat.credits': '크레딧', 'conv.stat.cacheRead': '캐시 읽기', 'conv.stat.version': '버전', 'conv.stat.tool': '도구',