Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
d2dc9b5
docs(design): tray glance — activity, clients, 24h histogram in the m…
Dumbris Jul 29, 2026
adc1877
docs(design): revise tray glance design after cross-model review
Dumbris Jul 29, 2026
29e217b
docs(design): tray glance round-2 review fixes
Dumbris Jul 29, 2026
b85362d
docs(design): tray glance round-3 review fixes
Dumbris Jul 29, 2026
f89a59c
docs(design): tray glance round-4 review fixes
Dumbris Jul 29, 2026
b2642a5
docs(design): tray glance round-5 review fixes
Dumbris Jul 29, 2026
8e9ce53
docs(plan): tray glance implementation plan (8 tasks, TDD, no placeho…
Dumbris Jul 29, 2026
e9ea15d
chore: git-ignore .superpowers/ SDD scratch workspace
Dumbris Jul 29, 2026
3cb3971
feat(api): add status filter to GET /api/v1/sessions
Dumbris Jul 29, 2026
23b6b8c
test(storage): pin limit truncation on the filtered session walk
Dumbris Jul 29, 2026
ca2b93e
feat(tray): add usage-aggregate API call and response models
Dumbris Jul 29, 2026
4de6132
feat(tray): add type-filtered glance activity API call
Dumbris Jul 29, 2026
b2c1efe
fix(tray): decode last_activity, add active-session API call
Dumbris Jul 29, 2026
de3a5d7
refactor(tray): extract GlanceDataSource protocol over APIClient
Dumbris Jul 29, 2026
0d1dfd2
test(tray): cover the non-2xx error path of the three glance calls
Dumbris Jul 29, 2026
0f312e5
fix(tray): stop fetchWrapped masking a payload's own decoding error
Dumbris Jul 29, 2026
f07a2e6
feat(tray): AppState glance fields + current-UTC-hour usage derivation
Dumbris Jul 29, 2026
8f274da
feat(tray): clear glance state whenever the core leaves .connected
Dumbris Jul 29, 2026
7575cdf
feat(tray): fetch glance activity, active sessions and 24h usage on t…
Dumbris Jul 29, 2026
3908e69
fix(tray): ignore glance updates unless the core is connected
Dumbris Jul 29, 2026
4ef390e
feat(tray): add GlanceFormatting pure helpers for glance rows
Dumbris Jul 29, 2026
55507b0
feat(tray): add GlanceSelection qualification rules 1-3
Dumbris Jul 29, 2026
c3f393d
feat(tray): collapse paired glance records and cap the row lists
Dumbris Jul 29, 2026
76dcd0b
test(tray): pin rowLabel's tool_call type guard and truncation bounds
Dumbris Jul 29, 2026
220cfca
feat(tray): adapt activity SSE payloads into ActivityEntry rows
Dumbris Jul 29, 2026
4b85209
feat(tray): bounded prepend for the glance activity feed
Dumbris Jul 29, 2026
f140c77
fix(tray): consume activity.*.completed SSE events without refetching
Dumbris Jul 29, 2026
33e29ef
feat(tray): glance section skeleton — visibility rule and header summary
Dumbris Jul 29, 2026
8714a65
feat(tray): glance Recent rows with full row identity
Dumbris Jul 29, 2026
8b1f3ab
feat(tray): glance Clients rows and 24h histogram submenu item
Dumbris Jul 29, 2026
ab31eb1
feat(tray): in-place glance row updates with structural-change guard
Dumbris Jul 29, 2026
4c406a7
feat(tray): encode glance status by shape and colour, not colour alone
Dumbris Jul 29, 2026
79482d9
feat(tray): key glance row updates on request id, not the reconcile-c…
Dumbris Jul 29, 2026
ca9c614
fix(tray): republish glance sessions when a live call count moves
Dumbris Jul 29, 2026
bfdae77
docs(tray): clarify when updateInPlace rewrites a row's whole identity
Dumbris Jul 29, 2026
ecdd37d
perf(tray): stop rewriting and re-imaging client rows on an unchanged…
Dumbris Jul 29, 2026
c7adce2
feat(tray): add MenuRebuildGuard so an open menu never restructures
Dumbris Jul 29, 2026
d6d3eff
feat(tray): build the session-filtered Web UI activity link
Dumbris Jul 29, 2026
caeb28e
test(tray): pin glance row click routing and the unfiltered-log fallback
Dumbris Jul 29, 2026
d27bd1c
feat(tray): render the glance section in the menu, guard rebuilds whi…
Dumbris Jul 29, 2026
c6f577f
refactor(tray): test the open-menu rebuild policy without an NSStatus…
Dumbris Jul 29, 2026
f0bec64
refactor(tray): enforce the menu's main-thread invariant at compile time
Dumbris Jul 29, 2026
365cf96
test(tray): kill the half-update and stale-dirty mutants
Dumbris Jul 29, 2026
056ce7b
refactor(tray): isolate GlanceSection as a type, not member by member
Dumbris Jul 29, 2026
6d10bab
feat(tray): shape sparse usage buckets onto a dense 24h histogram axis
Dumbris Jul 29, 2026
8939b52
feat(tray): summarise the 24h histogram in one VoiceOver sentence
Dumbris Jul 29, 2026
608be38
feat(tray): distinguish a failed usage refresh from "not loaded yet"
Dumbris Jul 29, 2026
1641149
feat(tray): SwiftUI Charts bar chart for the 24h activity histogram
Dumbris Jul 29, 2026
ee9a9a9
feat(tray): resolve the histogram's loading, failed and loaded states
Dumbris Jul 29, 2026
24bbef5
feat(tray): build the Activity (24h) submenu when it opens
Dumbris Jul 29, 2026
fefaac0
feat(tray): make the histogram real — one lazy, state-aware submenu
Dumbris Jul 29, 2026
385aa43
fix(tray): record a failed usage refresh instead of swallowing it
Dumbris Jul 29, 2026
afb0f19
fix(tray): stop encoding chart outcomes by colour alone
Dumbris Jul 29, 2026
20ce241
test(tray): pin the accent gap by chroma, and stop the colour test sk…
Dumbris Jul 29, 2026
2242de1
fix(tray): route the activity and sessions refreshes through GlanceDa…
Dumbris Jul 29, 2026
69739c6
fix(tray): reject a glance publish from a previous core connection
Dumbris Jul 29, 2026
15465aa
fix(tray): merge the reconciling poll into live rows instead of repla…
Dumbris Jul 29, 2026
b2d657b
fix(tray): request the glance page at the endpoint's maximum depth
Dumbris Jul 29, 2026
51cfff5
refactor(tray): delete builtWithTimeline, which had stopped protectin…
Dumbris Jul 29, 2026
cdd8268
feat(tray): say so when the glance feeds stop arriving
Dumbris Jul 29, 2026
4b8e039
test(tray): pin the legend, and the two fields the glance branches on
Dumbris Jul 29, 2026
a772b43
docs(tray): correct the stale comments the branch left behind, and zi…
Dumbris Jul 29, 2026
42a3d3e
fix(tray): keep live rows on an empty poll, and name the SSE events c…
Dumbris Jul 29, 2026
037006f
fix(tray): retain only rows the server has not seen yet
Dumbris Jul 29, 2026
99622fa
fix(tray): guard the SSE row publish with the connection generation too
Dumbris Jul 29, 2026
8b0b0ea
perf(api,tray): let the activity list omit payload fields, and have t…
Dumbris Jul 29, 2026
de344d0
docs(tray): state the row guarantee in request groups, not records
Dumbris Jul 29, 2026
d62862e
test(tray): rasterise the chart with ImageRenderer so the pixel tests…
Dumbris Jul 29, 2026
2059178
docs(tray): correct what the CI runner's raster actually showed
Dumbris Jul 29, 2026
e3fefd3
fix(tray): keep the unconfirmed-key set inside the capped feed
Dumbris Jul 29, 2026
7b77b31
test(tray): pin WHERE the connection generation is captured, not just…
Dumbris Jul 29, 2026
ca8e2ba
test(tray): measure the success series, and stop skipping an absent r…
Dumbris Jul 29, 2026
1d2a3cc
test(tray): pin that the manager's SSE wiring uses the session helper
Dumbris Jul 29, 2026
537cd70
Merge branch 'main' into feat/tray-glance
Dumbris Jul 30, 2026
059462b
fix(tray): deliver menu refreshes while the menu is tracking
Dumbris Jul 30, 2026
85451c1
test(tray): pin that a refresh lands while the menu is tracking
Dumbris Jul 30, 2026
99c8130
Merge branch 'main' into feat/tray-glance
Dumbris Jul 30, 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@ scripts/demo/node_modules
# Transient work artifacts (brainstorm logs, editor backups)
*.bak
**/execution_log.md

# Subagent-driven-development scratch workspace (ledger, briefs, review packages)
.superpowers/
18 changes: 17 additions & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,23 @@ Get secret metadata (not the value).

#### GET /api/v1/sessions

List active MCP sessions.
List recent MCP sessions.

**Query Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | integer | Max sessions (1-100, default: 10) |
| `offset` | integer | Pagination offset (default: 0) |
| `status` | string | Filter by session status: `active`, `closed`. Any other value returns `400`. |

The `status` filter is applied during the storage walk, **before** the `limit`
truncation, so a long-running session that is still active is returned even when
newer sessions would otherwise fill the page. When `status` is set, `total`
counts the matching sessions rather than every stored session.

Caveat (spec 082): handshake-only sessions are not persisted, so a connected but
idle client does not appear until its first tool call.

#### GET /api/v1/sessions/{id}

Expand Down
6,597 changes: 6,597 additions & 0 deletions docs/superpowers/plans/2026-07-29-tray-glance.md

Large diffs are not rendered by default.

185 changes: 185 additions & 0 deletions docs/superpowers/specs/2026-07-29-tray-glance-design.md

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion internal/httpapi/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter {
// @Param end_time query string false "Filter activities before this time (RFC3339)"
// @Param limit query int false "Maximum records to return (1-100, default 50)"
// @Param offset query int false "Pagination offset (default 0)"
// @Param exclude_payloads query bool false "Omit arguments, response and metadata (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped."
// @Success 200 {object} contracts.APIResponse{data=contracts.ActivityListResponse}
// @Failure 400 {object} contracts.APIResponse
// @Failure 401 {object} contracts.APIResponse
Expand All @@ -161,10 +162,26 @@ func (s *Server) handleListActivity(w http.ResponseWriter, r *http.Request) {
return
}

// Convert storage records to contract records
// Convert storage records to contract records.
//
// `exclude_payloads` is a projection, not a filter: it changes what is
// serialised, never which records match, so it is applied here rather than
// in the storage filter. Arguments, Response and Metadata are unbounded in
// practice (only Response is truncated, at 64KB), and a client that renders
// summary fields alone pays for them on every poll — measured against a real
// log, the newest 100 tool-call records are ~848KB whole and ~30KB projected.
// HasSensitiveData is derived by storageToContractActivity from Metadata
// BEFORE it is dropped, so the flag survives its source.
excludePayloads := r.URL.Query().Get("exclude_payloads") == "true"
contractActivities := make([]contracts.ActivityRecord, len(activities))
for i, a := range activities {
contractActivities[i] = storageToContractActivity(a)
if excludePayloads {
contractActivities[i].Arguments = nil
contractActivities[i].Response = ""
contractActivities[i].ResponseTruncated = false
contractActivities[i].Metadata = nil
}
}

response := contracts.ActivityListResponse{
Expand Down
100 changes: 93 additions & 7 deletions internal/httpapi/activity_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityListResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand All @@ -231,7 +231,7 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityListResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand All @@ -249,6 +249,92 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) {
})
}

