lensmcp 1.16.0 → 1.16.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/lib/cli.js +101 -17
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/stop/SKILL.md +10 -4
package/lib/cli.js
CHANGED
|
@@ -200,12 +200,24 @@ function runGateway(ctx, args, out, err) {
|
|
|
200
200
|
const logFile = join(cwd, '.lensmcp', 'gateway.log');
|
|
201
201
|
const dashUrl = `https://lensmcp.local${cfg.dashboardBasePath}/`;
|
|
202
202
|
const start = () => {
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
// Reconcile with reality FIRST: an alive pid-file wins; else adopt an orphaned gateway on :443 (the
|
|
204
|
+
// desync self-heal) — so a second `start` NEVER spawns a duplicate that EADDRINUSE-crashes + orphans.
|
|
205
|
+
const { pid: running, healed } = reconcileGateway(pidFile, cwd);
|
|
206
|
+
if (running !== undefined && isAlive(running)) {
|
|
207
|
+
if (healed)
|
|
208
|
+
out(`healed: adopted a running gateway (pid ${running}) the pid file had lost — no duplicate started.`);
|
|
205
209
|
out(`gateway already running (pid ${running}).`);
|
|
206
210
|
out(` dashboard → ${dashUrl}`);
|
|
207
211
|
return { exitCode: 0 };
|
|
208
212
|
}
|
|
213
|
+
// :443 is free of any lens gateway — but if a FOREIGN process holds it, spawning would just
|
|
214
|
+
// EADDRINUSE-crash and re-create the orphan mess. Surface it instead of stacking a doomed child.
|
|
215
|
+
const foreign = pidsOnPort443()[0];
|
|
216
|
+
if (foreign !== undefined) {
|
|
217
|
+
err(`port :443 is held by pid ${foreign}, which is not this workspace's gateway.\n` +
|
|
218
|
+
`Free it first (stop that process), then \`lensmcp gateway start\`.`);
|
|
219
|
+
return { exitCode: 1 };
|
|
220
|
+
}
|
|
209
221
|
const target = findGatewayTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
|
|
210
222
|
if (!target) {
|
|
211
223
|
err('Could not find a gateway target (an executor `@lensmcp/cluster:gateway`) in this workspace.\n' +
|
|
@@ -235,32 +247,31 @@ function runGateway(ctx, args, out, err) {
|
|
|
235
247
|
return { exitCode: 0 };
|
|
236
248
|
};
|
|
237
249
|
const stop = () => {
|
|
238
|
-
|
|
239
|
-
|
|
250
|
+
// Reality-based: kill the tracked pid OR an orphaned gateway the pid file lost track of (so `stop`
|
|
251
|
+
// works even after the desync — the case where the old CLI reported "not running" but :443 was held).
|
|
252
|
+
const { pid, healed } = reconcileGateway(pidFile, cwd);
|
|
253
|
+
if (pid === undefined) {
|
|
240
254
|
out('gateway not running.');
|
|
241
255
|
rmFile(pidFile);
|
|
242
256
|
return { exitCode: 0 };
|
|
243
257
|
}
|
|
258
|
+
if (healed)
|
|
259
|
+
out(`healed: found an orphaned gateway (pid ${pid}) the pid file had lost — stopping it.`);
|
|
244
260
|
// Detached child is its own process-group leader → negative pid kills the whole tree.
|
|
245
|
-
|
|
246
|
-
process.kill(-pid, 'SIGTERM');
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
try {
|
|
250
|
-
process.kill(pid, 'SIGTERM');
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
/* already gone */
|
|
254
|
-
}
|
|
255
|
-
}
|
|
261
|
+
killGatewayTree(pid);
|
|
256
262
|
rmFile(pidFile);
|
|
263
|
+
// Escalate: if a lens gateway STILL holds :443 (a differently-parented tree), kill that too, so
|
|
264
|
+
// `stop` genuinely frees the port instead of leaving a wedged listener behind.
|
|
265
|
+
const still = gatewayOnPort443(cwd);
|
|
266
|
+
if (still !== undefined && still !== pid)
|
|
267
|
+
killGatewayTree(still);
|
|
257
268
|
out(`gateway stopped (pid ${pid}).`);
|
|
258
269
|
return { exitCode: 0 };
|
|
259
270
|
};
|
|
260
271
|
const status = () => {
|
|
261
|
-
const pid =
|
|
272
|
+
const { pid, healed } = reconcileGateway(pidFile, cwd);
|
|
262
273
|
const alive = pid !== undefined && isAlive(pid);
|
|
263
|
-
out(`gateway: ${alive ? `running (pid ${pid})` : 'stopped'}`);
|
|
274
|
+
out(`gateway: ${alive ? `running (pid ${pid})${healed ? ' — recovered a stale pid file' : ''}` : 'stopped'}`);
|
|
264
275
|
out(` workspace → ${cfg.key}`);
|
|
265
276
|
out(` dashboard → ${dashUrl}`);
|
|
266
277
|
if (alive)
|
|
@@ -376,6 +387,79 @@ function isAlive(pid) {
|
|
|
376
387
|
return e.code === 'EPERM'; // exists but not ours
|
|
377
388
|
}
|
|
378
389
|
}
|
|
390
|
+
/* ───────── reality-based gateway discovery (self-heal the pid-file desync) ─────────
|
|
391
|
+
* The gateway is tracked ONLY by `.lensmcp/gateway.pid`. If a start crashed AFTER binding failed
|
|
392
|
+
* (a duplicate `start`/`restart` while an untracked gateway already held :443 → EADDRINUSE), the pid
|
|
393
|
+
* file ends up pointing at the DEAD child while the ORIGINAL gateway keeps running orphaned — so
|
|
394
|
+
* `status` falsely reports "stopped" and `stop` can't kill it. These helpers cross-check the ACTUAL
|
|
395
|
+
* :443 owner so start/stop/status reconcile with reality instead of trusting a stale pid file.
|
|
396
|
+
* Unix-only (lsof/ps); on a platform without them they no-op → the pid-file path is unchanged. */
|
|
397
|
+
/** All pids LISTENING on :443 (best-effort via lsof; [] when free / lsof unavailable). */
|
|
398
|
+
function pidsOnPort443() {
|
|
399
|
+
try {
|
|
400
|
+
const r = spawnSync('lsof', ['-nP', '-iTCP:443', '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
|
|
401
|
+
if (r.status !== 0 || !r.stdout)
|
|
402
|
+
return [];
|
|
403
|
+
return [...new Set(r.stdout.split(/\s+/).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** The full command line of a pid (best-effort via ps; '' on failure). */
|
|
410
|
+
function processCmd(pid) {
|
|
411
|
+
try {
|
|
412
|
+
return (spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).stdout ?? '').trim();
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** The pid on :443 that belongs to THIS workspace's lensmcp gateway. Only the gateway binds :443 in a
|
|
419
|
+
* lens workspace, so a run-executor / `@lensmcp/cluster` process of `cwd` holding :443 IS the gateway.
|
|
420
|
+
* Returns undefined when :443 is free, held by a foreign process, or lsof/ps are unavailable. */
|
|
421
|
+
function gatewayOnPort443(cwd) {
|
|
422
|
+
const sigExecutor = join(cwd, 'node_modules', 'nx', 'dist', 'bin', 'run-executor.js');
|
|
423
|
+
const sigCluster = join(cwd, 'node_modules', '@lensmcp', 'cluster');
|
|
424
|
+
for (const pid of pidsOnPort443()) {
|
|
425
|
+
const cmd = processCmd(pid);
|
|
426
|
+
if (cmd.includes(sigExecutor) || cmd.includes(sigCluster))
|
|
427
|
+
return pid;
|
|
428
|
+
}
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
/** Reconcile the tracked pid with reality: a LIVE pid-file wins; otherwise ADOPT an orphaned gateway
|
|
432
|
+
* still holding :443 (rewriting the pid file) — the self-heal for the stale-pid desync. */
|
|
433
|
+
function reconcileGateway(pidFile, cwd) {
|
|
434
|
+
const tracked = readPid(pidFile);
|
|
435
|
+
if (tracked !== undefined && isAlive(tracked))
|
|
436
|
+
return { pid: tracked, healed: false };
|
|
437
|
+
const orphan = gatewayOnPort443(cwd);
|
|
438
|
+
if (orphan !== undefined) {
|
|
439
|
+
try {
|
|
440
|
+
writeFileSync(pidFile, String(orphan));
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* pid-file unwritable — still return the orphan so stop/status act on reality */
|
|
444
|
+
}
|
|
445
|
+
return { pid: orphan, healed: true };
|
|
446
|
+
}
|
|
447
|
+
return { healed: false };
|
|
448
|
+
}
|
|
449
|
+
/** Best-effort kill of a detached gateway process GROUP (the leader is its own group via `detached`). */
|
|
450
|
+
function killGatewayTree(pid) {
|
|
451
|
+
try {
|
|
452
|
+
process.kill(-pid, 'SIGTERM');
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
try {
|
|
456
|
+
process.kill(pid, 'SIGTERM');
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
/* already gone */
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
379
463
|
function rmFile(path) {
|
|
380
464
|
try {
|
|
381
465
|
if (existsSync(path))
|
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.2",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "David Antoon",
|
|
8
8
|
"email": "davidmantoon@gmail.com"
|
|
@@ -10,8 +10,14 @@ Run the stop script with the Bash tool:
|
|
|
10
10
|
"${CLAUDE_PLUGIN_ROOT}/scripts/gateway-stop.sh"
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
It calls `lensmcp gateway stop` for `${CLAUDE_PROJECT_DIR}`, which
|
|
14
|
-
|
|
15
|
-
app/service pods + the lens dashboard it spawned).
|
|
13
|
+
It calls `lensmcp gateway stop` for `${CLAUDE_PROJECT_DIR}`, which terminates the gateway process
|
|
14
|
+
group (the gateway plus the app/service pods + the lens dashboard it spawned).
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
`stop`/`status`/`start` are **reality-based**: they trust `.lensmcp/gateway.pid` only when it is
|
|
17
|
+
alive, and otherwise **reconcile against the actual :443 owner**. So `stop` still kills an *orphaned*
|
|
18
|
+
gateway that a stale pid file had lost track of (the desync where a crashed duplicate-`start` left the
|
|
19
|
+
pid file pointing at a dead process while the original gateway kept running) — and `start` **adopts**
|
|
20
|
+
that orphan instead of spawning a duplicate that would `EADDRINUSE`-crash.
|
|
21
|
+
|
|
22
|
+
Report whether it stopped, whether it **healed** a stale/orphaned gateway (the CLI prints a `healed:` line),
|
|
23
|
+
or that it was already not running.
|