Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ccbud",
"version": "1.3.6",
"version": "1.3.7",
"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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.3.6"
version = "1.3.7"
description = "CCBuddy — Coding CLI Buddy"
authors = ["loadchange <soocto@gmail.com>"]
license = "GPL-3.0-only"
Expand Down
10 changes: 8 additions & 2 deletions src-tauri/src/exporthtml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ fn read_subagents(file: &Path) -> Value {
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
let shaped = shape_session(&parse_jsonl(&ent.path()));
let recs = parse_jsonl(&ent.path());
let shaped = shape_session(&recs);
let key = meta
.get("toolUseId")
.and_then(|v| v.as_str())
Expand All @@ -280,6 +281,7 @@ fn read_subagents(file: &Path) -> Value {
"agentId": agent_id,
"type": meta.get("agentType").or_else(|| meta.get("subagent_type")).and_then(|v| v.as_str()).unwrap_or("agent"),
"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 },
"messages": shaped.messages,
Expand Down Expand Up @@ -381,7 +383,11 @@ pub fn build_data(file: &str) -> Value {
if t.is_empty() { "(conversation)".to_string() } else { t }
};
let stem = path.file_stem().and_then(|x| x.to_str()).unwrap_or("");
let subagents = read_subagents(path);
let mut subagents = read_subagents(path);
if let Some(map) = subagents.as_object_mut() {
// The spawning Skill tool_use overrides the sentinel fallback (mirrors exportHtml.js).
crate::history::apply_skill_names(&s.messages, map);
}
json!({
"meta": {
"title": title,
Expand Down
71 changes: 70 additions & 1 deletion src-tauri/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,71 @@ pub fn dir_stats(config: &Value) -> Vec<Value> {
out
}

/// A skill-forked subagent transcript opens with a sentinel user line
/// "Base directory for this skill: <path>/<skill-dir>" — the last path segment names the skill.
/// Fallback attribution only: the spawning `Skill` tool_use in the parent thread
/// (apply_skill_names) is authoritative and overrides this when present. (history.js skillFromRecs)
const SKILL_BASE_DIR_PREFIX: &str = "Base directory for this skill: ";
pub(crate) fn skill_from_recs(recs: &[Value]) -> Option<String> {
let first = recs.iter().find(|r| {
r.get("type").and_then(|v| v.as_str()) == Some("user")
&& r.get("message").is_some()
&& !r.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false)
})?;
let text = content_text(first.get("message")?.get("content").unwrap_or(&Value::Null));
// Only the opening prompt carries the sentinel — don't scan further user turns.
let rest = text.trim().strip_prefix(SKILL_BASE_DIR_PREFIX)?;
let line = rest.lines().next().unwrap_or("").trim();
line.split(['/', '\\']).filter(|s| !s.is_empty()).last().map(|s| s.to_string())
}

/// Primary skill attribution (history.js applySkillNames): a subagent spawned by the `Skill` tool
/// is named by the spawning tool_use's input.skill (matched by tool_use id — the subagents map
/// key), in whichever thread the call lives (main or a nested subagent). Overrides the sentinel
/// fallback from skill_from_recs.
pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json::Map<String, Value>) {
if subs.is_empty() {
return;
}
fn scan(msgs: &[Value], subs: &serde_json::Map<String, Value>, out: &mut Vec<(String, String)>) {
for m in msgs {
let blocks = match m.get("content").and_then(|c| c.as_array()) {
Some(b) => b,
None => continue,
};
for b in blocks {
if b.get("type").and_then(|v| v.as_str()) != Some("tool_use")
|| b.get("name").and_then(|v| v.as_str()) != Some("Skill")
{
continue;
}
let id = match b.get("id").and_then(|v| v.as_str()) {
Some(i) if subs.contains_key(i) => i,
_ => continue,
};
if let Some(s) = b.get("input").and_then(|i| i.get("skill")).and_then(|v| v.as_str()) {
let s = s.trim();
if !s.is_empty() {
out.push((id.to_string(), s.to_string()));
}
}
}
}
}
let mut named: Vec<(String, String)> = vec![];
scan(main_messages, subs, &mut named);
for (_, v) in subs.iter() {
if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) {
scan(msgs, subs, &mut named);
}
}
for (id, name) in named {
if let Some(o) = subs.get_mut(&id).and_then(|s| s.as_object_mut()) {
o.insert("skill".into(), json!(name));
}
}
}

/// Read a session's child subagent dialogues from `<stem>/subagents/agent-*.jsonl` (+ .meta.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<String, Value> {
Expand Down Expand Up @@ -765,6 +830,7 @@ fn read_subagents(file: &str) -> serde_json::Map<String, Value> {
"file": ent.path().to_string_lossy(),
"type": agent_type,
"description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"skill": skill_from_recs(&recs),
"count": shaped.messages.len(),
"totals": shaped.totals,
"messages": shaped.messages,
Expand Down Expand Up @@ -921,7 +987,8 @@ pub fn get_session(file: &str) -> Value {
(true, Some(aid)) => format!("{}-{}", base_id, aid),
_ => base_id.clone(),
};
let subs = if subagent { serde_json::Map::new() } else { read_subagents(file) };
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);

json!({
Expand All @@ -939,6 +1006,8 @@ pub fn get_session(file: &str) -> Value {
"gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null),
"version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null),
"isSubagent": subagent,
// A standalone subagent transcript self-reports its invoking skill via the sentinel.
"skill": if subagent { skill_from_recs(&recs) } else { None::<String> },
"deleted": cc_deleted,
"imported": import_meta.is_some(),
"importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null),
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "CCBuddy",
"version": "1.3.6",
"version": "1.3.7",
"identifier": "dev.ccbud.gateway",
"build": {
"frontendDist": "../src/renderer",
Expand Down
3 changes: 2 additions & 1 deletion src/main/export-assets/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@
return '<details class="tool tool-' + cls + '"' + open + '><summary class="tool-head"><span class="tool-ico">' + ico + '</span><span class="tool-name">' + esc(label) + '</span><span class="tool-target">' + esc(target) + '</span>' + badge + '</summary>' + inner + '</details>' + (sub ? renderSubagent(sub) : '');
}
function renderSubagent(sub) {
return '<div class="subagent"><details class="subagent-d"><summary><span class="subagent-ico">🤖</span><span class="subagent-title">子代理 · ' + esc(sub.type || 'agent') + '</span><span class="subagent-desc">' + esc(sub.description || '') + '</span><span class="subagent-count">' + (sub.count || 0) + ' 条 · ' + fmtTok((sub.totals && sub.totals.out) || 0) + '↓</span></summary><div class="subagent-body"><div class="thread">' + renderThread(sub.messages || []) + '</div></div></details></div>';
var subName = (sub.type || 'agent') + (sub.skill ? ':' + sub.skill : ''); // skill-spawned agents carry the invoking skill
return '<div class="subagent"><details class="subagent-d"><summary><span class="subagent-ico">🤖</span><span class="subagent-title">子代理 · ' + esc(subName) + '</span><span class="subagent-desc">' + esc(sub.description || '') + '</span><span class="subagent-count">' + (sub.count || 0) + ' 条 · ' + fmtTok((sub.totals && sub.totals.out) || 0) + '↓</span></summary><div class="subagent-body"><div class="thread">' + renderThread(sub.messages || []) + '</div></div></details></div>';
}

function isReminder(t) { return /<(system-reminder|command-name|local-command)/.test(t); }
Expand Down
6 changes: 5 additions & 1 deletion src/main/exportHtml.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,20 @@ function readSubagents(file) {
const dir = path.join(path.dirname(file), path.basename(file, '.jsonl'), 'subagents');
let entries;
try { entries = fs.readdirSync(dir); } catch (_) { return {}; }
const { skillFromRecs } = require('./history');
const byTool = {};
for (const name of entries) {
if (!/^agent-.*\.jsonl$/.test(name)) continue;
const agentId = name.replace(/^agent-/, '').replace(/\.jsonl$/, '');
let meta = {};
try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'agent-' + agentId + '.meta.json'), 'utf8')); } catch (_) {}
const shaped = shapeSession(parseJsonl(path.join(dir, name)));
const recs = parseJsonl(path.join(dir, name));
const shaped = shapeSession(recs);
const sub = {
agentId,
type: meta.agentType || meta.subagent_type || 'agent',
description: meta.description || '',
skill: skillFromRecs(recs),
count: shaped.messages.length,
totals: shaped.totals,
messages: shaped.messages,
Expand Down Expand Up @@ -205,6 +208,7 @@ function buildData(file) {
const s = shapeSession(recs);
const cwd = s.metaRec.cwd || null;
const subagents = readSubagents(file);
require('./history').applySkillNames(s.messages, subagents); // spawning Skill tool_use overrides the sentinel fallback
return {
meta: {
title: firstUserText(s.messages) || '(conversation)',
Expand Down
43 changes: 41 additions & 2 deletions src/main/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,41 @@ function shapeMessages(recs) {
return { messages, totals, model, firstTs, lastTs };
}

// A skill-forked subagent transcript opens with a sentinel user line
// "Base directory for this skill: <path>/<skill-dir>" — the last path segment names the skill.
// Fallback attribution only: the spawning `Skill` tool_use in the parent thread (applySkillNames)
// is authoritative and overrides this when present.
const SKILL_BASE_DIR_PREFIX = 'Base directory for this skill: ';
function skillFromRecs(recs) {
for (const r of recs) {
if (!r || r.type !== 'user' || !r.message || r.isMeta) continue;
const raw = contentText(r.message.content).trim();
if (!raw.startsWith(SKILL_BASE_DIR_PREFIX)) return null; // only the opening prompt carries the sentinel
const line = raw.slice(SKILL_BASE_DIR_PREFIX.length).split('\n')[0].trim();
const segs = line.split(/[\\/]/).filter(Boolean);
return segs.length ? segs[segs.length - 1] : null;
}
return null;
}

// Primary skill attribution: a subagent spawned by the `Skill` tool is named by the spawning
// tool_use's input.skill (matched by tool_use id — the subagents map key), in whichever thread
// the call lives (main or a nested subagent). Overrides the sentinel fallback from skillFromRecs.
function applySkillNames(mainMessages, subs) {
const keys = Object.keys(subs);
if (!keys.length) return;
const scan = (msgs) => (msgs || []).forEach((m) => {
const blocks = m && Array.isArray(m.content) ? m.content : [];
for (const b of blocks) {
if (!b || b.type !== 'tool_use' || b.name !== 'Skill' || !subs[b.id]) continue;
const s = b.input && typeof b.input.skill === 'string' ? b.input.skill.trim() : '';
if (s) subs[b.id].skill = s;
}
});
scan(mainMessages);
for (const k of keys) scan(subs[k].messages);
}

// Read a session's subagent dialogues — <sessionFile-dir>/<sessionId>/subagents/agent-<id>.{jsonl,meta.json}
// — keyed by the spawning Task/Agent tool_use id (agent-<id>.meta.json's toolUseId), so the "对话"
// view can nest each subagent's timeline under the call that spawned it. Mirrors the HTML export.
Expand All @@ -182,13 +217,15 @@ function readSubagents(file) {
try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'agent-' + agentId + '.meta.json'), 'utf8')); } catch (_) {}
let raw;
try { raw = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (_) { continue; }
const shaped = shapeMessages(parseLines(raw));
const recs = parseLines(raw);
const shaped = shapeMessages(recs);
const key = meta.toolUseId || ('agent:' + agentId);
byTool[key] = {
agentId,
file: path.join(dir, name), // absolute path to this subagent's .jsonl (for "copy path")
type: meta.agentType || meta.subagent_type || 'agent',
description: meta.description || '',
skill: skillFromRecs(recs),
count: shaped.messages.length,
totals: shaped.totals,
messages: shaped.messages,
Expand Down Expand Up @@ -410,6 +447,7 @@ function createHistoryWatcher(opts) {
// Only a top-level session embeds its child subagent dialogues (a subagent file has no nested
// subagents/ dir of its own), so the renderer can nest them under their spawning Task call.
const subagents = subagent ? {} : readSubagents(file);
applySkillNames(messages, subagents);
const cwd = metaRec.cwd || null;
const baseId = metaRec.sessionId || path.basename(file, '.jsonl');
const sessId = subagent && agentRec.agentId ? `${baseId}-${agentRec.agentId}` : baseId;
Expand All @@ -428,6 +466,7 @@ function createHistoryWatcher(opts) {
gitBranch: metaRec.gitBranch || null,
version: metaRec.version || null,
isSubagent: subagent,
skill: subagent ? skillFromRecs(recs) : null, // a standalone subagent transcript self-reports via the sentinel
imported: !!imported,
importedFrom: imported ? imported.originalPath : null,
importedAt: imported ? imported.importedAt : null,
Expand Down Expand Up @@ -598,4 +637,4 @@ function createHistoryWatcher(opts) {
};
}

module.exports = { createHistoryWatcher, lineToMessage, firstUserText, readCcbud, decodeDirName, defaultDirs, subagentDir, readSubagentFiles, subagentTranscriptPaths };
module.exports = { createHistoryWatcher, lineToMessage, firstUserText, readCcbud, decodeDirName, defaultDirs, subagentDir, readSubagentFiles, subagentTranscriptPaths, skillFromRecs, applySkillNames };
Loading
Loading