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 @@ -680,7 +680,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
- `dialAddr` — the address the egress guard checked and the proxy dialed, `"ip:port"`, on a tunnel's open row when `listener.egress_guard` is on. With `host` it is the audit trail against DNS rebinding: under the guard the proxy resolves once, checks that answer, and dials exactly it, so the two fields together say "asked for the name, reached this address". Absent when the guard is off and on rows that dialed nothing.
- `httpMethod`, `httpPath` — the HTTP verb and path, so a request no parser recognized is still identifiable rather than showing only a host. Distinct from the `method` inside `a2a` / `mcp`, which is a protocol method name. On an opaque tunnel `httpMethod` is `CONNECT` and `httpPath` is absent — opaque bytes carry no request line. The path is query-stripped and percent-decoded, so query-borne credentials never reach the timeline, but a secret in a path *segment* (a bot token, a webhook path) does survive on this unauthenticated surface — worth knowing before exporting events off-box.
- `subagent` — present on rows from a Claude Code subagent or agent-team teammate: the `X-Claude-Code-Agent-Id` the request carried. For a subagent that is also the `<id>` in `~/.claude/projects/<dir>/<session>/subagents/agent-<id>.meta.json`; for a teammate the join is unverified. Resolved by `Context.Subagent`, pinned with the User-Agent by `ResolveClient` so no plugin can re-file a row, and **refused rather than sanitised** when unusable (control runes, over 128 bytes), because it is a join key and a cleaned-up id joins nothing. It does not move the row: a subagent's requests stay in their parent's session, so the header must not be added to `session.id_headers`. **It names an agent, it does not classify the row:** `inference.agentRole` stays the field that says whether a row is a subagent's, and a teammate's row is `agentRole: main` with `subagent` set — Claude Code writes `cc_is_subagent=true` only for `agentType: subagent`.
- `parentSubagent` — the agent that started `subagent`, from `X-Claude-Code-Parent-Agent-Id`, resolved with it in one memo (`Context.ParentSubagent`) and under the same rules. Claude Code sends it only when the spawner was itself an agent, so **absent on a row that names a `subagent` means the conversation started it**, not "unknown". The two keys are the tree's node and its edge.
- `parentSubagent` — the agent that started `subagent`, from `X-Claude-Code-Parent-Agent-Id`, resolved with it in one memo (`Context.ParentSubagent`) and under the same rules. Claude Code sends it only when the spawner was itself an agent, so **absent on a row that names a `subagent` means the conversation started it**, not "unknown". The two keys are the tree's node and its edge. agentop's events pane groups each subagent's rows under a collapsible header anchored at its first row (#1047): the label is the subagent's description from the harvested `agent-<id>.meta.json` (`SessionMetadata.Subagents`), falling back to the raw id; blocks are collapsed by default, `Enter` toggles, a sort disables grouping, and a live search opens every block.

### Invocation action vocabulary

Expand Down
14 changes: 13 additions & 1 deletion cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,18 @@ agentop is for, and the other four are surfaces you visit and leave.
the same as a body of zero bytes: a body no plugin asked to buffer is relayed
unmeasured and reads exactly as a body-less GET does.

