Skip to content

Commit 0efdd73

Browse files
authored
Merge pull request #108 from tddworks/hook
Claude Code Session Tracking: Real-time monitoring of Claude Code sessions via hooks.
2 parents ceebb1c + 07c47df commit 0efdd73

27 files changed

+1892
-10
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.33] - 2026-02-14
11+
12+
### Added
13+
- **Claude Code Session Tracking**: Real-time monitoring of Claude Code sessions via hooks. When Claude Code is running, ClaudeBar shows session status directly in the menu bar and popover:
14+
- **Menu bar indicator**: A terminal icon appears next to the quota icon with phase-colored status (green = active, blue = subagents working, orange = stopped)
15+
- **Session card**: Detailed session info in the popover showing phase, task count, active subagents, duration, and working directory
16+
- **System notifications**: Get notified when a session starts ("Claude Code Started") and finishes ("Claude Code Finished — Completed 3 tasks in 2m 5s")
17+
- **Hook Settings**: New "Claude Code Hooks" section in Settings with a single toggle to enable/disable. Automatically installs/uninstalls hooks in `~/.claude/settings.json`.
18+
19+
### Technical
20+
- Added `SessionEvent`, `ClaudeSession`, and `SessionMonitor` domain models for session lifecycle tracking
21+
- Added `HookHTTPServer` using Network.framework (`NWListener`) for localhost-only event reception on port 19847
22+
- Added `SessionEventParser` for parsing Claude Code hook JSON payloads
23+
- Added `HookInstaller` for auto-managing hooks in `~/.claude/settings.json` using the new matcher-based format
24+
- Added `PortDiscovery` for writing/reading `~/.claude/claudebar-hook-port`
25+
- Added `HookSettingsRepository` protocol and `UserDefaults` implementation
26+
- Added `com.apple.security.network.server` entitlement for `NWListener`
27+
- Added `AppLog.hooks` logging category
28+
- Added `LiveActivityManager` placeholder for future ActivityKit support (currently unavailable on macOS)
29+
1030
## [0.4.32] - 2026-02-12
1131

1232
### Added

Sources/App/ClaudeBarApp.swift

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,31 @@ import Infrastructure
55
import Sparkle
66
#endif
77

