modelstat 0.5.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelstat",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "modelstat daemon — reads local AI-tool usage and ships tokenised events to modelstat.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -87,6 +87,17 @@ struct LocalStatus: Decodable {
87
87
  let last_event_at: String?
88
88
  let daemon_version: String?
89
89
  let stats: LocalStatsCounters?
90
+ /// Server release verdict (the daemon sets this from the heartbeat response).
91
+ let update: UpdateInfo?
92
+ /// Effective auto-update setting — drives the tray's checkbox.
93
+ let auto_update: Bool?
94
+ }
95
+
96
+ struct UpdateInfo: Decodable {
97
+ /// "ok" | "update_available" | "upgrade_required".
98
+ let verdict: String?
99
+ /// Latest published version, when known.
100
+ let latest: String?
90
101
  }
91
102
 
92
103
  struct LocalStatsCounters: Decodable {
@@ -146,6 +157,11 @@ final class TrayController: NSObject {
146
157
  private let copyClaimMI = NSMenuItem(title: "Copy claim URL", action: #selector(copyClaimUrl), keyEquivalent: "c")
147
158
  private let jobsMI = NSMenuItem(title: "View pipeline…", action: #selector(openJobs), keyEquivalent: "j")
148
159
  private let pauseMI = NSMenuItem(title: "Pause", action: #selector(togglePaused), keyEquivalent: "p")
160
+ /// "Update now" — shown only when the server says this daemon is behind.
161
+ private let updateMI = NSMenuItem(title: "Update now", action: #selector(updateNow), keyEquivalent: "u")
162
+ /// Checkable "Auto-update" — reflects (and toggles) the daemon's setting.
163
+ private let autoUpdateMI = NSMenuItem(
164
+ title: "Auto-update", action: #selector(toggleAutoUpdate), keyEquivalent: "")
149
165
 
150
166
  override init() {
151
167
  self.cli = locateCli()
@@ -231,6 +247,11 @@ final class TrayController: NSObject {
231
247
  menu.addItem(copyClaimMI)
232
248
  menu.addItem(jobsMI)
233
249
  menu.addItem(pauseMI)
250
+ updateMI.target = self
251
+ autoUpdateMI.target = self
252
+ updateMI.isHidden = true
253
+ menu.addItem(updateMI)
254
+ menu.addItem(autoUpdateMI)
234
255
  menu.addItem(NSMenuItem.separator())
235
256
  let logsMI = NSMenuItem(title: "Open logs folder", action: #selector(openLogs), keyEquivalent: "l")
236
257
  logsMI.target = self
@@ -466,6 +487,9 @@ final class TrayController: NSObject {
466
487
  // Paused: togglePaused() owns the status line ("Paused"); don't let the
467
488
  // fast tick clobber it with a stale phase from the file.
468
489
  guard !paused else { return }
490
+ // Auto-update toggle + "Update now" read straight from the local heartbeat
491
+ // file, so render them before the loading/paired early-returns below.
492
+ renderUpdateItems()
469
493
  guard let s = latest else {
470
494
  setInfo(statusMI, "Loading…")
471
495
  return
@@ -572,6 +596,52 @@ final class TrayController: NSObject {
572
596
  }
573
597
  }
574
598
 
599
+ /// Reflect the daemon's auto-update setting + any pending update in the menu.
600
+ /// Both come from ~/.modelstat/last-status.json (written by the daemon every
601
+ /// heartbeat), so a toggle made here shows up within a second once the daemon
602
+ /// re-reads the preference.
603
+ private func renderUpdateItems() {
604
+ autoUpdateMI.state = (localLatest?.auto_update ?? true) ? .on : .off
605
+ if let upd = localLatest?.update, let verdict = upd.verdict, verdict != "ok" {
606
+ let suffix = upd.latest.map { " (\($0))" } ?? ""
607
+ updateMI.title =
608
+ verdict == "upgrade_required"
609
+ ? "Update required — update now\(suffix)" : "Update now\(suffix)"
610
+ updateMI.isHidden = false
611
+ } else {
612
+ updateMI.isHidden = true
613
+ }
614
+ }
615
+
616
+ @objc private func toggleAutoUpdate() {
617
+ runManaged(["autoupdate", "toggle"])
618
+ // Optimistic flip; the next 1s tick confirms the real state from disk.
619
+ autoUpdateMI.state = (autoUpdateMI.state == .on) ? .off : .on
620
+ }
621
+
622
+ @objc private func updateNow() {
623
+ runManaged(["upgrade"])
624
+ }
625
+
626
+ /// Fire-and-forget a `modelstat <args>` invocation (autoupdate / upgrade).
627
+ /// Best-effort, non-blocking; output is appended to the daemon log.
628
+ private func runManaged(_ args: [String]) {
629
+ guard let cli else { return }
630
+ let p = Process()
631
+ if cli.pathExtension == "mjs" {
632
+ p.launchPath = "/usr/bin/env"
633
+ p.arguments = ["node", cli.path] + args
634
+ } else {
635
+ p.launchPath = cli.path
636
+ p.arguments = args
637
+ }
638
+ let logsDir = ("~/.modelstat/logs" as NSString).expandingTildeInPath
639
+ let out = FileHandle(forWritingAtPath: "\(logsDir)/out.log") ?? FileHandle.standardOutput
640
+ p.standardOutput = out
641
+ p.standardError = out
642
+ try? p.run()
643
+ }
644
+
575
645
  /// Phases where the agent is doing visible work right now — drives the
576
646
  /// pulsing status dot. "watching"/"idle" are healthy-but-quiet (steady
577
647
  /// dot); "offline"/"error" are problems (steady dot, not a busy pulse).