Skip to main content

kaizen/shell/
cli.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! CLI command implementations.
3
4use crate::collect::tail::claude::scan_claude_session_dir;
5use crate::collect::tail::codex::scan_codex_session_dir;
6use crate::collect::tail::copilot_cli::scan_copilot_cli_workspace;
7use crate::collect::tail::copilot_vscode::scan_copilot_vscode_workspace;
8use crate::collect::tail::cursor::scan_session_dir_all;
9use crate::collect::tail::goose::scan_goose_workspace;
10use crate::collect::tail::openclaw::scan_openclaw_workspace;
11use crate::collect::tail::opencode::scan_opencode_workspace;
12use crate::core::config;
13use crate::core::event::{Event, SessionRecord};
14use crate::metrics::report;
15use crate::shell::fmt::fmt_ts;
16use crate::shell::scope;
17use crate::store::{SYNC_STATE_LAST_AGENT_SCAN_MS, SYNC_STATE_LAST_AUTO_PRUNE_MS, Store};
18use anyhow::Result;
19use serde::Serialize;
20use std::collections::HashMap;
21use std::io::IsTerminal;
22use std::path::{Path, PathBuf};
23
24pub use crate::shell::init::cmd_init;
25pub use crate::shell::insights::cmd_insights;
26
27#[derive(Serialize)]
28struct SessionsListJson {
29    workspace: String,
30    #[serde(skip_serializing_if = "Vec::is_empty")]
31    workspaces: Vec<String>,
32    count: usize,
33    sessions: Vec<SessionRecord>,
34}
35
36#[derive(Serialize)]
37struct SummaryJsonOut {
38    workspace: String,
39    #[serde(skip_serializing_if = "Vec::is_empty")]
40    workspaces: Vec<String>,
41    #[serde(flatten)]
42    stats: crate::store::SummaryStats,
43    cost_usd: f64,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    cost_note: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    hotspot: Option<crate::metrics::types::RankedFile>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    slowest_tool: Option<crate::metrics::types::RankedTool>,
50}
51
52/// Summary/MCP: sessions exist but rollup has no stored micro-USD — show honest footnote, not invented spend.
53pub(crate) fn summary_needs_cost_rollup_note(session_count: u64, total_cost_usd_e6: i64) -> bool {
54    session_count > 0 && total_cost_usd_e6 == 0
55}
56
57pub(crate) fn cost_rollup_zero_note_paragraph() -> &'static str {
58    "Cost rollup shows $0.00 because stored events have no cost_usd_e6 — common when Cursor agent-transcript lines omit usage/tokens. \
59If you expect non-zero spend, ingest Claude/Codex transcripts with usage, hooks with total_cost_usd, or Kaizen proxy Cost events; run `kaizen summary --refresh` after ingest changes. \
60See docs/usage.md#cost-shows-zero."
61}
62
63pub(crate) fn cost_rollup_zero_doctor_hint() -> &'static str {
64    "Cost rollup $0.00 with sessions but no cost_usd_e6 — often Cursor transcripts without usage; see docs/usage.md#cost-shows-zero"
65}
66
67struct ScanSpinner(Option<indicatif::ProgressBar>);
68
69impl ScanSpinner {
70    fn start(msg: &'static str) -> Self {
71        if !std::io::stdout().is_terminal() {
72            return Self(None);
73        }
74        let p = indicatif::ProgressBar::new_spinner();
75        p.set_message(msg.to_string());
76        p.enable_steady_tick(std::time::Duration::from_millis(120));
77        Self(Some(p))
78    }
79}
80
81impl Drop for ScanSpinner {
82    fn drop(&mut self) {
83        if let Some(p) = self.0.take() {
84            p.finish_and_clear();
85        }
86    }
87}
88
89fn now_ms_u64() -> u64 {
90    std::time::SystemTime::now()
91        .duration_since(std::time::UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_millis() as u64
94}
95
96/// Minimum interval between automatic local DB prunes after a successful rescan (24h).
97const AUTO_PRUNE_INTERVAL_MS: u64 = 86_400_000;
98
99pub(crate) fn maybe_auto_prune_after_scan(store: &Store, cfg: &config::Config) -> Result<()> {
100    if cfg.retention.hot_days == 0 {
101        return Ok(());
102    }
103    let now = now_ms_u64();
104    if let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS)?
105        && now.saturating_sub(last) < AUTO_PRUNE_INTERVAL_MS
106    {
107        return Ok(());
108    }
109    let cutoff = now.saturating_sub((cfg.retention.hot_days as u64).saturating_mul(86_400_000));
110    store.prune_sessions_started_before(cutoff as i64)?;
111    store.sync_state_set_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS, now)?;
112    Ok(())
113}
114
115/// Full transcript rescan unless throttled by `[scan].min_rescan_seconds` or `refresh` is true.
116pub(crate) fn maybe_scan_all_agents(
117    ws: &Path,
118    cfg: &config::Config,
119    ws_str: &str,
120    store: &Store,
121    refresh: bool,
122) -> Result<()> {
123    let interval_ms = cfg.scan.min_rescan_seconds.saturating_mul(1000);
124    let now = now_ms_u64();
125    if !refresh
126        && interval_ms > 0
127        && let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AGENT_SCAN_MS)?
128        && now.saturating_sub(last) < interval_ms
129    {
130        return Ok(());
131    }
132    scan_all_agents(ws, cfg, ws_str, store)?;
133    store.sync_state_set_u64(SYNC_STATE_LAST_AGENT_SCAN_MS, now_ms_u64())?;
134    Ok(())
135}
136
137pub(crate) fn maybe_refresh_store(workspace: &Path, store: &Store, refresh: bool) -> Result<()> {
138    if !refresh {
139        return Ok(());
140    }
141    let cfg = config::load(workspace)?;
142    let ws_str = workspace.to_string_lossy().to_string();
143    maybe_scan_all_agents(workspace, &cfg, &ws_str, store, true)
144}
145
146fn combine_counts(rows: Vec<Vec<(String, u64)>>) -> Vec<(String, u64)> {
147    let mut counts = HashMap::new();
148    for set in rows {
149        for (key, value) in set {
150            *counts.entry(key).or_insert(0_u64) += value;
151        }
152    }
153    let mut out = counts.into_iter().collect::<Vec<_>>();
154    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
155    out
156}
157
158fn workspace_names(roots: &[PathBuf]) -> Vec<String> {
159    roots
160        .iter()
161        .map(|path| path.to_string_lossy().to_string())
162        .collect()
163}
164
165fn open_workspace_store(workspace: &Path) -> Result<Store> {
166    Store::open(&crate::core::workspace::db_path(workspace)?)
167}
168
169pub(crate) fn open_workspace_read_store(workspace: &Path, refresh: bool) -> Result<Store> {
170    let db_path = crate::core::workspace::db_path(workspace)?;
171    if refresh || !db_path.exists() {
172        Store::open(&db_path)
173    } else {
174        Store::open_query(&db_path)
175    }
176}
177
178/// `kaizen sessions list` — same output as CLI stdout.
179pub fn sessions_list_text(
180    workspace: Option<&Path>,
181    json_out: bool,
182    refresh: bool,
183    all_workspaces: bool,
184    limit: Option<usize>,
185) -> Result<String> {
186    let roots = scope::resolve(workspace, all_workspaces)?;
187    let output_limit = limit.unwrap_or(100);
188    let query_limit = if output_limit == 0 {
189        i64::MAX as usize
190    } else {
191        output_limit
192    };
193    let mut sessions = Vec::new();
194    if crate::daemon::enabled() && !refresh {
195        for workspace in &roots {
196            let ws_str = workspace.to_string_lossy().to_string();
197            let response =
198                crate::daemon::request_blocking(crate::ipc::DaemonRequest::ListSessions {
199                    workspace: ws_str,
200                    offset: 0,
201                    limit: query_limit,
202                    filter: crate::store::SessionFilter::default(),
203                })?;
204            match response {
205                crate::ipc::DaemonResponse::Sessions(page) => sessions.extend(page.rows),
206                crate::ipc::DaemonResponse::Error { message, .. } => anyhow::bail!(message),
207                _ => anyhow::bail!("unexpected daemon sessions response"),
208            }
209        }
210    } else {
211        for workspace in &roots {
212            let store = open_workspace_read_store(workspace, refresh)?;
213            maybe_refresh_store(workspace, &store, refresh)?;
214            let ws_str = workspace.to_string_lossy().to_string();
215            if output_limit == 0 {
216                sessions.extend(store.list_sessions(&ws_str)?);
217            } else {
218                sessions.extend(
219                    store
220                        .list_sessions_page(
221                            &ws_str,
222                            0,
223                            query_limit,
224                            crate::store::SessionFilter::default(),
225                        )?
226                        .rows,
227                );
228            }
229        }
230    }
231    sessions.sort_by(|a, b| {
232        b.started_at_ms
233            .cmp(&a.started_at_ms)
234            .then_with(|| a.id.cmp(&b.id))
235    });
236    if output_limit > 0 {
237        let n = output_limit;
238        sessions.truncate(n);
239    }
240    let scope_label = scope::label(&roots);
241    let workspaces = if roots.len() > 1 {
242        workspace_names(&roots)
243    } else {
244        Vec::new()
245    };
246    if json_out {
247        return Ok(format!(
248            "{}\n",
249            serde_json::to_string_pretty(&SessionsListJson {
250                workspace: scope_label,
251                workspaces,
252                count: sessions.len(),
253                sessions,
254            })?
255        ));
256    }
257    use std::fmt::Write;
258    let mut out = String::new();
259    if roots.len() > 1 {
260        writeln!(&mut out, "Scope: {scope_label}").unwrap();
261        writeln!(&mut out).unwrap();
262    }
263    writeln!(
264        &mut out,
265        "{:<40} {:<10} {:<10} STARTED",
266        "ID", "AGENT", "STATUS"
267    )
268    .unwrap();
269    writeln!(&mut out, "{}", "-".repeat(80)).unwrap();
270    for s in &sessions {
271        writeln!(
272            &mut out,
273            "{:<40} {:<10} {:<10} {}",
274            s.id,
275            s.agent,
276            format!("{:?}", s.status),
277            fmt_ts(s.started_at_ms),
278        )
279        .unwrap();
280    }
281    if sessions.is_empty() {
282        writeln!(&mut out, "(no sessions)").unwrap();
283        sessions_empty_state_hints(&mut out);
284    }
285    Ok(out)
286}
287
288fn sessions_empty_state_hints(out: &mut String) {
289    use std::fmt::Write;
290    let _ = writeln!(out);
291    let _ = writeln!(out, "No sessions found for this workspace. Try:");
292    let _ = writeln!(out, "  · `kaizen doctor` — verify config and hooks");
293    let _ = writeln!(out, "  · a short agent session in this repo, then re-run");
294    let _ = writeln!(
295        out,
296        "  · docs: https://github.com/marquesds/kaizen/blob/main/docs/config.md (sources)"
297    );
298}
299
300/// `kaizen sessions list` — scan all agent transcripts, upsert sessions, print table.
301pub fn cmd_sessions_list(
302    workspace: Option<&Path>,
303    json_out: bool,
304    refresh: bool,
305    all_workspaces: bool,
306    limit: Option<usize>,
307) -> Result<()> {
308    print!(
309        "{}",
310        sessions_list_text(workspace, json_out, refresh, all_workspaces, limit)?
311    );
312    Ok(())
313}
314
315/// `kaizen sessions show` — same output as CLI stdout.
316pub fn session_show_text(id: &str, workspace: Option<&Path>) -> Result<String> {
317    let ws = workspace_path(workspace)?;
318    let store = open_workspace_store(&ws)?;
319    use std::fmt::Write;
320    let mut out = String::new();
321    match store.get_session(id)? {
322        Some(s) => {
323            writeln!(&mut out, "id:           {}", s.id).unwrap();
324            writeln!(&mut out, "agent:        {}", s.agent).unwrap();
325            writeln!(
326                &mut out,
327                "model:        {}",
328                s.model.as_deref().unwrap_or("-")
329            )
330            .unwrap();
331            writeln!(&mut out, "workspace:    {}", s.workspace).unwrap();
332            writeln!(&mut out, "started_at:   {}", fmt_ts(s.started_at_ms)).unwrap();
333            writeln!(
334                &mut out,
335                "ended_at:     {}",
336                s.ended_at_ms.map(fmt_ts).unwrap_or_else(|| "-".to_string())
337            )
338            .unwrap();
339            writeln!(&mut out, "status:       {:?}", s.status).unwrap();
340            writeln!(&mut out, "trace_path:   {}", s.trace_path).unwrap();
341            if let Some(fp) = &s.prompt_fingerprint {
342                writeln!(&mut out, "prompt_fp:    {fp}").unwrap();
343                if let Ok(Some(snap)) = store.get_prompt_snapshot(fp) {
344                    for f in snap.files() {
345                        writeln!(&mut out, "  - {}", f.path).unwrap();
346                    }
347                }
348            }
349        }
350        None => anyhow::bail!("session not found: {id} — try `kaizen sessions list`"),
351    }
352    let evals = store.list_evals_for_session(id).unwrap_or_default();
353    if !evals.is_empty() {
354        writeln!(&mut out, "evals:").unwrap();
355        for e in &evals {
356            writeln!(
357                &mut out,
358                "  {} score={:.2} flagged={} {}",
359                e.rubric_id, e.score, e.flagged, e.rationale
360            )
361            .unwrap();
362        }
363    }
364    let fb = store
365        .feedback_for_sessions(&[id.to_string()])
366        .unwrap_or_default();
367    if let Some(r) = fb.get(id) {
368        let score = r
369            .score
370            .as_ref()
371            .map(|s| s.0.to_string())
372            .unwrap_or_else(|| "-".into());
373        let label = r
374            .label
375            .as_ref()
376            .map(|l| l.to_string())
377            .unwrap_or_else(|| "-".into());
378        writeln!(&mut out, "feedback:     score={score} label={label}").unwrap();
379        if let Some(n) = &r.note {
380            writeln!(&mut out, "  note: {n}").unwrap();
381        }
382    }
383    Ok(out)
384}
385
386/// `kaizen sessions show <id>` — print full session fields.
387pub fn cmd_session_show(id: &str, workspace: Option<&Path>) -> Result<()> {
388    print!("{}", session_show_text(id, workspace)?);
389    Ok(())
390}
391
392pub fn sessions_tree_text(id: &str, max_depth: u32, workspace: Option<&Path>) -> Result<String> {
393    let ws = workspace_path(workspace)?;
394    let store = open_workspace_store(&ws)?;
395    let nodes = store.session_span_tree(id)?;
396    if nodes.is_empty() {
397        if store.get_session(id)?.is_none() {
398            anyhow::bail!("session not found: {id}");
399        }
400        return Ok(format!("(no tool spans for session {id})\n"));
401    }
402    let total_cost: i64 = nodes.iter().map(|n| n.subtree_cost_usd_e6).sum();
403    let mut out = String::new();
404    for node in &nodes {
405        render_node(&mut out, node, 0, max_depth, total_cost);
406    }
407    Ok(out)
408}
409
410fn render_node(
411    out: &mut String,
412    node: &crate::store::span_tree::SpanNode,
413    depth: u32,
414    max_depth: u32,
415    session_total: i64,
416) {
417    use std::fmt::Write;
418    if depth > max_depth {
419        return;
420    }
421    let indent = "│  ".repeat(depth as usize);
422    let prefix = if depth == 0 { "┌─ " } else { "├─ " };
423    let cost_str = match node.span.subtree_cost_usd_e6 {
424        Some(c) => {
425            let pct = if session_total > 0 {
426                c * 100 / session_total
427            } else {
428                0
429            };
430            let flag = if pct > 40 { " ⚡" } else { "" };
431            format!(" ${:.4}{}", c as f64 / 1_000_000.0, flag)
432        }
433        None => String::new(),
434    };
435    writeln!(
436        out,
437        "{}{}{} [{}]{}",
438        indent, prefix, node.span.tool, node.span.status, cost_str
439    )
440    .unwrap();
441    for child in &node.children {
442        render_node(out, child, depth + 1, max_depth, session_total);
443    }
444}
445
446/// `kaizen sessions tree <id>` — produce text output (ASCII or JSON).
447pub fn cmd_sessions_tree_text(
448    id: &str,
449    depth: u32,
450    json: bool,
451    workspace: Option<&Path>,
452) -> Result<String> {
453    if json {
454        let ws = workspace_path(workspace)?;
455        let store = open_workspace_read_store(&ws, false)?;
456        let nodes = store.session_span_tree(id)?;
457        Ok(serde_json::to_string_pretty(&nodes)?)
458    } else {
459        sessions_tree_text(id, depth, workspace)
460    }
461}
462
463/// `kaizen sessions tree <id>` — print ASCII span tree.
464pub fn cmd_sessions_tree(id: &str, depth: u32, json: bool, workspace: Option<&Path>) -> Result<()> {
465    print!("{}", cmd_sessions_tree_text(id, depth, json, workspace)?);
466    Ok(())
467}
468
469/// `kaizen summary` — same output as CLI stdout.
470pub fn summary_text(
471    workspace: Option<&Path>,
472    json_out: bool,
473    refresh: bool,
474    all_workspaces: bool,
475    source: crate::core::data_source::DataSource,
476) -> Result<String> {
477    let roots = scope::resolve(workspace, all_workspaces)?;
478    let mut total_cost_usd_e6 = 0_i64;
479    let mut session_count = 0_u64;
480    let mut by_agent = Vec::new();
481    let mut by_model = Vec::new();
482    let mut top_tools = Vec::new();
483    let mut hottest = Vec::new();
484    let mut slowest = Vec::new();
485
486    for workspace in &roots {
487        let cfg = config::load(workspace)?;
488        let store = open_workspace_read_store(
489            workspace,
490            refresh || source != crate::core::data_source::DataSource::Local,
491        )?;
492        crate::shell::remote_pull::maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
493        maybe_refresh_store(workspace, &store, refresh)?;
494        let ws_str = workspace.to_string_lossy().to_string();
495        let read_store = open_workspace_read_store(workspace, false)?;
496        let query = crate::store::query::QueryStore::open(&crate::core::paths::project_data_dir(
497            workspace,
498        )?)?;
499        let mut stats = query.summary_stats(&read_store, &ws_str)?;
500        if source != crate::core::data_source::DataSource::Local
501            && let Ok(Some(agg)) =
502                crate::shell::remote_observe::try_remote_event_agg(&read_store, &cfg, workspace)
503        {
504            stats = crate::shell::remote_observe::merge_summary_stats(stats, &agg, source);
505        }
506        total_cost_usd_e6 += stats.total_cost_usd_e6;
507        session_count += stats.session_count;
508        by_agent.push(stats.by_agent);
509        by_model.push(stats.by_model);
510        top_tools.push(stats.top_tools);
511        if let Ok(metrics) = report::build_report(&read_store, &ws_str, 7) {
512            if let Some(file) = metrics.hottest_files.first().cloned() {
513                hottest.push(if roots.len() == 1 {
514                    file
515                } else {
516                    crate::metrics::types::RankedFile {
517                        path: scope::decorate_path(workspace, &file.path),
518                        ..file
519                    }
520                });
521            }
522            if let Some(tool) = metrics.slowest_tools.first().cloned() {
523                slowest.push(tool);
524            }
525        }
526    }
527
528    let stats = crate::store::SummaryStats {
529        session_count,
530        total_cost_usd_e6,
531        by_agent: combine_counts(by_agent),
532        by_model: combine_counts(by_model),
533        top_tools: combine_counts(top_tools),
534    };
535    let cost_dollars = stats.total_cost_usd_e6 as f64 / 1_000_000.0;
536    let hotspot = hottest
537        .into_iter()
538        .max_by(|a, b| a.value.cmp(&b.value).then_with(|| b.path.cmp(&a.path)));
539    let slowest_tool = slowest.into_iter().max_by(|a, b| {
540        a.p95_ms
541            .unwrap_or(0)
542            .cmp(&b.p95_ms.unwrap_or(0))
543            .then_with(|| b.tool.cmp(&a.tool))
544    });
545    let scope_label = scope::label(&roots);
546    let workspaces = if roots.len() > 1 {
547        workspace_names(&roots)
548    } else {
549        Vec::new()
550    };
551    let cost_note = summary_needs_cost_rollup_note(stats.session_count, stats.total_cost_usd_e6)
552        .then_some(cost_rollup_zero_note_paragraph().to_string());
553    if json_out {
554        return Ok(format!(
555            "{}\n",
556            serde_json::to_string_pretty(&SummaryJsonOut {
557                workspace: scope_label,
558                workspaces,
559                cost_usd: cost_dollars,
560                stats,
561                cost_note,
562                hotspot,
563                slowest_tool,
564            })?
565        ));
566    }
567    use std::fmt::Write;
568    let mut out = String::new();
569    if roots.len() > 1 {
570        writeln!(&mut out, "Scope: {}", scope::label(&roots)).unwrap();
571    }
572    writeln!(
573        &mut out,
574        "Sessions: {}   Cost: ${:.2}",
575        stats.session_count, cost_dollars
576    )
577    .unwrap();
578
579    if !stats.by_agent.is_empty() {
580        let parts: Vec<String> = stats
581            .by_agent
582            .iter()
583            .map(|(a, n)| format!("{a} {n}"))
584            .collect();
585        writeln!(&mut out, "By agent:  {}", parts.join(" · ")).unwrap();
586    }
587    if !stats.by_model.is_empty() {
588        let parts: Vec<String> = stats
589            .by_model
590            .iter()
591            .map(|(m, n)| format!("{m} {n}"))
592            .collect();
593        writeln!(&mut out, "By model:  {}", parts.join(" · ")).unwrap();
594    }
595    if !stats.top_tools.is_empty() {
596        let parts: Vec<String> = stats
597            .top_tools
598            .iter()
599            .take(5)
600            .map(|(t, n)| format!("{t} {n}"))
601            .collect();
602        writeln!(&mut out, "Top tools: {}", parts.join(" · ")).unwrap();
603    }
604    if let Some(file) = hotspot {
605        writeln!(&mut out, "Hotspot:   {} ({})", file.path, file.value).unwrap();
606    }
607    if let Some(tool) = slowest_tool {
608        let p95 = tool
609            .p95_ms
610            .map(|v| format!("{v}ms"))
611            .unwrap_or_else(|| "-".into());
612        writeln!(&mut out, "Slowest:   {} p95 {}", tool.tool, p95).unwrap();
613    }
614    if cost_note.is_some() {
615        writeln!(&mut out).unwrap();
616        writeln!(&mut out, "Note: {}", cost_rollup_zero_note_paragraph()).unwrap();
617    }
618    Ok(out)
619}
620
621/// `kaizen summary` — aggregate session + cost stats across all agents.
622pub fn cmd_summary(
623    workspace: Option<&Path>,
624    json_out: bool,
625    refresh: bool,
626    all_workspaces: bool,
627    source: crate::core::data_source::DataSource,
628) -> Result<()> {
629    print!(
630        "{}",
631        summary_text(workspace, json_out, refresh, all_workspaces, source,)?
632    );
633    Ok(())
634}
635
636pub(crate) fn scan_all_agents(
637    ws: &Path,
638    cfg: &config::Config,
639    ws_str: &str,
640    store: &Store,
641) -> Result<()> {
642    let _spin = ScanSpinner::start("Scanning agent sessions…");
643    let slug = workspace_slug(ws_str);
644    let cursor_slug = crate::core::paths::cursor_slug(ws);
645    let claude_slug = crate::core::paths::claude_code_slug(ws);
646    let sync_ctx = crate::sync::ingest_ctx(cfg, ws.to_path_buf());
647
648    for root in &cfg.scan.roots {
649        let expanded = expand_home(root);
650        let cursor_dir = PathBuf::from(&expanded)
651            .join(&cursor_slug)
652            .join("agent-transcripts");
653        scan_agent_dirs(
654            &cursor_dir,
655            store,
656            |p| {
657                scan_session_dir_all(p).map(|sessions| {
658                    sessions
659                        .into_iter()
660                        .map(|(mut r, evs)| {
661                            r.workspace = ws_str.to_string();
662                            (r, evs)
663                        })
664                        .collect()
665                })
666            },
667            sync_ctx.as_ref(),
668        )?;
669    }
670
671    let home = std::env::var("HOME").unwrap_or_default();
672
673    let claude_dir = PathBuf::from(&home)
674        .join(".claude/projects")
675        .join(&claude_slug)
676        .join("sessions");
677    scan_agent_dirs(
678        &claude_dir,
679        store,
680        |p| {
681            scan_claude_session_dir(p).map(|(mut r, evs)| {
682                r.workspace = ws_str.to_string();
683                vec![(r, evs)]
684            })
685        },
686        sync_ctx.as_ref(),
687    )?;
688
689    let codex_dir = PathBuf::from(&home).join(".codex/sessions").join(&slug);
690    scan_agent_dirs(
691        &codex_dir,
692        store,
693        |p| {
694            scan_codex_session_dir(p).map(|(mut r, evs)| {
695                r.workspace = ws_str.to_string();
696                vec![(r, evs)]
697            })
698        },
699        sync_ctx.as_ref(),
700    )?;
701
702    let tail = &cfg.sources.tail;
703    let home_pb = PathBuf::from(&home);
704    if tail.goose {
705        let sessions = scan_goose_workspace(&home_pb, ws)?;
706        persist_session_batch(store, sessions, sync_ctx.as_ref())?;
707    }
708    if tail.openclaw {
709        let sessions = scan_openclaw_workspace(ws)?;
710        persist_session_batch(store, sessions, sync_ctx.as_ref())?;
711    }
712    if tail.opencode {
713        let sessions = scan_opencode_workspace(ws)?;
714        persist_session_batch(store, sessions, sync_ctx.as_ref())?;
715    }
716    if tail.copilot_cli {
717        let sessions = scan_copilot_cli_workspace(ws)?;
718        persist_session_batch(store, sessions, sync_ctx.as_ref())?;
719    }
720    if tail.copilot_vscode {
721        let sessions = scan_copilot_vscode_workspace(ws)?;
722        persist_session_batch(store, sessions, sync_ctx.as_ref())?;
723    }
724
725    maybe_auto_prune_after_scan(store, cfg)?;
726    Ok(())
727}
728
729fn persist_session_batch(
730    store: &Store,
731    sessions: Vec<(SessionRecord, Vec<Event>)>,
732    sync_ctx: Option<&crate::sync::SyncIngestContext>,
733) -> Result<()> {
734    for (mut record, events) in sessions {
735        if record.start_commit.is_none() && !record.workspace.is_empty() {
736            let binding = crate::core::repo::binding_for_session(
737                Path::new(&record.workspace),
738                record.started_at_ms,
739                record.ended_at_ms,
740            );
741            record.start_commit = binding.start_commit;
742            record.end_commit = binding.end_commit;
743            record.branch = binding.branch;
744            record.dirty_start = binding.dirty_start;
745            record.dirty_end = binding.dirty_end;
746            record.repo_binding_source = binding.source;
747        }
748        store.upsert_session(&record)?;
749        let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
750        for ev in events {
751            store.append_event_with_sync(&ev, sync_ctx)?;
752        }
753        if record.status == crate::core::event::SessionStatus::Done {
754            store.flush_projector_session(&record.id, flush_ms)?;
755        }
756    }
757    Ok(())
758}
759
760pub(crate) fn scan_agent_dirs<F>(
761    dir: &Path,
762    store: &Store,
763    scanner: F,
764    sync_ctx: Option<&crate::sync::SyncIngestContext>,
765) -> Result<()>
766where
767    F: Fn(&Path) -> Result<Vec<(SessionRecord, Vec<Event>)>>,
768{
769    if !dir.exists() {
770        return Ok(());
771    }
772    for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
773        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
774            continue;
775        }
776        match scanner(&entry.path()) {
777            Ok(sessions) => {
778                for (mut record, events) in sessions {
779                    if record.start_commit.is_none() && !record.workspace.is_empty() {
780                        let binding = crate::core::repo::binding_for_session(
781                            Path::new(&record.workspace),
782                            record.started_at_ms,
783                            record.ended_at_ms,
784                        );
785                        record.start_commit = binding.start_commit;
786                        record.end_commit = binding.end_commit;
787                        record.branch = binding.branch;
788                        record.dirty_start = binding.dirty_start;
789                        record.dirty_end = binding.dirty_end;
790                        record.repo_binding_source = binding.source;
791                    }
792                    store.upsert_session(&record)?;
793                    let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
794                    for ev in events {
795                        store.append_event_with_sync(&ev, sync_ctx)?;
796                    }
797                    if record.status == crate::core::event::SessionStatus::Done {
798                        store.flush_projector_session(&record.id, flush_ms)?;
799                    }
800                }
801            }
802            Err(e) => tracing::warn!("scan {:?}: {e}", entry.path()),
803        }
804    }
805    Ok(())
806}
807
808pub(crate) fn workspace_path(workspace: Option<&Path>) -> Result<PathBuf> {
809    crate::core::workspace::resolve(workspace)
810}
811
812/// Resolve workspace from `--workspace` or `--project` (mutually exclusive at clap level).
813///
814/// Returns `(canonical_path, how_it_was_selected)`.
815pub fn resolve_target(
816    workspace: Option<&Path>,
817    project: Option<&str>,
818) -> Result<(PathBuf, crate::shell::scope::ScopeOrigin)> {
819    use crate::shell::scope::ScopeOrigin;
820    if let Some(name) = project {
821        let path = crate::core::workspace::resolve_project_name(name)?;
822        return Ok((path, ScopeOrigin::ExplicitProject(name.to_owned())));
823    }
824    let path = crate::core::workspace::resolve(workspace)?;
825    let origin = if workspace.is_some() {
826        ScopeOrigin::ExplicitWorkspace
827    } else {
828        ScopeOrigin::Cwd
829    };
830    Ok((path, origin))
831}
832
833/// Convert workspace path string to cursor project slug.
834pub(crate) fn workspace_slug(ws: &str) -> String {
835    crate::core::paths::workspace_slug(std::path::Path::new(ws))
836}
837
838pub(crate) fn expand_home(path: &str) -> String {
839    if let (Some(rest), Ok(home)) = (path.strip_prefix("~/"), std::env::var("HOME")) {
840        return format!("{home}/{rest}");
841    }
842    path.to_string()
843}
844
845#[cfg(test)]
846mod cost_rollup_note_tests {
847    use super::*;
848
849    #[test]
850    fn needs_note_only_when_sessions_and_zero_cost() {
851        assert!(summary_needs_cost_rollup_note(1, 0));
852        assert!(!summary_needs_cost_rollup_note(0, 0));
853        assert!(!summary_needs_cost_rollup_note(1, 1));
854    }
855
856    #[test]
857    fn paragraph_names_gap_and_doc_anchor() {
858        let s = cost_rollup_zero_note_paragraph();
859        assert!(s.contains("cost_usd_e6"));
860        assert!(s.contains("usage"));
861        assert!(s.contains("docs/usage.md#cost-shows-zero"));
862    }
863}