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 +16 -0
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +102 -15
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
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:
|
|
271
|
-
//
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
678
|
-
writeFileSync(path, '');
|
|
765
|
+
rmSync(path, { force: true });
|
|
679
766
|
}
|
|
680
767
|
catch {
|
|
681
768
|
/* ignore */
|
package/package.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "1.16.27",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "David Antoon",
|
|
8
8
|
"email": "davidmantoon@gmail.com"
|