// TestActivityList_ExcludePayloads covers the projection the tray glance asks
// for. The glance renders summary fields only, but a full record carries
// arguments, response and metadata — measured against a real activity log, the
// newest 100 matching records are ~848 KB whole and ~30 KB in summary form, and
// the tray refetches every 30 seconds.
func TestActivityList_ExcludePayloads(t *testing.T) {
logger := zap.NewNop().Sugar()
activities := []*storage.ActivityRecord{
{
ID: "activity-with-payload",
Type: storage.ActivityTypeToolCall,
ServerName: "github",
ToolName: "create_issue",
Status: "error",
ErrorMessage: "auth failed",
DurationMs: 42,
RequestID: "req-1",
SessionID: "sess-1",
Timestamp: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
Arguments: map[string]interface{}{"title": "a very large argument blob"},
Response: "a very large response body",
Metadata: map[string]interface{}{
"sensitive_data_detection": map[string]interface{}{
"detected": true,
"detections": []interface{}{
map[string]interface{}{"type": "github_token", "severity": "high"},
},
},
},
},
}
mockCtrl := &mockActivityController{apiKey: "test-key", activities: activities}
srv := NewServer(mockCtrl, logger, nil)

get := func(t *testing.T, path string) contracts.ActivityRecord {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Data contracts.ActivityListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.Len(t, resp.Data.Activities, 1)
return resp.Data.Activities[0]
}

t.Run("exclude_payloads=true drops the three heavy fields", func(t *testing.T) {
got := get(t, "/api/v1/activity?exclude_payloads=true")

assert.Nil(t, got.Arguments, "arguments must not be serialised")
assert.Empty(t, got.Response, "response must not be serialised")
assert.Nil(t, got.Metadata, "metadata must not be serialised")
})

t.Run("exclude_payloads=true keeps every field the glance renders", func(t *testing.T) {
got := get(t, "/api/v1/activity?exclude_payloads=true")

assert.Equal(t, "activity-with-payload", got.ID)
assert.Equal(t, contracts.ActivityType("tool_call"), got.Type)
assert.Equal(t, "github", got.ServerName)
assert.Equal(t, "create_issue", got.ToolName)
assert.Equal(t, "error", got.Status)
assert.Equal(t, "auth failed", got.ErrorMessage)
assert.Equal(t, int64(42), got.DurationMs)
assert.Equal(t, "req-1", got.RequestID)
assert.Equal(t, "sess-1", got.SessionID)
assert.False(t, got.Timestamp.IsZero())
// Derived from metadata BEFORE it is dropped: the tray's row icon and
// the poll's late correction both key off this flag.
assert.True(t, got.HasSensitiveData, "the flag survives its source being dropped")
})

t.Run("the default response is unchanged", func(t *testing.T) {
got := get(t, "/api/v1/activity")

assert.NotNil(t, got.Arguments, "omitting the parameter must not change what other clients get")
assert.Equal(t, "a very large response body", got.Response)
assert.NotNil(t, got.Metadata)
})
}

