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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ When `session.enabled` is true (default) and `listener.session_api_addr` is non-
| Method & Path | Format | Purpose |
|---|---|---|
| `GET /` | text | One-line-per-endpoint index. Answers "is this the session API, and on the right port?" — the reason a 404 here was worth replacing. |
| `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext}]}`. `id`, `createdAt`, `updatedAt`, `eventCount` and `active` are always present; every other field is `omitempty` — absent rather than zero, on the standing rule that an unknown value must not render as a real one. (Do not read that off the position of `active`: it sits second-to-last, between two `omitempty` fields.) **`title` is a suggestion, not an identifier:** the proxy derives it from the session's own events (a `/rename`, else a `<user_query>`, else ordinary user prose, with `<system-reminder>` blocks excised), so it is a display convenience and nothing addresses a session by it. Absent when nothing in the events named it. **Folded at append time and FIRST-WINS, except that a `/rename` always overrides** — so ordinary conversation does not re-title a session on every turn, and a `/rename` survives eviction of the event that carried it. abctl does not read this field yet — its TITLE column still comes from harvested Claude Code transcripts, and reconciling the two is outstanding. |
| `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext}]}`. `id`, `createdAt`, `updatedAt`, `eventCount` and `active` are always present; every other field is `omitempty` — absent rather than zero, on the standing rule that an unknown value must not render as a real one. (Do not read that off the position of `active`: it sits second-to-last, between two `omitempty` fields.) **`title` is a suggestion, not an identifier:** the proxy derives it from the session's own events (a `/rename`, else a `<user_query>`, else ordinary user prose, with `<system-reminder>` blocks excised), so it is a display convenience and nothing addresses a session by it. Absent when nothing in the events named it. **Folded at append time and FIRST-WINS, except that a `/rename` always overrides** — so ordinary conversation does not re-title a session on every turn, and a `/rename` survives eviction of the event that carried it. abctl reads this field as a FALLBACK: its TITLE column prefers a harvested Claude Code transcript title and uses the served title only for a session the harvest cannot name. That precedence is fixed rather than a judgement about which string is better — both sides rank candidates their own way and do not agree on every session. That is the case worth having: an agent with no transcript tree on the operator's disk still routes through the proxy, so a row that used to render blank now has a name. abctl deliberately still treats such a row as unnamed for its own re-harvest backoff, so a served title does not stop it looking for a harvested one. |
| `GET /v1/sessions/{id}` | `application/json` | The session's most recent events. `?limit=N` (default 500, max 2000) sets the window; `?before=<seq>` returns the page ending just before that event, so the whole session is reachable by paging backward from the tail. `totalEvents` is the session's true length and `oldestSeq` the oldest event the store still holds — both present only when this response is not the whole session, so a client can tell "this is the beginning" from "there is more behind me" without a second request. 404 if unknown/expired. **One response is still not a full snapshot:** with `session.max_events` unset a session can hold thousands of events, and one real session's whole history encoded to 1.1GB — 17s to write, against clients that time out in 10. That cap is why `before` exists — until it did, a session past 2000 events had a beginning no request could reach at any limit, while still costing memory. The response is written one event at a time rather than encoded whole, so serving it costs the proxy heap proportional to one event; see the chatty-traffic gotcha below. |
| `GET /v1/events` | `text/event-stream` | SSE stream of new events. Optional `?session=<id>` filters to one session. Heartbeat every 30s. |
| `GET /v1/pipeline` | `application/json` | Active pipeline composition: `{inbound: [...], outbound: [...]}`. Each plugin entry carries `name`, `direction`, `position`, `readsBody`, plus the static metadata (`requires`, `requiresAny`, `description`) and runtime `config` when present. abctl renders this as the Pipeline pane. |
Expand Down
51 changes: 34 additions & 17 deletions cmd/abctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ reads those transcripts and writes what it finds to
`~/.cortex/session-metadata.json`, so the sessions table can show a `TITLE`
column instead of a bare id.

