Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b956ef0
feat: Report where each session's inference traffic goes
huang195 Oct 8, 2026
2aa52c4
refactor: Move the router pieces agentop's TUI needs into a package
huang195 Oct 8, 2026
267e053
feat: Warn how many running sessions use a server agentop server removes
huang195 Oct 8, 2026
03161ee
feat: Show each agent's inference server on agentop's agents pane
huang195 Oct 8, 2026
d3ec262
feat: Choose an agent's inference server with S on agentop's agents pane
huang195 Oct 8, 2026
38e58b2
feat: Serve each plugin's error policy on /v1/pipeline
huang195 Oct 8, 2026
1d30e54
fix: Treat an observing inference-router as one that routes nothing
huang195 Oct 8, 2026
2f315fb
fix: Show the whole result of a server switch
huang195 Oct 8, 2026
5f686cc
feat: Show each session's inference server in agentop's sessions table
huang195 Oct 8, 2026
4d7aafb
docs: Document S, the SERVER columns and inferenceHost
huang195 Oct 8, 2026
3beff9f
test: Pin that a denied request leaves inferenceHost where it was
huang195 Oct 8, 2026
232e784
fix: Leave a session no pin holds out of S's and remove's counts
huang195 Oct 8, 2026
a5a3115
fix: Say which sessions agentop server remove leaves erroring
huang195 Oct 8, 2026
bb3df12
docs: Say observe drops rejections, body writes and redirects only
huang195 Oct 8, 2026
72c6e3c
refactor: Name a host's server by servers.ForHost in agentop server
huang195 Oct 8, 2026
7cd3ebd
fix: Name the config file in S's reasons as agentop server does
huang195 Oct 8, 2026
5f2889f
fix: Show what S's switch did, whole, from the file, and sanitised
huang195 Oct 8, 2026
cb39ea2
docs: Say how S reports a failed switch, and every reason it refuses
huang195 Oct 8, 2026
8f1d79c
fix: Keep widening the terminal from taking AGENT or SERVER away
huang195 Oct 8, 2026
4b94ea1
Merge main into feat/agentop-server-picker
huang195 Oct 8, 2026
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,11 @@ 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}]}`. `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. 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` and `promptContext` 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. 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/{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. |
| `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. agentop renders this as the Pipeline pane. |
| `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. `onError` is the plugin's `on_error`, sent only when it is not the default — in practice only `observe`, since an `on_error: off` plugin is not built and so is not listed. A plugin under observe runs and records, but its rejections, body writes and redirects are dropped — header writes still apply — so in those respects its `config` says what it would do, not what it does. agentop renders this as the Pipeline pane. |
| `GET /v1/plugins` | `application/json` | Catalog of every registered plugin (whether or not in the active pipeline): `{plugins: [{name, requires, requiresAny, description, ...}]}`. agentop renders this as the Catalog pane (`P` key). 404s when the binary's session API was constructed without `WithCatalog`. |
| `GET /healthz` | text | Liveness probe. |

Expand Down
86 changes: 85 additions & 1 deletion cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,9 @@ agentop server reset --agent claude-code # stop routing claude-
agentop server remove glm # refused while an agent uses it
```

