lensmcp 1.16.26 → 1.16.27

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/lib/cli.d.ts CHANGED
@@ -13,5 +13,21 @@ interface CliContext {
13
13
  err?: (line: string) => void;
14
14
  }
15
15
  export declare function runCli(ctx: CliContext): Promise<CliResult>;
16
+ export type AliveGatewayReality = 'serving' | 'booting' | 'zombie';
17
+ /** Classify what a TRACKED-ALIVE gateway pid really is. ALIVE ≠ SERVING: the tracked pid is the
18
+ * `nx run` WRAPPER, and the process that actually binds :443 is its run-executor child. When that
19
+ * child crashes while the wrapper wedges (a lost nx-daemon connection), the pid file points at a live
20
+ * process that serves NOTHING — the state that made `start` a no-op forever ("already running") while
21
+ * the workspace 404'd. So:
22
+ * - serving: this workspace's gateway (the wrapper or a descendant) holds :443 — genuinely up.
23
+ * - booting: :443 is unbound and the spawn is fresh — the listeners just haven't bound yet.
24
+ * - zombie: past the boot window with nothing of ours on :443, or the port is already held by
25
+ * someone else (a boot could never win it) — dead weight to reap. */
26
+ export declare function classifyAliveGateway(o: {
27
+ oursOn443: boolean;
28
+ port443Held: boolean;
29
+ pidFileAgeMs: number | undefined;
30
+ bootGraceMs?: number;
31
+ }): AliveGatewayReality;
16
32
  export {};
17
33
  //# sourceMappingURL=cli.d.ts.map
package/lib/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAqFD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAqFD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE;AA0lBD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE;;;;;;;;iFAQiF;AACjF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,mBAAmB,CAItB"}
package/lib/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { request as httpRequest } from 'node:http';
4
4
  import { homedir, tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, resolve } from 'node:path';