This is **one of two** routes to a name, and the one this section is about. The
proxy also derives a title from a session's own events and serves it on
`/v1/sessions`; `TITLE` prefers the harvested name and falls back to that one,
so the column can be populated for a session with no transcript here at all.
Everything below concerns the harvest only — including `--skip-claude-metadata`,
which suppresses this route and not the served fallback.

The scan runs in the **background**, while the viewer is already up: `abctl observe` paints
immediately and the titles appear when the scan finishes — usually before you have
picked a pod. The viewer opens with whatever titles the last run recorded, so a scan
Expand All @@ -150,15 +157,20 @@ full scan, and milliseconds once the file exists.
Pass `--skip-claude-metadata` when the scan is unwanted, or when `~/.claude` should
simply not be touched. It suppresses only the *scan*: the viewer still reads
`~/.cortex/session-metadata.json`, so titles recorded by earlier runs keep rendering and
only sessions new or renamed since the last scan show as bare ids. There is no flag that
hides titles already on disk — delete the file for that.

A harvest that cannot run is never fatal — the worst a missing or unreadable file
costs is the `TITLE` column, and the viewer still opens. A file that does not parse is
rebuilt from the transcripts rather than costing anything; the entries a rebuild cannot
recover are sessions whose transcripts Claude Code has already pruned. The failures that
need a human, such as an unreadable metadata file, print one line to stderr with the
repair before the viewer starts; success says nothing.
only sessions new or renamed since the last scan go unharvested — and those still show the
title the proxy serves, if it derived one, rather than a bare id. There is no flag that
hides titles already on disk — delete the file for that, and note that it does not suppress
the served title either, which arrives over the API and not from any file.

A harvest that cannot run is never fatal, and it costs less than it used to: the viewer
still opens, and a session the proxy has named still shows that name, because the served
title arrives over the API and not from this file. What a missing or unreadable file
costs is therefore the `TITLE` column only for sessions the proxy has not named — which,
on a machine whose agents all route through the proxy, may be none of them. A file that
does not parse is rebuilt from the transcripts rather than costing anything; the entries
a rebuild cannot recover are sessions whose transcripts Claude Code has already pruned.
The failures that need a human, such as an unreadable metadata file, print one line to
stderr with the repair before the viewer starts; success says nothing.

The config directory is `CLAUDE_CONFIG_DIR` when set, and `~/.claude`
otherwise. To read a different directory, or to force a full re-read of every
Expand Down Expand Up @@ -577,15 +589,20 @@ abctl is for, and the other three are surfaces you visit and leave.

