modelstat 0.0.29 → 0.0.31

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.29",
3
+ "version": "0.0.31",
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",
@@ -167,12 +167,20 @@ async function rebootServiceIfInstalled() {
167
167
  const { spawnSync } = await import("node:child_process");
168
168
  console.log("[modelstat] refreshing background service…");
169
169
 
170
- // Stop first best effort, ignore failures.
170
+ // Stop the SERVICE (launchctl bootout / systemctl --user disable)
171
+ // via the fresh bundle's own knowledge of each platform.
171
172
  spawnSync(process.execPath, [freshBundle, "stop"], {
172
173
  stdio: "ignore",
173
174
  timeout: 30_000,
174
175
  });
175
176
 
177
+ // Belt-and-braces: kill any stray daemon process that the service
178
+ // supervisor didn't reap (stale lock, killed parent, KeepAlive
179
+ // race during the bundle swap below). Lock file at
180
+ // ~/.modelstat/daemon.lock has the live PID; SIGTERM it gently
181
+ // first, escalate to SIGKILL if it's still around 2 s later.
182
+ await killStaleDaemon(stateDir);
183
+
176
184
  // Copy the new bundle over the old `~/.modelstat/bin/modelstat.mjs`
177
185
  // so the service supervisor (launchd/systemd) loads the new code on
178
186
  // next launch. The fresh bundle's `connect` / `start` does this on
@@ -201,6 +209,48 @@ async function rebootServiceIfInstalled() {
201
209
  console.log("[modelstat] ✓ background service restarted with new build");
202
210
  }
203
211
 
212
+ /**
213
+ * Kill any daemon process the service supervisor didn't reap.
214
+ * Reads ~/.modelstat/daemon.lock for the PID, sends SIGTERM, then
215
+ * SIGKILL after a short grace period. Tolerates a missing/stale
216
+ * lock file — we just want to make sure the new bundle is the only
217
+ * thing holding the lock when we restart.
218
+ */
219
+ async function killStaleDaemon(stateDir) {
220
+ const { readFileSync } = await import("node:fs");
221
+ const lockPath = join(stateDir, "daemon.lock");
222
+ let payload;
223
+ try {
224
+ payload = JSON.parse(readFileSync(lockPath, "utf8"));
225
+ } catch {
226
+ return; // no lock file — nothing to do
227
+ }
228
+ const pid = Number(payload?.pid);
229
+ if (!Number.isInteger(pid) || pid <= 0) return;
230
+ if (pid === process.pid) return; // shouldn't happen, but defensive
231
+ try {
232
+ process.kill(pid, "SIGTERM");
233
+ } catch (err) {
234
+ if (err && err.code === "ESRCH") return; // already gone
235
+ // EPERM: not our process — leave it alone.
236
+ return;
237
+ }
238
+ // Wait up to 2 s for graceful exit.
239
+ for (let i = 0; i < 20; i++) {
240
+ try {
241
+ process.kill(pid, 0); // signal 0 = existence check
242
+ } catch {
243
+ return; // exited
244
+ }
245
+ await new Promise((r) => setTimeout(r, 100));
246
+ }
247
+ try {
248
+ process.kill(pid, "SIGKILL");
249
+ } catch {
250
+ /* gone or denied */
251
+ }
252
+ }
253
+
204
254
  main().catch((err) => {
205
255
  console.warn(
206
256
  `[modelstat] postinstall failed: ${err && err.message ? err.message : err}`,
@@ -54,6 +54,12 @@ struct AgentStats: Decodable {
54
54
  let agent_url: String?
55
55
  let device: DeviceInfo?
56
56
  let analyzed: AnalyzedInfo?
57
+ /// Daemon heartbeat snapshot mirrored to ~/.modelstat/last-status.json.
58
+ /// Present on both unclaimed and claimed responses post-0.0.30 so the
59
+ /// tray can show real numbers even when the public device-view 404s
60
+ /// (claimed devices). Older daemons that don't write the file leave
61
+ /// this nil.
62
+ let local: LocalStatus?
57
63
  }
58
64
 
59
65
  struct DeviceInfo: Decodable {
@@ -65,10 +71,32 @@ struct DeviceInfo: Decodable {
65
71
 
66
72
  struct AnalyzedInfo: Decodable {
67
73
  let count: Int?
74
+ /// `processing` = sessions with at least one un-classified segment;
75
+ /// `finished` = every segment classified. From device-view endpoint.
76
+ let processing: Int?
77
+ let finished: Int?
68
78
  let totalTokens: String?
69
79
  let totalCostUsd: Double?
70
80
  }
71
81
 
82
+ struct LocalStatus: Decodable {
83
+ let status: String?
84
+ let message: String?
85
+ let queue_size: Int?
86
+ let last_event_at: String?
87
+ let agent_version: String?
88
+ let stats: LocalStatsCounters?
89
+ }
90
+
91
+ struct LocalStatsCounters: Decodable {
92
+ let installations_detected: Int?
93
+ let identities_detected: Int?
94
+ let files_scanned: Int?
95
+ let files_unchanged: Int?
96
+ let events_uploaded: Int?
97
+ let batches_uploaded: Int?
98
+ }
99
+
72
100
  @MainActor
73
101
  final class TrayController: NSObject {
74
102
  private let statusItem: NSStatusItem
@@ -80,9 +108,13 @@ final class TrayController: NSObject {
80
108
  private var pollTimer: Timer?
81
109
 
82
110
  // Menu items we update on every poll
83
- private let statusMI = NSMenuItem(title: "Starting…", action: nil, keyEquivalent: "")
111
+ private let statusMI = NSMenuItem(title: "Loading…", action: nil, keyEquivalent: "")
84
112
  private let deviceMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
85
113
  private let analyzedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
114
+ /// Pipeline activity — sessions processing/finished + events uploaded.
115
+ private let pipelineMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
116
+ /// What the agent has discovered on this machine — installations + identities.
117
+ private let detectedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
86
118
  private let claimMI = NSMenuItem(title: "Open device page", action: #selector(openDashboard), keyEquivalent: "o")
87
119
  private let copyClaimMI = NSMenuItem(title: "Copy claim URL", action: #selector(copyClaimUrl), keyEquivalent: "c")
88
120
  private let jobsMI = NSMenuItem(title: "View pipeline…", action: #selector(openJobs), keyEquivalent: "j")
@@ -120,7 +152,11 @@ final class TrayController: NSObject {
120
152
  statusMI.isEnabled = false
121
153
  deviceMI.isEnabled = false
122
154
  analyzedMI.isEnabled = false
123
- for mi in [statusMI, deviceMI, analyzedMI] { menu.addItem(mi) }
155
+ pipelineMI.isEnabled = false
156
+ detectedMI.isEnabled = false
157
+ for mi in [statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI] {
158
+ menu.addItem(mi)
159
+ }
124
160
  menu.addItem(NSMenuItem.separator())
125
161
  claimMI.target = self
126
162
  copyClaimMI.target = self
@@ -233,41 +269,102 @@ final class TrayController: NSObject {
233
269
 
234
270
  private func renderStats() {
235
271
  guard let s = latest else {
236
- statusMI.title = "pairing…"
272
+ statusMI.title = "Loading…"
237
273
  return
238
274
  }
239
275
  if s.paired == false {
240
- statusMI.title = "not paired — run `modelstat connect`"
276
+ statusMI.title = "Not paired — run `npx modelstat@latest`"
241
277
  deviceMI.title = ""
242
278
  analyzedMI.title = ""
279
+ pipelineMI.title = ""
280
+ detectedMI.title = ""
243
281
  claimMI.title = "Open modelstat.ai"
244
282
  copyClaimMI.isHidden = true
245
283
  return
246
284
  }
285
+
286
+ // Live agent phase comes from the local heartbeat mirror.
287
+ // Falls back to the device-view's reported agent_status. If
288
+ // both are missing we say "running" rather than "starting" so
289
+ // the menu doesn't lie about the daemon's state.
290
+ let phase = s.local?.status ?? s.device?.agent_status ?? "running"
291
+ let phaseMsg = s.local?.message
292
+ if let m = phaseMsg, !m.isEmpty {
293
+ statusMI.title = "● \(phase) — \(m)"
294
+ } else {
295
+ statusMI.title = "● \(phase)"
296
+ }
297
+
247
298
  if s.claimed == true {
248
- statusMI.title = "Claimed ✓"
249
- deviceMI.title = "Synced to your account"
250
- analyzedMI.title = ""
299
+ // Claimed device: device-view 404s for the tray (no auth) so
300
+ // we lean entirely on the local heartbeat snapshot for live
301
+ // numbers, plus point the menu items at the dashboard.
302
+ deviceMI.title = s.local?.agent_version ?? "Claimed ✓ — synced to your account"
251
303
  claimMI.title = "Open dashboard"
252
304
  copyClaimMI.isHidden = true
253
- return
305
+ } else {
306
+ // Unclaimed: device-view fills in the rich numbers.
307
+ let host = s.device?.hostname ?? "unknown"
308
+ let os = s.device?.os_family ?? ""
309
+ deviceMI.title = "\(host) · \(os)"
310
+ claimMI.title = "Open device page"
311
+ copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
254
312
  }
255
- // Unclaimed: show the rich summary from the stats payload.
256
- let status = s.device?.agent_status ?? "ready"
257
- statusMI.title = "Agent: \(status)"
258
- let host = s.device?.hostname ?? "unknown"
259
- let os = s.device?.os_family ?? ""
260
- deviceMI.title = "\(host) · \(os)"
313
+
314
+ // Sessions / tokens / cost (only available for unclaimed since
315
+ // the device-view exposes them). Claimed devices show pipeline
316
+ // + detected counts instead — see below.
261
317
  if let a = s.analyzed {
262
318
  let tok = a.totalTokens ?? "0"
263
319
  let cnt = a.count ?? 0
264
320
  let usd = String(format: "%.2f", a.totalCostUsd ?? 0.0)
265
- analyzedMI.title = "\(cnt) sessions · \(fmtTokens(tok)) tokens · $\(usd)"
321
+ let proc = a.processing ?? 0
322
+ let done = a.finished ?? cnt
323
+ let breakdown = proc > 0 ? " (\(done) finished · \(proc) processing)" : ""
324
+ analyzedMI.title = "\(cnt) sessions\(breakdown) · \(fmtTokens(tok)) tokens · $\(usd)"
325
+ } else {
326
+ analyzedMI.title = ""
327
+ }
328
+
329
+ // Pipeline activity — events + batches uploaded since daemon
330
+ // started. Sourced from the local heartbeat mirror so it works
331
+ // for both claimed and unclaimed devices.
332
+ if let c = s.local?.stats {
333
+ let events = c.events_uploaded ?? 0
334
+ let batches = c.batches_uploaded ?? 0
335
+ let scanned = c.files_scanned ?? 0
336
+ let queue = s.local?.queue_size ?? 0
337
+ var bits: [String] = []
338
+ if events > 0 || batches > 0 {
339
+ bits.append("\(fmtCount(events)) events · \(batches) batches uploaded")
340
+ }
341
+ if scanned > 0 { bits.append("\(scanned) files scanned") }
342
+ if queue > 0 { bits.append("\(queue) in queue") }
343
+ pipelineMI.title = bits.isEmpty ? "" : bits.joined(separator: " · ")
266
344
  } else {
267
- analyzedMI.title = "no activity yet"
345
+ pipelineMI.title = ""
268
346
  }
269
- claimMI.title = "Open device page"
270
- copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
347
+
348
+ // What the agent found on this machine — installations +
349
+ // identities (Claude Keychain, Codex JWT, …). Mirror of the
350
+ // discover() output the daemon ran at startup.
351
+ if let c = s.local?.stats {
352
+ let installs = c.installations_detected ?? 0
353
+ let ids = c.identities_detected ?? 0
354
+ if installs > 0 || ids > 0 {
355
+ detectedMI.title = "\(installs) tools · \(ids) accounts detected"
356
+ } else {
357
+ detectedMI.title = ""
358
+ }
359
+ } else {
360
+ detectedMI.title = ""
361
+ }
362
+ }
363
+
364
+ private func fmtCount(_ n: Int) -> String {
365
+ if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
366
+ if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
367
+ return String(n)
271
368
  }
272
369
 
273
370
  private func fmtTokens(_ raw: String) -> String {