@@ -267,15 +267,36 @@ function runGateway(ctx, args, out, err) {
267
267
  const registeredMarker = join(cwd, '.lensmcp', 'registered.json');
268
268
  const dashUrl = `https://lensmcp.local${cfg.dashboardBasePath}/`;
269
269
  const start = () => {
270
- // Reconcile with reality FIRST: an alive pid-file wins; else adopt an orphaned gateway on :443 (the
271
- // desync self-heal) — so a second `start` NEVER spawns a duplicate that EADDRINUSE-crashes + orphans.
270
+ // Reconcile with reality FIRST: adopt an orphaned gateway on :443 (the desync self-heal) — so a
271
+ // second `start` NEVER spawns a duplicate that EADDRINUSE-crashes + orphans.
272
272
  const { pid: running, healed } = reconcileGateway(pidFile, cwd);
273
273
  if (running !== undefined && isAlive(running)) {
274
- if (healed)
275
- out(`healed: adopted a running gateway (pid ${running}) the pid file had lost no duplicate started.`);
276
- out(`gateway already running (pid ${running}).`);
277
- out(` dashboard ${dashUrl}`);
278
- return { exitCode: 0 };
274
+ // Bare liveness is NOT enough — classify what the alive pid really is (see classifyAliveGateway:
275
+ // the `nx run` wrapper can outlive its crashed run-executor child, the actual :443 binder).
276
+ const reality = classifyAliveGateway({
277
+ oursOn443: gatewayOnPort443(cwd) !== undefined,
278
+ port443Held: pidsOnPort443().length > 0,
279
+ pidFileAgeMs: pidFileAgeMs(pidFile),
280
+ });
281
+ if (reality === 'serving') {
282
+ if (healed)
283
+ out(`healed: adopted a running gateway (pid ${running}) the pid file had lost — no duplicate started.`);
284
+ out(`gateway already running (pid ${running}).`);
285
+ out(` dashboard → ${dashUrl}`);
286
+ return { exitCode: 0 };
287
+ }
288
+ if (reality === 'booting') {
289
+ out(`gateway starting (pid ${running}) — :443 not bound yet.`);
290
+ out(` dashboard → ${dashUrl}`);
291
+ out(` logs → ${logFile}`);
292
+ return { exitCode: 0 };
293
+ }
294
+ // A zombie: reap the wrapper tree (it would hold the nx run-lock against a respawn), clear the
295
+ // stale nx daemon state, and fall through to the real paths (register into a live daemon / spawn).
296
+ killGatewayTree(running, 'SIGKILL');
297
+ rmFile(pidFile);
298
+ refreshNxGraph(cwd, ctx.env, () => { });
299
+ out(`reaped a dead gateway (pid ${running} was alive but nothing served :443 for this workspace).`);
279
300
  }
280
301
  // :443 is held by another process. If it's a lens DAEMON (its control socket answers), REGISTER this
281
302
  // workspace INTO it — the daemon then hosts this workspace's services + dashboard under the one :443
@@ -283,7 +304,20 @@ function runGateway(ctx, args, out, err) {
283
304
  // EADDRINUSE-crash + orphan).
284
305
  const foreign = pidsOnPort443()[0];
285
306
  if (foreign !== undefined) {
286
- if (daemonRequest('GET', '/list') !== null) {
307
+ const list = daemonRequest('GET', '/list');
308
+ if (list !== null) {
309
+ // Idempotent for a GUEST too: an already-registered workspace short-circuits — re-registering
310
+ // would make the daemon tear down + respawn its services and dashboard for nothing.
311
+ const registered = list.status === 200 && list.json && typeof list.json === 'object'
312
+ ? (list.json.workspaces ?? []).some((w) => w.key === cfg.key)
313
+ : false;
314
+ if (registered) {
315
+ mkdirSync(dirname(registeredMarker), { recursive: true });
316
+ writeFileSync(registeredMarker, JSON.stringify({ wsKey: cfg.key }));
317
+ out(`already registered into the shared gateway daemon (pid ${foreign}).`);
318
+ out(` dashboard → ${dashUrl}`);
319
+ return { exitCode: 0 };
320
+ }
287
321
  const res = daemonRequest('POST', '/register', { wsKey: cfg.key, root: cwd, projects: buildProjectsMap(cwd) });
288
322
  if (res && res.status === 200) {
289
323
  mkdirSync(dirname(registeredMarker), { recursive: true });
@@ -330,8 +364,13 @@ function runGateway(ctx, args, out, err) {
330
364
  };
331
365
  const stop = () => {
332
366
  // If THIS workspace registered into a shared daemon (P4), UNREGISTER from it instead of killing a
333
- // gateway it never spawned — the daemon reaps this workspace's services + dashboard.
334
- if (existsSync(registeredMarker)) {
367
+ // gateway it never spawned — the daemon reaps this workspace's services + dashboard. But OWNERSHIP
368
+ // BEATS THE MARKER: when :443 is held by THIS workspace's OWN gateway, the marker is stale litter
369
+ // (left over from a guest era before this workspace's daemon took over). Honoring it would
370
+ // self-unregister and LEAVE THE OWNED DAEMON RUNNING (observed live: tetros owned the :443 daemon,
371
+ // yet `stop` "unregistered 'tetros'" from itself and the daemon kept serving). Guest path only when
372
+ // we genuinely do NOT own the port; otherwise clear the lying marker and fall through to the kill.
373
+ if (existsSync(registeredMarker) && gatewayOnPort443(cwd) === undefined) {
335
374
  let wsKey = cfg.key;
336
375
  try {
337
376
  wsKey = JSON.parse(readFileSync(registeredMarker, 'utf8')).wsKey ?? cfg.key;
@@ -342,6 +381,7 @@ function runGateway(ctx, args, out, err) {
342
381
  out(res && res.status === 200 ? `unregistered '${wsKey}' from the shared gateway daemon.` : 'unregister sent (the daemon may already be gone).');
343
382
  return { exitCode: 0 };
344
383
  }
384
+ rmFile(registeredMarker); // a stale marker on the daemon OWNER — clear it before the kill path
345
385
  // Reality-based: kill the tracked pid OR an orphaned gateway the pid file lost track of (so `stop`
346
386
  // works even after the desync — the case where the old CLI reported "not running" but :443 was held).
347
387
  const { pid, healed } = reconcileGateway(pidFile, cwd);
@@ -387,7 +427,16 @@ function runGateway(ctx, args, out, err) {
387
427
  };
388
428
  const status = () => {
389
429
  const { pid, healed } = reconcileGateway(pidFile, cwd);
390
- const alive = pid !== undefined && isAlive(pid);
430
+ // ALIVE SERVING (see classifyAliveGateway) a zombie `nx run` wrapper whose executor child died
431
+ // must not report "running — DAEMON on :443" while the workspace 404s.
432
+ const reality = pid !== undefined && isAlive(pid)
433
+ ? classifyAliveGateway({
434
+ oursOn443: gatewayOnPort443(cwd) !== undefined,
435
+ port443Held: pidsOnPort443().length > 0,
436
+ pidFileAgeMs: pidFileAgeMs(pidFile),
437
+ })
438
+ : undefined;
439
+ const alive = reality === 'serving' || reality === 'booting';
391
440
  // Machine-readable status for the IDE plugin / scripts: the daemon's rich /status (identity + workspaces
392
441
  // + per-service live state) plus THIS workspace's role + the dashboard URL. Degrades to a minimal object
393
442
  // when no daemon is reachable, so a consumer always gets valid JSON.
@@ -423,7 +472,9 @@ function runGateway(ctx, args, out, err) {
423
472
  };
424
473
  if (alive) {
425
474
  // This workspace's own pid owns :443 → it IS the daemon (it may be hosting other workspaces too).
426
- out(`gateway: running (pid ${pid}) — DAEMON on :443${healed ? ' — recovered a stale pid file' : ''}`);
475
+ out(reality === 'booting'
476
+ ? `gateway: starting (pid ${pid}) — :443 not bound yet`
477
+ : `gateway: running (pid ${pid}) — DAEMON on :443${healed ? ' — recovered a stale pid file' : ''}`);
427
478
  out(` workspace → ${cfg.key}`);
428
479
  out(` dashboard → ${dashUrl}`);
429
480
  out(` chooser → https://lensmcp.local/`);
@@ -441,6 +492,14 @@ function runGateway(ctx, args, out, err) {
441
492
  printWorkspaces();
442
493
  return { exitCode: 0 };
443
494
  }
495
+ if (reality === 'zombie') {
496
+ out(`gateway: NOT serving — tracked pid ${pid} is alive but nothing holds :443 for this workspace`);
497
+ out(' (a crashed gateway under a wedged nx wrapper) — run `lensmcp gateway restart` to recover.');
498
+ out(` workspace → ${cfg.key}`);
499
+ out(` logs → ${logFile}`);
500
+ printWorkspaces();
501
+ return { exitCode: 1 };
502
+ }
444
503
  out('gateway: stopped');
445
504
  out(` workspace → ${cfg.key}`);
446
505
  out(` dashboard → ${dashUrl}`);
@@ -596,6 +655,33 @@ function gatewayOnPort443(cwd) {
596
655
  }
597
656
  return undefined;
598
657
  }
658
+ /** How long since `start` wrote the pid file (ms) — the spawn timestamp. undefined when unreadable. */
659
+ function pidFileAgeMs(pidFile) {
660
+ try {
661
+ return Date.now() - statSync(pidFile).mtimeMs;
662
+ }
663
+ catch {
664
+ return undefined;
665
+ }
666
+ }
667
+ /** A freshly-spawned gateway legitimately holds NO port yet — services + lens children boot before the
668
+ * listeners bind. Give a spawn this long before an alive-but-portless pid is judged a zombie. */
669
+ const BOOT_GRACE_MS = 180_000;
670
+ /** Classify what a TRACKED-ALIVE gateway pid really is. ALIVE ≠ SERVING: the tracked pid is the
671
+ * `nx run` WRAPPER, and the process that actually binds :443 is its run-executor child. When that
672
+ * child crashes while the wrapper wedges (a lost nx-daemon connection), the pid file points at a live
673
+ * process that serves NOTHING — the state that made `start` a no-op forever ("already running") while
674
+ * the workspace 404'd. So:
675
+ * - serving: this workspace's gateway (the wrapper or a descendant) holds :443 — genuinely up.
676
+ * - booting: :443 is unbound and the spawn is fresh — the listeners just haven't bound yet.
677
+ * - zombie: past the boot window with nothing of ours on :443, or the port is already held by
678
+ * someone else (a boot could never win it) — dead weight to reap. */
679
+ export function classifyAliveGateway(o) {
680
+ if (o.oursOn443)
681
+ return 'serving';
682
+ const fresh = o.pidFileAgeMs !== undefined && o.pidFileAgeMs < (o.bootGraceMs ?? BOOT_GRACE_MS);
683
+ return !o.port443Held && fresh ? 'booting' : 'zombie';
684
+ }
599
685
  /** Reconcile the tracked pid with reality: a LIVE pid-file wins; otherwise ADOPT an orphaned gateway
600
686
  * still holding :443 (rewriting the pid file) — the self-heal for the stale-pid desync. */
601
687
  function reconcileGateway(pidFile, cwd) {
@@ -673,9 +759,10 @@ function firstClusterHost(cwd) {
673
759
  return undefined;
674
760
  }
675
761
  function rmFile(path) {
762
+ // DELETE, not truncate: an emptied-but-existing registered.json still `existsSync`s, so status/stop
763
+ // would keep treating an UNREGISTERED workspace as a guest (the lying-marker bug).
676
764
  try {
677
- if (existsSync(path))
678
- writeFileSync(path, '');
765
+ rmSync(path, { force: true });
679
766
  }
680
767
  catch {
681
768
  /* ignore */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.16.26",
3
+ "version": "1.16.27",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -2,7 +2,7 @@
2
2
  "name": "lensmcp",
3
3
  "displayName": "LensMCP",
4
4
  "description": "The observability lens for coding agents. One command brings up the dev cluster gateway (every project.json `cluster` decl → its host on :443), the per-project lens dashboard at https://lensmcp.local/<project>/, and the MCP server your agent connects to — scoped automatically to whatever project you opened Claude Code in.",
5
- "version": "1.16.26",
5
+ "version": "1.16.27",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"