In the TUI, `S` on the agents pane does what `use` and `reset` do, for an agent
the pane lists; see [Choosing an agent's server](#choosing-an-agents-server-s).

With Claude Code's `ANTHROPIC_BASE_URL` at `ete`, the listing after `use` reads:

```
Expand Down Expand Up @@ -707,7 +710,14 @@ With Claude Code's `ANTHROPIC_BASE_URL` at `ete`, the listing after `use` reads:
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.
which server each session started on. 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.
- **`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 @@ -777,6 +787,17 @@ agentop is for, and the other three are surfaces you visit and leave.
on a given session. The column does not say which source it used. Numerics are
right-aligned so the digits line up between rows.

`SERVER` appears when this Cortex's `inference-router` routes — not under
`on_error: observe`, which routes nothing — and has more than one server: the
server each session's inference traffic last went to, which for a session that
was running when its agent was switched is still the one it started on. A host no
server has, such as an unrouted agent's own provider, is shown without its port in
parentheses, and a session with no inference traffic seen as an em dash. Read-only:
no key here moves a session, since that would move a running conversation. On a
terminal too narrow for it the column is left out, not squeezed, and so is
`AGENT`; neither shows until `COST` and `SAVED` do, so widening the window never
takes one away.

A **`pending:` bucket**, where an agent's calls that name no session collect
until one does, is listed after the real sessions. Its row shows the agent
under `SESSION` rather than the truncated id, and, when nothing else names it,
Expand Down Expand Up @@ -1377,6 +1398,7 @@ Layered on top of all of them:
| `A` | any session-view pane (not the picker) | open the per-agent cost breakdown — what each coding agent has spent today, plus a row of dashes for any agent that owns a listed session but has spent nothing today, since the sessions list is not limited to today and every session in it needs a row to scope to. Capital `A` because lowercase `a` cycles the spend drawer's axis. Refetches on every press and opens however many agents it finds, one or none included; pressed on the agents pane it only refreshes the rows. Not in the footer, for width; the `?` overlay names it |
| `↑↓` / `jk` | agents | move the cursor |
| `↵` | agents | scope to the agent under the cursor and list its sessions, whichever pane `A` was pressed on — including the agent already scoped, which stays scoped. **The first row, All agents, clears the scope.** It reaches the sessions list, the usage pane, and the spend band and its drawer; `agentop cost --agent` is a separate process |
| `S` | agents | choose the inference server the highlighted agent's new sessions use: a picker over the pane, its cursor on what the agent has now. `↵` applies the highlighted entry, `Esc`/`q`/`Ctrl+C` close it, and an entry the config file already holds writes nothing. Opens only on an agent's row, when agentop is attached to this machine's Cortex and its `inference-router` routes; elsewhere a panel says why. See [Choosing an agent's server](#choosing-an-agents-server-s) |
| `Esc` | agents | back to the pane `A` was pressed on, leaving the scope as it is. When the picker opened itself at startup, or was reached with `Esc` from the sessions list, it is the level above that list instead, and `Esc` backs out as `Esc` on sessions does: to the pods picker, or nowhere with `--endpoint` |
| `e` | pipeline | edit pipeline subtree in `$EDITOR` |
| `y` | edit/diff | apply the edit |
Expand Down Expand Up @@ -1446,6 +1468,68 @@ that cannot break the last hour down for one agent. Unscoped, the sessions pane
two agents are listed, and the agents pane a `SESSIONS` count once sessions name
their agent.

### Choosing an agent's server (`S`)

When this Cortex runs the [`inference-router`](../../docs/plugin-catalog.md#inference-router),
the agents pane gains a `SERVER` column: the server each agent's new sessions go to,
or `own choice` for an agent the router leaves alone. `All agents`, `Other` and
`unknown` show nothing there — routing is chosen per agent, `Other` is every client
Cortex does not recognise, and `unknown` is requests that carry no User-Agent, which
belong to no one agent. A router under `on_error: observe` records what it would
route and routes nothing, so it gets no column.

`S` on an agent's row opens a picker over the pane, its cursor on what the agent has
now: *its own choice (not routed)* first, then each server with its host and model
mapping. `↵` applies the highlighted entry, as in every agentop picker; `Esc`, `q` or
`Ctrl+C` closes it; choosing what the config file already holds writes nothing — the
file decides, not where the cursor opened, since the route can change while the
picker is up. The change is
the one `agentop server use` or `reset` makes — the same file, the same checks — and
the footer shows `[switching claude-code → glm…]` until the proxy has reloaded, then
one line, which stays until the next key:

```
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.

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
refused is the proxy's error, with the file put back and the agent where it was; a
proxy that stopped answering before it reported the reload leaves the change
unconfirmed and the file put back; an error before the write changed nothing. A
failure that lands while another pane is up is the one line instead. A proxy that
reports neither a reload nor a refusal within 30 seconds is the line too: the file is
left as written, and it names the `/reload/status` to check. A choice the file
already holds writes nothing and says so — that it is already in force, or, when the
pane has not caught up with an `agentop server use` or a hand edit, that the file
holds it. A line wider than the terminal loses its beginning, not its end, and after
every outcome the pane rereads the proxy's pipeline, so the column shows what the
proxy runs.

`S` opens only on an agent's row, when agentop is attached to this machine's Cortex —
the one `~/.cortex/config.yaml` names, its stats server answering when agentop
started — and that Cortex's router routes; `[S] server` is in the footer only there.
Elsewhere `S` opens a small panel saying why, which `Esc`, `q`, `Ctrl+C` or `↵`
closes: on `All agents` or `Other`; on `unknown`, which the router refuses to route;
away from this machine's Cortex; before agentop has read that Cortex's pipeline; while
a switch is still in flight; under `on_error: observe`, in the words `agentop server`
uses; on a router config this agentop cannot decode, which takes an agentop as new as
the proxy; and with no router on `/v1/pipeline`, which is either none configured or
one under `on_error: off` — the proxy does not build an `off` plugin, so agentop
cannot tell the two apart, and the panel names both fixes. The pane lists only agents
it has seen, so an agent that has not run yet is routed with `agentop server use
<name> --agent <agent>` instead.

## Settings

agentop remembers the events-table column selection, the sort order, and the active
Expand Down
Loading
Loading