# render-session

Single-binary tool (Rust + rmcp + axum) for viewing AI session output.
Provides an MCP server for write operations and an HTTP viewer for read-only browsing.

Release notes are tracked in [CHANGELOG.md](CHANGELOG.md).

## Phase status

| Phase | Status | Scope |
|---|---|---|
| 1 (scaffold) | done | Workspace layout, crate skeletons, binary entry points |
| 1 (HTTP serve) | done | `serve --port N --dir D`; axum router, rust-embed index.html, `/api/list` + `/api/md/{id}` |
| 1 (MCP tools) | done | viewer.lease / list / release / report.write / draft.write |
| 2 (config-gen) | done | 3-layer config merger (`extends:` chains, cycle detection), `gen --project D` emitting `list.json` + `render-lanes.md` |
| 3 (block IF) | done | `Item.block_type` announce + `X-Block-Type` HTTP header. Concrete plugins deferred to frontend. |
| 4 (a) source (session) | done | `sources/session` module + `capture` subcommand. Legacy `render-session.py` session / recent modes ported. |
| 4 (b) filter middleware | done | `filters` module with `Filter` trait + 4 built-in (`HasTable` / `HasMermaid` / `KeywordMatch` / `RegexMatch`) + `FilterRegistry` + `FilterChainConfig`. `capture_recent` supports config-driven filter chain via `categories.recent.filter` yaml field. When no config is present (`filter = None`), a default `any-of` chain (`has-table` / `has-box` / `has-mermaid`) is applied automatically — `has_visual_artifact` hardcode removed. |
| 4 (c) viewer watcher | done | `serve --watch-tick <secs>` and `viewer.lease(tick=<secs>)` enable a background `tokio::spawn` watcher loop that calls `auto_capture_once` every <tick> seconds. Idempotent emit (existing files skipped). Hook-based capture (`UserPromptSubmit`) DEPRECATED in favor of viewer-watcher. |
| 4 (d) config strategy | done | figment-based 3-layer config (user-global → project → lane) + env overrides. `config::load` + `LoadedConfig` + `config show/info/doctor` CLI. `RENDER_SESSION_CONFIG` path override + `RENDER_SESSION_<KEY>` value overrides with `__` path separator. See `docs/runbook/config-strategy.md`. |
| 5 (a) read-path unify | done | `build_list` / `resolve_id` read category dirs via `config::resolve_category_dir`, matching Phase 4e write-side. `/api/list` now returns files from config-specified dirs (e.g. `categories.session.dir`). Fallback to `<project>/render-site/<category>/` preserved when no config is present. |
| 5 (a) info | done | `render-session info` CLI + `info` MCP tool. Reports effective config layers, per-category mount dirs (with per-key source_layer provenance: env / lane / project / user-global / default), and active viewer lease status. Format: `table` (default) or `json`. |
| 5 (b/c) data.root SSOT | done | **BREAKING**: default write root changed from `<project>/render-site/` to `<project>/.claude/site/`. New `config::data_root` / `category_dir` / `site_shell` SSOT functions replace 5 scattered path wrappers. `data.root` provenance tracked per-key in `InfoReport`. See Config section for restore instructions. |
| viewer lifecycle mode | done | Three lifecycle modes (`detached` / `session` / `launchd`) controlled by `RENDER_SESSION_VIEWER__LIFECYCLE` env or `viewer.lifecycle` in `.render-session.yaml`. `detached` (default) uses double-fork for MCP session isolation; viewer persists after MCP session ends. `viewer_lease` reuses a live viewer via `{data.root}/viewer.json` PID file (atomic write + `kill(pid,0)` liveness check). CLI `viewer {list,kill,plist}` subcommands and `serve --lifecycle` flag added. `~/.render-session/known-projects.json` registry tracks all leased projects. See `docs/runbook/viewer-lifecycle.md`. |
| has-box filter + badge | done | `HasBox` ASCII box detector (3-line state machine; top `┌`/`╭` → middle `│` → bottom `└`/`╰`). Per-builtin config toggle via `FiltersConfig.builtins.<name>` (individual disable; no global flag). Viewer row badges (T/B/M) as anchor links jumping to the matched block. See Config → Filters section. |
| filter combinators | done | `any-of` / `all-of` combinator steps in `FilterChainConfig`. `FilterStep` now accepts `children: [...]` for recursive YAML config. `any-of` short-circuits on the first matching child; `all-of` short-circuits on the first failing child. Combinator names never appear in matched-filters badges — only leaf filter names propagate (Crux C1). Disabled builtins inside children are treated as Drop via existing env-override path. See Config → Filters section. |
| viewer lane cap+toggle | done | Shared `makeCapToggle(items, renderItem, opts)` helper defined once inside `buildTabs()` and called by all four lanes (recent / reports / session / drafts). Items beyond cap=5 are hidden; a `<button aria-expanded>` toggle (W3C ARIA APG Disclosure pattern) is appended when extras exist. The drafts lane calls the helper per `DraftGroup` (per-theme-group cap; flat slice prohibited). Session lane normalises `Option<DocEntry>` to a one-element array. Toggle state resets to collapsed on page reload. API shape unchanged. |
| 5+ | pending | rss / jira / github / linear / slack-export source plugins; `SourcePlugin` trait concrete impls. Frontend block plugins (markdown-it / mermaid.js), SPA viewer integration. |

