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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
never error, and session files stay untouched.
- **Mutation evidence carries the command it ran.** `ToolCallEvidence` gains a bounded one-line `summary` for command-carrying tools (Bash and the provider dialects normalized onto the `command` input). Paths answered "did this step touch the workspace" while hiding *how* — auditing a Bash mutation meant opening the raw JSONL. (#21)
- **`ccx trace --width N` controls outline headline truncation** (0 = untruncated; default stays 160), applied to text and JSON outlines alike. Previously a constant, and the only escape was `--full`'s entire bundle — which bit JSON skill/script consumers hardest. (#23)
- **`ccx search --content` scans transcript lines.** Search covered names, paths, and summaries only, so the main session-mining question — "what did we discuss about X" — fell back to raw `grep -r` over the store, losing session identity, provider abstraction, and date filters. `--content` streams every candidate session file plus its subagent files (grep parity by design: raw-line match with unbounded line reads, no parse, so every provider's format works and nothing grep finds is missed — including matches past lines too large for any fixed scanner budget), ranks results by hit count, and composes with `--after`/`--before`/`-p`/`--model`. Truncated result lists say so on stderr. It is a crawl (~15s over a multi-GB store) because no message index exists yet; a content index is the follow-up that would make it a query.

### Fixed
- **Git-root fallback is provenance, not a warning.** `session_git_root_missing` fired whenever the session's recorded cwd didn't resolve locally, even when the process-cwd fallback then found the right repo — the common case in containers, where every trace carried the warning despite correct correlation. A successful fallback now records `git.resolved_from` (`"session_cwd"` | `"process_cwd"`); the warning fires only when nothing resolves. (#22)
- **Codex cost no longer double-bills cached input and reasoning tokens.** Codex usage fields are subsets, not disjoint categories: `input_tokens` includes `cached_input_tokens` (upstream: `non_cached_input = input - cached`) and `output_tokens` includes reasoning (OpenAI `output_tokens_details`) — ccx billed every field separately, overstating a real 36-minute session 2.6x ($101.99 shown, $38.54 honest) and printing "6.6m in" for ~206k of uncached input while Claude's `in` excludes cache. The Codex backend now normalizes to the exclusive semantics the rest of ccx assumes; `ComputeCost` drops the reasoning term; cache format bumped so upgrades reparse. Found by the first cross-provider field eval. (#27)
- **Trace sidechain reports are whole in JSON evidence.** `--full` and `--turn` capped `sidechains[].summary` at 240 runes — for research sessions the subagent final report IS the value, so the "complete trace bundle" was incomplete exactly where it mattered. The top-level sidechains list now carries the untruncated (ANSI-stripped) report; step-level entries stay bounded light refs keyed by `agent_id`, as documented.
- **`ccx view` respects pipes: `--color=auto|always|never`.** ANSI codes were emitted unconditionally, so piped output read as binary to grep — silent false-negatives unless you knew to add `-a`. `auto` (the default) follows whether stdout is a terminal; detection is stdlib-only (`os.ModeCharDevice`), keeping the zero-dependency stance. Session content is also scrubbed: escapes and control bytes embedded in tool results (untrusted terminal input) no longer reach the terminal or pipes in any color mode.
- **`ccx view` no longer indents sequential messages ever-deeper.** Every message nested one level under its `parentUuid` predecessor, so a long linear conversation drifted right without bound (400+ columns by the end of an 8.5k-line session). Sequential messages are siblings; indentation now marks only real branch descent — main chain into sidechain, or one agent into another.

### Changed
- **Web UI wears terminal material now** — cctrace's design language ported onto ccx's markup (every selector and JS hook kept, values rewritten). 13px `ui-monospace` body replaces 17px system sans ('Courier New' led the old mono stack); warm-tinted neutrals with terracotta as the single accent plus a five-hue semantic set (green/red/amber/purple/blue) replace ~12 stray hues; pastel role bubbles become hairline surfaces with faint washes — user turns get the cctrace anchor mechanic (space above + accent-washed header row); thinking is muted italic. Chrome details: thin scrollbars, accent selection, visible focus, tinted shadows, one radius scale. Second side-stripe purge caught what 0.11 missed (tool blocks, outline active item, agent turns, doctor/memory cards). Devlog: `docs/devlog/2026-07-29-web-terminal-material.org`.
Expand Down
68 changes: 68 additions & 0 deletions docs/devlog/2026-08-02-dogfood-friction-fixes.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
* [2026-08-02] Dev Log: Dogfood friction fixes :TRACE:VIEW:SEARCH:

** Context
A topic-mining session used ccx as its primary instrument against the
real session store (fixture: a 12-hour research session — 3 sidechains,
8.5k-line view) and filed four friction findings. All four were
reproducible; all four are fixed here.

** Why
Each flaw broke the same workflow ccx exists for — getting evidence
back out of session history:
1. =trace --full= capped sidechain summaries at 240 runes; for research
sessions the subagent final report IS the value.
2. =view= piped ANSI to non-TTYs; grep saw binary and silently missed.
3. =search= covered names/summaries only; content questions fell back
to raw grep, losing session identity and filters.
4. =view= indented every message under its parentUuid predecessor;
linear sessions drifted 400+ columns right.

** What
- =FIX= trace: top-level =sidechains[].summary= untruncated
(ANSI-stripped) in =--full=/=--turn=; step refs stay bounded.
- =FEAT= =ccx view --color=auto|always|never=; auto = stdout is a TTY.
- =FIX= view tree: siblings share depth; indent only on branch descent
(main -> sidechain, agent -> other agent).
- =FEAT= =ccx search --content=: streaming raw-line scan over session +
subagent files, ranked by hit count, composing with date/provider
filters.

** How
- =internal/trace/analysis.go=: =collectSidechainEvidence= keeps the
full final report; =buildTurn= bounds the step-level light ref at
attach time. Full evidence lives once, keyed by agent_id — the shape
the code already claimed.
- =internal/render/terminal.go=: color constants became package vars
behind =setColors(enabled)=; =printMessage= computes child depth from
the =IsSidechain=/=AgentID= boundary instead of unconditional +1.
- =internal/cmd/view.go=: =resolveColorMode= maps the flag; auto uses
=os.ModeCharDevice= (stdlib; no isatty dependency).
- =internal/cmd/search.go=: =contentMatches= scans =<id>.jsonl= plus
=<id>/subagents/*.jsonl= with the 10MB scanner budget.

** Decisions
| Decision | Alternatives | Rationale | DRI | Timestamp |
| Grep-parity raw-line matching for --content | parse each line, match text fields only | The complaint was false negatives vs grep; raw scan is provider-agnostic, cheap, and misses nothing grep finds. Structure-key false positives are rare for real topic queries. | agent | 2026-08-02 |
| Content scan is a crawl (~15s full store) | build SQLite FTS index first | No message index exists (db = stars/tags only); index lifecycle is its own feature. Crawl lands the workflow now; index is the follow-up. | agent | 2026-08-02 |
| Step-level sidechain refs stay bounded | untruncate everywhere | Light refs exist to keep turns readable; full evidence lives once at top level, keyed by agent_id. | agent | 2026-08-02 |
| Depth = agent-boundary crossings only | cap depth at N; keep +1 per message | Sequential messages are siblings in fact; a cap would hide, not fix, the wrong model. | agent | 2026-08-02 |

** Notes
- The friction report's "SQLite index presumably makes content search
cheap" was wrong — the db stores stars/tags only. Worth an FTS issue.
- Verification detour: the harness shell aliased =grep= to a function
that swallowed matched output, which briefly looked like a fifth bug
(messages missing from view). =/usr/bin/grep= restored reality:
67 goose matches in the piped view, no =-a= needed.
- =view= still truncates tool results to 10 lines by design; --content
search is the tool for exhaustive content questions, not view+grep.
- Two-axis review follow-ups applied: content control chars scrubbed in
every color mode (spec asked for the audit; embedded escapes leaked),
=--content= switched to unbounded line reads (10MB scanner budget
silently dropped matches past image-carrying lines), subagent layout
knowledge moved to =parser.SubagentFiles= with =agent-= prefix parity.
- Deferred, known: file-based subagent transcripts parse as extra root
messages, so =view= renders sidechains flat after the main chain
rather than nested under the spawning call. Correct nesting needs a
parser tree change (affects turns/web) — separate issue, not a
release blocker.
93 changes: 85 additions & 8 deletions internal/cmd/search.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
Expand All @@ -21,11 +23,16 @@ var searchCmd = &cobra.Command{
Short: "Search across projects and sessions",
Long: `Search for projects and sessions by name or summary.

With --content, also scan transcript lines inside session files
(including subagent files) — grep parity, but with session identity,
provider abstraction, and date filters.

Examples:
ccx search auth # Find sessions about authentication
ccx search myproject # Find project by name
ccx search "fix bug" # Multi-word search
ccx search -t session # Only search sessions`,
ccx search auth # Find sessions about authentication
ccx search myproject # Find project by name
ccx search "fix bug" # Multi-word search
ccx search -t session # Only search sessions
ccx search --content goose # Scan message content (slower)`,
Args: cobra.MinimumNArgs(1),
RunE: runSearch,
}
Expand All @@ -38,6 +45,7 @@ var (
searchAfter string
searchBefore string
searchModel string
searchContent bool
)

func init() {
Expand All @@ -48,6 +56,7 @@ func init() {
searchCmd.Flags().StringVar(&searchAfter, "after", "", "sessions after date (YYYY-MM-DD)")
searchCmd.Flags().StringVar(&searchBefore, "before", "", "sessions before date (YYYY-MM-DD)")
searchCmd.Flags().StringVar(&searchModel, "model", "", "filter by model name substring")
searchCmd.Flags().BoolVar(&searchContent, "content", false, "also scan message content in session files (slower)")

rootCmd.AddCommand(searchCmd)
}
Expand All @@ -58,6 +67,7 @@ type searchResult struct {
Session string `json:"session,omitempty"`
Summary string `json:"summary"`
Time string `json:"time,omitempty"`
Matches int `json:"matches,omitempty"`
Priority int `json:"-"`
}

Expand Down Expand Up @@ -159,6 +169,24 @@ func runSearch(cmd *cobra.Command, args []string) error {
Time: formatAge(s.StartTime),
Priority: 2,
})
continue
}

// Content scan: raw transcript lines, main file plus subagent
// files. Grep parity by design — no parse, so it works for
// every provider's format and misses nothing grep would find.
if searchContent {
if n := countContentMatches(s.FilePath, query); n > 0 {
results = append(results, searchResult{
Type: "content",
Project: projDisplay,
Session: truncateID(s.ID, 8),
Summary: fmt.Sprintf("%d hits · %s", n, sessionSummaryPreview(s.Summary, 48)),
Time: formatAge(s.StartTime),
Matches: n,
Priority: 3,
})
}
}
}
}
Expand Down Expand Up @@ -187,13 +215,18 @@ func runSearch(cmd *cobra.Command, args []string) error {
}
}

// Sort by priority
sort.Slice(results, func(i, j int) bool {
return results[i].Priority < results[j].Priority
// Sort by priority, then by match count within content results.
// Stable so equal-rank results keep discovery order across runs.
sort.SliceStable(results, func(i, j int) bool {
if results[i].Priority != results[j].Priority {
return results[i].Priority < results[j].Priority
}
return results[i].Matches > results[j].Matches
})

// Limit results
// Limit results — never silently.
if searchLimit > 0 && len(results) > searchLimit {
fmt.Fprintf(os.Stderr, "showing %d of %d results (raise with -n)\n", searchLimit, len(results))
results = results[:searchLimit]
}

Expand Down Expand Up @@ -270,3 +303,47 @@ func truncateID(id string, max int) string {
}
return id[:max]
}

// countContentMatches counts transcript lines containing query across
// the main session file and any subagent files beside it (layout
// knowledge lives in parser.SubagentFiles; providers without subagent
// files simply contribute none).
func countContentMatches(sessionPath, query string) int {
if sessionPath == "" {
return 0
}
count := countMatchingLines(sessionPath, query)
for _, f := range parser.SubagentFiles(sessionPath) {
count += countMatchingLines(f, query)
}
return count
}

// countMatchingLines streams one JSONL file and counts lines matching
// query case-insensitively. bufio.Reader, not Scanner: transcript
// lines carrying embedded images exceed any fixed budget, and a
// silent early stop is exactly the false-negative class --content
// exists to kill. Unreadable files warn instead of lying "0 hits".
func countMatchingLines(path, query string) int {
file, err := os.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: skipping unreadable %s: %v\n", filepath.Base(path), err)
return 0
}
defer file.Close()

count := 0
reader := bufio.NewReaderSize(file, 64*1024)
for {
line, err := reader.ReadString('\n')
if line != "" && strings.Contains(strings.ToLower(line), query) {
count++
}
if err != nil {
if err != io.EOF {
fmt.Fprintf(os.Stderr, "warning: read error in %s: %v\n", filepath.Base(path), err)
}
return count
}
}
}
80 changes: 80 additions & 0 deletions internal/cmd/search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestCountMatchingLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "s.jsonl")
content := `{"type":"user","text":"tell me about Pi-Agent"}
{"type":"assistant","text":"nothing relevant"}
{"type":"assistant","text":"pi-agent uses ACP"}
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := countMatchingLines(path, "pi-agent"); got != 2 {
t.Fatalf("case-insensitive matches: got %d, want 2", got)
}
if got := countMatchingLines(path, "absent-term"); got != 0 {
t.Fatalf("no-match count: got %d, want 0", got)
}
if got := countMatchingLines(filepath.Join(dir, "missing.jsonl"), "x"); got != 0 {
t.Fatalf("missing file must count 0, got %d", got)
}
}

// countContentMatches must also cover subagent transcripts, which live
// in <id>/subagents/agent-*.jsonl beside the main <id>.jsonl — and
// must count exactly the files view --show-agents renders.
func TestCountContentMatchesIncludesSubagents(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "abc-123.jsonl")
if err := os.WriteFile(main, []byte(`{"text":"goose in main"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
subDir := filepath.Join(dir, "abc-123", "subagents")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatal(err)
}
sub := `{"text":"goose in sidechain"}
{"text":"more goose here"}
`
if err := os.WriteFile(filepath.Join(subDir, "agent-1.jsonl"), []byte(sub), 0o644); err != nil {
t.Fatal(err)
}
// Non-jsonl files (meta.json) and jsonl without the agent- prefix
// must be skipped, matching what the session parser loads.
if err := os.WriteFile(filepath.Join(subDir, "agent-1.meta.json"), []byte(`{"text":"goose meta"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "notes.jsonl"), []byte(`{"text":"goose notes"}`), 0o644); err != nil {
t.Fatal(err)
}

if got := countContentMatches(main, "goose"); got != 3 {
t.Fatalf("main+subagent matches: got %d, want 3", got)
}
if got := countContentMatches("", "goose"); got != 0 {
t.Fatalf("empty path must count 0, got %d", got)
}
}

// Grep parity must survive lines larger than any fixed scanner budget
// (transcript lines with embedded images run past 10MB).
func TestCountMatchingLinesOversizedLine(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.jsonl")
huge := `{"pad":"` + strings.Repeat("x", 11*1024*1024) + `"}`
content := huge + "\n" + `{"text":"needle after the giant line"}` + "\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := countMatchingLines(path, "needle"); got != 1 {
t.Fatalf("match after oversized line: got %d, want 1", got)
}
}
24 changes: 24 additions & 0 deletions internal/cmd/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var (
viewFlat bool
viewBrief bool
viewAll bool
viewColor string
)

func init() {
Expand All @@ -48,6 +49,23 @@ func init() {
viewCmd.Flags().BoolVar(&viewShowAgents, "show-agents", false, "show agent sidechains")
viewCmd.Flags().BoolVar(&viewFlat, "flat", false, "disable tree rendering")
viewCmd.Flags().BoolVarP(&viewBrief, "brief", "b", false, "conversation only: human input, agent responses, compactions")
viewCmd.Flags().StringVar(&viewColor, "color", "auto", "colorize output: auto, always, never")
}

// resolveColorMode maps --color to a concrete decision; auto follows
// whether stdout is a terminal, so piped output stays grep-clean.
func resolveColorMode(mode string) (bool, error) {
switch mode {
case "auto":
info, err := os.Stdout.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0, nil
case "always":
return true, nil
case "never":
return false, nil
default:
return false, fmt.Errorf("invalid --color %q (valid: auto, always, never)", mode)
}
}

func runView(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -87,10 +105,16 @@ func runView(cmd *cobra.Command, args []string) error {
fullSession = render.BriefSession(fullSession)
}

color, err := resolveColorMode(viewColor)
if err != nil {
return err
}

opts := render.TerminalOptions{
ShowThinking: viewShowThinking,
ShowAgents: viewShowAgents,
FlatMode: viewFlat,
Color: color,
Theme: config.Theme(),
}

Expand Down
Loading
Loading