A session with subagent traffic groups each subagent's rows under a
collapsible header at the subagent's first row: `▸─ <label> · N rows · tokens
· cost`, where the label is the subagent's description from Claude Code's own
records when the harvest can name it and the raw agent id otherwise. Blocks
are collapsed by default — the point is that an agent's fan-out stops drowning
the conversation — and `Enter` on the header opens it, showing its rows in
time order with a `│` gutter in the DIR column; `Enter` again closes it. The
main conversation's rows stay top-level between them, so the interleaving
stays honest. Blocks exist only while the table is chronological: a sort ranks
rows, and grouping would contradict the ranking. A search opens every block,
so a match inside a collapsed one is findable.

Live-updates while in view — if the cursor is on the last row, it
auto-follows new events.

Expand Down Expand Up @@ -1447,7 +1459,7 @@ Layered on top of all of them:
| `Enter` | pods | port-forward + connect |
| `Esc` | pods | back to namespaces |
| `r` | namespaces, pods | reload agent list from cluster |
| `Enter` / `→` / `l` | sessions, events | drill into selection |
| `Enter` / `→` / `l` | sessions, events | drill into selection; on a subagent block's header row in the events pane, collapse/expand the block instead |
| `Esc` / `←` / `h` | detail, events | back out |
| `Esc` | sessions | back to the agents picker when the list was reached by picking an agent there; otherwise (picker mode) tear down port-forward and back to pods. Sessions and the agents picker above it are the only panes that tear down — every key-opened surface returns to its caller instead |
| `/` | any pane, and the key help | search what the pane shows, as in a text editor: every row stays, the matched characters are highlighted, and the cursor (or, in a text view, the view) jumps to the first match at or after where you pressed `/`, wrapping past the end, as you type. Case-insensitive substring of the text the pane displays: a table's columns that are turned on, at full width (a value cut to `…` matches in full, and so does a column the terminal had no room for); a message's or plugin's detail line by line, before it is wrapped; the key help's and the usage chart's lines as drawn. `deny` finds denied events because ACTION shows it. `Enter` keeps the place; `Esc` puts the place and the previous search back; `Enter` on an empty box clears the search. Each pane keeps its own search; the events pane's and a message's detail's last while you stay with one session (the detail's carries from message to message), and none is saved |
Expand Down
20 changes: 19 additions & 1 deletion cmd/agentop/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,27 @@ type model struct {
// one per network message. Populated by rebuildEventsTable so
// selectedEvent / selectedEventRow can return the message the cursor is
// on (and any folded tunnel) without re-walking the cache. Reset on
// every rebuild.
// every rebuild. A subagent block's HEADER row contributes an empty
// eventRow here (it has no event of its own); headerRows says which
// rendered rows those are.
visibleRows []eventRow

// subagentBlocks is the events pane's per-agent grouping of the session
// being viewed (#1047): one block per subagent id named by a row, nil
// while a sort ranks the rows (grouping would contradict the ranking) or
// when no row names one. Rebuilt on every rebuild; the collapse state
// lives in subagentOpen, not here.
subagentBlocks map[string]*subagentBlock
// subagentOpen is the subagent ids whose blocks are expanded. Absent =
// collapsed, which is the default. Kept across rebuilds so a live tail
// does not re-collapse a block the operator opened; cleared when another
// session is opened, since ids are per-session.
subagentOpen map[string]bool
// headerRows maps a rendered-row index to the block whose header row sits
// there, so Enter can tell a header from a message without a second walk.
// Rebuilt on every rebuild.
headerRows map[int]*subagentBlock

// selectedEventKey pins the events-pane cursor to an event across
// rebuilds (see eventKey). Zero value = unpinned.
selectedEventKey eventKey
Expand Down
130 changes: 130 additions & 0 deletions cmd/agentop/tui/events_blocks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package tui

import (
"fmt"
)

// subagentBlock is one subagent's share of the events pane (#1047): the rows its
// requests produced, grouped under a collapsible header anchored at the first of
// them, so an agent's fan-out can be read as one line — or opened, when the
// question is which of two subagents is the slow or costly one.
//
// Membership is STRICT: a row belongs to the block its `subagent` field names, and
// a row naming no agent is the main conversation's and stays top-level. Rows the
// header can't attribute — an opaque CONNECT, whose CONNECT carries no agent-id
// header — stay top-level too, because folding unattributed rows in by
// time-contiguity would misfile the main conversation's rows that interleave with
// a subagent's.
type subagentBlock struct {
id string
// rows indexes cellContexts, in arrival order, among the rows that passed the
// inactive filter — the same population the row loop renders, so the header's
// rollup counts what expanding would show.
rows []int
// tokens and cost are the block's rollup, from the same readings the TOKENS
// and COST cells sort by (rowTokens / rowCostUSD). A request whose response
// has not landed contributes nothing yet, like its cells.
tokens int
cost float64
// anchored is set when the header has been emitted for this rebuild, so the
// header lands at the block's FIRST surviving row and never before it.
anchored bool
}

// headerText is the block's collapsible line: the rollup the issue asked for —
// how many rows, how many tokens, what cost — behind the label. The caller wraps
// it in a one-cell table.Row (rendered as a section row spanning every column)
// and hands the SAME string to the search, so what the header displays is what
// the search matches — the label alone would hide the rollup figures from it.
func (b *subagentBlock) headerText(label string, open bool) string {
glyph := "▸"
if open {
glyph = "▾"
}
return fmt.Sprintf("%s─ %s · %d rows · %s tok · %s",
glyph, label, len(b.rows), humanizeCount(int64(b.tokens)), formatUSDTotal(b.cost))
}

// subagentLabel names a block. The wire carries only the 16-hex id, so a label a
// human can act on comes from the harvested meta.json for this session
// (SessionMetadata.Subagents), falling back to the raw id for everything the
// harvest cannot name — a teammate, a non-Claude-Code agent, a stale harvest.
// Description first: it is the one Claude Code shows in its own tree, and the
// type alone ("general-purpose") answers less than the id does about WHICH
// general-purpose agent this was.
func (m *model) subagentLabel(id string) string {
if info, ok := m.sessionsData[m.selectedSess].Subagents[id]; ok {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: In a live session, a subagent spawned after agentop started will probably keep showing its raw id. sessionsData is refreshed only by the harvest, and the TUI runs that at startup, when you arrive at the picker, and on the Sessions pane, and only there while some row is still untitled (untitledSettled). An already-titled session that is being watched in the Events pane never triggers another harvest, so its Subagents map stays frozen at launch. That hits exactly the case #1047 is about: watching a fan-out happen. The fallback to the id is graceful, so this doesn't block. It's still worth checking during the live test you mentioned. If it shows up, one option is a cheap re-harvest when a row names a subagent that isn't in Subagents (rate-limited the way the untitled backoff is). Another is to read that session's subagents/ dir directly on a miss.

if info.Description != "" {
return sanitizeLabel(info.Description)
}
if info.AgentType != "" {
return sanitizeLabel(info.AgentType)
}
}
return id
}

// blockOpen reports whether a block's rows are shown. Collapsed is the default —
// the issue's motivating case is an agent whose subagents drown the conversation,
// so the pane opens quiet and Enter buys the detail. A live search opens every
// block: a match inside a collapsed block must be findable, and hidden rows are
// unreachable from a search that only sees rendered rows.
func (m *model) blockOpen(id string, searching bool) bool {
return searching || m.subagentOpen[id]
}

// rowInactiveHidden is the hideInactive (`s` toggle) predicate, stated once so the
// row loop and collectSubagentBlocks filter the same population: a block's rollup
// must count the rows expanding it would show, and a second spelling of the
// predicate here would drift with the first edit to either.
func (m *model) rowInactiveHidden(ctxs []cellContext, partner map[int]int, j int) bool {
return m.hideInactive && eventInactive(ctxs[j].invs) && !partnerActive(ctxs, partner, j)
}

// collectSubagentBlocks groups the rows that survive the inactive filter by their
// `subagent` field. Nil when no row names one, which is every session without
// Claude Code subagents — those rebuild exactly as before.
func (m *model) collectSubagentBlocks(ctxs []cellContext, partner map[int]int) map[string]*subagentBlock {
var blocks map[string]*subagentBlock
for j, cc := range ctxs {
if m.rowInactiveHidden(ctxs, partner, j) {
continue
}
id := cc.row.event.Subagent
if id == "" {
continue
}
if blocks == nil {
blocks = make(map[string]*subagentBlock)
}
b := blocks[id]
if b == nil {
b = &subagentBlock{id: id}
blocks[id] = b
}
b.rows = append(b.rows, j)
b.tokens += rowTokens(cc)
b.cost += rowCostUSD(cc)
}
return blocks
}

// toggleSubagentBlock flips the block under the events cursor, if the cursor is
// on a header row. Reports whether it acted, so the caller can skip the
// detail-pane open that Enter otherwise means on this pane.
func (m *model) toggleSubagentBlock() bool {
b, ok := m.headerRows[m.eventsTbl.Cursor()]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: While a search is live or committed, every block renders open (blockOpen ORs in searching) and the header always shows ▾. Enter on a header in that state still flips subagentOpen but changes nothing on screen. The operator only sees what changed later, when they clear the search and the block comes back in the opposite state from the one they last saw. Two options: make the toggle a no-op while q != "", or document that Enter on a header under a search sets the state the block returns to afterwards.

if !ok {
return false
}
if m.subagentOpen == nil {
m.subagentOpen = make(map[string]bool)
}
if m.subagentOpen[b.id] {
delete(m.subagentOpen, b.id)
} else {
m.subagentOpen[b.id] = true
}
m.rebuildEventsTable()
return true
}
Loading
Loading