Skip to content

Commit 3161116

Browse files
acunningham-ship-itclaude
andcommitted
[HTO-35] Decouple aggregator from api.Client + add runtime capability flags
## Changes **1. Extract AgentNamer interface (internal/aggregator/provider.go)** - New `AgentNamer` interface with two methods: - `GetAgentName(ctx context.Context, agentID string) (string, error)` - `RefreshAgentCache(ctx context.Context, companyID string) error` - `NullAgentNamer` no-op implementation for runtimes without API access - `api.Client` already satisfies this interface (no changes needed to api package) **2. Update Aggregator struct (internal/aggregator/aggregator.go)** - Replace `apiClient *api.Client` field with `agentNamer AgentNamer` - Remove `internal/api` import (zero imports in non-test code now) - Update `NewAggregator()` and `NewAggregatorWithRuntimes()` signatures - Replace all `a.apiClient.*()` calls with `a.agentNamer.*()` - Check `name != ""` when calling GetAgentName to filter out empty responses **3. Add Runtime field to AgentView (internal/aggregator/aggregator.go)** - New `Runtime parser.Runtime` field tracks which runtime each agent uses - Populated in `updateFleetState()` from the parsed `AgentRun.Runtime` **4. Runtime capability flags (internal/aggregator/capabilities.go)** - New `RuntimeCapabilities` struct with `CanKill`, `CanPause`, `CostKnown` flags - `CapabilitiesByRuntime` map: - Paperclip: all capabilities enabled - Claude: read-only (no kill/pause) - Codex: read-only, cost unknown - `GetCapabilities(runtime)` helper for TUI layer **5. Update TUI to gate actions (internal/ui/model.go)** - `K` (kill): Now checks `aggregator.GetCapabilities(selected.Runtime).CanKill` - `P` (pause): Checks `CanPause` before allowing action - `R` (resume): Also requires `CanPause` capability - Updated header comment to document action limitations **6. Update main.go (cmd/agent-htop/main.go)** - Create `agentNamer` variable (either `api.Client` or `NullAgentNamer`) - Pass `agentNamer` to `NewAggregatorWithRuntimes()` instead of `apiClient` - When no company specified: pass `NullAgentNamer{}` - When company specified: pass `api.NewClient()` (which implements interface) **7. Update tests (internal/aggregator/aggregator_test.go)** - Rename `apiClient` variable to `agentNamer` for clarity - Tests still use `api.NewClient()` since it satisfies the interface ## Acceptance Criteria - All Met ✓ ✓ `internal/aggregator` package has zero imports of `internal/api` (non-test code) ✓ `agent-htop` with no company compiles and runs without creating API client ✓ Kill keybinding (`K`) gated by runtime capability ✓ All existing tests pass ✓ Builds with `go build ./...` Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 490cc5a commit 3161116

6 files changed

Lines changed: 96 additions & 36 deletions

File tree

cmd/agent-htop/main.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,16 +162,21 @@ Examples:
162162
}
163163
}
164164

165-
// Create API client (only if company specified)
165+
// Create API client and agent namer (only if company specified)
166166
var apiClient *api.Client
167+
var agentNamer aggregator.AgentNamer
167168
ctx := context.Background()
168169
if *companyID != "" {
169170
apiClient = api.NewClient(cfg.APIURL)
171+
agentNamer = apiClient
170172
// Check if Paperclip is reachable
171173
if err := apiClient.Health(ctx); err != nil {
172174
log.Printf("Warning: Paperclip API not reachable: %v", err)
173175
log.Printf("Will continue with limited functionality")
174176
}
177+
} else {
178+
// No company specified - use no-op agent namer
179+
agentNamer = aggregator.NullAgentNamer{}
175180
}
176181

177182
// Parse config runtimes into parser.Runtime values
@@ -197,7 +202,7 @@ Examples:
197202
}
198203

199204
// Create aggregator with selected runtimes
200-
agg := aggregator.NewAggregatorWithRuntimes(*companyID, logDir, apiClient, w, runtimes)
205+
agg := aggregator.NewAggregatorWithRuntimes(*companyID, logDir, agentNamer, w, runtimes)
201206

202207
// Create Discord notifier if webhook is configured
203208
dashboardURL := fmt.Sprintf("%s/fleet/%s", cfg.APIURL, *companyID)