func TestActivityList_DetectionTypeFilter(t *testing.T) {
logger := zap.NewNop().Sugar()
activities := createTestActivityRecords()
Expand All @@ -268,7 +354,7 @@ func TestActivityList_DetectionTypeFilter(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityListResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand Down Expand Up @@ -342,7 +428,7 @@ func TestActivityList_SeverityFilter(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityListResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand Down Expand Up @@ -618,7 +704,7 @@ func TestActivityResponse_SensitiveDataFields(t *testing.T) {
"activity-2-credit-card": "high",
"activity-3-multiple": "critical", // Has both critical and high, critical is max
"activity-4-medium": "medium",
"activity-5-clean": "", // No sensitive data
"activity-5-clean": "", // No sensitive data
}

for _, activity := range resp.Data.Activities {
Expand Down Expand Up @@ -680,7 +766,7 @@ func TestActivityDetail_SensitiveDataFields(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityDetailResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand All @@ -703,7 +789,7 @@ func TestActivityDetail_SensitiveDataFields(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Success bool `json:"success"`
Data contracts.ActivityDetailResponse `json:"data"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func (m *MockServerController) GetDockerRecoveryStatus() *storage.DockerRecovery
}
}
func (m *MockServerController) IsDockerAvailable() bool { return true }
func (m *MockServerController) GetRecentSessions(_ int) ([]*contracts.MCPSession, int, error) {
func (m *MockServerController) GetRecentSessions(_ int, _ string) ([]*contracts.MCPSession, int, error) {
return []*contracts.MCPSession{}, 0, nil
}
func (m *MockServerController) GetSessionByID(_ string) (*contracts.MCPSession, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ func (m *baseController) CallTool(ctx context.Context, toolName string, args map
func (m *baseController) GetRuntime() *runtime.Runtime { return nil }
func (m *baseController) GetSessions(limit, offset int) (interface{}, int, error) { return nil, 0, nil }
func (m *baseController) GetSessionByID(id string) (*contracts.MCPSession, error) { return nil, nil }
func (m *baseController) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) {
func (m *baseController) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) {
return nil, 0, nil
}
func (m *baseController) GetToolCallsBySession(sessionID string, limit, offset int) ([]*contracts.ToolCallRecord, int, error) {
Expand Down
18 changes: 15 additions & 3 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ type ServerController interface {
ReplayToolCall(id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error)
GetToolCallsBySession(sessionID string, limit, offset int) ([]*contracts.ToolCallRecord, int, error)

// Session management
GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error)
// Session management. status filters on session status ("active" /
// "closed"); an empty string means no filter.
GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error)
GetSessionByID(sessionID string) (*contracts.MCPSession, error)

// Configuration management
Expand Down Expand Up @@ -4828,7 +4829,9 @@ func getBool(m map[string]interface{}, key string) bool {
// @Produce json
// @Param limit query int false "Maximum number of sessions to return (1-100, default 10)"
// @Param offset query int false "Number of sessions to skip for pagination (default 0)"
// @Param status query string false "Filter by session status" Enums(active, closed)
// @Success 200 {object} contracts.GetSessionsResponse "Sessions retrieved successfully"
// @Failure 400 {object} contracts.ErrorResponse "Invalid status filter"
// @Failure 401 {object} contracts.ErrorResponse "Unauthorized - missing or invalid API key"
// @Failure 405 {object} contracts.ErrorResponse "Method not allowed"
// @Failure 500 {object} contracts.ErrorResponse "Failed to get sessions"
Expand Down Expand Up @@ -4859,8 +4862,17 @@ func (s *Server) handleGetSessions(w http.ResponseWriter, r *http.Request) {
}
}

// Status filter. The domain is closed ("active" / "closed"), so an unknown
// value is a client bug — rejecting it is honest, where silently ignoring it
// would return unfiltered sessions that the caller believes are filtered.
status := r.URL.Query().Get("status")
if status != "" && status != "active" && status != "closed" {
s.writeError(w, r, http.StatusBadRequest, "Invalid status. Use 'active' or 'closed'")
return
}

// Get recent sessions from controller
sessions, total, err := s.controller.GetRecentSessions(limit)
sessions, total, err := s.controller.GetRecentSessions(limit, status)
if err != nil {
s.logger.Error("Failed to get sessions", "error", err)
s.writeError(w, r, http.StatusInternalServerError, "Failed to get sessions")
Expand Down
Loading
Loading