agent: add `pm agent serve` daemon for live session tracking
A headless daemon (stdlib threaded HTTP + SSE, ephemeral WAL SQLite) that
ingests best-effort hook events from Claude/Codex/Cursor and broadcasts live
session state to observers (the Emacs sidebar, curl).
- serve/{server,store,status,fallback}: HTTP+SSE server, WAL store, pure
status-derivation, and a Tier-2 transcript fallback poller. Status vocab:
working / waiting_response / waiting_permission / idle / unknown, with
openui-style activity-gating and a pid-liveness cleanup backstop.
- install-hooks: a shared stdlib reporter installed for Claude (settings.json),
Codex (a --profile pm config, since isaac regenerates ~/.codex/hooks.json),
and Cursor (~/.cursor/hooks.json, incl. shell/MCP approval). Skips the
Claude-hook double-report Cursor triggers.
- transcript: shared title library (custom-title > ai-title > synthetic >
first prompt) that also parses Codex rollouts natively; the fallback fills
source-reader titles (e.g. Cursor) for sessions it cannot parse.
- config [serve], opt-in WAL in sqlite_db, systemd unit, tests.
Add pm agent ls: list recent Claude / Codex / Cursor sessions per project
Centralizes the "what was I working on in this project?" answer that
was previously scattered across three private on-disk stores with no
cross-agent view. Each pm project section shows the N most recent
sessions across every agent, with the full session id so it can be
pasted straight into `claude --resume <id>`, `codex resume <id>`, or
`cursor-agent --resume <id>`.
## UX
`pm agent ls [-p NAME | --all] [--agent claude,codex,cursor] [-n N]
[--json]`
- Default scope follows the existing `detect_current_project` helper:
inside a pm project, shows that project only; outside any project,
shows every project. `--project NAME` and `--all` are mutually
exclusive (same pattern as `WtSelection`).
- `-n/--limit` defaults to **15** for a single project and **5** per
project when multiple are listed. Explicit `-n N` always wins.
- `--agent claude,codex` narrows to a subset; unknown names raise
`ValueError` so a typo fails fast instead of silently returning
nothing.
- Per-agent color (blue / green / magenta) on the Agent column; full
UUID on the Session column so no follow-up lookup is needed.
- Projects with zero sessions across every selected agent still get a
section with `-` placeholders in every data column — lets the user
tell "no chats" apart from "project doesn't exist".
## Source adapters
Each agent adapter in `agent/sources/` exposes one `async fetch(owned,
limit) -> list[SessionEntry]` entry point. Blocking I/O (sqlite
connections, file walks, JSONL reads) is wrapped in
`asyncio.to_thread` so the orchestrator can fan out with
`asyncio.TaskGroup` at both project and source levels — N projects ×
M agents runs concurrently, and any adapter failure propagates as an
`ExceptionGroup` instead of being silently swallowed (return-empty
semantics are reserved for "agent not installed", i.e. the store
directory is missing).
- **Claude** (`~/.claude/projects/<enc>/<uuid>.jsonl`): path encoding
is `str(p).replace("/", "-").replace(".", "-")`. Title priority:
most-recent `<synthetic>` assistant message whose content starts
with `"Summary:"` (Claude's compaction summaries) → first `user`
message that is not a system-injected wrapper
(`<local-command-caveat>`, `<bash-input>`, `<system-reminder>`,
etc.). `<synthetic>` is also used for non-response stubs
(`"No response requested."`, 54× in this repo's history) and API
error envelopes, so matching bare `model == "<synthetic>"` would
surface those as titles — the `Summary:` prefix check filters them
out.
- **Codex** (`~/.codex/state_5.sqlite`): one `file:...?mode=ro` query
against the indexed `threads` table, filtered by `cwd IN (...)` and
`(title != '' OR first_user_message != '')`, ordered by
`updated_at`. Title falls back from `title` to `first_user_message`.
- **Cursor**: joins two stores — `~/.cursor/projects/<enc>/agent-
transcripts/<uuid>/` lists sessions per cwd, and
`~/.config/cursor/chats/<workspace>/<uuid>/store.db` holds the
hex-encoded JSON meta blob (`name`, `createdAt`) in
`meta.value WHERE key='0'`. Encoding is `[/.]+` → `-` (collapses
runs — so `/home/a.b/.projects/x` becomes
`home-a-b-projects-x`, not `home-a-b--projects-x`). Title prefers
the store `name`; falls back to the first `<user_query>` in the
transcript JSONL.
## Scope mapping (agent/scope.py)
`owned_paths(paths, project)` returns the set of cwds an agent might
record for a pm project: the project dir itself, its resolved real
path, each worktree symlink inside the project, and each symlink's
resolved target. `~/.projects/foo/bar` and
`~/.worktrees/<repo>/<uuid>` both match back to project `foo`.
Broken symlinks are silently skipped via a `try/except OSError`
wrapper around `Path.resolve`.
## Empty sessions
Sessions with no real user content (transcript is only
`<bash-input>` / `<local-command-*>` wrappers, or Codex row has both
empty title and first_user_message) are filtered at the source level.
Each scanning source (Claude, Cursor) walks candidates in mtime order
until it has `limit` valid results or runs out — a fixed-multiplier
overfetch would miss valid sessions when empties cluster at the top.
## Scope / registry helpers
- `cli/_shared.py`: new `ProjectOrAllScope` dataclass and
`resolve_project_scope` helper, shared infrastructure so future
`pm agent <verb>` subcommands inherit the same "current project or
all" defaulting without duplication.
- `agent/sources/__init__.py`: `REGISTRY` + `parse_agents` for the
`--agent` flag.
## Bug fix: render.emit_sections on empty input
`max(int, *empty_gen)` raised `TypeError` because `max(single_int)`
treats the int as a non-iterable. Early-return when no sections to
render — previously any command calling `emit_sections([])` crashed.
## Tests
- `tests/agent/test_scope.py`: owned-path collection including real
worktree resolution, graceful handling of broken symlinks.
- `tests/agent/test_sources_claude.py`: encoding, synthetic-summary
preference, non-summary synthetics ignored, system-injected user
messages skipped, bash-only sessions dropped, limit backfills past
empty sessions (covers the pathological "N empties newer than M
reals" case).
- `tests/agent/test_sources_codex.py`: cwd filter + ordering,
archived rows hidden, title / first_user_message fallback, empty-
row filter, limit.
- `tests/agent/test_sources_cursor.py`: path encoding regression
(`[/.]+` → `-`), store-name wins over transcript prompt, transcript
fallback when no store.db, empty sessions dropped.
- `tests/agent/test_ls.py`: cross-source merge + sort, limit after
merge, placeholder row for projects with no sessions, exception
propagation via `BaseExceptionGroup`, concurrency smoke test
(three sources sleeping in `to_thread`, wall time well below
serial).
agent: add `pm agent serve` daemon for live session tracking
A headless daemon (stdlib threaded HTTP + SSE, ephemeral WAL SQLite) that
ingests best-effort hook events from Claude/Codex/Cursor and broadcasts live
session state to observers (the Emacs sidebar, curl).
- serve/{server,store,status,fallback}: HTTP+SSE server, WAL store, pure
status-derivation, and a Tier-2 transcript fallback poller. Status vocab:
working / waiting_response / waiting_permission / idle / unknown, with
openui-style activity-gating and a pid-liveness cleanup backstop.
- install-hooks: a shared stdlib reporter installed for Claude (settings.json),
Codex (a --profile pm config, since isaac regenerates ~/.codex/hooks.json),
and Cursor (~/.cursor/hooks.json, incl. shell/MCP approval). Skips the
Claude-hook double-report Cursor triggers.
- transcript: shared title library (custom-title > ai-title > synthetic >
first prompt) that also parses Codex rollouts natively; the fallback fills
source-reader titles (e.g. Cursor) for sessions it cannot parse.
- config [serve], opt-in WAL in sqlite_db, systemd unit, tests.