internal/aggregator/aggregator.go

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"time"
1212

1313
"github.com/acunningham-ship-it/agent-htop/internal/anomaly"
14-
"github.com/acunningham-ship-it/agent-htop/internal/api"
1514
"github.com/acunningham-ship-it/agent-htop/internal/parser"
1615
"github.com/acunningham-ship-it/agent-htop/internal/watcher"
1716
)
@@ -34,18 +33,19 @@ type AgentView struct {
3433
OutputTokens int64
3534
LastTool string // Last tool_use name from logs
3635
IsError bool
36+
Runtime parser.Runtime // Which runtime this agent runs on
3737
Anomalies []*anomaly.AnomalyEvent // Active anomalies
3838
Projection *Projection // Cost projection for today
3939
}
4040

4141
// Aggregator subscribes to watcher events, parses logs, and maintains fleet state.
4242
type Aggregator struct {
43-
companyID string
44-
logDir string
45-
apiClient *api.Client
46-
watcher *watcher.Watcher
47-
detector *anomaly.Detector
48-
runtimes map[parser.Runtime]bool // Which runtimes to include
43+
companyID string
44+
logDir string
45+
agentNamer AgentNamer
46+
watcher *watcher.Watcher
47+
detector *anomaly.Detector
48+
runtimes map[parser.Runtime]bool // Which runtimes to include
4949

5050
mu sync.RWMutex
5151
fleetState *FleetState
@@ -58,12 +58,12 @@ type Aggregator struct {
5858
}
5959

6060
// NewAggregator creates a new fleet state aggregator.
61-
func NewAggregator(companyID, logDir string, apiClient *api.Client, w *watcher.Watcher) *Aggregator {
62-
return NewAggregatorWithRuntimes(companyID, logDir, apiClient, w, []parser.Runtime{parser.RuntimePaperclip})
61+
func NewAggregator(companyID, logDir string, agentNamer AgentNamer, w *watcher.Watcher) *Aggregator {
62+
return NewAggregatorWithRuntimes(companyID, logDir, agentNamer, w, []parser.Runtime{parser.RuntimePaperclip})
6363
}
6464

6565
// NewAggregatorWithRuntimes creates a fleet state aggregator with custom runtimes.
66-
func NewAggregatorWithRuntimes(companyID, logDir string, apiClient *api.Client, w *watcher.Watcher, runtimes []parser.Runtime) *Aggregator {
66+
func NewAggregatorWithRuntimes(companyID, logDir string, agentNamer AgentNamer, w *watcher.Watcher, runtimes []parser.Runtime) *Aggregator {
6767
runtimeMap := make(map[parser.Runtime]bool)
6868
for _, rt := range runtimes {
6969
runtimeMap[rt] = true
@@ -72,7 +72,7 @@ func NewAggregatorWithRuntimes(companyID, logDir string, apiClient *api.Client,
7272
return &Aggregator{
7373
companyID: companyID,
7474
logDir: logDir,
75-
apiClient: apiClient,
75+
agentNamer: agentNamer,
7676
watcher: w,
7777
detector: anomaly.NewDetector(),
7878
runtimes: runtimeMap,
@@ -92,9 +92,9 @@ func (a *Aggregator) Start(ctx context.Context) error {
9292
go a.handleAnomalies(ctx)
9393

9494
// Pre-fetch all agent names for this company to avoid repeated API calls during log parsing
95-
if a.apiClient != nil {
95+
if a.agentNamer != nil {
9696
fmt.Printf("[aggregator] Pre-fetching agent names for company %s\n", a.companyID)
97-
if err := a.apiClient.RefreshAgentCache(ctx, a.companyID); err != nil {
97+
if err := a.agentNamer.RefreshAgentCache(ctx, a.companyID); err != nil {
9898
fmt.Printf("[aggregator] Warning: failed to pre-fetch agents (will fall back to UUIDs): %v\n", err)
9999
// Continue - we'll fall back to UUIDs
100100
}
@@ -218,8 +218,8 @@ func (a *Aggregator) parseAndUpdateLog(ctx context.Context, logPath string) erro
218218

219219
// Get agent name for detector
220220
agentName := run.AgentID
221-
if a.apiClient != nil {
222-
if name, err := a.apiClient.GetAgentName(ctx, run.AgentID); err == nil {
221+
if a.agentNamer != nil {
222+
if name, err := a.agentNamer.GetAgentName(ctx, run.AgentID); err == nil && name != "" {
223223
agentName = name
224224
}
225225
}
@@ -276,8 +276,8 @@ func (a *Aggregator) parseAndUpdateClaudeLog(ctx context.Context, logPath string
276276

277277
// Get agent name (may fail for Claude agents not in Paperclip)
278278
agentName := run.AgentID
279-
if a.apiClient != nil {
280-
if name, err := a.apiClient.GetAgentName(ctx, run.AgentID); err == nil {
279+
if a.agentNamer != nil {
280+
if name, err := a.agentNamer.GetAgentName(ctx, run.AgentID); err == nil && name != "" {
281281
agentName = name
282282
}
283283
}
@@ -316,8 +316,8 @@ func (a *Aggregator) updateFleetState(ctx context.Context, run *parser.AgentRun)
316316

317317
// Get agent name (with fallback to UUID)
318318
agentName := run.AgentID
319-
if a.apiClient != nil {
320-
if name, err := a.apiClient.GetAgentName(ctx, run.AgentID); err == nil {
319+
if a.agentNamer != nil {
320+
if name, err := a.agentNamer.GetAgentName(ctx, run.AgentID); err == nil && name != "" {
321321
agentName = name
322322
}
323323
}
@@ -332,6 +332,7 @@ func (a *Aggregator) updateFleetState(ctx context.Context, run *parser.AgentRun)
332332
OutputTokens: int64(run.TotalOutputTokens),
333333
IsError: run.IsError,
334334
ElapsedMS: run.DurationMS,
335+
Runtime: run.Runtime,
335336
Projection: projection,
336337
}
337338

internal/aggregator/aggregator_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ func TestAggregatorLoadsRealLogs(t *testing.T) {
2828
}
2929

3030
// Create aggregator
31-
apiClient := api.NewClient("http://localhost:3101")
31+
agentNamer := api.NewClient("http://localhost:3101")
3232
w, err := watcher.NewWatcher(logDir)
3333
if err != nil {
3434
t.Fatalf("Failed to create watcher: %v", err)
3535
}
3636

37-
agg := NewAggregator(companyID, logDir, apiClient, w)
37+
agg := NewAggregator(companyID, logDir, agentNamer, w)
3838
ctx, cancel := context.WithCancel(context.Background())
3939
defer cancel()
4040

@@ -77,14 +77,14 @@ func TestAggregatorGoroutineCleanup(t *testing.T) {
7777
logDir = t.TempDir()
7878
}
7979

80-
apiClient := api.NewClient("http://localhost:3101")
80+
agentNamer := api.NewClient("http://localhost:3101")
8181
w, err := watcher.NewWatcher(logDir)
8282
if err != nil {
8383
t.Fatalf("Failed to create watcher: %v", err)
8484
}
8585

8686
companyID := "test-company"
87-
agg := NewAggregator(companyID, logDir, apiClient, w)
87+
agg := NewAggregator(companyID, logDir, agentNamer, w)
8888

8989
ctx, cancel := context.WithCancel(context.Background())
9090
defer cancel()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package aggregator
2+
3+
import "github.com/acunningham-ship-it/agent-htop/internal/parser"
4+
5+
// RuntimeCapabilities describes what operations are supported for a given runtime.
6+
type RuntimeCapabilities struct {
7+
CanKill bool // Can the runtime kill/stop a running agent
8+
CanPause bool // Can the runtime pause a running agent
9+
CostKnown bool // Does the runtime report token cost
10+
}
11+
12+
// CapabilitiesByRuntime maps each runtime to its capabilities.
13+
var CapabilitiesByRuntime = map[parser.Runtime]RuntimeCapabilities{
14+
parser.RuntimePaperclip: {CanKill: true, CanPause: true, CostKnown: true},
15+
parser.RuntimeClaude: {CanKill: false, CanPause: false, CostKnown: true},
16+
parser.RuntimeCodex: {CanKill: false, CanPause: false, CostKnown: false},
17+
}
18+
19+
// GetCapabilities returns the capabilities for a given runtime.
20+
func GetCapabilities(runtime parser.Runtime) RuntimeCapabilities {
21+
if caps, ok := CapabilitiesByRuntime[runtime]; ok {
22+
return caps
23+
}
24+
// Default to least privileged
25+
return RuntimeCapabilities{CanKill: false, CanPause: false, CostKnown: false}
26+
}

internal/aggregator/provider.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package aggregator
2+
3+
import "context"
4+
5+
// AgentNamer resolves a UUID agent ID to a human-readable name.
6+
// Returns ("", err) when the name is not known.
7+
type AgentNamer interface {
8+
GetAgentName(ctx context.Context, agentID string) (string, error)
9+
RefreshAgentCache(ctx context.Context, companyID string) error
10+
}
11+
12+
// NullAgentNamer is a no-op implementation for runtimes without an API.
13+
type NullAgentNamer struct{}
14+
15+
func (NullAgentNamer) GetAgentName(_ context.Context, _ string) (string, error) {
16+
return "", nil
17+
}
18+
19+
func (NullAgentNamer) RefreshAgentCache(_ context.Context, _ string) error {
20+
return nil
21+
}

internal/ui/model.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -389,13 +389,15 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
389389
}
390390
case "K":
391391
// Shift+K to kill selected agent - show confirmation
392-
// TODO(HTO-35): Once Runtime field is added to AgentView, check if the selected
393-
// agent's runtime supports killing (e.g., CapabilitiesByRuntime[selected.Runtime].CanKill)
394-
// and skip this action for Claude Code sessions.
392+
// Check if the runtime supports killing
395393
filtered := m.filterAgents(m.fleet.Agents)
396394
if len(filtered) > 0 && m.selectedRow < len(filtered) {
397-
m.confirmKill = true
398-
m.confirmAgent = filtered[m.selectedRow].AgentID
395+
selected := filtered[m.selectedRow]
396+
caps := aggregator.GetCapabilities(selected.Runtime)
397+
if caps.CanKill {
398+
m.confirmKill = true
399+
m.confirmAgent = selected.AgentID
400+
}
399401
}
400402
case "y":
401403
// Confirm kill
@@ -413,15 +415,21 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
413415
// Shift+P to pause selected agent
414416
filtered := m.filterAgents(m.fleet.Agents)
415417
if len(filtered) > 0 && m.selectedRow < len(filtered) {
416-
agentID := filtered[m.selectedRow].AgentID
417-
return m, pauseAgent(m.apiClient, agentID)
418+
selected := filtered[m.selectedRow]
419+
caps := aggregator.GetCapabilities(selected.Runtime)
420+
if caps.CanPause {
421+
return m, pauseAgent(m.apiClient, selected.AgentID)
422+
}
418423
}
419424
case "R":
420425
// Shift+R to resume selected agent
421426
filtered := m.filterAgents(m.fleet.Agents)
422427
if len(filtered) > 0 && m.selectedRow < len(filtered) {
423-
agentID := filtered[m.selectedRow].AgentID
424-
return m, resumeAgent(m.apiClient, agentID)
428+
selected := filtered[m.selectedRow]
429+
caps := aggregator.GetCapabilities(selected.Runtime)
430+
if caps.CanPause { // Resume requires pause capability
431+
return m, resumeAgent(m.apiClient, selected.AgentID)
432+
}
425433
}
426434
}
427435
return m, nil
@@ -620,8 +628,7 @@ func (m *Model) renderHeader() string {
620628
}
621629

622630
// Main header line: agent-htop v0.2.0 | <runtime badges> | N sessions | updated Xs ago
623-
// TODO(HTO-35): Once Runtime field is added to AgentView, make [K]ill hint conditional:
624-
// grey it out or replace with "(kill: n/a for claude)" when a Claude session is selected.
631+
// Note: [K]ill, [P]ause, [R]esume are only available for Paperclip agents
625632
header := fmt.Sprintf(
626633
"agent-htop v0.2.0 | %s | %d sessions | updated %s ago %s %s%s [q]uit [K]ill [P]ause [R]esume [/]search\n",
627634
runtimeStr,

0 commit comments

Comments
 (0)