## Build

```sh
cargo build
```

## Run

```sh
# Show available subcommands
cargo run -p render-session -- --help

# Start MCP server (stdio) — called by .mcp.json clients
# render-session-mcp reads stdin / writes stdout following MCP protocol
cargo run -p render-session-mcp
# or via the CLI alias (thin exec wrapper):
cargo run -p render-session -- mcp

# Start HTTP viewer
# Serves GET / (index.html), GET /api/list, GET /api/md/{id}
# Reads markdown from category dirs resolved via config (categories.<cat>.dir),
# falling back to <dir>/render-site/{drafts,reports,session,recent}/ when no config is present.
cargo run -p render-session -- serve --port 8000 --dir /path/to/project

# Write a report file (reads body from stdin)
echo "## Summary\n\nContent here." | cargo run -p render-session -- report --dir /path/to/project --title "My Report"

# Generate list.json + render-lanes.md from project config
# Emits: <D>/.claude/site/list.json and <D>/.claude/site/rules/render-lanes.md (Phase 5b/c default)
cargo run -p render-session -- gen --project /path/to/project

# Capture Claude Code session output
# Reads from ~/.claude/projects/<slug>/*.jsonl (slug derived from project path if omitted)
# session mode: emits <D>/.claude/site/session/latest.md
cargo run -p render-session -- capture --project /path/to/project --mode session
# recent mode: emits <D>/.claude/site/recent/<turn_id>-<ts>.md for last N visual-artifact turns
cargo run -p render-session -- capture --project /path/to/project --mode recent --n 5
# both (default): session + recent
cargo run -p render-session -- capture --project /path/to/project

# Phase 4 (c): start viewer with built-in watcher (auto-captures every 5 seconds)
render-session serve --port 8000 --dir /path/to/project --watch-tick 5
# or via MCP: { "tool": "viewer.lease", "args": { "dir": "/path/to/project", "tick": 5 } }

# Phase 5 (a) info: show effective config layers, mount dirs, and viewer status
# Table output (default)
render-session info --project /path/to/project
# JSON output
render-session info --project /path/to/project --format json
# Via MCP: { "tool": "info", "args": { "dir": "/path/to/project", "format": "table" } }

# Viewer lifecycle mode: start viewer with explicit lifecycle (default: detached)
render-session serve --port 8000 --dir /path/to/project --lifecycle detached
render-session serve --port 8000 --dir /path/to/project --lifecycle session --watch-tick 5

# Viewer CLI subcommands
render-session viewer list                        # list active viewer leases
render-session viewer kill --pid 12345            # kill viewer by PID
render-session viewer kill --all                  # kill all active viewers
render-session viewer kill --dir /path/to/project # kill viewer(s) for a directory
render-session viewer plist --dir /path/to/project # generate launchd plist (macOS only)
```

## Config

render-session loads configuration from 3 layers + env overrides (Outline rust §1-9-1 F-2):

- **user-global**: `~/.config/render-session/config.yaml`
  (override path with `RENDER_SESSION_CONFIG=/path/to/config.yaml`)
