diff --git a/README.en.md b/README.en.md
index 365c387..ffaed69 100644
--- a/README.en.md
+++ b/README.en.md
@@ -10,6 +10,7 @@ Turns your local git repositories and their GitHub issues into static HTML, and
- **All issues in one place too**: open issues from every repo in a single list
- **Multiple GitHub accounts**: pull issues from separate accounts, such as personal and work, and keep them apart
- **Light on a phone**: static HTML with no JavaScript, so it opens quickly while you're away
+- **Extensible**: a single Python module can add your own pages or an issue source. A bundled example shows Claude Code session history ([Extensions](#extensions))
@@ -106,6 +107,20 @@ An extension has only the functions it needs from the following.
`ctx` holds the config, the scanned repositories, the output directories, the `[plugins.]` table (`ctx.options`), and `ctx.read(repo, path)` to read a file at HEAD.
+### Example: show Claude Code sessions
+
+[`examples/plugins/sessions.py`](examples/plugins/sessions.py) reads Claude Code sessions from [ccreel](https://github.com/yktsnet/ccreel)'s `list --json` / `show --json` and adds a list page and a page per session. It needs `ccreel` on your `PATH`. Copy it next to `codeview.toml` and list it in the config to try it.
+
+```toml
+[plugins]
+modules = ["sessions"]
+
+[plugins.sessions]
+days = 14 # how far back to show (default 30)
+```
+
+A `sessions` entry appears in the left menu of the top page, and each session page links to its issue and repository. The available keys are described at the top of `sessions.py`.
+
## Publishing
The generated files contain the code of every repository. **Keep the repository you publish from (the one holding `codeview.toml`) private, and never put the generated files anywhere public.**
diff --git a/README.md b/README.md
index 8bc8f85..55321b3 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
- **Issue も1か所にまとまる**: 全リポの未消化の Issue を1つの一覧にする
- **GitHub の複数アカウントに対応**: 個人用と仕事用のように、別々のアカウントの Issue を分けて取り込める
- **スマホで軽い**: JavaScript なしの静的 HTML なので、外出先でもすぐ開ける
+- **拡張でページを足せる**: Python のモジュール1つで、自前のページや Issue の取り込み元を足せる。実例として、Claude Code のセッション履歴を載せる拡張を同梱している([Extensions](#extensions))
@@ -106,6 +107,20 @@ gh_config_dir = "~/.config/gh-work" # 仕事のアカウントで gh auth logi
`ctx` は設定・走査したリポの一覧・出力先・`[plugins.<名前>]` の表(`ctx.options`)・HEAD のファイルを読む `ctx.read(repo, path)` を持つ。
+### 例: Claude Code のセッションを載せる
+
+[`examples/plugins/sessions.py`](examples/plugins/sessions.py) は、[ccreel](https://github.com/yktsnet/ccreel) の `list --json` / `show --json` から Claude Code のセッションを読み、一覧と本文のページを足す拡張である。ccreel が `PATH` に要る。`codeview.toml` の隣へコピーして、設定に挙げれば試せる。
+
+```toml
+[plugins]
+modules = ["sessions"]
+
+[plugins.sessions]
+days = 14 # 載せる期間(既定 30)
+```
+
+トップの左メニューに `sessions` が加わり、セッションごとのページから Issue とリポへ飛べる。設定できるキーは `sessions.py` の先頭の説明にある。
+
## Publishing
生成物には全リポのコードが入る。**配信するリポ(`codeview.toml` を置いたリポ)は private にし、生成物を公開の場所に置かない。**
diff --git a/context/structure.md b/context/structure.md
index e6fa56e..00c0d8e 100644
--- a/context/structure.md
+++ b/context/structure.md
@@ -15,9 +15,13 @@ codeview/
cloudflare.py Cloudflare API と資格情報
cf_setup.py Pages プロジェクト・カスタムドメイン・Access を作る
cf_status.py Pages プロジェクトと直近のデプロイを表示する
+examples/
+ plugins/
+ sessions.py 拡張の実例。ccreel のセッションを一覧と本文のページにする(本体のパッケージには入らない)
tests/
conftest.py 見本のリポ群・設定・偽の gh を作るフィクスチャ
test_*.py build・issues・plugins・publish の外から見た振る舞い
+ test_example_sessions.py 例の拡張 sessions.py の振る舞い。偽の ccreel で確かめる
```
## データの流れ
diff --git a/docs/guarantees.md b/docs/guarantees.md
index b70a6ea..cbc6033 100644
--- a/docs/guarantees.md
+++ b/docs/guarantees.md
@@ -139,6 +139,36 @@
| 旧形式の状態 | `test_publish_if_changed_republishes_over_old_state` |
| 間隔の値の検証 | `test_check_every_hours_must_be_positive_integer` |
+### 5. `tests/test_example_sessions.py` — 例の拡張 `examples/plugins/sessions.py`
+
+- `[plugins.sessions]` の `config_dirs` ごとに `ccreel list --json --limit 0 --since d --config-dir ` を呼び、全部をまとめて新しい順に `public/sessions/index.html` へ一覧を書く。各セッションの本文を `ccreel show --json` から `public/sessions/.html` に書く
+- 本文のページには発言と応答のテキストを出し、応答の Markdown は生の HTML・`
+"""
+
+COPY_SCRIPT = r"""
+
+"""
+
+# 本文のページは public/ の外に置いて使い回す。本体は生成のたびに public/ を空にする
+CACHE_INDEX = "index.json"
+
+# 1回の生成のあいだ、side と build と fingerprint で同じ一覧を使う
+_cache: dict[str, list[dict]] = {}
+
+
+def _opts(ctx) -> dict:
+ where = "config: [plugins.sessions]"
+ opts = {"ccreel": "ccreel", "config_dirs": ["~/.claude"], "days": 30, "every_hours": 3, **ctx.options}
+
+ def fail(msg: str):
+ from codeview.config import ConfigError
+
+ raise ConfigError(f"{where}: {msg}")
+
+ if not isinstance(opts["ccreel"], str) or not opts["ccreel"]:
+ fail("'ccreel' must be a non-empty string")
+ dirs = opts["config_dirs"]
+ if not isinstance(dirs, list) or not dirs or not all(isinstance(d, str) and d for d in dirs):
+ fail("'config_dirs' must be a non-empty list of strings")
+ for key in ("days", "every_hours"):
+ if isinstance(opts[key], bool) or not isinstance(opts[key], int) or opts[key] <= 0:
+ fail(f"'{key}' must be a positive integer")
+ unknown = set(opts) - {"ccreel", "config_dirs", "days", "every_hours"}
+ if unknown:
+ fail(f"unknown key '{sorted(unknown)[0]}'")
+ return opts
+
+
+def _ccreel(opts: dict, config_dir: str, *args: str) -> str:
+ cmd = [os.path.expanduser(opts["ccreel"]), *args, "--config-dir", os.path.expanduser(config_dir)]
+ return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=120).stdout
+
+
+def _ts(value: str) -> int:
+ return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
+
+
+def _sessions(ctx) -> list[dict]:
+ """config dir をまとめた新しい順の一覧。ccreel が無い・失敗した config dir は警告して飛ばし、生成は止めない。"""
+ if "list" in _cache:
+ return _cache["list"]
+ opts = _opts(ctx)
+ rows: list[dict] = []
+ for config_dir in opts["config_dirs"]:
+ found: list[dict] = []
+ try:
+ out = _ccreel(opts, config_dir, "list", "--json", "--limit", "0", "--since", f"{opts['days']}d")
+ for line in out.splitlines():
+ if line.strip():
+ row = json.loads(line)
+ row["ts"] = _ts(row["updated"])
+ row["started_ts"] = _ts(row["started"])
+ row["config_dir"] = config_dir
+ found.append(row)
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError) as e:
+ detail = getattr(e, "stderr", None) or str(e)
+ print(f"warning: sessions: ccreel list failed for {config_dir}, skip: {detail.strip()}", file=sys.stderr)
+ continue
+ rows += found
+ rows.sort(key=lambda r: r["ts"], reverse=True)
+ _cache["list"] = rows
+ return rows
+
+
+def _dir_label(ctx, r: dict) -> str | None:
+ """config dir が複数のときだけ、どこのセッションかを示す名前を返す。"""
+ if len(_opts(ctx)["config_dirs"]) < 2:
+ return None
+ return Path(r["config_dir"]).name
+
+
+def _turns(opts: dict, r: dict) -> list[dict] | None:
+ sid = r["id"]
+ try:
+ out = _ccreel(opts, r["config_dir"], "show", sid, "--json", "--max-chars", "20000", "--max-total", "0")
+ return [json.loads(line) for line in out.splitlines() if line.strip()]
+ except (OSError, subprocess.SubprocessError, ValueError) as e:
+ detail = getattr(e, "stderr", None) or str(e)
+ print(f"warning: sessions: ccreel show {sid} failed: {detail.strip()}", file=sys.stderr)
+ return None
+
+
+def _time(ts: int) -> str:
+ # 本体の TIME_SCRIPT が開いた時点の相対時刻に書き換える。JS が無ければ日時のまま読める
+ stamp = f"{datetime.fromtimestamp(ts):%Y-%m-%d %H:%M}"
+ return f''
+
+
+def _issue_href(public: Path, repo: str, num: str) -> str | None:
+ """取り込み元の拡張が書いた Issue のページ。番号は先頭の 0 を無視して比べる。消化済みならページは無い。"""
+ for page in sorted(public.glob(f"issues/*/{repo}/*.html")):
+ if page.stem.isdigit() and int(page.stem) == int(num):
+ return "/" + page.relative_to(public).as_posix()
+ return None
+
+
+def _repo_slug(ctx, name: str) -> str | None:
+ return next((r.slug for r in ctx.repos if r.path.name == name), None)
+
+
+def css(ctx) -> str:
+ # 本体が最初に呼ぶ口なので、設定の誤りをここで ConfigError にして、public/ に触れる前に止める
+ _opts(ctx)
+ return CSS
+
+
+def side(ctx) -> str:
+ rows = _sessions(ctx)
+ if not rows:
+ return ""
+ return (
+ '
{"".join(_row(r, _dir_label(ctx, r)) for r in rows)}
'
+ '
No matching sessions
'
+ )
+
+
+def _turn_card(t: dict, script_collector: list[str]) -> str:
+ from codeview.render import render_markdown
+
+ esc = html.escape
+ ts = _ts(t["time"]) if t.get("time") else None
+ when = _time(ts) if ts else ""
+ head = f'
turn {t["turn"]}{when}
'
+ prompt = f'
{esc(t["prompt"])}
'
+
+ text = "\n\n".join(m["text"] for m in t.get("messages", []) if m.get("kind") == "text" and m.get("text"))
+ if text.strip():
+ body, script = render_markdown(text.encode(), safe=True)
+ if 'class="mermaid"' in body:
+ script_collector.append(script)
+ reply = f'
{body}
'
+ else:
+ reply = ""
+
+ return f'
{head}
{prompt}{reply}
'
+
+
+def _session_main(r: dict, deps: dict, turns: list[dict] | None) -> tuple[str, str]:
+ esc = html.escape
+ title = r["title"] or r.get("first_prompt") or "(no title)"
+ title = " ".join(title.split())
+ scripts: list[str] = []
+ sub = [f"{esc(r['repo'])}"]
+ if r.get("issue"):
+ href = deps["issue_href"]
+ sub.append(
+ f'#{esc(r["issue"])}'
+ if href
+ else f"#{esc(r['issue'])}"
+ )
+ label = deps["label"]
+ if label:
+ sub.append(f'{esc(label)}')
+ if r.get("branch"):
+ sub.append(f'{esc(r["branch"])}')
+ sub.append(f"{r['prompts']} prompts")
+
+ links = [
+ '