Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,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, agent, adopted, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext, inferenceHost, inferenceHostFromHistory}]}`. `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 fourth from the end, between two `omitempty` fields.) **`inferenceHost`** is the host of the session's most recent request that carried an inference parse — after a redirect, the server it was sent to rather than the host the client named — folded at append time, latest wins, and left alone by tunnel rows, MCP calls, responses and denials; agentop's sessions-table `SERVER` column names a session's inference server from it, and both `S`'s result line and `agentop server remove`'s warning count running sessions by it. **`inferenceHostFromHistory`** is `true` when that host came only from the archive's fold, because no request the proxy's entry for the session holds has set it — a session resumed after a restart that has sent no inference since. The inference-router's pins live in memory, so after a restart nothing holds such a session where its history says: both of agentop's counts leave it out. **`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. agentop 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. agentop 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. **`?archived=true` adds history** where the proxy runs a session archive (`session.archive`, laptop only — see the chatty-traffic gotcha): every session the archive holds and memory does not, marked `resident: false`, plus a top-level `archive` object — `{bytes, maxBytes, retentionDays}` and, only when nonzero, `droppedEvents`, `writeErrors`, `droppedRenames`, `paused`, any of which means the history has gaps. A session in both is listed once, as its resident row. **That row covers the session's whole history**: `eventCount`, `totalTokens`, `costMicros`, `avoidedMicros`, `currencies`, `saturated`, `title`, `agent`, `createdAt`, `promptContext` and `inferenceHost` continue from the archive's fold of what came before this process's entry, each by its own rule, in the default list too — so with an archive and `session.max_events` unset (the default) a restart changes no figure, and without an archive they start over. Without an archive the parameter is ignored and no `archive` object is sent, which is how a client tells "no history here" from "no history yet". |
| `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, agent, adopted, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext, inferenceHost, inferenceHostFromHistory}]}`. `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 fourth from the end, between two `omitempty` fields.) **`inferenceHost`** is the host of the session's most recent request that carried an inference parse — after a redirect, the server it was sent to rather than the host the client named — folded at append time, latest wins, and left alone by tunnel rows, MCP calls, responses and denials; agentop's sessions-table `SERVER` column names a session's inference server from it, and both `S`'s result line and `agentop server remove`'s warning count running sessions by it. **`inferenceHostFromHistory`** is `true` when that host came only from the archive's fold, because no request the proxy's entry for the session holds has set it — a session resumed after a restart that has sent no inference since. On a local install the inference-router keeps its pins in the plugin store (`~/.cortex/plugin-state.json`), which a restart keeps, so both of agentop's counts include such a session. **`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. agentop 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. agentop 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. **`?archived=true` adds history** where the proxy runs a session archive (`session.archive`, laptop only — see the chatty-traffic gotcha): every session the archive holds and memory does not, marked `resident: false`, plus a top-level `archive` object — `{bytes, maxBytes, retentionDays}` and, only when nonzero, `droppedEvents`, `writeErrors`, `droppedRenames`, `paused`, any of which means the history has gaps. A session in both is listed once, as its resident row. **That row covers the session's whole history**: `eventCount`, `totalTokens`, `costMicros`, `avoidedMicros`, `currencies`, `saturated`, `title`, `agent`, `createdAt`, `promptContext` and `inferenceHost` continue from the archive's fold of what came before this process's entry, each by its own rule, in the default list too — so with an archive and `session.max_events` unset (the default) a restart changes no figure, and without an archive they start over. Without an archive the parameter is ignored and no `archive` object is sent, which is how a client tells "no history here" from "no history yet". |
| `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. **With a session archive, a page continues from disk** below the oldest event memory holds: a session resumed after a restart pages back through its whole history, one evicted from memory is served instead of 404'd, and `totalEvents`/`oldestSeq` describe the merged history. `GET /v1/sessions/{id}/events/{seq}` falls back to the archive the same way, and answers 404 rather than a neighbour when neither holds that seq. |
| `DELETE /v1/sessions` | `application/json` | Clear every session: the store, and the session archive where one runs. **The cost ledger is kept** — spend totals, no content. Answers `{sessions, archivedSessions, bytes}`; a failed disk half is a 500 adding `archiveError`, with memory cleared regardless (the store clears under its lock before the archive is waited on, up to 10s). **The one state-changing route on this unauthenticated API, so it is guarded against a browser**, each guard pinned by its own test in `core/sessionapi/clear_test.go`: DELETE only (a cross-site DELETE needs a preflight this server never approves; a simple cross-site POST needs none); a loopback `Host` — exactly `localhost`, `127.0.0.1` or `::1`, which defeats DNS rebinding; no `Origin` header; and `WithClearAllowed`, which `cmd/cortex` sets from `listener.bind_loopback_only`. A refusal is a 403 `{"error": ...}`. agentop's `X` drives it. |
| `GET /v1/events` | `text/event-stream` | SSE stream of new events. Optional `?session=<id>` filters to one session. Heartbeat every 30s. |
Expand Down
28 changes: 9 additions & 19 deletions cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,7 @@ With several LiteLLM servers, each with its own URL and key, `agentop server`
chooses which one an agent's **new** sessions use, through the
[`inference-router`](../../docs/plugin-catalog.md#inference-router) plugin. A
session stays on the server it started on, including one already running when its
agent is first routed, until the proxy restarts: a restarted proxy treats every
session it sees as a new one. Routing is opt-in per agent: until an agent is given
agent is first routed. Routing is opt-in per agent: until an agent is given
a server, its traffic goes where the agent sends it.

```sh
Expand Down Expand Up @@ -702,22 +701,17 @@ With Claude Code's `ANTHROPIC_BASE_URL` at `ete`, the listing after `use` reads:
before replacing it says which mapping it will have.
- **`use <name> --agent <agent>`** routes the agent's new sessions to the server,
including an agent that has not run yet. **`reset --agent <agent>`** stops
routing it. Either way a session already running stays where it is if it has sent
a request to one of the servers since the proxy started, which is how the router
knows where it is — unless the proxy has since dropped it from memory, which keeps
the most recently used sessions (100 by default), before the router pinned it.
routing it. Either way a session already running stays where it is: its pin holds
it, kept in `~/.cortex/plugin-state.json` so a proxy restart forgets none.
- **`remove <name>`** refuses while an agent is routed to the server, and names
the command that takes the agent off it; it also refuses the last server. A
session that started on a removed server gets a 503 asking for a new session
until the server is added back, or until the proxy restarts, which forgets
which server each session started on. That holds while its requests are
until the server is added back. That holds while its requests are
addressed to a server that is left: one addressed to the removed server's own
host is not routed at all, and goes there with the agent's own key. So when
sessions the proxy is running last sent their inference to the server, `remove`
warns how many, in those terms, before it goes ahead. It leaves
out the default and `pending:` buckets, which the router never pins, and a session
resumed after a restart that has sent no inference since, which no pin survived
to hold.
out the default and `pending:` buckets, which the router never pins.
- **`agentop server`** lists each server's host (its whole URL when it is plain
`http`), its model mapping and the agents routed to it, then checks
`~/.claude/settings.json`: that `ANTHROPIC_BASE_URL` names one of the servers,
Expand Down Expand Up @@ -751,8 +745,7 @@ the same way, says the proxy stopped answering, and exits 1. A file someone edit
while the proxy was reloading is not put back but left as found, with an error
saying so. If the proxy reports neither a reload nor a refusal within 30 seconds,
the command exits 1 and leaves the file as written. With no proxy running the file is still written, and the change applies at
the next start, to every session from then: a proxy that starts knows where no
session is. Nothing is written unless the result loads as a Cortex config and
the next start. Nothing is written unless the result loads as a Cortex config and
passes the router's own rules; what only the running proxy can check, such as
whether its build includes the router, is what a refused reload reports. A change
that changes nothing writes nothing. `--config PATH` points every form at another
Expand Down Expand Up @@ -1495,12 +1488,9 @@ New claude-code sessions → glm. 3 running sessions stay where they are.
The count is of the agent's sessions this proxy holds in memory whose inference last
went somewhere other than the new choice (for a switch back to its own choice, to any
of the servers), and is left out when there are none. A switch moves none of them:
the router keeps a running session on the server it started on until the proxy
restarts, unless the proxy dropped the session from memory before the router pinned
it (see [`agentop server`](#choosing-an-inference-server-agentop-server)). So a
session resumed after a restart that has sent no inference since is not counted: no
pin survived the restart, and its next request goes to the new choice. The sessions
table's `SERVER` column shows where each one is.
the router keeps a running session on the server it started on, across a proxy
restart too (see [`agentop server`](#choosing-an-inference-server-agentop-server)).
The sessions table's `SERVER` column shows where each one is.

When the switch does not go through, a panel over the pane says so, whole, and where
it left the config file; `Esc`, `q`, `Ctrl+C` or `↵` closes it. A reload the proxy
Expand Down
5 changes: 3 additions & 2 deletions cmd/agentop/cmd_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ that Claude Code's settings let it be routed.
A server is a gateway, such as LiteLLM, with its own URL and key. Routing is per
agent and opt-in: until "use" gives an agent a server, its traffic goes wherever
the agent sends it. A session stays on the server it started on, so a change
applies to new sessions only. A proxy restart forgets where each session is, and
treats each one it then sees as new.
applies to new sessions only, and a proxy restart moves none, since each
session's server is kept in ~/.cortex. A Claude Code conversation that began
before routing was set up stays where Claude Code sends it.

"add" reads the server's API key at a prompt that does not echo, or from stdin
with --key-stdin; it is never an argument. Adding a name that exists asks before
Expand Down
30 changes: 9 additions & 21 deletions cmd/agentop/cmd_server_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,11 @@ func serverRemove(args []string, stdout, stderr io.Writer) int {
// agent's current server, which cannot be name, since remove refuses a server an agent is
// routed to. None of them can get the error, whatever host it last sent to.
//
// IN MEMORY, NOT IN THE ARCHIVE, AND BY A REQUEST THIS PROXY SAW: the router pins a session by
// the first request it routes and keeps the pin in memory, so a session only the archive holds
// has no pin left to break, and neither has a resident one whose host is only its history's.
// SessionSummary.Active would not do — it marks the one most recently updated session.
// IN MEMORY, NOT IN THE ARCHIVE: running means a session this proxy has seen a request from.
// One resumed after a restart counts, its host its history's: the router keeps its pins in the
// plugin store, so its pin survived. One only the archive holds has sent nothing since and is
// not running. SessionSummary.Active would not do — it marks the one most recently updated
// session.
//
// 0 when the proxy does not answer, which is the quiet side for a warning: the remove itself
// is what matters, and it goes ahead either way.
Expand All @@ -317,9 +318,7 @@ func runningOn(sessionsURL string, c routerconfig.Config, name string) int {
}
n := 0
for _, s := range list {
// Its host only its history's: resumed after a restart, nothing sent since, so no pin
// survived to hold it to name (see SessionSummary.InferenceHostFromHistory).
if s.InferenceHostFromHistory || s.ID == session.DefaultSessionID || strings.HasPrefix(s.ID, session.PendingPrefix) {
if s.ID == session.DefaultSessionID || strings.HasPrefix(s.ID, session.PendingPrefix) {
continue
}
if on, ok := servers.ForHost(c, s.InferenceHost); ok && on == name {
Expand Down Expand Up @@ -423,21 +422,15 @@ func serverReset(args []string, stdout, stderr io.Writer) int {

// runningSessionsStay is what use and reset mean for sessions already running,
// said once the running proxy has the change. It holds a session by its pin or by
// the request to a server its history records. One that has sent nothing since the
// proxy started has neither, and a quiet one the store has evicted (session.max_sessions,
// least recently used first, or a configured session.ttl) has no history, so either
// looks new unless the router pinned it; hence "can be".
const runningSessionsStay = "Sessions already running stay where they are, except one that has sent nothing " +
"since the proxy last started, or that the proxy has dropped from memory (it keeps the most recently used, " +
"100 by default), which can be treated as a new one."
// the request to a server its history records.
const runningSessionsStay = "Sessions already running stay where they are."

// writeReport is what runServerWrite says about a change, by how it landed.
type writeReport struct {
// done is the change, said once the proxy has it or will load it at start.
done string
// live is what the change means for sessions already running. Said only when
// the running proxy reloaded it: that proxy's pins and history are what hold a
// running session where it is, and a proxy that starts later has neither.
// the running proxy reloaded it.
live string
// unchanged is said when there was nothing to write.
unchanged string
Expand Down Expand Up @@ -470,11 +463,6 @@ func runServerWrite(stdout, stderr io.Writer, path, statsURL string, ch edit.Con
say(r.done, r.live, r.note)
case edit.WriteNotRunning:
next := fmt.Sprintf("Written to %s. No Cortex answered at its stats address, so this applies when the proxy next starts.", shown)
if r.live != "" {
// Not r.live: a proxy that starts holds no pins and no history.
next += " A proxy that starts knows where no session is, so it treats every session it sees as a new one, " +
"the ones running now included."
}
say(r.done, next, r.note)
case edit.WriteReloadFailed:
// WritePluginConfig has put the file back, so it does not hold the change;
Expand Down
Loading
Loading