modelstat 0.10.1 → 0.10.2

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.10.1",
3
+ "version": "0.10.2",
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",
@@ -30,8 +30,8 @@
30
30
  "tsx": "^4.19.2",
31
31
  "typescript": "^5.7.3",
32
32
  "@modelstat/daemon-core": "0.0.0",
33
- "@modelstat/core": "0.0.0",
34
- "@modelstat/parsers": "0.0.0"
33
+ "@modelstat/parsers": "0.0.0",
34
+ "@modelstat/core": "0.0.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=20.18.0"
@@ -25,7 +25,7 @@
25
25
  * skip-mode just see the download then, instead of now.
26
26
  */
27
27
 
28
- import { existsSync } from "node:fs";
28
+ import { existsSync, readFileSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
@@ -144,13 +144,24 @@ async function rebootServiceIfInstalled() {
144
144
  "user",
145
145
  "modelstat.service",
146
146
  );
147
+ // Refresh if there's an installed service OR a daemon currently RUNNING. The
148
+ // latter catches a daemon that was hand-spawned (`modelstat start --force`) or
149
+ // whose plist was removed: it has a live pid in the lock file but no plist. We
150
+ // (re)install the MANAGED service for it too, so it ends up always-on instead
151
+ // of an unmanaged process that vanishes on the next reboot.
147
152
  const hasService = existsSync(launchdPlist) || existsSync(systemdUnit);
148
- if (!hasService) {
153
+ const runningPid = liveDaemonPid(stateDir);
154
+ if (!hasService && !runningPid) {
149
155
  console.log(
150
156
  "[modelstat] no background service installed — run `modelstat connect` to set one up",
151
157
  );
152
158
  return;
153
159
  }
160
+ if (!hasService && runningPid) {
161
+ console.log(
162
+ `[modelstat] found a running but UNMANAGED daemon (pid ${runningPid}, no service file) — converting it to a managed always-on service`,
163
+ );
164
+ }
154
165
 
155
166
  // Locate the freshly-installed bundle in this package. From
156
167
  // scripts/postinstall.mjs → ../dist/cli.mjs.
@@ -185,47 +196,58 @@ async function rebootServiceIfInstalled() {
185
196
  // first, escalate to SIGKILL if it's still around 2 s later.
186
197
  await killStaleDaemon(stateDir);
187
198
 
188
- // Re-stage the installed bundle AND its native summariser runtime in
189
- // ONE canonical step by spawning the freshly-unpacked bundle's own
190
- // `_setup-runtime` command. That runs the SAME service.ts staging the
191
- // `modelstat connect` path uses: it copies dist/cli.mjs over
192
- // ~/.modelstat/bin/modelstat.mjs and `npm install`s the complete
193
- // node-llama-cpp closure (incl. this platform's prebuilt binary) into
194
- // ~/.modelstat/bin/node_modules. freshBundle runs from the just-
195
- // unpacked npm tree, so its installNativeRuntime() pins the version.
199
+ // (Re)install the MANAGED service from the fresh bundle in ONE canonical step.
200
+ // `_install-service` installService() (see service.ts) stages the bundle +
201
+ // native runtime (copies dist/cli.mjs to ~/.modelstat/bin and npm-installs the
202
+ // node-llama-cpp + @huggingface/transformers closures into
203
+ // ~/.modelstat/bin/node_modules) AND (re)writes + loads the launchd plist /
204
+ // systemd unit, then kickstarts it.
196
205
  //
197
- // This replaces the old hand-copy + symlink-the-npm-cache approach,
198
- // whose symlinks dangled the moment the cache/global was pruned the
199
- // opposite of self-contained. One mechanism, every platform.
200
- console.log("[modelstat] staging new bundle + native runtime…");
201
- const setup = spawnSync(process.execPath, [freshBundle, "_setup-runtime"], {
206
+ // Why install, NOT a detached `start`: a hand-spawned `start` leaves an
207
+ // UNMANAGED daemon it dies on reboot and has no crash-restart. Installing
208
+ // the service makes the daemon ALWAYS-ON (RunAtLoad + KeepAlive on macOS,
209
+ // Restart=always on Linux) and CONVERTS a previously hand-spawned daemon
210
+ // (running, no plist) into a managed one. launchd/systemd not this script —
211
+ // owns the process, so it survives the npm install exiting. The `stop` +
212
+ // killStaleDaemon above already evicted the old daemon and bootout cleared
213
+ // KeepAlive, so nothing races this restage.
214
+ console.log("[modelstat] installing managed background service (always-on)…");
215
+ const inst = spawnSync(process.execPath, [freshBundle, "_install-service"], {
202
216
  stdio: "inherit",
203
217
  timeout: 300_000,
204
218
  });
205
- if (setup.status !== 0) {
219
+ if (inst.status !== 0) {
206
220
  console.warn(
207
- `[modelstat] couldn't stage the new bundle/runtime (exit ${setup.status ?? "?"}); the service may still be on the previous build`,
221
+ `[modelstat] couldn't (re)install the managed service (exit ${inst.status ?? "?"}); the daemon may be on the previous build or unmanaged — run \`modelstat connect\` to fix`,
222
+ );
223
+ } else {
224
+ console.log(
225
+ "[modelstat] ✓ managed background service installed + running the new build",
208
226
  );
209
227
  }
228
+ }
210
229
 
211
- // Start back up. `--force` is REQUIRED, not optional: the stop +
212
- // killStaleDaemon above happen BEFORE the (slow, up-to-300 s) native
213
- // `_setup-runtime` restage, and a launchd/systemd KeepAlive can respawn
214
- // the OLD daemon during that window. A plain `start` would then see a live
215
- // owner and no-op ("already running to force-replace it: start --force"),
216
- // leaving the service stuck on the previous build even though the global
217
- // package upgraded. `--force` evicts whatever survived and guarantees the
218
- // freshly-staged bundle is the one running. `start` re-runs preflight (incl.
219
- // the processing-version reconcile that wipes cursors when we ship a new
220
- // pipeline), so the upgrade picks up the new behaviour immediately. Detached
221
- // so the daemon survives this script.
222
- const { spawn } = await import("node:child_process");
223
- const child = spawn(process.execPath, [freshBundle, "start", "--force"], {
224
- detached: true,
225
- stdio: "ignore",
226
- });
227
- child.unref();
228
- console.log("[modelstat] ✓ background service restarted with new build");
230
+ /**
231
+ * The pid of a currently-running daemon per ~/.modelstat/daemon.lock, or null if
232
+ * the lock is missing/stale/dead. Lets the refresh detect a daemon that's
233
+ * RUNNING but UNMANAGED (hand-spawned, or its plist was removed) so the upgrade
234
+ * can convert it into a managed always-on service. `kill(pid, 0)` probes
235
+ * liveness without signalling; EPERM (another user's process) still counts.
236
+ */
237
+ function liveDaemonPid(stateDir) {
238
+ try {
239
+ const payload = JSON.parse(readFileSync(join(stateDir, "daemon.lock"), "utf8"));
240
+ const pid = Number(payload?.pid);
241
+ if (!Number.isInteger(pid) || pid <= 0) return null;
242
+ try {
243
+ process.kill(pid, 0);
244
+ return pid;
245
+ } catch (e) {
246
+ return e && e.code === "EPERM" ? pid : null;
247
+ }
248
+ } catch {
249
+ return null;
250
+ }
229
251
  }
230
252
 
231
253
  /**