8+
extension Notification.Name {
9+
static let hookSettingsChanged = Notification.Name("com.tddworks.claudebar.hookSettingsChanged")
10+
}
11+
812
@main
913
struct ClaudeBarApp: App {
1014
/// The main domain service - monitors all AI providers
1115
/// This is the single source of truth for providers and their state
1216
@State private var monitor: QuotaMonitor
1317

18+
/// Monitors Claude Code sessions via hook events
19+
@State private var sessionMonitor = SessionMonitor()
20+
21+
/// The hook HTTP server that receives events from Claude Code
22+
private let hookServer = HookHTTPServer()
23+
24+
/// Task for the hook server event loop (allows cancellation on toggle off)
25+
@State private var hookServerTask: Task<Void, Never>?
26+
1427
/// Alerts users when quota status degrades
1528
private let quotaAlerter = NotificationAlerter()
1629

30+
/// Sends session start/end notifications
31+
private let sessionAlertSender = SystemAlertSender()
32+
1733
#if ENABLE_SPARKLE
1834
/// Sparkle updater for auto-updates
1935
@State private var sparkleUpdater = SparkleUpdater()
@@ -78,6 +94,11 @@ struct ClaudeBarApp: App {
7894
)
7995
AppLog.monitor.info("QuotaMonitor initialized")
8096

97+
// Start hook server if hooks are enabled
98+
if settingsRepository.isHookEnabled() {
99+
startHookServer()
100+
}
101+
81102
// Note: Notification permission is requested in onAppear, not here
82103
// Menu bar apps need the run loop to be active before requesting permissions
83104

@@ -92,36 +113,110 @@ struct ClaudeBarApp: App {
92113
ThemeMode(rawValue: settings.themeMode) ?? .system
93114
}
94115

116+
private func startHookServer() {
117+
// Cancel any existing server task
118+
hookServerTask?.cancel()
119+
hookServer.stop()
120+
121+
hookServerTask = Task {
122+
do {
123+
let events = try await hookServer.start()
124+
AppLog.hooks.info("Hook server started, listening for events")
125+
for await event in events {
126+
await sessionMonitor.processEvent(event)
127+
await sendSessionNotification(for: event)
128+
}
129+
} catch {
130+
AppLog.hooks.error("Failed to start hook server: \(error.localizedDescription)")
131+
}
132+
}
133+
}
134+
135+
func stopHookServer() {
136+
hookServerTask?.cancel()
137+
hookServerTask = nil
138+
hookServer.stop()
139+
}
140+
141+
@MainActor private func sendSessionNotification(for event: SessionEvent) {
142+
let projectName = (event.cwd as NSString).lastPathComponent
143+
144+
switch event.eventName {
145+
case .sessionStart:
146+
Task {
147+
try? await sessionAlertSender.send(
148+
title: "Claude Code Started",
149+
body: "Session started in \(projectName)",
150+
categoryIdentifier: "SESSION_START"
151+
)
152+
}
153+
case .sessionEnd:
154+
let taskCount = sessionMonitor.recentSessions.first?.completedTaskCount ?? 0
155+
let duration = sessionMonitor.recentSessions.first?.durationDescription ?? ""
156+
let summary = taskCount > 0
157+
? "Completed \(taskCount) task\(taskCount == 1 ? "" : "s") in \(duration)"
158+
: "Session ended after \(duration)"
159+
Task {
160+
try? await sessionAlertSender.send(
161+
title: "Claude Code Finished",
162+
body: "\(projectName)\(summary)",
163+
categoryIdentifier: "SESSION_END"
164+
)
165+
}
166+
default:
167+
break
168+
}
169+
}
170+
95171
var body: some Scene {
96172
MenuBarExtra {
97173
#if ENABLE_SPARKLE
98-
MenuContentView(monitor: monitor, quotaAlerter: quotaAlerter)
174+
MenuContentView(monitor: monitor, sessionMonitor: sessionMonitor, quotaAlerter: quotaAlerter) { enabled in
175+
if enabled { startHookServer() } else { stopHookServer() }
176+
}
99177
.appThemeProvider(themeModeId: settings.themeMode)
100178
.environment(\.sparkleUpdater, sparkleUpdater)
101179
#else
102-
MenuContentView(monitor: monitor, quotaAlerter: quotaAlerter)
180+
MenuContentView(monitor: monitor, sessionMonitor: sessionMonitor, quotaAlerter: quotaAlerter) { enabled in
181+
if enabled { startHookServer() } else { stopHookServer() }
182+
}
103183
.appThemeProvider(themeModeId: settings.themeMode)
104184
#endif
105185
} label: {
106-
// Show overall status (worst across all enabled providers) in menu bar
107-
StatusBarIcon(status: monitor.selectedProviderStatus)
186+
// Show overall status + active session indicator in menu bar
187+
StatusBarIcon(status: monitor.selectedProviderStatus, activeSession: sessionMonitor.activeSession)
108188
.appThemeProvider(themeModeId: settings.themeMode)
109189
}
110190
.menuBarExtraStyle(.window)
111191
}
192+
112193
}
113194

