modelstat 0.0.7 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelstat",
3
- "version": "0.0.7",
3
+ "version": "0.0.12",
4
4
  "description": "modelstat companion — reads local AI-tool usage and ships tokenised events to modelstat.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "vendor/tray-mac",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -17,16 +18,15 @@
17
18
  "dev:connect": "tsx ./src/cli.ts connect",
18
19
  "dev:scan": "tsx ./src/cli.ts scan",
19
20
  "dev:watch": "tsx ./src/cli.ts watch",
20
- "dev:register": "tsx ./src/cli.ts register",
21
21
  "dev:discover": "tsx ./src/cli.ts discover",
22
22
  "build": "tsup",
23
- "prepack": "pnpm run build",
23
+ "build:tray": "bash ./scripts/build-tray.sh",
24
+ "prepack": "pnpm run build && pnpm run build:tray",
24
25
  "pack:tarball": "pnpm run build && npm pack --pack-destination ../..",
25
26
  "install:local": "bash ./scripts/install-local.sh",
26
27
  "typecheck": "tsc --noEmit"
27
28
  },
28
29
  "dependencies": {
29
- "better-sqlite3": "^11.7.0",
30
30
  "chokidar": "^4.0.3",
31
31
  "conf": "^13.1.0",
32
32
  "dotenv": "^16.4.7",
@@ -34,9 +34,9 @@
34
34
  "undici": "^7.1.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "@modelstat/companion-core": "workspace:*",
37
38
  "@modelstat/core": "workspace:*",
38
39
  "@modelstat/parsers": "workspace:*",
39
- "@types/better-sqlite3": "^7.6.12",
40
40
  "@types/node": "^22.10.5",
41
41
  "tsup": "^8.3.5",
42
42
  "tsx": "^4.19.2",
@@ -0,0 +1,32 @@
1
+ // swift-tools-version:5.9
2
+ //
3
+ // ModelstatTray — a tiny menu-bar app for macOS.
4
+ //
5
+ // No third-party deps. AppKit provides NSStatusItem for the menu-bar
6
+ // icon; Foundation is enough for subprocess + JSON. The whole thing
7
+ // is a single main.swift so release builds are fast and the bundle
8
+ // we ship in the DMG is small (~1 MB).
9
+ //
10
+ // Build:
11
+ // swift build -c release (executable at .build/release/modelstat-tray)
12
+ // ./build-app.sh (wraps the binary in ModelstatTray.app)
13
+ //
14
+ // Wired into the macOS install path in apps/agent-dev/src/service.ts —
15
+ // the launchd plist launches THIS instead of the headless daemon; the
16
+ // tray then spawns `modelstat start` as a child so there's still only
17
+ // one process managing the pipeline.
18
+ import PackageDescription
19
+
20
+ let package = Package(
21
+ name: "ModelstatTray",
22
+ platforms: [.macOS(.v12)],
23
+ products: [
24
+ .executable(name: "modelstat-tray", targets: ["ModelstatTray"])
25
+ ],
26
+ targets: [
27
+ .executableTarget(
28
+ name: "ModelstatTray",
29
+ path: "Sources/ModelstatTray"
30
+ )
31
+ ]
32
+ )
@@ -0,0 +1,42 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>Modelstat</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>modelstat-tray</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>ai.modelstat.tray</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>Modelstat</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>APPL</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>0.1.0</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>1</string>
23
+ <key>LSMinimumSystemVersion</key>
24
+ <string>12.0</string>
25
+ <!-- Agent-style app: no Dock icon, no window at launch, just the
26
+ menu-bar item. -->
27
+ <key>LSUIElement</key>
28
+ <true/>
29
+ <!-- Launch at login when the installer drops us into
30
+ ~/Library/LaunchAgents (actually driven by the launchd plist). -->
31
+ <key>LSBackgroundOnly</key>
32
+ <false/>
33
+ <key>NSHighResolutionCapable</key>
34
+ <true/>
35
+ <key>NSSupportsAutomaticTermination</key>
36
+ <false/>
37
+ <key>NSSupportsSuddenTermination</key>
38
+ <true/>
39
+ <key>NSHumanReadableCopyright</key>
40
+ <string>© 2026 modelstat</string>
41
+ </dict>
42
+ </plist>
@@ -0,0 +1,353 @@
1
+ // ModelstatTray — macOS menu-bar companion for the modelstat agent.
2
+ //
3
+ // What it does:
4
+ // · puts a "◉" status item in the menu bar
5
+ // · runs `modelstat start` as a child process so the pipeline is live
6
+ // · polls `modelstat stats --json` every 5 s to refresh the dropdown
7
+ // · offers: Open dashboard, Copy claim URL, View pipeline, Pause/Resume,
8
+ // Quit
9
+ //
10
+ // Spawning the CLI is deliberate: the CLI already owns discover/scan/
11
+ // watch + IngestClient retry semantics, and the tray never needs to
12
+ // touch ingestion or auth. Keeps this binary a thin shell — one file,
13
+ // ~300 LOC, no dependencies beyond AppKit/Foundation.
14
+
15
+ import AppKit
16
+ import Foundation
17
+
18
+ // ── Resolve the `modelstat` CLI on $PATH, then at the install-path
19
+ // the agent-dev installer writes into (~/.modelstat/bin/modelstat.mjs)
20
+ // so we don't rely on shell profiles loading in launchd's env.
21
+ func locateCli() -> URL? {
22
+ let fm = FileManager.default
23
+ let home = NSHomeDirectory()
24
+ let candidates = [
25
+ "\(home)/.modelstat/bin/modelstat.mjs",
26
+ "/opt/homebrew/bin/modelstat",
27
+ "/usr/local/bin/modelstat",
28
+ "/usr/bin/modelstat",
29
+ ]
30
+ for p in candidates {
31
+ if fm.isExecutableFile(atPath: p) { return URL(fileURLWithPath: p) }
32
+ }
33
+ // Last-ditch: `which modelstat` via a login shell so PATH lookups
34
+ // honour the user's zsh/bash config.
35
+ let task = Process()
36
+ task.launchPath = "/bin/zsh"
37
+ task.arguments = ["-l", "-c", "which modelstat"]
38
+ let pipe = Pipe()
39
+ task.standardOutput = pipe
40
+ task.standardError = Pipe()
41
+ do { try task.run(); task.waitUntilExit() } catch { return nil }
42
+ let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
43
+ let path = out.trimmingCharacters(in: .whitespacesAndNewlines)
44
+ return path.isEmpty ? nil : URL(fileURLWithPath: path)
45
+ }
46
+
47
+ struct AgentStats: Decodable {
48
+ let paired: Bool?
49
+ let claimed: Bool?
50
+ let dashboard: String?
51
+ let claim_code: String?
52
+ let status: String?
53
+ let claim_url: String?
54
+ let agent_url: String?
55
+ let device: DeviceInfo?
56
+ let analyzed: AnalyzedInfo?
57
+ }
58
+
59
+ struct DeviceInfo: Decodable {
60
+ let hostname: String?
61
+ let os_family: String?
62
+ let agent_status: String?
63
+ let last_seen_at: String?
64
+ }
65
+
66
+ struct AnalyzedInfo: Decodable {
67
+ let count: Int?
68
+ let totalTokens: String?
69
+ let totalCostUsd: Double?
70
+ }
71
+
72
+ @MainActor
73
+ final class TrayController: NSObject {
74
+ private let statusItem: NSStatusItem
75
+ private let menu = NSMenu()
76
+ private let cli: URL?
77
+ private var daemon: Process?
78
+ private var paused = false
79
+ private var latest: AgentStats?
80
+ private var pollTimer: Timer?
81
+
82
+ // Menu items we update on every poll
83
+ private let statusMI = NSMenuItem(title: "Starting…", action: nil, keyEquivalent: "")
84
+ private let deviceMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
85
+ private let analyzedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
86
+ private let claimMI = NSMenuItem(title: "Open device page", action: #selector(openDashboard), keyEquivalent: "o")
87
+ private let copyClaimMI = NSMenuItem(title: "Copy claim URL", action: #selector(copyClaimUrl), keyEquivalent: "c")
88
+ private let jobsMI = NSMenuItem(title: "View pipeline…", action: #selector(openJobs), keyEquivalent: "j")
89
+ private let pauseMI = NSMenuItem(title: "Pause", action: #selector(togglePaused), keyEquivalent: "p")
90
+
91
+ override init() {
92
+ self.cli = locateCli()
93
+ self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
94
+ super.init()
95
+ configureStatusItem()
96
+ buildMenu()
97
+ startDaemon()
98
+ refreshStats()
99
+ pollTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
100
+ Task { @MainActor in self?.refreshStats() }
101
+ }
102
+ }
103
+
104
+ private func configureStatusItem() {
105
+ // SF Symbol falls back to a bullet on older macOS versions; the
106
+ // title always works so we stack title + symbol for robustness.
107
+ if #available(macOS 11.0, *),
108
+ let btn = statusItem.button,
109
+ let img = NSImage(systemSymbolName: "circle.hexagongrid.fill", accessibilityDescription: "modelstat")
110
+ {
111
+ img.isTemplate = true
112
+ btn.image = img
113
+ } else {
114
+ statusItem.button?.title = "◉"
115
+ }
116
+ statusItem.button?.toolTip = "modelstat"
117
+ }
118
+
119
+ private func buildMenu() {
120
+ statusMI.isEnabled = false
121
+ deviceMI.isEnabled = false
122
+ analyzedMI.isEnabled = false
123
+ for mi in [statusMI, deviceMI, analyzedMI] { menu.addItem(mi) }
124
+ menu.addItem(NSMenuItem.separator())
125
+ claimMI.target = self
126
+ copyClaimMI.target = self
127
+ jobsMI.target = self
128
+ pauseMI.target = self
129
+ menu.addItem(claimMI)
130
+ menu.addItem(copyClaimMI)
131
+ menu.addItem(jobsMI)
132
+ menu.addItem(pauseMI)
133
+ menu.addItem(NSMenuItem.separator())
134
+ let logsMI = NSMenuItem(title: "Open logs folder", action: #selector(openLogs), keyEquivalent: "l")
135
+ logsMI.target = self
136
+ menu.addItem(logsMI)
137
+ menu.addItem(NSMenuItem.separator())
138
+ let quitMI = NSMenuItem(title: "Quit modelstat", action: #selector(quit), keyEquivalent: "q")
139
+ quitMI.target = self
140
+ menu.addItem(quitMI)
141
+ statusItem.menu = menu
142
+ }
143
+
144
+ // ── Daemon lifecycle ─────────────────────────────────────────────
145
+
146
+ private func startDaemon() {
147
+ guard let cli else {
148
+ statusMI.title = "modelstat CLI not found"
149
+ return
150
+ }
151
+ let p = Process()
152
+ if cli.pathExtension == "mjs" {
153
+ p.launchPath = "/usr/bin/env"
154
+ p.arguments = ["node", cli.path, "start"]
155
+ } else {
156
+ p.launchPath = cli.path
157
+ p.arguments = ["start"]
158
+ }
159
+ // Bolt stdout/stderr onto the same log the launchd plist uses so
160
+ // `modelstat status` still sees the same tail.
161
+ let logsDir = ("~/.modelstat/logs" as NSString).expandingTildeInPath
162
+ try? FileManager.default.createDirectory(atPath: logsDir, withIntermediateDirectories: true)
163
+ let out = FileHandle(forWritingAtPath: "\(logsDir)/out.log") ?? FileHandle.standardOutput
164
+ let err = FileHandle(forWritingAtPath: "\(logsDir)/err.log") ?? FileHandle.standardError
165
+ p.standardOutput = out
166
+ p.standardError = err
167
+ p.terminationHandler = { [weak self] proc in
168
+ // Daemon exited — if we didn't pause it intentionally, restart
169
+ // after 2s so a crash is self-healing without user clicks.
170
+ Task { @MainActor in
171
+ guard let self else { return }
172
+ guard !self.paused else { return }
173
+ self.daemon = nil
174
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
175
+ self?.startDaemon()
176
+ }
177
+ _ = proc
178
+ }
179
+ }
180
+ do {
181
+ try p.run()
182
+ daemon = p
183
+ } catch {
184
+ statusMI.title = "modelstat start failed: \(error.localizedDescription)"
185
+ }
186
+ }
187
+
188
+ private func stopDaemon() {
189
+ daemon?.terminate()
190
+ daemon = nil
191
+ }
192
+
193
+ // ── Polling `modelstat stats --json` ────────────────────────────
194
+
195
+ private func refreshStats() {
196
+ guard let cli else { return }
197
+ let p = Process()
198
+ if cli.pathExtension == "mjs" {
199
+ p.launchPath = "/usr/bin/env"
200
+ p.arguments = ["node", cli.path, "stats", "--json"]
201
+ } else {
202
+ p.launchPath = cli.path
203
+ p.arguments = ["stats", "--json"]
204
+ }
205
+ let pipe = Pipe()
206
+ p.standardOutput = pipe
207
+ p.standardError = Pipe()
208
+ do {
209
+ try p.run()
210
+ } catch {
211
+ return
212
+ }
213
+ // Run on a background queue so we don't block the main loop.
214
+ DispatchQueue.global(qos: .utility).async { [weak self] in
215
+ p.waitUntilExit()
216
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
217
+ let stats = try? JSONDecoder().decode(AgentStats.self, from: data)
218
+ DispatchQueue.main.async {
219
+ self?.latest = stats
220
+ self?.renderStats()
221
+ }
222
+ }
223
+ }
224
+
225
+ private func renderStats() {
226
+ guard let s = latest else {
227
+ statusMI.title = "pairing…"
228
+ return
229
+ }
230
+ if s.paired == false {
231
+ statusMI.title = "not paired — run `modelstat connect`"
232
+ deviceMI.title = ""
233
+ analyzedMI.title = ""
234
+ claimMI.title = "Open modelstat.ai"
235
+ copyClaimMI.isHidden = true
236
+ return
237
+ }
238
+ if s.claimed == true {
239
+ statusMI.title = "Claimed ✓"
240
+ deviceMI.title = "Synced to your account"
241
+ analyzedMI.title = ""
242
+ claimMI.title = "Open dashboard"
243
+ copyClaimMI.isHidden = true
244
+ return
245
+ }
246
+ // Unclaimed: show the rich summary from the stats payload.
247
+ let status = s.device?.agent_status ?? "ready"
248
+ statusMI.title = "Agent: \(status)"
249
+ let host = s.device?.hostname ?? "unknown"
250
+ let os = s.device?.os_family ?? ""
251
+ deviceMI.title = "\(host) · \(os)"
252
+ if let a = s.analyzed {
253
+ let tok = a.totalTokens ?? "0"
254
+ let cnt = a.count ?? 0
255
+ let usd = String(format: "%.2f", a.totalCostUsd ?? 0.0)
256
+ analyzedMI.title = "\(cnt) sessions · \(fmtTokens(tok)) tokens · $\(usd)"
257
+ } else {
258
+ analyzedMI.title = "no activity yet"
259
+ }
260
+ claimMI.title = "Open device page"
261
+ copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
262
+ }
263
+
264
+ private func fmtTokens(_ raw: String) -> String {
265
+ guard let n = Double(raw) else { return raw }
266
+ if n >= 1e9 { return String(format: "%.1fB", n / 1e9) }
267
+ if n >= 1e6 { return String(format: "%.1fM", n / 1e6) }
268
+ if n >= 1e3 { return String(format: "%.0fK", n / 1e3) }
269
+ return String(format: "%.0f", n)
270
+ }
271
+
272
+ // ── Menu actions ────────────────────────────────────────────────
273
+
274
+ @objc private func openDashboard() {
275
+ let url: String
276
+ if latest?.claimed == true {
277
+ url = "https://modelstat.ai/dashboard"
278
+ } else if let claim = latest?.claim_url, !claim.isEmpty {
279
+ url = claim
280
+ } else {
281
+ url = "https://modelstat.ai"
282
+ }
283
+ if let u = URL(string: url) { NSWorkspace.shared.open(u) }
284
+ }
285
+
286
+ @objc private func openJobs() {
287
+ let url: String
288
+ if latest?.claimed == true {
289
+ url = "https://modelstat.ai/dashboard/jobs"
290
+ } else if let claim = latest?.claim_url, !claim.isEmpty {
291
+ url = claim + "/jobs"
292
+ } else {
293
+ url = "https://modelstat.ai/dashboard/jobs"
294
+ }
295
+ if let u = URL(string: url) { NSWorkspace.shared.open(u) }
296
+ }
297
+
298
+ @objc private func copyClaimUrl() {
299
+ guard let claim = latest?.claim_url, !claim.isEmpty else { return }
300
+ let pb = NSPasteboard.general
301
+ pb.clearContents()
302
+ pb.setString(claim, forType: .string)
303
+ }
304
+
305
+ @objc private func togglePaused() {
306
+ paused.toggle()
307
+ if paused {
308
+ stopDaemon()
309
+ pauseMI.title = "Resume"
310
+ statusMI.title = "Paused"
311
+ } else {
312
+ pauseMI.title = "Pause"
313
+ startDaemon()
314
+ }
315
+ }
316
+
317
+ @objc private func openLogs() {
318
+ let path = ("~/.modelstat/logs" as NSString).expandingTildeInPath
319
+ NSWorkspace.shared.open(URL(fileURLWithPath: path))
320
+ }
321
+
322
+ @objc private func quit() {
323
+ stopDaemon()
324
+ pollTimer?.invalidate()
325
+ NSApp.terminate(nil)
326
+ }
327
+ }
328
+
329
+ // ── App bootstrap ──────────────────────────────────────────────────
330
+ //
331
+ // LSUIElement=true in Info.plist hides the Dock icon. Without it the
332
+ // agent would bounce in the Dock on every boot, which is not what
333
+ // anyone signed up for. We set it here as a belt — the plist is the
334
+ // braces — so a malformed bundle still behaves.
335
+ //
336
+ // TrayController is @MainActor, so its init must run on the main
337
+ // actor. We wrap the bootstrap in a main-actor function to satisfy
338
+ // Swift 6's strict concurrency without bloating the controller.
339
+ @MainActor
340
+ func bootstrap() {
341
+ let app = NSApplication.shared
342
+ app.setActivationPolicy(.accessory)
343
+ _ = TrayController()
344
+ app.run()
345
+ }
346
+
347
+ // Swift's top-level code runs on the main thread implicitly, but the
348
+ // compiler doesn't infer main-actor isolation there. `DispatchQueue.main.async`
349
+ // schedules the bootstrap on the main queue where the @MainActor hop
350
+ // becomes a no-op; we keep the current thread alive with RunLoop.main
351
+ // until AppKit takes over.
352
+ DispatchQueue.main.async { bootstrap() }
353
+ RunLoop.main.run()
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # Build ModelstatTray.app from the Swift package.
3
+ #
4
+ # Output:
5
+ # .build/release/modelstat-tray (raw executable)
6
+ # build/ModelstatTray.app (bundle ready to drop into /Applications)
7
+ #
8
+ # Usage:
9
+ # ./build-app.sh (release build, universal when running on arm64)
10
+ # SWIFT_ARCH=x86_64 ./build-app.sh (cross-build for Intel)
11
+ #
12
+ # We deliberately DO NOT codesign here — the installer pipeline takes
13
+ # care of that with the team's Developer ID. This script just has to
14
+ # produce a runnable bundle; `codesign` + `create-dmg` happen in CI.
15
+
16
+ set -euo pipefail
17
+
18
+ HERE="$(cd "$(dirname "$0")" && pwd)"
19
+ cd "$HERE"
20
+
21
+ APP_NAME="ModelstatTray"
22
+ BUNDLE="build/${APP_NAME}.app"
23
+
24
+ # Clean prior bundle; leave the .build/ cache so incremental swift
25
+ # compiles stay fast.
26
+ rm -rf "$BUNDLE"
27
+ mkdir -p "$BUNDLE/Contents/MacOS" "$BUNDLE/Contents/Resources"
28
+
29
+ echo "▶ swift build -c release"
30
+ swift build -c release
31
+
32
+ cp ".build/release/modelstat-tray" "$BUNDLE/Contents/MacOS/modelstat-tray"
33
+ chmod +x "$BUNDLE/Contents/MacOS/modelstat-tray"
34
+ cp "Resources/Info.plist" "$BUNDLE/Contents/Info.plist"
35
+
36
+ # Embedded PkgInfo file — AppKit used to be picky about this. Costs
37
+ # four bytes to include and silences a startup warning on older macOS.
38
+ printf "APPL????" > "$BUNDLE/Contents/PkgInfo"
39
+
40
+ echo
41
+ echo "✓ Built $BUNDLE"
42
+ echo " run: open '$BUNDLE'"
43
+ echo " install: cp -R '$BUNDLE' /Applications/"