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/dist/cli.mjs +57 -62
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.mjs +88 -25
- package/vendor/tray-mac/Sources/ModelstatTray/main.swift +18 -18
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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]
|
|
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
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* lock
|
|
319
|
-
*
|
|
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 "
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
controller = TrayController()
|
|
462
|
-
app.run()
|
|
463
|
+
DispatchQueue.main.async {
|
|
464
|
+
MainActor.assumeIsolated {
|
|
465
|
+
controller = TrayController()
|
|
466
|
+
}
|
|
463
467
|
}
|
|
464
468
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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()
|