claude-status-menubar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # claude-status-menubar
2
+
3
+ macOS menu bar app showing live status of Claude Desktop (Cowork) sessions —
4
+ **✳ Running / Done / Idle** — with a notification when a session finishes.
5
+ Compiles locally on install (no Gatekeeper warnings, nothing to notarize).
6
+
7
+ ## Install (end users)
8
+
9
+ ```bash
10
+ npx claude-status-menubar
11
+ ```
12
+
13
+ Requirements: macOS, Claude Desktop, Xcode Command Line Tools
14
+ (`xcode-select --install` if the installer says the compiler is missing).
15
+
16
+ Installs `~/Applications/Claude Status.app` + a LaunchAgent for auto-start at
17
+ login. The ✳ icon appears near the right side of the menu bar (pinned right of
18
+ the notch; Cmd-drag to move it).
19
+
20
+ ## Uninstall
21
+
22
+ ```bash
23
+ launchctl bootout "gui/$(id -u)/com.claude-status.menubar"
24
+ rm -rf ~/Applications/"Claude Status.app" ~/Library/LaunchAgents/com.claude-status.menubar.plist
25
+ defaults delete com.claude-status.menubar 2>/dev/null
26
+ ```
27
+
28
+ ## Publishing (for the maintainer — one-time setup)
29
+
30
+ 1. `cd` into this folder.
31
+ 2. `npm login` (create a free account at npmjs.com if needed).
32
+ 3. `npm publish` — if the name `claude-status-menubar` is taken, rename in
33
+ `package.json` to a scoped name like `@yourusername/claude-status-menubar`
34
+ and publish with `npm publish --access public`. Users then run
35
+ `npx @yourusername/claude-status-menubar`.
36
+ 4. Future updates: bump `version` in package.json, `npm publish` again.
37
+
38
+ No-npm alternative: push this folder to GitHub and share
39
+ `curl -fsSL https://raw.githubusercontent.com/<user>/<repo>/main/assets/build_install.sh` —
40
+ but users also need `ClaudeStatus.swift` next to it, so for curl distribution
41
+ point people at `git clone` + `bash assets/build_install.sh` instead.
@@ -0,0 +1,217 @@
1
+ // Claude Status — menu bar tracker for Claude Desktop (Cowork) sessions.
2
+ // Polls ~/Library/Application Support/Claude/local-agent-mode-sessions/ every few
3
+ // seconds. A session whose files were written within `quietThreshold` seconds is
4
+ // "running"; when a running session goes quiet, a macOS notification fires.
5
+ //
6
+ // Build/install: ./build_install.sh (installs to ~/Applications/Claude Status.app)
7
+
8
+ import AppKit
9
+ import Foundation
10
+
11
+ struct Session {
12
+ let id: String
13
+ let title: String
14
+ let lastActivity: Date
15
+ let isArchived: Bool
16
+ }
17
+
18
+ final class SessionScanner {
19
+ let root = NSString(string: "~/Library/Application Support/Claude/local-agent-mode-sessions").expandingTildeInPath
20
+ private var titleCache: [String: (mtime: Date, title: String, archived: Bool)] = [:]
21
+
22
+ func scan() -> [Session] {
23
+ let fm = FileManager.default
24
+ var sessions: [Session] = []
25
+ guard let level1 = try? fm.contentsOfDirectory(atPath: root) else { return [] }
26
+ for d1 in level1 {
27
+ let p1 = root + "/" + d1
28
+ var isDir: ObjCBool = false
29
+ guard fm.fileExists(atPath: p1, isDirectory: &isDir), isDir.boolValue else { continue }
30
+ guard let level2 = try? fm.contentsOfDirectory(atPath: p1) else { continue }
31
+ for d2 in level2 {
32
+ let p2 = p1 + "/" + d2
33
+ guard fm.fileExists(atPath: p2, isDirectory: &isDir), isDir.boolValue else { continue }
34
+ guard let files = try? fm.contentsOfDirectory(atPath: p2) else { continue }
35
+ for f in files where f.hasPrefix("local_") && f.hasSuffix(".json") {
36
+ let jsonPath = p2 + "/" + f
37
+ let id = String(f.dropLast(5)) // strip ".json"
38
+ guard let attrs = try? fm.attributesOfItem(atPath: jsonPath),
39
+ var mtime = attrs[.modificationDate] as? Date else { continue }
40
+ // The audit log also updates on every tool call — use whichever is fresher.
41
+ let auditPath = p2 + "/" + id + "/audit.jsonl"
42
+ if let a = try? fm.attributesOfItem(atPath: auditPath),
43
+ let am = a[.modificationDate] as? Date, am > mtime {
44
+ mtime = am
45
+ }
46
+ let meta = titleFor(id: id, path: jsonPath, mtime: mtime)
47
+ sessions.append(Session(id: id, title: meta.0, lastActivity: mtime, isArchived: meta.1))
48
+ }
49
+ }
50
+ }
51
+ return sessions.sorted { $0.lastActivity > $1.lastActivity }
52
+ }
53
+
54
+ private func titleFor(id: String, path: String, mtime: Date) -> (String, Bool) {
55
+ if let c = titleCache[id], c.mtime == mtime { return (c.title, c.archived) }
56
+ var title = "Untitled session"
57
+ var archived = false
58
+ if let data = FileManager.default.contents(atPath: path),
59
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
60
+ title = (obj["title"] as? String)
61
+ ?? (obj["initialMessage"] as? String)
62
+ ?? title
63
+ archived = (obj["isArchived"] as? Bool) ?? false
64
+ }
65
+ titleCache[id] = (mtime, title, archived)
66
+ return (title, archived)
67
+ }
68
+ }
69
+
70
+ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
71
+ private let scanner = SessionScanner()
72
+ private var statusItem: NSStatusItem!
73
+ private let menu = NSMenu()
74
+ private var timer: Timer?
75
+ private var runningIDs: Set<String> = []
76
+ private var latest: [Session] = []
77
+
78
+ /// Seconds with no file writes before a session counts as finished.
79
+ /// (Long tool calls can be quiet for ~45s, so don't set this too low.)
80
+ private let quietThreshold: TimeInterval = 45
81
+ private let pollInterval: TimeInterval = 3
82
+ /// How long the menu bar shows "Done" after the last session finishes.
83
+ private let doneHold: TimeInterval = 300
84
+ private var lastFinishAt: Date?
85
+
86
+ func applicationDidFinishLaunching(_ notification: Notification) {
87
+ NSLog("ClaudeStatus: didFinishLaunching")
88
+ // Single instance guard. Note: LaunchServices can briefly keep stale
89
+ // entries for a just-killed instance, so verify the pid is actually
90
+ // alive (kill(pid, 0)) before yielding — and use exit(), because
91
+ // NSApp.terminate() is silently swallowed during launch.
92
+ let myPid = ProcessInfo.processInfo.processIdentifier
93
+ if let bid = Bundle.main.bundleIdentifier {
94
+ let others = NSRunningApplication.runningApplications(withBundleIdentifier: bid)
95
+ .filter { $0.processIdentifier != myPid && kill($0.processIdentifier, 0) == 0 }
96
+ if !others.isEmpty {
97
+ NSLog("ClaudeStatus: live duplicate instance found — exiting")
98
+ exit(0)
99
+ }
100
+ }
101
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
102
+ // Persist position (defaults key "NSStatusItem Preferred Position ClaudeStatus").
103
+ // Seeded by build_install.sh to sit right of the notch on notched MacBooks —
104
+ // without this, new items spawn leftmost and can be hidden under the notch.
105
+ statusItem.autosaveName = "ClaudeStatus"
106
+ NSLog("ClaudeStatus: status item created, button=%@", statusItem.button == nil ? "nil" : "ok")
107
+ if let button = statusItem.button {
108
+ // Text-only: an SF Symbol image can fail to load and leave a
109
+ // zero-width (invisible) status item. Text always renders.
110
+ button.title = "\u{2733}" // ✳
111
+ }
112
+ menu.delegate = self
113
+ statusItem.menu = menu
114
+ refresh()
115
+ timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
116
+ self?.refresh()
117
+ }
118
+ }
119
+
120
+ private func refresh() {
121
+ let sessions = scanner.scan().filter { !$0.isArchived }
122
+ latest = sessions
123
+ let now = Date()
124
+ let nowRunning = Set(
125
+ sessions.filter { now.timeIntervalSince($0.lastActivity) < quietThreshold }.map { $0.id }
126
+ )
127
+ let finished = runningIDs.subtracting(nowRunning)
128
+ for id in finished {
129
+ if let s = sessions.first(where: { $0.id == id }) {
130
+ notifyDone(s)
131
+ }
132
+ }
133
+ if !finished.isEmpty { lastFinishAt = now }
134
+ runningIDs = nowRunning
135
+ updateButton(running: nowRunning.count, now: now)
136
+ }
137
+
138
+ private func updateButton(running: Int, now: Date) {
139
+ guard let button = statusItem.button else { return }
140
+ let state: String
141
+ if running > 0 {
142
+ state = running > 1 ? "Running \(running)" : "Running"
143
+ } else if let t = lastFinishAt, now.timeIntervalSince(t) < doneHold {
144
+ state = "Done"
145
+ } else {
146
+ state = "Idle"
147
+ }
148
+ let font = NSFont.systemFont(ofSize: 14, weight: .medium)
149
+ let terracotta = NSColor(srgbRed: 0.85, green: 0.47, blue: 0.34, alpha: 1.0) // Claude brand-ish
150
+ let title = NSMutableAttributedString()
151
+ title.append(NSAttributedString(string: "\u{2733} ",
152
+ attributes: [.font: font, .foregroundColor: terracotta]))
153
+ title.append(NSAttributedString(string: state,
154
+ attributes: [.font: font, .foregroundColor: NSColor.labelColor]))
155
+ button.attributedTitle = title
156
+ }
157
+
158
+ private func notifyDone(_ s: Session) {
159
+ let safeTitle = s.title
160
+ .replacingOccurrences(of: "\\", with: "")
161
+ .replacingOccurrences(of: "\"", with: "'")
162
+ let script = "display notification \"\(safeTitle)\" with title \"Claude — session finished\" sound name \"Glass\""
163
+ let p = Process()
164
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
165
+ p.arguments = ["-e", script]
166
+ try? p.run()
167
+ }
168
+
169
+ // MARK: - Menu
170
+
171
+ func menuNeedsUpdate(_ menu: NSMenu) {
172
+ menu.removeAllItems()
173
+ let now = Date()
174
+ let recent = latest.filter { now.timeIntervalSince($0.lastActivity) < 48 * 3600 }
175
+ let shown = recent.isEmpty ? Array(latest.prefix(5)) : Array(recent.prefix(10))
176
+
177
+ if shown.isEmpty {
178
+ menu.addItem(NSMenuItem(title: "No Claude sessions found", action: nil, keyEquivalent: ""))
179
+ }
180
+ for s in shown {
181
+ let running = runningIDs.contains(s.id)
182
+ let mark = running ? "\u{25CF} " : "\u{25CB} " // ● / ○
183
+ let when = running ? "running" : relative(now.timeIntervalSince(s.lastActivity))
184
+ let item = NSMenuItem(title: "\(mark)\(truncate(s.title, 42)) — \(when)",
185
+ action: #selector(openClaude), keyEquivalent: "")
186
+ item.target = self
187
+ menu.addItem(item)
188
+ }
189
+ menu.addItem(.separator())
190
+ let open = NSMenuItem(title: "Open Claude", action: #selector(openClaude), keyEquivalent: "o")
191
+ open.target = self
192
+ menu.addItem(open)
193
+ menu.addItem(NSMenuItem(title: "Quit Claude Status",
194
+ action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
195
+ }
196
+
197
+ @objc private func openClaude() {
198
+ NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Claude.app"))
199
+ }
200
+
201
+ private func relative(_ t: TimeInterval) -> String {
202
+ if t < 90 { return "just now" }
203
+ if t < 3600 { return "\(Int(t / 60))m ago" }
204
+ if t < 86400 { return "\(Int(t / 3600))h ago" }
205
+ return "\(Int(t / 86400))d ago"
206
+ }
207
+
208
+ private func truncate(_ s: String, _ n: Int) -> String {
209
+ s.count <= n ? s : String(s.prefix(n - 1)) + "\u{2026}"
210
+ }
211
+ }
212
+
213
+ let app = NSApplication.shared
214
+ let delegate = AppDelegate()
215
+ app.delegate = delegate
216
+ app.setActivationPolicy(.accessory)
217
+ app.run()
@@ -0,0 +1,79 @@
1
+ #!/bin/bash
2
+ # Build "Claude Status" menu bar app, install to ~/Applications, register auto-start.
3
+ # Safe to re-run: rebuilds and replaces the existing install.
4
+ # Touches ONLY: ~/Applications/Claude Status.app and
5
+ # ~/Library/LaunchAgents/com.claude-status.menubar.plist
6
+ set -euo pipefail
7
+
8
+ SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ APP="$HOME/Applications/Claude Status.app"
10
+ BUILD="$SRC_DIR/.build"
11
+ LABEL="com.claude-status.menubar"
12
+ LA="$HOME/Library/LaunchAgents/$LABEL.plist"
13
+
14
+ if ! xcrun swiftc --version >/dev/null 2>&1; then
15
+ echo "ERROR: Swift compiler not found. Install Xcode Command Line Tools first:"
16
+ echo " xcode-select --install"
17
+ exit 1
18
+ fi
19
+
20
+ mkdir -p "$BUILD" "$HOME/Applications" "$HOME/Library/LaunchAgents"
21
+
22
+ echo "== Compiling..."
23
+ xcrun swiftc -O -o "$BUILD/ClaudeStatus" "$SRC_DIR/ClaudeStatus.swift" -framework AppKit
24
+
25
+ echo "== Stopping any existing instance..."
26
+ launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
27
+ pkill -x ClaudeStatus 2>/dev/null || true
28
+ sleep 1
29
+
30
+ echo "== Bundling..."
31
+ rm -rf "$APP"
32
+ mkdir -p "$APP/Contents/MacOS"
33
+ cp "$BUILD/ClaudeStatus" "$APP/Contents/MacOS/ClaudeStatus"
34
+ cat > "$APP/Contents/Info.plist" <<'PLIST'
35
+ <?xml version="1.0" encoding="UTF-8"?>
36
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
+ <plist version="1.0">
38
+ <dict>
39
+ <key>CFBundleIdentifier</key><string>com.claude-status.menubar</string>
40
+ <key>CFBundleName</key><string>Claude Status</string>
41
+ <key>CFBundleExecutable</key><string>ClaudeStatus</string>
42
+ <key>CFBundlePackageType</key><string>APPL</string>
43
+ <key>CFBundleShortVersionString</key><string>1.0</string>
44
+ <key>LSUIElement</key><true/>
45
+ <key>NSHighResolutionCapable</key><true/>
46
+ </dict>
47
+ </plist>
48
+ PLIST
49
+ codesign --force -s - "$APP"
50
+
51
+ # On notched MacBooks new menu bar items spawn leftmost and can be hidden
52
+ # under the notch. Pin the item 500pt from the right edge (user can Cmd-drag
53
+ # it afterwards; the position persists). Only seed if not already set.
54
+ echo "== Seeding menu bar position (right of the notch), if not already set..."
55
+ defaults read "$LABEL" "NSStatusItem Preferred Position ClaudeStatus" >/dev/null 2>&1 || \
56
+ defaults write "$LABEL" "NSStatusItem Preferred Position ClaudeStatus" -float 500
57
+
58
+ echo "== Registering LaunchAgent (auto-start at login)..."
59
+ cat > "$LA" <<PLIST
60
+ <?xml version="1.0" encoding="UTF-8"?>
61
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62
+ <plist version="1.0">
63
+ <dict>
64
+ <key>Label</key><string>$LABEL</string>
65
+ <key>Program</key><string>$APP/Contents/MacOS/ClaudeStatus</string>
66
+ <key>RunAtLoad</key><true/>
67
+ <key>KeepAlive</key><false/>
68
+ </dict>
69
+ </plist>
70
+ PLIST
71
+ launchctl bootstrap "gui/$(id -u)" "$LA"
72
+
73
+ sleep 2
74
+ if pgrep -x ClaudeStatus > /dev/null; then
75
+ echo "== OK: Claude Status is running — look for the ✳ near the right side of your menu bar."
76
+ else
77
+ echo "== WARNING: app did not start; run manually: open '$APP'"
78
+ exit 1
79
+ fi
package/bin/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Installer entry point for `npx claude-status-menubar`.
3
+ // Compiles the Swift menu bar app locally and installs it to ~/Applications
4
+ // with a LaunchAgent for auto-start. See assets/build_install.sh for details.
5
+
6
+ const { spawnSync } = require("child_process");
7
+ const path = require("path");
8
+
9
+ if (process.platform !== "darwin") {
10
+ console.error("Claude Status is a macOS menu bar app — this installer only runs on macOS.");
11
+ process.exit(1);
12
+ }
13
+
14
+ const script = path.join(__dirname, "..", "assets", "build_install.sh");
15
+ const result = spawnSync("bash", [script], { stdio: "inherit" });
16
+ process.exit(result.status === null ? 1 : result.status);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "claude-status-menubar",
3
+ "version": "1.0.0",
4
+ "description": "macOS menu bar app showing live status of Claude Desktop (Cowork) sessions — ✳ Running / Done / Idle — with a notification when a session finishes. Compiles locally, no Gatekeeper friction.",
5
+ "bin": {
6
+ "claude-status-menubar": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "assets"
11
+ ],
12
+ "os": [
13
+ "darwin"
14
+ ],
15
+ "keywords": [
16
+ "claude",
17
+ "menubar",
18
+ "macos",
19
+ "status",
20
+ "notifications",
21
+ "anthropic",
22
+ "cowork"
23
+ ],
24
+ "author": "Trevor Luong",
25
+ "license": "MIT"
26
+ }