- **project**: `<project>/.render-session.yaml`
- **lane**: `<project>/workspace/render-session/lane.yaml`
- **env override**: `RENDER_SESSION_<KEY>` with `__` for nested keys
  (e.g. `RENDER_SESSION_CATEGORIES__RECENT__ENABLED=false`)

Later layers override earlier ones. Missing files are silently skipped.

### Inspect config

```sh
# Show effective merged config
render-session config show --project ./my-project

# Show effective config with per-value provenance (which layer set each value)
render-session config show --project ./my-project --origin

# List layer paths and active env overrides
render-session config info --project ./my-project

# Health check (path existence + env override value validation)
render-session config doctor --project ./my-project
```

See [`docs/runbook/config-strategy.md`](docs/runbook/config-strategy.md) for full details.

### Filters

Built-in filters (`has-table`, `has-mermaid`, `has-box`) are enabled by default. Each can be toggled
independently via config or env override without changing code:

```yaml
# .render-session.yaml
filters:
  builtins:
    has-box: false      # disable ASCII box detector
    has-mermaid: false  # disable mermaid fence detector
    # has-table omitted → remains ON (absent key = enabled)
```

Or via env override (double-underscore figment path separator):

```sh
RENDER_SESSION_FILTERS__BUILTINS__HAS_BOX=false render-session capture --project ./my-project
```

Viewer row badges (T = table, B = box, M = mermaid) appear as anchor links in the latest/recent lane.
Clicking a badge scrolls the markdown preview to the corresponding block.

#### Combinator steps (`any-of` / `all-of`)

Use `any-of` to pass a turn when **at least one** child filter matches, or `all-of` to require
**all** children to match. Children are evaluated with short-circuit semantics (first Pass for
`any-of`, first Drop for `all-of`).

```yaml
# .render-session.yaml — keep turns that have a table OR a box
categories:
  recent:
    filter:
      mode: all
      steps:
        - name: any-of
          children:
            - name: has-table
            - name: has-box
```

Combinators can be nested:

```yaml
# Keep turns that match a keyword AND (have a table OR a box)
categories:
  recent:
    filter:
      mode: all
      steps:
        - name: keyword-match
          config:
            keywords: ["summary"]
        - name: any-of
          children:
            - name: has-table
            - name: has-box
```

Leaf filter names (e.g. `has-table`, `has-box`) continue to appear in matched-filters badges;
combinator names (`any-of`, `all-of`) are never shown in the badge UI.

### Viewer lifecycle

Control how the HTTP viewer process is managed:

```sh
# Via env override (double-underscore separator required)
export RENDER_SESSION_VIEWER__LIFECYCLE=detached   # default: persist after MCP session ends
export RENDER_SESSION_VIEWER__LIFECYCLE=session    # exit with MCP session
export RENDER_SESSION_VIEWER__LIFECYCLE=launchd    # plist-only, managed by macOS launchd
```

Or in `.render-session.yaml`:

```yaml
viewer:
  lifecycle: detached   # detached | session | launchd
```

`viewer_lease` reuses a live viewer when `{data.root}/viewer.json` records a live PID; stale entries
are discarded and a new viewer is spawned. See [`docs/runbook/viewer-lifecycle.md`](docs/runbook/viewer-lifecycle.md) for full details,
launchd setup, PID file format, and troubleshooting.

### Data root and write paths (Phase 5b/c)

As of Phase 5b/c the default write root is `<project>/.claude/site/`.  Category dirs resolve as:

```
<project>/.claude/site/session/   # capture --mode session
<project>/.claude/site/recent/    # capture --mode recent
<project>/.claude/site/reports/   # report.write MCP / render-session report
<project>/.claude/site/drafts/    # draft.write MCP
<project>/.claude/site/           # gen list.json + render-lanes.md
```

`categories.<cat>.dir` explicit overrides take precedence over the data root (Crux 1 invariant):

```yaml
# .render-session.yaml
categories:
  recent:
    dir: custom/path/recent   # overrides .claude/site/recent
```

**To restore the previous `render-site/` layout** (migration):

```yaml
# .render-session.yaml
data:
  root: render-site
```

or via env override (no config file required):

```sh
RENDER_SESSION_DATA__ROOT=render-site render-session capture --project ./my-project
```

The `viewer.lease` dir argument remains unchanged — it points to the project root and the viewer
resolves the internal path to `.claude/site/site/` automatically.

## License

Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)

at your option.
