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 +41 -0
- package/assets/ClaudeStatus.swift +217 -0
- package/assets/build_install.sh +79 -0
- package/bin/cli.js +16 -0
- package/package.json +26 -0
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
|
+
}
|