modelstat 0.0.32 → 0.0.34

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.32",
3
+ "version": "0.0.34",
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",
@@ -47,23 +47,38 @@ function inThisMonorepo() {
47
47
  async function main() {
48
48
  if (process.env.MODELSTAT_SKIP_POSTINSTALL === "1") {
49
49
  console.log(
50
- "[modelstat] postinstall skipped (MODELSTAT_SKIP_POSTINSTALL=1) — model downloads lazily on first scan",
51
- );
52
- return;
53
- }
54
- if (process.env.CI === "true" || process.env.CI === "1") {
55
- console.log(
56
- "[modelstat] postinstall skipped (CI=1) — model downloads lazily on first scan",
50
+ "[modelstat] postinstall skipped (MODELSTAT_SKIP_POSTINSTALL=1)",
57
51
  );
58
52
  return;
59
53
  }
60
54
  if (inThisMonorepo()) {
61
- // Dev environment — don't pull 2.7 GB on every workspace install.
55
+ // Dev environment — don't pull 2.7 GB on every workspace install,
56
+ // and don't muck with the developer's running daemon.
62
57
  return;
63
58
  }
64
- if (!process.stdout.isTTY) {
59
+
60
+ // ── Always-run path: refresh the existing service if there is one ─
61
+ // The bundle at ~/.modelstat/bin/modelstat.mjs and its sibling
62
+ // node_modules need to track every upgrade, otherwise the daemon
63
+ // launches against stale code (or, worse, code that imports a
64
+ // native module the local install doesn't have anymore). This MUST
65
+ // run regardless of TTY/CI — the previous behaviour gated it on
66
+ // an interactive shell, which left every npm-cache / npx-prewarm
67
+ // install with a half-installed bundle the daemon couldn't load.
68
+ await rebootServiceIfInstalled();
69
+
70
+ // ── TTY-only path: pre-download the heavy summariser model ───────
71
+ // The 2.7 GB GGUF is the only thing we want to skip in non-TTY
72
+ // installs (CI, npm cache, packagers). The agent always preflights
73
+ // the summariser before producing segments (see src/pipeline.ts),
74
+ // so users in skip-mode just see the download then, instead of now.
75
+ const skipModelDownload =
76
+ process.env.CI === "true" ||
77
+ process.env.CI === "1" ||
78
+ !process.stdout.isTTY;
79
+ if (skipModelDownload) {
65
80
  console.log(
66
- "[modelstat] postinstall: non-TTY — model will download lazily on first `modelstat scan`",
81
+ "[modelstat] non-interactive install summariser model will download lazily on first scan",
67
82
  );
68
83
  return;
69
84
  }
@@ -110,16 +125,6 @@ async function main() {
110
125
  "[modelstat] the model will download lazily on first `modelstat scan` instead",
111
126
  );
112
127
  }
113
-
114
- // ── Auto-replace + restart the existing service ───────────────
115
- // If a previous version of the agent is already installed as a
116
- // launchd / systemd service, its bundle copy at
117
- // ~/.modelstat/bin/modelstat.mjs is now STALE — the new code we
118
- // just installed lives in <global node_modules>/modelstat/dist.
119
- // Stop the service, refresh the bundle copy, restart. Without
120
- // this step the user has to manually `modelstat stop && modelstat
121
- // connect` after every upgrade.
122
- await rebootServiceIfInstalled();
123
128
  console.log(
124
129
  "[modelstat] all set — your dashboard already has the new agent running",
125
130
  );
@@ -312,13 +317,71 @@ async function setupNativeNodeModules(installedBundle) {
312
317
  }
313
318
 
314
319
  /**
315
- * Kill any daemon process the service supervisor didn't reap.
316
- * Reads ~/.modelstat/daemon.lock for the PID, sends SIGTERM, then
317
- * SIGKILL after a short grace period. Tolerates a missing/stale
318
- * lock file we just want to make sure the new bundle is the only
319
- * thing holding the lock when we restart.
320
+ * Kill any modelstat-related process the service supervisor didn't
321
+ * reap. Two passes:
322
+ * (1) PID from ~/.modelstat/daemon.lock the most reliable hit
323
+ * when the lock is fresh and our own daemon wrote it.
324
+ * (2) Process scan via `pgrep -f` on macOS/Linux — catches stray
325
+ * daemons launched by an OLDER bundle (different command
326
+ * line, different lock format), zombies that survived a
327
+ * launchd KeepAlive bounce, and the case where the user has
328
+ * BOTH the launchd-managed daemon AND a hand-spawned one
329
+ * running. The pattern is intentionally narrow
330
+ * (`modelstat\\.mjs`) so we don't kill `modelstatd` or any
331
+ * other process that happens to share a substring.
332
+ *
333
+ * SIGTERM first, escalate to SIGKILL after 2 s. All failures
334
+ * tolerated — we just want the new bundle to be the only modelstat
335
+ * process holding the lock when we restart.
320
336
  */
321
337
  async function killStaleDaemon(stateDir) {
338
+ await killByLockfile(stateDir);
339
+ await killByProcessScan();
340
+ }
341
+
342
+ async function killByProcessScan() {
343
+ const { spawnSync } = await import("node:child_process");
344
+ // pgrep is on macOS by default, Linux via procps.
345
+ const r = spawnSync("pgrep", ["-f", "modelstat\\.mjs"], {
346
+ encoding: "utf8",
347
+ });
348
+ if (r.status !== 0 || !r.stdout) return;
349
+ const pids = r.stdout
350
+ .split(/\s+/)
351
+ .map((s) => Number(s))
352
+ .filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
353
+ if (pids.length === 0) return;
354
+ for (const pid of pids) {
355
+ try {
356
+ process.kill(pid, "SIGTERM");
357
+ } catch {
358
+ /* gone or not ours */
359
+ }
360
+ }
361
+ // Wait up to 2 s for graceful exits.
362
+ for (let i = 0; i < 20; i++) {
363
+ let alive = 0;
364
+ for (const pid of pids) {
365
+ try {
366
+ process.kill(pid, 0);
367
+ alive += 1;
368
+ } catch {
369
+ /* exited */
370
+ }
371
+ }
372
+ if (alive === 0) return;
373
+ await new Promise((r) => setTimeout(r, 100));
374
+ }
375
+ for (const pid of pids) {
376
+ try {
377
+ process.kill(pid, "SIGKILL");
378
+ } catch {
379
+ /* gone or denied */
380
+ }
381
+ }
382
+ }
383
+
384
+ async function killByLockfile(stateDir) {
322
385
  const { readFileSync } = await import("node:fs");
323
386
  const lockPath = join(stateDir, "daemon.lock");
324
387
  let payload;
@@ -439,33 +439,33 @@ final class TrayController: NSObject {
439
439
  // anyone signed up for. We set it here as a belt — the plist is the
440
440
  // braces — so a malformed bundle still behaves.
441
441
  //
442
- // TrayController is @MainActor, so its init must run on the main
443
- // actor. We wrap the bootstrap in a main-actor function to satisfy
444
- // Swift 6's strict concurrency without bloating the controller.
445
- //
446
442
  // IMPORTANT: nothing in AppKit retains TrayController for us. The
447
443
  // NSStatusItem holds the menu, and NSMenuItem.target is a weak
448
444
  // reference, so the controller has no strong owners. Without a
449
445
  // global anchor, ARC deallocates the controller as soon as init
450
446
  // returns — which leaves the timer's `[weak self]` callback firing
451
- // against nil and the menu title frozen on "Starting…" forever.
447
+ // against nil and the menu title frozen on "Loading…" forever.
452
448
  // The `controller` global below is the strong reference that keeps
453
449
  // the controller alive for the entire app lifetime.
450
+ //
451
+ // CRITICAL: NSApplication.run() must NOT be called from inside a
452
+ // `DispatchQueue.main.async { ... }` closure. NSApplication.run()
453
+ // blocks for the lifetime of the app, and from libdispatch's
454
+ // perspective the wrapping closure is "still executing" the entire
455
+ // time — which means every other main-queue async block (including
456
+ // the stats-poll completion that updates the menu title) gets queued
457
+ // behind it and never runs. Schedule the controller setup separately
458
+ // and call app.run() directly from top-level code so libdispatch's
459
+ // main queue stays free to drain.
454
460
  @MainActor
455
461
  private var controller: TrayController?
456
462
 
457
- @MainActor
458
- func bootstrap() {
459
- let app = NSApplication.shared
460
- app.setActivationPolicy(.accessory)
461
- controller = TrayController()
462
- app.run()
463
+ DispatchQueue.main.async {
464
+ MainActor.assumeIsolated {
465
+ controller = TrayController()
466
+ }
463
467
  }
464
468
 
465
- // Swift's top-level code runs on the main thread implicitly, but the
466
- // compiler doesn't infer main-actor isolation there. `DispatchQueue.main.async`
467
- // schedules the bootstrap on the main queue where the @MainActor hop
468
- // becomes a no-op; we keep the current thread alive with RunLoop.main
469
- // until AppKit takes over.
470
- DispatchQueue.main.async { bootstrap() }
471
- RunLoop.main.run()
469
+ let app = NSApplication.shared
470
+ app.setActivationPolicy(.accessory)
471
+ app.run()