114195
/// The menu bar icon that reflects the overall quota status.
196+
/// When a Claude Code session is active, shows a terminal icon with phase color.
115197
/// Uses theme's `statusBarIconName` if set, otherwise shows status-based icons.
116198
struct StatusBarIcon: View {
117199
let status: QuotaStatus
200+
var activeSession: ClaudeSession? = nil
118201

119202
@Environment(\.appTheme) private var theme
120203

121204
var body: some View {
122-
Image(systemName: iconName)
123-
.symbolRenderingMode(.palette)
124-
.foregroundStyle(iconColor)
205+
if let session = activeSession {
206+
// Active session: show terminal icon with phase color
207+
HStack(spacing: 3) {
208+
Image(systemName: "terminal.fill")
209+
.symbolRenderingMode(.palette)
210+
.foregroundStyle(sessionPhaseColor(session.phase))
211+
Image(systemName: iconName)
212+
.symbolRenderingMode(.palette)
213+
.foregroundStyle(iconColor)
214+
}
215+
} else {
216+
Image(systemName: iconName)
217+
.symbolRenderingMode(.palette)
218+
.foregroundStyle(iconColor)
219+
}
125220
}
126221

127222
private var iconName: String {
@@ -143,6 +238,10 @@ struct StatusBarIcon: View {
143238
private var iconColor: Color {
144239
theme.statusColor(for: status)
145240
}
241+
242+
private func sessionPhaseColor(_ phase: ClaudeSession.Phase) -> Color {
243+
phase.color
244+
}
146245
}
147246

148247
// MARK: - StatusBarIcon Preview

Sources/App/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,7 @@
3838
<true/>
3939
<key>SUAutomaticallyUpdate</key>
4040
<false/>
41+
<key>NSSupportsLiveActivities</key>
42+
<true/>
4143
</dict>
4244
</plist>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
import Domain
3+
import Infrastructure
4+
5+
/// Placeholder for ActivityKit Live Activity integration.
6+
///
7+
/// ActivityKit types (`ActivityAttributes`, `Activity`) are currently marked as
8+
/// explicitly unavailable on macOS — even in macOS 26. When Apple adds macOS
9+
/// support for Live Activities, this manager can be activated to show session
10+
/// status as a system-level Live Activity.
11+
///
12+
/// In the meantime, session tracking is handled by `SessionIndicatorView`
13+
/// in the menu bar popover, which works on all macOS versions.
14+
///
15+
/// To activate when ActivityKit becomes available on macOS:
16+
/// 1. Create `Sources/HookActivityWidget/` with ActivityAttributes + Widget view
17+
/// 2. Add widget extension target to Project.swift
18+
/// 3. Uncomment the ActivityKit code below
19+
public final class LiveActivityManager: @unchecked Sendable {
20+
public init() {}
21+
22+
/// Starts tracking a session. Currently a no-op on macOS.
23+
public func startActivity(for session: ClaudeSession) {
24+
AppLog.hooks.debug("Live Activity not available on macOS — using menu popover instead")
25+
}
26+
27+
/// Updates tracking for a session. Currently a no-op on macOS.
28+
public func updateActivity(for session: ClaudeSession) {
29+
// No-op until ActivityKit is available on macOS
30+
}
31+
32+
/// Ends tracking for a session. Currently a no-op on macOS.
33+
public func endActivity() {
34+
// No-op until ActivityKit is available on macOS
35+
}
36+
}

Sources/App/Views/MenuContentView.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import Sparkle
99
/// Uses the pluggable theme system for consistent styling across all themes.
1010
struct MenuContentView: View {
1111
let monitor: QuotaMonitor
12+
let sessionMonitor: SessionMonitor
1213
let quotaAlerter: QuotaAlerter
14+
var onHookSettingsChanged: ((Bool) -> Void)?
1315

1416
@Environment(\.appTheme) private var theme
1517
@Environment(\.colorScheme) private var colorScheme
@@ -71,6 +73,13 @@ struct MenuContentView: View {
7173
.padding(.bottom, 16)
7274
}
7375

76+
// Session Indicator (shown when Claude Code is active)
77+
if let session = sessionMonitor.activeSession {
78+
SessionIndicatorView(session: session)
79+
.padding(.horizontal, 16)
80+
.padding(.bottom, 8)
81+
}
82+
7483
// Main Content Area - no scroll, dynamic height
7584
VStack(spacing: 12) {
7685
metricsContent
@@ -98,6 +107,10 @@ struct MenuContentView: View {
98107
.frame(width: 400)
99108
.fixedSize(horizontal: false, vertical: true)
100109
.clipShape(RoundedRectangle(cornerRadius: 16))
110+
.onReceive(NotificationCenter.default.publisher(for: .hookSettingsChanged)) { notification in
111+
let enabled = notification.userInfo?["enabled"] as? Bool ?? false
112+
onHookSettingsChanged?(enabled)
113+
}
101114
.task {
102115
// Request alert permission once (after app run loop is active)
103116
if !hasRequestedNotificationPermission {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import SwiftUI
2+
import Domain
3+
4+
/// Displays the current Claude Code session status in the menu popover.
5+
/// Shown when there's an active session (SessionMonitor.activeSession != nil).
6+
struct SessionIndicatorView: View {
7+
let session: ClaudeSession
8+
9+
@Environment(\.appTheme) private var theme
10+
11+
var body: some View {
12+
HStack(spacing: 10) {
13+
// Phase indicator dot
14+
Circle()
15+
.fill(phaseColor)
16+
.frame(width: 8, height: 8)
17+
.overlay(
18+
Circle()
19+
.fill(phaseColor.opacity(0.4))
20+
.frame(width: 14, height: 14)
21+
)
22+
23+
VStack(alignment: .leading, spacing: 2) {
24+
HStack(spacing: 6) {
25+
Text("Claude Code")
26+
.font(.system(size: 11, weight: .semibold, design: theme.fontDesign))
27+
.foregroundStyle(theme.textPrimary)
28+
29+
Text(phaseLabel)
30+
.font(.system(size: 9, weight: .medium, design: theme.fontDesign))
31+
.foregroundStyle(phaseLabelColor)
32+
.padding(.horizontal, 6)
33+
.padding(.vertical, 2)
34+
.background(
35+
Capsule()
36+
.fill(phaseColor.opacity(0.15))
37+
)
38+
}
39+
40+
HStack(spacing: 8) {
41+
if session.completedTaskCount > 0 {
42+
Label("\(session.completedTaskCount) tasks", systemImage: "checkmark.circle.fill")
43+
.font(.system(size: 9, weight: .medium, design: theme.fontDesign))
44+
.foregroundStyle(theme.textSecondary)
45+
}
46+
47+
if session.activeSubagentCount > 0 {
48+
Label("\(session.activeSubagentCount) agents", systemImage: "person.2.fill")
49+
.font(.system(size: 9, weight: .medium, design: theme.fontDesign))
50+
.foregroundStyle(theme.textSecondary)
51+
}
52+
53+
Text(session.durationDescription)
54+
.font(.system(size: 9, weight: .medium, design: theme.fontDesign))
55+
.foregroundStyle(theme.textTertiary)
56+
57+
Spacer()
58+
59+
// Working directory (last path component)
60+
Text(cwdShort)
61+
.font(.system(size: 9, weight: .medium, design: theme.fontDesign))
62+
.foregroundStyle(theme.textTertiary)
63+
.lineLimit(1)
64+
}
65+
}
66+
}
67+
.padding(12)
68+
.background(
69+
ZStack {
70+
RoundedRectangle(cornerRadius: theme.cardCornerRadius)
71+
.fill(theme.cardGradient)
72+
73+
RoundedRectangle(cornerRadius: theme.cardCornerRadius)
74+
.stroke(phaseColor.opacity(0.3), lineWidth: 1)
75+
}
76+
)
77+
}
78+
79+
// MARK: - Phase Display
80+
81+
private var phaseLabel: String { session.phase.label }
82+
private var phaseColor: Color { session.phase.color }
83+
84+
private var phaseLabelColor: Color {
85+
session.phase == .ended ? theme.textTertiary : session.phase.color
86+
}
87+
88+
private var cwdShort: String {
89+
(session.cwd as NSString).lastPathComponent
90+
}
91+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SwiftUI
2+
import Domain
3+
4+
extension ClaudeSession.Phase {
5+
/// The display color for this session phase.
6+
/// Single source of truth — used by StatusBarIcon, SessionIndicatorView, etc.
7+
var color: Color {
8+
switch self {
9+
case .active: return .green
10+
case .subagentsWorking: return .blue
11+
case .stopped: return .orange
12+
case .ended: return .gray
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)