- **Sessions** (default): table of active sessions in the store, most
recently updated first. Columns: session (truncated), title, updated
(relative), event count, tokens, cost, saved, context. `TITLE` is populated
from Claude Code's transcripts — see
(relative), event count, tokens, cost, saved, context. `TITLE` comes from
Claude Code's transcripts — see
[`--skip-claude-metadata`](#naming-sessions-from-claude-code---skip-claude-metadata)
— and is empty for a session nothing has harvested. The proxy now also derives
a title of its own from the session's events and reports it as `title` on
`/v1/sessions`; **this pane does not read that field yet**, so a harvested
title is still the only thing that fills this column. Reconciling the two is
outstanding work. Numerics are right-aligned
so the digits line up between rows.
— and **falls back to the title the proxy serves** on `/v1/sessions`, which it
derives from the session's own events. So a session with no transcript on this
machine can still be named, and the cell is empty when neither source names it
— or when the proxy has stopped listing the session, since a row kept alive by
its cached events alone has no summary to carry a served title. A session named
only by the proxy therefore loses its name at that point while its events
remain, which is the one case where a title visibly disappears. The harvested
title wins when both exist — a fixed precedence, not a claim that it is always
the better string; the two sides rank candidates differently and may not agree
on a given session. The column does not say which source it used. Numerics are
right-aligned so the digits line up between rows.

`CONTEXT(1M)` is a gauge, not a figure: how full the **conversation's**
context was on its latest turn, against a fixed one-million-token window. The
Expand Down
8 changes: 6 additions & 2 deletions cmd/abctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ func wantsInfoFlagOnly(args []string) bool {
// which is why this exists at all.
func observeHarvester(f observeFlags, warn io.Writer) tui.HarvestFunc {
// Nil under --skip-claude-metadata, which is what turns the harvest off: the viewer then
// shows whatever titles the metadata file already held, from the last run.
// shows whatever titles the metadata file already held, from the last run — AND the titles
// /v1/sessions serves, which this flag does not touch. It declines a filesystem scan, not
// naming: a session the proxy has named still shows that name with the harvest off entirely.
// The flag help one line below says the same; both are here because "harvest off" reads as
// "no titles" and has stopped meaning that.
if *f.skipClaudeMetadata {
return nil
}
Expand Down Expand Up @@ -405,7 +409,7 @@ func registerObserveFlags(fs *flag.FlagSet) observeFlags {
// flag to decline is narrower, and it is the reason to keep it: a machine where
// ~/.claude should simply not be touched.
skipClaudeMetadata: fs.Bool("skip-claude-metadata", false,
"do not harvest session titles from Claude Code's transcripts. By default abctl observe scans CLAUDE_CONFIG_DIR / ~/.claude in the background once the viewer is up and records titles in ~/.cortex/session-metadata.json, so sessions show a name instead of a bare UUID. This skips the scan; titles already recorded by earlier runs are still shown, so only sessions new or renamed since the last scan appear as bare ids."),
"do not harvest session titles from Claude Code's transcripts. By default abctl observe scans CLAUDE_CONFIG_DIR / ~/.claude in the background once the viewer is up and records titles in ~/.cortex/session-metadata.json, so sessions show a name instead of a bare UUID. This skips the scan; titles already recorded by earlier runs are still shown, and a session the harvest has not named falls back to the title the proxy serves, so a bare id usually means neither source named it — except for a session the proxy has stopped listing, whose served title is not retained and so goes away with the listing."),
}
}

Expand Down
21 changes: 17 additions & 4 deletions cmd/abctl/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,16 @@ type model struct {
// events stop carrying the evidence (view=summary strips it) while the answer stays true.
// Which turn wins is not a question of size — see pipeline.PromptContextFold.
contextRun map[string]pipeline.PromptContextFold
// sessionsData is what an agent knows about its own sessions that the proxy does
// not — a title, mostly. Read once at startup from ~/.cortex/session-metadata.json,
// which `abctl experimental read-claude-sessions` writes; empty when that has never
// run, which renders as an empty TITLE column rather than as a failure.
// sessionsData is what the HARVEST knows about an agent's sessions — a title, mostly.
// Read once at startup from ~/.cortex/session-metadata.json, which
// `abctl experimental read-claude-sessions` writes; empty when that has never run,
// which is not a failure.
//
// NO LONGER THE ONLY THING THAT NAMES A SESSION, and this doc claimed both halves of
// that. It is not "what the proxy does not know": /v1/sessions serves a title derived
// from the session's own events, and sessionTitleFor falls back to it. So an empty map
// renders an empty TITLE column only for sessions the proxy has not named either — the
// two sources overlap rather than partition. This map still WINS where both have one.
//
// Keyed by the same session id the proxy buckets on, so a lookup is direct. Nil-safe
// by construction: a read on a nil map yields the zero SessionMetadata, so an
Expand Down Expand Up @@ -1299,6 +1305,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Repaint what names sessions. The sessions table is the only place a title is
// rendered into a cell; every other user of sessionLabel builds its text on each
// View, so those pick the new names up on the next frame with nothing to do here.
//
// Two inputs name a session now, not one: this map and the served title on
// m.sessions (see sessionTitleFor). Only the harvest needs a rebuild triggered
// here, because m.sessions is replaced by the two-second list refresh, which
// rebuilds the table on its own path. So the dependency set is wider than this
// call site suggests — a future input that names sessions and does NOT already
// rebuild needs its own repaint.
m.rebuildSessionsTable()
}
return m, nil
Expand Down
Loading
Loading