2026-07-05-live-serve-sessions.md
docs/superpowers/plans/2026-07-05-live-serve-sessions.md
Live Serve Sessions Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Make the tesserae serve page's session data current by reading the harness roots live — a new /api/sessions endpoint and a live /api/transcript-search (merged with the index), both backed by a bounded recent-window root scan — plus a --max-turns cap on summary/decisions and max_turns on the live endpoints.
Architecture: A shared tesserae/live_sessions.py reuses activity_summary.iter_project_transcripts (with a recent-window mtime floor, so scans stay fast) to list current sessions and search recent turns live. serve.py exposes these as origin-gated JSON endpoints; the Sessions page fetches /api/sessions and renders live, falling back to the compiled content on static hosting. The typed knowledge graph is unchanged.
Tech Stack: Python 3 stdlib, http.server (existing serve), pytest; JS fetch in the generated site. Reuses existing modules; no new deps.
Global Constraints
- Live scans are recent-window bounded (
days, default 30) — themtime >= window.startprune keeps a full-root scan fast; old immutable sessions stay served from the index/compiled graph. - Origin-gated:
/api/sessionsand/api/transcript-searchreuse the existing clip-origin gate (self._clip_origin()), never emitAccess-Control-Allow-Origin, and reject cross-site browsers — they expose local history. - Graceful fallback: the Sessions page must render its compiled content when
/api/sessionsis absent (static gh-pages, no server). --max-turnsmaps toscan_messages(turn_limit=...); default is the current effectively-unbounded100_000.- Reuse:
iter_project_transcripts,parse_ts,in_window,Window,_claude_turns,_codex_turns,_parse_jsonl. - One commit per task; conventional messages.
File Structure
- Modify
tesserae/cli.py—--max-turnson thesummary+decisionsparsers/handlers →scan_messages(turn_limit=...). - Modify
tesserae/activity_summary.py— threadturn_limitfrombuild_summaryintoscan_messages;tesserae/decisions.py— thread it intogather_decisions. - Create
tesserae/live_sessions.py—live_session_list,live_transcript_search(+_recent_window). - Modify
tesserae/serve.py—/api/sessionsin both handlers; repoint_run_transcript_searchto merge live + index; parsedays/max_turns. - Modify
tesserae/site/pages.py(+js.pyif needed) — Sessions page fetches/api/sessions, renders live with fallback. - Create
tests/test_live_sessions.py; extendtests/test_decisions.py/ a CLI test for--max-turns.
Task 1: --max-turns on summary + decisions
Files: Modify tesserae/cli.py, tesserae/activity_summary.py, tesserae/decisions.py; Test tests/test_live_sessions.py
Interfaces:
build_summary(windows, project_names=None, *, synthesize=True, write=True, turn_limit=100_000)— passesturn_limitto itsscan_messagescall.gather_decisions(windows, project_names=None, *, include_agent=True, turn_limit=100_000)— passesturn_limitto itsscan_messagescall (human parse is unaffected — it reads raw rows).- CLI:
summary/decisionsgain--max-turns N(int, default None → unbounded);_handle_summary/_handle_decisionsmapturn_limit = args.max_turns or 100_000. - [ ] Step 1: Write the failing test (
tests/test_live_sessions.py)
def test_scan_messages_respects_turn_limit(tmp_path, monkeypatch):
import os, json
from datetime import datetime, timezone
import tesserae.activity_summary as A
from tesserae.activity_summary import scan_messages, resolve_windows
from tesserae.harness_sessions import _claude_project_dir
proj = tmp_path / "proj"; proj.mkdir()
root = tmp_path / ".claude-acct"
d = root / "projects" / _claude_project_dir(proj); d.mkdir(parents=True)
rows = [{"type": "user", "timestamp": f"2026-07-04T10:00:0{i}Z",
"message": {"role": "user", "content": [{"type": "text", "text": f"t{i}"}]}}
for i in range(5)]
tx = d / "s.jsonl"; tx.write_text("\n".join(json.dumps(r) for r in rows))
stamp = datetime(2026, 7, 4, 10, tzinfo=timezone.utc).timestamp(); os.utime(tx, (stamp, stamp))
monkeypatch.setattr(A, "discover_harness_roots", lambda: [root])
monkeypatch.setattr(A, "_root_supports_claude", lambda r: True)
monkeypatch.setattr(A, "_root_supports_codex", lambda r: False)
(w,) = resolve_windows(day="2026-07-04", tz=timezone.utc)
out = scan_messages([("proj", proj)], [w], turn_limit=2)
assert len(out["proj"]["2026-07-04"]) == 2 # capped
- [ ] Step 2: Run to verify it fails —
scan_messagesalready takesturn_limit, but the cap is applied AFTER window filtering today. Verify current behaviour: it may already pass — if so, this test just locks it in; the real change is threading the CLI flag. Run:.venv/bin/python -m pytest tests/test_live_sessions.py::test_scan_messages_respects_turn_limit -v. - [ ] Step 3: Implement —
scan_messagesalready takesturn_limitand passes it to_claude_turns/_codex_turns(the parser cap), which bounds the parsed turns before windowing — soturn_limit=2yields ≤2 turns. Confirm this holds; if the cap is only on the parser (pre-window) the test asserts the pre-window slice. Then thread the flag: addturn_limitparam tobuild_summaryandgather_decisions, forwarding to theirscan_messages(...)calls; add--max-turns(type=int, default=None) to_build_summary_parserand_build_decisions_parser; in_handle_summary/_handle_decisionscomputeturn_limit = args.max_turns or 100_000and pass it. - [ ] Step 4: Run —
.venv/bin/python -m pytest tests/test_live_sessions.py tests/test_cli_summary.py tests/test_decisions.py -q→ PASS. - [ ] Step 5: Commit —
git add -A && git commit -m "feat(cli): --max-turns on summary + decisions"
Task 2: tesserae/live_sessions.py — live session list + transcript search
Files: Create tesserae/live_sessions.py; Test tests/test_live_sessions.py
Interfaces:
_recent_window(days: int) -> Window—Window(now-days, now, "recent")in KST (reuseactivity_summary.KST/_local_tz).live_session_list(projects: Sequence[Tuple[str, object]], *, days: int = 30, max_turns: int = 100_000) -> list[dict]— one dict per session touched in the lastdays:{"session_id","project","harness","account","turns","first_ts","last_ts","preview"}(preview = first user turn's text, ≤160 chars). Sorted bylast_tsdesc.live_transcript_search(query: str, projects, *, days: int = 30, max_turns: int = 100_000, limit: int = 20) -> list[dict]— turns whose text containsquery(casefold), each{"session_id","project","harness","ts","role","text"}(text ≤240 chars), newest first, capped tolimit.- [ ] Step 1: Write the failing test
def _seed(tmp_path, monkeypatch, texts, day="2026-07-04"):
import os, json
from datetime import datetime, timezone
import tesserae.activity_summary as A
from tesserae.harness_sessions import _claude_project_dir
proj = tmp_path / "proj"; proj.mkdir()
root = tmp_path / ".claude-acct"
d = root / "projects" / _claude_project_dir(proj); d.mkdir(parents=True)
rows = [{"type": "user" if i % 2 == 0 else "assistant",
"timestamp": f"{day}T10:0{i}:00Z",
"message": {"role": "user", "content": [{"type": "text", "text": t}]}}
for i, t in enumerate(texts)]
tx = d / "s.jsonl"; tx.write_text("\n".join(json.dumps(r) for r in rows))
stamp = datetime(2026, 7, 4, 10, tzinfo=timezone.utc).timestamp(); os.utime(tx, (stamp, stamp))
monkeypatch.setattr(A, "discover_harness_roots", lambda: [root])
monkeypatch.setattr(A, "_root_supports_claude", lambda r: True)
monkeypatch.setattr(A, "_root_supports_codex", lambda r: False)
return proj
def test_live_session_list_and_search(tmp_path, monkeypatch):
from tesserae.live_sessions import live_session_list, live_transcript_search
proj = _seed(tmp_path, monkeypatch, ["design the parser", "ok done", "ship it"])
# A huge window so 'recent' catches the fixture regardless of wall clock:
sessions = live_session_list([("proj", proj)], days=100000)
assert len(sessions) == 1
s = sessions[0]
assert s["project"] == "proj" and s["turns"] == 3
assert s["account"] == ".claude-acct" and "parser" in s["preview"]
hits = live_transcript_search("parser", [("proj", proj)], days=100000)
assert len(hits) == 1 and "parser" in hits[0]["text"]
assert live_transcript_search("nonexistent", [("proj", proj)], days=100000) == []
- [ ] Step 2: Run to verify it fails — module not found.
- [ ] Step 3: Implement
tesserae/live_sessions.py:
"""Live session views for `tesserae serve` — read the harness roots directly
(recent-window bounded) so the served page never lags the way the compiled
projection can. Reuses the activity-summary discovery + parsing."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List, Sequence, Tuple
from tesserae.activity_summary import (
KST, Window, in_window, iter_project_transcripts, parse_ts,
)
from tesserae.harness_sessions import _parse_jsonl, _claude_turns, _codex_turns
def _recent_window(days: int) -> Window:
now = datetime.now(KST)
return Window(now - timedelta(days=max(1, days)), now, "recent")
def _turns_for(harness: str, rows, max_turns: int):
if harness == "codex":
return _codex_turns(rows, limit=max_turns)
return _claude_turns(rows, limit=max_turns)
def live_session_list(projects, *, days: int = 30, max_turns: int = 100_000):
window = _recent_window(days)
out: List[dict] = []
for name, harness, path, key in iter_project_transcripts(projects, [window]):
rows = _parse_jsonl(path)
stamped = []
for t in _turns_for(harness, rows, max_turns):
ts = parse_ts(str(t.get("timestamp") or ""))
if ts and in_window(ts, window):
stamped.append((ts, t))
if not stamped:
continue
stamped.sort(key=lambda p: p[0])
preview = next(
(str(t.get("text") or "")[:160] for _ts, t in stamped if t.get("role") == "user"),
"",
)
out.append({
"session_id": key, "project": name, "harness": harness,
"account": key.split(":", 1)[0], "turns": len(stamped),
"first_ts": stamped[0][0].isoformat(), "last_ts": stamped[-1][0].isoformat(),
"preview": preview,
})
out.sort(key=lambda s: s["last_ts"], reverse=True)
return out
def live_transcript_search(query, projects, *, days: int = 30, max_turns: int = 100_000, limit: int = 20):
q = (query or "").casefold()
if not q:
return []
window = _recent_window(days)
hits: List[Tuple[datetime, dict]] = []
for name, harness, path, key in iter_project_transcripts(projects, [window]):
rows = _parse_jsonl(path)
for t in _turns_for(harness, rows, max_turns):
text = str(t.get("text") or "")
ts = parse_ts(str(t.get("timestamp") or ""))
if ts and in_window(ts, window) and q in text.casefold():
hits.append((ts, {
"session_id": key, "project": name, "harness": harness,
"ts": ts.isoformat(), "role": str(t.get("role") or ""),
"text": text[:240],
}))
hits.sort(key=lambda p: p[0], reverse=True)
return [h for _ts, h in hits[: max(1, int(limit))]]
- [ ] Step 4: Run —
.venv/bin/python -m pytest tests/test_live_sessions.py -q→ PASS. - [ ] Step 5: Commit —
git add tesserae/live_sessions.py tests/test_live_sessions.py && git commit -m "feat(serve): live_sessions core — list + search harness roots directly"
Task 3: serve /api/sessions + live-merged /api/transcript-search
Files: Modify tesserae/serve.py; Test tests/test_live_sessions.py
Interfaces:
- New module helper
_run_sessions(handler, query_string, *, project_root, project_name)→handler._send_json(200, {"sessions": live_session_list([(project_name, project_root)], days=?, max_turns=?)});days/max_turnsparsed from the query (defaults 30 / 100_000). _run_transcript_searchgainsproject_root/project_name: it now callslive_transcript_search(...)and merges the hits ahead of the indexsearch_transcripts(...)["results"], deduped by(session_id, ts)where derivable, capped tolimit; returns{"available": True, "results": merged, "total": len(merged), "live": len(live_hits)}.- [ ] Step 1: Read
serve.py147-170 (_run_transcript_search), 289-331 (_AskAwareHandler.do_GET, incl.self.project_root,self._clip_origin,self._send_json), and 700-730 (the fleet handler's transcript-search call, which passes a per-aliasroot). Note the exact index-result item shape returned bysearch_transcripts(runmemex searchonce or inspect.results[0]) so the merged live hits carry compatible keys — if the memex item hastext/snippet+session+ts, map the live hit'stext→that field. - [ ] Step 2: Write the failing test (endpoint-level, via the module helpers with a fake handler):
def test_run_sessions_and_search_merge(tmp_path, monkeypatch):
import tesserae.serve as S
proj = _seed(tmp_path, monkeypatch, ["design the parser", "reply"])
monkeypatch.setattr(S, "search_transcripts",
lambda *a, **k: {"available": True, "results": [{"text": "old indexed hit"}], "total": 1},
raising=False)
sent = {}
class H: # minimal fake handler
project_root = proj
def _send_json(self, status, body): sent["status"] = status; sent["body"] = body
S._run_sessions(H(), "days=100000", project_root=proj, project_name="proj")
assert sent["status"] == 200 and sent["body"]["sessions"][0]["project"] == "proj"
S._run_transcript_search(H(), "q=parser", project=None, project_root=proj, project_name="proj")
results = sent["body"]["results"]
assert any("parser" in (r.get("text") or "") for r in results) # live hit present
assert any(r.get("text") == "old indexed hit" for r in results) # index hit merged
- [ ] Step 3: Implement — add
_run_sessions; extend_run_transcript_searchto acceptproject_root=None, project_name=None, run the live search when a root is given, and merge. Wire/api/sessionsinto_AskAwareHandler.do_GET(origin-gated, same as transcript-search) passingself.project_rootand its name; passproject_root/project_nameinto the existing transcript-search call. Do the same in the fleet handler (_run_sessions/_run_transcript_searchwith the per-aliasroot). - [ ] Step 4: Run —
.venv/bin/python -m pytest tests/test_live_sessions.py -q→ PASS. - [ ] Step 5: Commit —
git add tesserae/serve.py tests/test_live_sessions.py && git commit -m "feat(serve): /api/sessions live endpoint + live-merged transcript-search"
Task 4: Sessions page renders live with fallback
Files: Modify tesserae/site/pages.py (Sessions index generation); Test: Playwright screenshot
Interfaces:
- The generated
sessions/index.htmlincludes a small inline script: on load,fetch('/api/sessions'); on a 200, replace/prepend a "Live sessions" list rendered from the JSON (session_id/account/turns/last_ts/preview); on any error or non-200 (static hosting), leave the compiled content untouched. - [ ] Step 1: Read how
pages.pygenerates the sessions index (around thecard(title="Sessions", href="sessions/index.html"…)producer and the sessions page renderer). - [ ] Step 2: Add the inline fetch+render script to the sessions index page (guarded, best-effort, fallback-safe).
- [ ] Step 3:
.venv/bin/python -m tesserae export site;tesserae serve --port 8480(background); Playwrightbrowser_navigateto/tesserae/sessions/and screenshot — confirm the live list renders under serve. Then confirm the page still renders its compiled content when/api/sessions404s (open the built file directly / static). - [ ] Step 4: Commit —
git add tesserae/site/pages.py && git commit -m "feat(site): sessions page renders live /api/sessions with compiled fallback"
Task 5: Final verification
- [ ]
.venv/bin/python -m pytest tests/test_live_sessions.py tests/test_decisions.py tests/test_activity_summary_gather.py tests/test_activity_summary_render.py tests/test_cli_summary.py tests/test_cli_command_table.py -q— green. - [ ] Real drive:
tesserae serverunning →GET /api/sessionsreturns current sessions;GET /api/transcript-search?q=<recent term>returns fresh live hits ahead of index hits. Screenshot the live Sessions page.
Self-review notes
- Coverage: live session list (T2), live transcript search + merge (T2/T3),
/api/sessionsendpoint (T3), page live+fallback (T4),--max-turnsall surfaces (T1 +max_turnsparams in T2/T3), recent-window bound (T2_recent_window), origin gate (T3), graceful static fallback (T4). Mapped. - Type consistency:
live_session_list(projects, *, days, max_turns) -> list[dict],live_transcript_search(query, projects, *, days, max_turns, limit) -> list[dict],_recent_window(days) -> Window,build_summary(..., turn_limit=…),gather_decisions(..., turn_limit=…)— consistent. - Confirm during execution: the memex index result-item shape (T3 Step 1) so merged live hits carry compatible keys; the exact
pages.pysessions-index producer (T4 Step 1).