lensmcp 1.16.29 → 1.16.31

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/bundled/bridge.js CHANGED
@@ -3733,7 +3733,74 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
3733
3733
  // libs/bridge/src/lib/env-sink.ts
3734
3734
  import { connect } from "node:net";
3735
3735
  import { createSocket } from "node:dgram";
3736
- import { appendFileSync } from "node:fs";
3736
+ import { appendFileSync, renameSync, statSync, unlinkSync } from "node:fs";
3737
+ var MAX_EVENT_BYTES = (() => {
3738
+ const n = Number(process.env["LENSMCP_EVENT_MAX_BYTES"]);
3739
+ return Number.isFinite(n) && n > 0 ? n : 50 * 1024 * 1024;
3740
+ })();
3741
+ var MAX_EVENT_FILES = (() => {
3742
+ const n = Number(process.env["LENSMCP_EVENT_MAX_FILES"]);
3743
+ return Number.isFinite(n) && n >= 1 && n <= 20 ? Math.floor(n) : 5;
3744
+ })();
3745
+ var ROTATE_CHECK_BYTES = 1024 * 1024;
3746
+ var bytesSinceCheck = 0;
3747
+ function rotateIfLarge(file, addedBytes) {
3748
+ bytesSinceCheck += addedBytes;
3749
+ if (bytesSinceCheck < ROTATE_CHECK_BYTES) return;
3750
+ bytesSinceCheck = 0;
3751
+ try {
3752
+ if (statSync(file).size <= MAX_EVENT_BYTES) return;
3753
+ } catch {
3754
+ return;
3755
+ }
3756
+ try {
3757
+ unlinkSync(`${file}.${MAX_EVENT_FILES}`);
3758
+ } catch {
3759
+ }
3760
+ for (let gen = MAX_EVENT_FILES - 1; gen >= 1; gen--) {
3761
+ try {
3762
+ renameSync(`${file}.${gen}`, `${file}.${gen + 1}`);
3763
+ } catch {
3764
+ }
3765
+ }
3766
+ try {
3767
+ renameSync(file, `${file}.1`);
3768
+ } catch {
3769
+ }
3770
+ }
3771
+ var FLOOD_PER_SEC = (() => {
3772
+ const raw = process.env["LENSMCP_EVENT_MAX_EPS"];
3773
+ if (raw === "off") return Infinity;
3774
+ const n = Number(raw);
3775
+ if (raw !== void 0 && raw !== "" && Number.isFinite(n)) return n <= 0 ? Infinity : n;
3776
+ return 5;
3777
+ })();
3778
+ var FLOOD_BURST = Math.max(30, FLOOD_PER_SEC === Infinity ? 0 : FLOOD_PER_SEC * 4);
3779
+ var floodBuckets = /* @__PURE__ */ new Map();
3780
+ function floodAdmit(fingerprint2) {
3781
+ if (FLOOD_PER_SEC === Infinity) return 0;
3782
+ const now = Date.now();
3783
+ let b = floodBuckets.get(fingerprint2);
3784
+ if (!b) {
3785
+ if (floodBuckets.size >= 2048) {
3786
+ for (const [k, v] of floodBuckets) if (now - v.lastSeenAt > 6e4) floodBuckets.delete(k);
3787
+ if (floodBuckets.size >= 2048) floodBuckets.clear();
3788
+ }
3789
+ b = { tokens: FLOOD_BURST, lastRefillAt: now, suppressed: 0, lastSeenAt: now };
3790
+ floodBuckets.set(fingerprint2, b);
3791
+ }
3792
+ b.lastSeenAt = now;
3793
+ b.tokens = Math.min(FLOOD_BURST, b.tokens + (now - b.lastRefillAt) / 1e3 * FLOOD_PER_SEC);
3794
+ b.lastRefillAt = now;
3795
+ if (b.tokens >= 1) {
3796
+ b.tokens -= 1;
3797
+ const suppressed = b.suppressed;
3798
+ b.suppressed = 0;
3799
+ return suppressed;
3800
+ }
3801
+ b.suppressed += 1;
3802
+ return -1;
3803
+ }
3737
3804
  var cachedEnvSink;
3738
3805
  function resolveEnvSink() {
3739
3806
  if (cachedEnvSink !== void 0) return cachedEnvSink;
@@ -3741,7 +3808,13 @@ function resolveEnvSink() {
3741
3808
  if (filePath) {
3742
3809
  cachedEnvSink = (event) => {
3743
3810
  try {
3744
- appendFileSync(filePath, JSON.stringify(event) + "\n");
3811
+ const suppressed = floodAdmit(event.fingerprint);
3812
+ if (suppressed < 0) return;
3813
+ const line = JSON.stringify(
3814
+ suppressed > 0 ? { ...event, raw: { ...event.raw ?? {}, floodSuppressed: suppressed } } : event
3815
+ ) + "\n";
3816
+ rotateIfLarge(filePath, line.length);
3817
+ appendFileSync(filePath, line);
3745
3818
  } catch {
3746
3819
  }
3747
3820
  };
@@ -33558,7 +33558,7 @@ var require_chrome_remote_interface = __commonJS({
33558
33558
  });
33559
33559
 
33560
33560
  // libs/browser-capture/src/capture-runner.ts
33561
- import { appendFileSync, readFileSync, renameSync, statSync } from "node:fs";
33561
+ import { appendFileSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs";
33562
33562
 
33563
33563
  // libs/core/dist/lib/ulid.js
33564
33564
  var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -33826,6 +33826,7 @@ function planOrphanReap(psLines, isOwnerAlive, selfPid) {
33826
33826
  function reapOrphanCaptureChrome(log) {
33827
33827
  if (process.platform === "win32")
33828
33828
  return 0;
33829
+ let reaped = 0;
33829
33830
  let out;
33830
33831
  try {
33831
33832
  out = execFileSync2("ps", ["ax", "-ww", "-o", "pid=,command="], {
@@ -33834,20 +33835,33 @@ function reapOrphanCaptureChrome(log) {
33834
33835
  maxBuffer: 64 * 1024 * 1024
33835
33836
  });
33836
33837
  } catch {
33837
- return 0;
33838
33838
  }
33839
- let reaped = 0;
33840
- for (const orphan of planOrphanReap(out.split("\n"), isAlive, process.pid)) {
33841
- try {
33842
- process.kill(orphan.pid, "SIGKILL");
33843
- reaped += 1;
33844
- log?.(`[browser-capture] reaped orphaned capture Chrome pid=${orphan.pid} (owner ${orphan.ownerPid} is gone)`);
33845
- } catch {
33839
+ if (out !== void 0) {
33840
+ for (const orphan of planOrphanReap(out.split("\n"), isAlive, process.pid)) {
33841
+ try {
33842
+ process.kill(orphan.pid, "SIGKILL");
33843
+ reaped += 1;
33844
+ log?.(`[browser-capture] reaped orphaned capture Chrome pid=${orphan.pid} (owner ${orphan.ownerPid} is gone)`);
33845
+ } catch {
33846
+ }
33847
+ removeUserDataDir(orphan.userDataDir);
33846
33848
  }
33847
- removeUserDataDir(orphan.userDataDir);
33848
33849
  }
33850
+ sweepStaleMarkerDirs();
33849
33851
  return reaped;
33850
33852
  }
33853
+ function sweepStaleMarkerDirs() {
33854
+ const ownerRe = new RegExp(`^${CAPTURE_DIR_MARKER}(\\d+)-`);
33855
+ try {
33856
+ for (const entry of readdirSync(tmpdir())) {
33857
+ const owner = Number(ownerRe.exec(entry)?.[1]);
33858
+ if (!Number.isFinite(owner) || owner === process.pid || isAlive(owner))
33859
+ continue;
33860
+ removeUserDataDir(join2(tmpdir(), entry));
33861
+ }
33862
+ } catch {
33863
+ }
33864
+ }
33851
33865
  function isAlive(pid) {
33852
33866
  try {
33853
33867
  process.kill(pid, 0);
@@ -34883,7 +34897,11 @@ reapOrphanCaptureChrome((m) => console.error(m));
34883
34897
  setInterval(() => reapOrphanCaptureChrome((m) => console.error(m)), 6e4).unref();
34884
34898
  var MAX_EVENT_BYTES = (() => {
34885
34899
  const n = Number(process.env["LENSMCP_EVENT_MAX_BYTES"]);
34886
- return Number.isFinite(n) && n > 0 ? n : 256 * 1024 * 1024;
34900
+ return Number.isFinite(n) && n > 0 ? n : 50 * 1024 * 1024;
34901
+ })();
34902
+ var MAX_EVENT_FILES = (() => {
34903
+ const n = Number(process.env["LENSMCP_EVENT_MAX_FILES"]);
34904
+ return Number.isFinite(n) && n >= 1 && n <= 20 ? Math.floor(n) : 5;
34887
34905
  })();
34888
34906
  var ROTATE_CHECK_BYTES = 1024 * 1024;
34889
34907
  var bytesSinceCheck = 0;
@@ -34892,7 +34910,22 @@ function rotateIfLarge(file, lineBytes) {
34892
34910
  if (bytesSinceCheck < ROTATE_CHECK_BYTES) return;
34893
34911
  bytesSinceCheck = 0;
34894
34912
  try {
34895
- if (statSync(file).size > MAX_EVENT_BYTES) renameSync(file, `${file}.1`);
34913
+ if (statSync(file).size <= MAX_EVENT_BYTES) return;
34914
+ } catch {
34915
+ return;
34916
+ }
34917
+ try {
34918
+ unlinkSync(`${file}.${MAX_EVENT_FILES}`);
34919
+ } catch {
34920
+ }
34921
+ for (let gen = MAX_EVENT_FILES - 1; gen >= 1; gen--) {
34922
+ try {
34923
+ renameSync(`${file}.${gen}`, `${file}.${gen + 1}`);
34924
+ } catch {
34925
+ }
34926
+ }
34927
+ try {
34928
+ renameSync(file, `${file}.1`);
34896
34929
  } catch {
34897
34930
  }
34898
34931
  }
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
 
7
7
  // servers/lensmcp-mcp/dist/dashboard.js
8
8
  import "reflect-metadata";
9
- import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
9
+ import { closeSync as closeSync2, openSync as openSync2, readdirSync, readFileSync as readFileSync2, readSync as readSync2, statSync as statSync2 } from "node:fs";
10
10
  import { createServer as createServer2 } from "node:http";
11
11
 
12
12
  // libs/core/dist/lib/ulid.js
@@ -352,6 +352,10 @@ function compileStory(events, options = {}) {
352
352
  if (pool.length === 0)
353
353
  return void 0;
354
354
  pool.sort((a, b) => a.timestamp - b.timestamp);
355
+ const MAX_STORY_EVENTS = 1e3;
356
+ if (pool.length > MAX_STORY_EVENTS) {
357
+ pool = [pool[0], ...pool.slice(pool.length - (MAX_STORY_EVENTS - 1))];
358
+ }
355
359
  const origin = pool[0];
356
360
  const phases = [];
357
361
  let finalRender;
@@ -16428,6 +16432,7 @@ var URI_CURRENT = "flow://current";
16428
16432
  var URI_ORIGINS = "flow://origins/recent";
16429
16433
  var MAX_FLOWS = 50;
16430
16434
  var MAX_ORIGINS = 50;
16435
+ var MAX_EVENTS_PER_FLOW = 2e3;
16431
16436
  function installFlowReducer(args) {
16432
16437
  const state2 = {
16433
16438
  flows: /* @__PURE__ */ new Map(),
@@ -16470,6 +16475,9 @@ function installFlowReducer(args) {
16470
16475
  void args.resources.markDirty(URI_ORIGINS);
16471
16476
  }
16472
16477
  entry.events.push(event);
16478
+ if (entry.events.length > MAX_EVENTS_PER_FLOW) {
16479
+ entry.events.splice(0, entry.events.length - MAX_EVENTS_PER_FLOW);
16480
+ }
16473
16481
  entry.summary.eventCount += 1;
16474
16482
  entry.summary.lastSeenAt = event.timestamp;
16475
16483
  const idx = state2.recentOrder.indexOf(flowId);
@@ -17677,6 +17685,7 @@ NestApp = __decorate([
17677
17685
  var URI_PROVIDERS = "nest://providers";
17678
17686
  var URI_REQUESTS = "nest://requests/recent";
17679
17687
  var MAX_RECENT2 = 50;
17688
+ var MAX_PROVIDERS = 500;
17680
17689
  function installNestReducer(args) {
17681
17690
  const state2 = { providers: /* @__PURE__ */ new Map(), recentRequests: [] };
17682
17691
  const handler = (event) => {
@@ -17685,7 +17694,15 @@ function installNestReducer(args) {
17685
17694
  const raw = event.raw ?? null;
17686
17695
  if (raw?.kind === "singleton-instance" && raw.provider) {
17687
17696
  const p = raw.provider;
17697
+ state2.providers.delete(p.instanceId);
17688
17698
  state2.providers.set(p.instanceId, { ...p });
17699
+ if (state2.providers.size > MAX_PROVIDERS) {
17700
+ for (const k of state2.providers.keys()) {
17701
+ state2.providers.delete(k);
17702
+ if (state2.providers.size <= MAX_PROVIDERS)
17703
+ break;
17704
+ }
17705
+ }
17689
17706
  void args.resources.markDirty(URI_PROVIDERS);
17690
17707
  return;
17691
17708
  }
@@ -18582,6 +18599,7 @@ var URI_MEMOS_RECOMPUTED = "react://memos/recomputed";
18582
18599
  var URI_HOOKS_RECENT = "react://hooks/recent";
18583
18600
  var MAX_RECENT4 = 100;
18584
18601
  var MAX_SLOW2 = 50;
18602
+ var MAX_INSTANCES = 500;
18585
18603
  function installReactReducer(args) {
18586
18604
  const slowRenderMs = resolveThresholds(args.thresholds).slowRenderMs;
18587
18605
  const state2 = {
@@ -18605,7 +18623,15 @@ function installReactReducer(args) {
18605
18623
  if (!r)
18606
18624
  return;
18607
18625
  pushBounded3(state2.rendersRecent, r, MAX_RECENT4);
18626
+ state2.rendersByInstance.delete(r.componentInstanceId);
18608
18627
  state2.rendersByInstance.set(r.componentInstanceId, r);
18628
+ if (state2.rendersByInstance.size > MAX_INSTANCES) {
18629
+ for (const k of state2.rendersByInstance.keys()) {
18630
+ state2.rendersByInstance.delete(k);
18631
+ if (state2.rendersByInstance.size <= MAX_INSTANCES)
18632
+ break;
18633
+ }
18634
+ }
18609
18635
  if (r.actualDurationMs >= slowRenderMs) {
18610
18636
  pushBounded3(state2.rendersSlow, r, MAX_SLOW2);
18611
18637
  state2.rendersSlow.sort((a, b) => b.actualDurationMs - a.actualDurationMs);
@@ -18836,6 +18862,7 @@ var URI_UPDATES = "valtio://updates/recent";
18836
18862
  var URI_STORES = "valtio://stores";
18837
18863
  var URI_SUBSCRIBERS = "valtio://subscribers";
18838
18864
  var MAX_UPDATES = 100;
18865
+ var MAX_PATHS = 1e3;
18839
18866
  function installValtioReducer(args) {
18840
18867
  const state2 = {
18841
18868
  updates: [],
@@ -18859,7 +18886,16 @@ function installValtioReducer(args) {
18859
18886
  if (state2.updates.length > MAX_UPDATES) {
18860
18887
  state2.updates.splice(0, state2.updates.length - MAX_UPDATES);
18861
18888
  }
18862
- state2.latestUpdateByPath.set(`${u.storeId}::${u.path}`, rec);
18889
+ const pathKey = `${u.storeId}::${u.path}`;
18890
+ state2.latestUpdateByPath.delete(pathKey);
18891
+ state2.latestUpdateByPath.set(pathKey, rec);
18892
+ if (state2.latestUpdateByPath.size > MAX_PATHS) {
18893
+ for (const k of state2.latestUpdateByPath.keys()) {
18894
+ state2.latestUpdateByPath.delete(k);
18895
+ if (state2.latestUpdateByPath.size <= MAX_PATHS)
18896
+ break;
18897
+ }
18898
+ }
18863
18899
  void args.resources.markDirty(URI_UPDATES);
18864
18900
  return;
18865
18901
  }
@@ -19547,8 +19583,25 @@ var URI_CURRENT7 = "memory://current";
19547
19583
  var URI_OWNERS = "memory://owners";
19548
19584
  var URI_GROWING = "memory://owners/growing";
19549
19585
  var URI_LEAKS = "memory://leaks/suspected";
19586
+ var MAX_LEAKS = 200;
19587
+ var MAX_FLOWIDS_PER_OWNER = 50;
19550
19588
  function installMemoryReducer(args) {
19551
19589
  const state2 = { owners: /* @__PURE__ */ new Map(), leaks: [] };
19590
+ const pushLeak = (leak) => {
19591
+ state2.leaks.push(leak);
19592
+ if (state2.leaks.length > MAX_LEAKS)
19593
+ state2.leaks.splice(0, state2.leaks.length - MAX_LEAKS);
19594
+ };
19595
+ const noteFlow = (o, flowId) => {
19596
+ o.flowIds.add(flowId);
19597
+ if (o.flowIds.size > MAX_FLOWIDS_PER_OWNER) {
19598
+ for (const id of o.flowIds) {
19599
+ o.flowIds.delete(id);
19600
+ if (o.flowIds.size <= MAX_FLOWIDS_PER_OWNER)
19601
+ break;
19602
+ }
19603
+ }
19604
+ };
19552
19605
  const ensure = (ownerId) => {
19553
19606
  let o = state2.owners.get(ownerId);
19554
19607
  if (!o) {
@@ -19579,7 +19632,7 @@ function installMemoryReducer(args) {
19579
19632
  o.estimatedBytes = raw.mutation.afterCount * 64;
19580
19633
  o.mutations += 1;
19581
19634
  if (event.context.flowId)
19582
- o.flowIds.add(event.context.flowId);
19635
+ noteFlow(o, event.context.flowId);
19583
19636
  void args.resources.markDirty(URI_OWNERS);
19584
19637
  void args.resources.markDirty(URI_GROWING);
19585
19638
  return;
@@ -19587,7 +19640,7 @@ function installMemoryReducer(args) {
19587
19640
  case "memory-retention": {
19588
19641
  if (!raw.retention)
19589
19642
  return;
19590
- state2.leaks.push({
19643
+ pushLeak({
19591
19644
  kind: "retention",
19592
19645
  ownerId: raw.retention.ownerId,
19593
19646
  flowId: raw.retention.flowId,
@@ -19600,7 +19653,7 @@ function installMemoryReducer(args) {
19600
19653
  case "singleton-stale-generation": {
19601
19654
  if (!raw.staleGeneration)
19602
19655
  return;
19603
- state2.leaks.push({
19656
+ pushLeak({
19604
19657
  kind: "stale-generation",
19605
19658
  logicalId: raw.staleGeneration.logicalId,
19606
19659
  detail: { ...raw.staleGeneration },
@@ -20358,7 +20411,19 @@ session.bus.subscribe(appendLog);
20358
20411
  (() => {
20359
20412
  try {
20360
20413
  const file2 = process.env["LENSMCP_EVENT_FILE"] ?? `${process.cwd()}/.lensmcp/events.jsonl`;
20361
- const lines = readFileSync2(file2, "utf8").trim().split("\n").slice(-25e3);
20414
+ const REPLAY_BYTES = 8 * 1024 * 1024;
20415
+ const size = statSync2(file2).size;
20416
+ const start = Math.max(0, size - REPLAY_BYTES);
20417
+ const buf = Buffer.alloc(size - start);
20418
+ const fd = openSync2(file2, "r");
20419
+ try {
20420
+ readSync2(fd, buf, 0, buf.length, start);
20421
+ } finally {
20422
+ closeSync2(fd);
20423
+ }
20424
+ const lines = buf.toString("utf8").trim().split("\n").slice(-25e3);
20425
+ if (start > 0)
20426
+ lines.shift();
20362
20427
  for (const line of lines) {
20363
20428
  try {
20364
20429
  const e = JSON.parse(line);
package/bundled/main.js CHANGED
@@ -362,6 +362,10 @@ function compileStory(events, options = {}) {
362
362
  if (pool.length === 0)
363
363
  return void 0;
364
364
  pool.sort((a, b) => a.timestamp - b.timestamp);
365
+ const MAX_STORY_EVENTS = 1e3;
366
+ if (pool.length > MAX_STORY_EVENTS) {
367
+ pool = [pool[0], ...pool.slice(pool.length - (MAX_STORY_EVENTS - 1))];
368
+ }
365
369
  const origin = pool[0];
366
370
  const phases = [];
367
371
  let finalRender;
@@ -16430,6 +16434,7 @@ var URI_CURRENT = "flow://current";
16430
16434
  var URI_ORIGINS = "flow://origins/recent";
16431
16435
  var MAX_FLOWS = 50;
16432
16436
  var MAX_ORIGINS = 50;
16437
+ var MAX_EVENTS_PER_FLOW = 2e3;
16433
16438
  function installFlowReducer(args) {
16434
16439
  const state2 = {
16435
16440
  flows: /* @__PURE__ */ new Map(),
@@ -16472,6 +16477,9 @@ function installFlowReducer(args) {
16472
16477
  void args.resources.markDirty(URI_ORIGINS);
16473
16478
  }
16474
16479
  entry.events.push(event);
16480
+ if (entry.events.length > MAX_EVENTS_PER_FLOW) {
16481
+ entry.events.splice(0, entry.events.length - MAX_EVENTS_PER_FLOW);
16482
+ }
16475
16483
  entry.summary.eventCount += 1;
16476
16484
  entry.summary.lastSeenAt = event.timestamp;
16477
16485
  const idx = state2.recentOrder.indexOf(flowId);
@@ -17679,6 +17687,7 @@ NestApp = __decorate([
17679
17687
  var URI_PROVIDERS = "nest://providers";
17680
17688
  var URI_REQUESTS = "nest://requests/recent";
17681
17689
  var MAX_RECENT2 = 50;
17690
+ var MAX_PROVIDERS = 500;
17682
17691
  function installNestReducer(args) {
17683
17692
  const state2 = { providers: /* @__PURE__ */ new Map(), recentRequests: [] };
17684
17693
  const handler = (event) => {
@@ -17687,7 +17696,15 @@ function installNestReducer(args) {
17687
17696
  const raw = event.raw ?? null;
17688
17697
  if (raw?.kind === "singleton-instance" && raw.provider) {
17689
17698
  const p = raw.provider;
17699
+ state2.providers.delete(p.instanceId);
17690
17700
  state2.providers.set(p.instanceId, { ...p });
17701
+ if (state2.providers.size > MAX_PROVIDERS) {
17702
+ for (const k of state2.providers.keys()) {
17703
+ state2.providers.delete(k);
17704
+ if (state2.providers.size <= MAX_PROVIDERS)
17705
+ break;
17706
+ }
17707
+ }
17691
17708
  void args.resources.markDirty(URI_PROVIDERS);
17692
17709
  return;
17693
17710
  }
@@ -18584,6 +18601,7 @@ var URI_MEMOS_RECOMPUTED = "react://memos/recomputed";
18584
18601
  var URI_HOOKS_RECENT = "react://hooks/recent";
18585
18602
  var MAX_RECENT4 = 100;
18586
18603
  var MAX_SLOW2 = 50;
18604
+ var MAX_INSTANCES = 500;
18587
18605
  function installReactReducer(args) {
18588
18606
  const slowRenderMs = resolveThresholds(args.thresholds).slowRenderMs;
18589
18607
  const state2 = {
@@ -18607,7 +18625,15 @@ function installReactReducer(args) {
18607
18625
  if (!r)
18608
18626
  return;
18609
18627
  pushBounded3(state2.rendersRecent, r, MAX_RECENT4);
18628
+ state2.rendersByInstance.delete(r.componentInstanceId);
18610
18629
  state2.rendersByInstance.set(r.componentInstanceId, r);
18630
+ if (state2.rendersByInstance.size > MAX_INSTANCES) {
18631
+ for (const k of state2.rendersByInstance.keys()) {
18632
+ state2.rendersByInstance.delete(k);
18633
+ if (state2.rendersByInstance.size <= MAX_INSTANCES)
18634
+ break;
18635
+ }
18636
+ }
18611
18637
  if (r.actualDurationMs >= slowRenderMs) {
18612
18638
  pushBounded3(state2.rendersSlow, r, MAX_SLOW2);
18613
18639
  state2.rendersSlow.sort((a, b) => b.actualDurationMs - a.actualDurationMs);
@@ -18838,6 +18864,7 @@ var URI_UPDATES = "valtio://updates/recent";
18838
18864
  var URI_STORES = "valtio://stores";
18839
18865
  var URI_SUBSCRIBERS = "valtio://subscribers";
18840
18866
  var MAX_UPDATES = 100;
18867
+ var MAX_PATHS = 1e3;
18841
18868
  function installValtioReducer(args) {
18842
18869
  const state2 = {
18843
18870
  updates: [],
@@ -18861,7 +18888,16 @@ function installValtioReducer(args) {
18861
18888
  if (state2.updates.length > MAX_UPDATES) {
18862
18889
  state2.updates.splice(0, state2.updates.length - MAX_UPDATES);
18863
18890
  }
18864
- state2.latestUpdateByPath.set(`${u.storeId}::${u.path}`, rec);
18891
+ const pathKey = `${u.storeId}::${u.path}`;
18892
+ state2.latestUpdateByPath.delete(pathKey);
18893
+ state2.latestUpdateByPath.set(pathKey, rec);
18894
+ if (state2.latestUpdateByPath.size > MAX_PATHS) {
18895
+ for (const k of state2.latestUpdateByPath.keys()) {
18896
+ state2.latestUpdateByPath.delete(k);
18897
+ if (state2.latestUpdateByPath.size <= MAX_PATHS)
18898
+ break;
18899
+ }
18900
+ }
18865
18901
  void args.resources.markDirty(URI_UPDATES);
18866
18902
  return;
18867
18903
  }
@@ -19549,8 +19585,25 @@ var URI_CURRENT7 = "memory://current";
19549
19585
  var URI_OWNERS = "memory://owners";
19550
19586
  var URI_GROWING = "memory://owners/growing";
19551
19587
  var URI_LEAKS = "memory://leaks/suspected";
19588
+ var MAX_LEAKS = 200;
19589
+ var MAX_FLOWIDS_PER_OWNER = 50;
19552
19590
  function installMemoryReducer(args) {
19553
19591
  const state2 = { owners: /* @__PURE__ */ new Map(), leaks: [] };
19592
+ const pushLeak = (leak) => {
19593
+ state2.leaks.push(leak);
19594
+ if (state2.leaks.length > MAX_LEAKS)
19595
+ state2.leaks.splice(0, state2.leaks.length - MAX_LEAKS);
19596
+ };
19597
+ const noteFlow = (o, flowId) => {
19598
+ o.flowIds.add(flowId);
19599
+ if (o.flowIds.size > MAX_FLOWIDS_PER_OWNER) {
19600
+ for (const id of o.flowIds) {
19601
+ o.flowIds.delete(id);
19602
+ if (o.flowIds.size <= MAX_FLOWIDS_PER_OWNER)
19603
+ break;
19604
+ }
19605
+ }
19606
+ };
19554
19607
  const ensure = (ownerId) => {
19555
19608
  let o = state2.owners.get(ownerId);
19556
19609
  if (!o) {
@@ -19581,7 +19634,7 @@ function installMemoryReducer(args) {
19581
19634
  o.estimatedBytes = raw.mutation.afterCount * 64;
19582
19635
  o.mutations += 1;
19583
19636
  if (event.context.flowId)
19584
- o.flowIds.add(event.context.flowId);
19637
+ noteFlow(o, event.context.flowId);
19585
19638
  void args.resources.markDirty(URI_OWNERS);
19586
19639
  void args.resources.markDirty(URI_GROWING);
19587
19640
  return;
@@ -19589,7 +19642,7 @@ function installMemoryReducer(args) {
19589
19642
  case "memory-retention": {
19590
19643
  if (!raw.retention)
19591
19644
  return;
19592
- state2.leaks.push({
19645
+ pushLeak({
19593
19646
  kind: "retention",
19594
19647
  ownerId: raw.retention.ownerId,
19595
19648
  flowId: raw.retention.flowId,
@@ -19602,7 +19655,7 @@ function installMemoryReducer(args) {
19602
19655
  case "singleton-stale-generation": {
19603
19656
  if (!raw.staleGeneration)
19604
19657
  return;
19605
- state2.leaks.push({
19658
+ pushLeak({
19606
19659
  kind: "stale-generation",
19607
19660
  logicalId: raw.staleGeneration.logicalId,
19608
19661
  detail: { ...raw.staleGeneration },
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;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"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AASA,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;AA6FD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE;AA0nBD,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
@@ -2,9 +2,10 @@ import { spawn, spawnSync } from 'node:child_process';
2
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
- import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ensureLensConfig } from './workspace-scope.js';
8
+ import { sweepLensRuntimeFiles } from './sweep.js';
8
9
  const HELP = `lensmcp — CLI for LensMCP (FrontMCP-based observability for coding agents)
9
10
 
10
11
  Usage:
@@ -12,10 +13,16 @@ Usage:
12
13
 
13
14
  Commands:
14
15
  setup [--cwd <dir>] [--skip-format] [--register-host-config <mode>]
15
- First-time bootstrap — runs \`install\` then \`trust\` in
16
+ First-time bootstrap — \`install\` → \`trust\` → \`pods\` in
16
17
  one idempotent pass: plugin + .mcp.json + nx config +
17
18
  vite/nest wiring, then the dev CA (System keychain),
18
- /etc/hosts (both families) + DNS flush. sudo self-prompts
19
+ /etc/hosts (both families) + DNS flush, then a recycle of
20
+ THIS workspace's devserver pods (they would otherwise keep
21
+ serving the wiring setup just replaced) plus a gateway
22
+ reconcile — nx graph refresh, adopt an orphaned :443,
23
+ register this workspace into a running daemon. A running
24
+ gateway is NEVER restarted (the :443 door is shared) and
25
+ other workspaces' pods are never touched. sudo self-prompts
19
26
  only for the steps that need it — run in a real terminal.
20
27
  Then \`lensmcp gateway start\`.
21
28
  mcp [--cwd <dir>] [--transport stdio|http] [--port <n>]
@@ -68,14 +75,16 @@ Commands:
68
75
  promote, abort, add-rule, remove-rule, status. Validated +
69
76
  atomic write; the prod gateway picks it up live. Run
70
77
  \`lensmcp rollout\` (no args) for the op list.
71
- logs [service] [--tail <n>] [--no-follow] [--plain] [--cwd <dir>]
78
+ logs [service] [--tail <n>] [--no-follow] [--plain] [--color] [--cwd <dir>]
72
79
  Attach this terminal to a running service's live log
73
80
  stream (docker-logs style — for humans and agents alike).
74
81
  No <service> lists every attachable service across
75
82
  workspaces. --tail <n> replays that many history lines
76
83
  first (default 100); --no-follow prints them and exits
77
84
  (the agent-friendly snapshot); --plain strips ANSI color
78
- (automatic when output is piped). Needs no ports — it
85
+ (automatic when output is piped); --color FORCES color
86
+ through a pipe — for consumers that RENDER ANSI, like
87
+ the IDE plugin's Logs console. Needs no ports — it
79
88
  finds the devserver's control socket under $TMPDIR.
80
89
  debug <service> [--port <base>] [--cwd <dir>]
81
90
  Open every pod's Node inspector of a RUNNING service —
@@ -239,6 +248,15 @@ function daemonRequest(method, urlPath, body) {
239
248
  catch { /* non-JSON body */ }
240
249
  return Number.isFinite(status) ? { status, json } : null;
241
250
  }
251
+ /** The workspace key that OWNS the running daemon (undefined when no daemon answers) — used to name the
252
+ * workspace a guest must run privileged/workspace-scoped work (`trust`) in. */
253
+ function daemonWsKey() {
254
+ const s = daemonRequest('GET', '/status');
255
+ if (s?.status !== 200 || !s.json || typeof s.json !== 'object')
256
+ return undefined;
257
+ const d = s.json.daemon;
258
+ return typeof d?.wsKey === 'string' && d.wsKey.length > 0 ? d.wsKey : undefined;
259
+ }
242
260
  /** The nx projects map (name → workspace-relative root) for cluster discovery — built by scanning
243
261
  * project.json files, the same walk `findGatewayTarget` uses. Sent to the daemon so it can `discoverRoutes`
244
262
  * this workspace when it registers. */
@@ -336,8 +354,17 @@ function runGateway(ctx, args, out, err) {
336
354
  }
337
355
  const target = findGatewayTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
338
356
  if (!target) {
339
- err('Could not find a gateway target (an executor `@lensmcp/cluster:gateway`) in this workspace.\n' +
340
- 'Pass --project <p> --target <t>, or add a gateway project (see @lensmcp/cluster).');
357
+ // This workspace hosts no gateway of its own. That is a NORMAL state for a guest workspace — it just
358
+ // means there is nothing on :443 to join right now. Say what to actually do, in order of likelihood,
359
+ // instead of only naming the two flags (the papercut the IDE plugin surfaced verbatim).
360
+ err(`This workspace ('${cfg.key}') has no gateway target — no project declares the`);
361
+ err('`@lensmcp/cluster:gateway` executor, so there is nothing here to launch on :443.');
362
+ err('');
363
+ err(' • Joining a shared gateway? Start it in the workspace that OWNS it, then run');
364
+ err(' `lensmcp gateway start` here — this workspace registers into it (no local target needed).');
365
+ err(' • Should this workspace host its own gateway? Scaffold it: `lensmcp install`');
366
+ err(' (or `lensmcp setup` for the full install + trust bootstrap).');
367
+ err(' • Already have a differently-named target? `lensmcp gateway start --project <p> --target <t>`.');
341
368
  return { exitCode: 1 };
342
369
  }
343
370
  const nxBin = findNxBinary(cwd);
@@ -345,6 +372,7 @@ function runGateway(ctx, args, out, err) {
345
372
  err('Could not find the `nx` binary in the workspace. Install Nx first: `yarn add -D nx`.');
346
373
  return { exitCode: 1 };
347
374
  }
375
+ sweepLensRuntimeFiles(cwd, logFile);
348
376
  const fd = openSync(logFile, 'a');
349
377
  const child = spawn(nxBin, ['run', `${target.project}:${target.target}`], {
350
378
  cwd,
@@ -586,14 +614,34 @@ function findGatewayTarget(cwd, project, target) {
586
614
  // Sensible fallback used by most workspaces (tetros: tools/gateway → `gateway:serve`).
587
615
  return project || target ? { project: project ?? 'gateway', target: target ?? 'serve' } : undefined;
588
616
  }
589
- /** Find a project + target whose executor is `@lensmcp/cluster:trust`. Falls back
590
- * unconditionally to `gateway:trust` — the conventional home the init generator
591
- * scaffolds — since trust is idempotent and safe to attempt anywhere. */
617
+ /**
618
+ * Find a project + target whose executor is `@lensmcp/cluster:trust`. Falls back to `gateway:trust` — the
619
+ * conventional home the init generator scaffolds — but ONLY when a project named `gateway` actually exists
620
+ * (an explicit `--project` is always taken at face value). The old unconditional fallback made `trust` in a
621
+ * workspace that never ran `lensmcp install` shell out to `nx run gateway:trust` and die on Nx's opaque
622
+ * "Cannot find project 'gateway'"; returning undefined lets the caller say what to do instead.
623
+ */
592
624
  function findTrustTarget(cwd, project, target) {
593
- return (scanExecutorTarget(cwd, '@lensmcp/cluster:trust', project, target) ?? {
594
- project: project ?? 'gateway',
595
- target: target ?? 'trust',
596
- });
625
+ const found = scanExecutorTarget(cwd, '@lensmcp/cluster:trust', project, target);
626
+ if (found)
627
+ return found;
628
+ if (project)
629
+ return { project, target: target ?? 'trust' };
630
+ return projectExists(cwd, 'gateway') ? { project: 'gateway', target: target ?? 'trust' } : undefined;
631
+ }
632
+ /** Does a project of this name exist in the workspace? (project.json-declared, like every other scan here.) */
633
+ function projectExists(cwd, name) {
634
+ for (const file of walkProjectFiles(cwd, (n) => n === 'project.json')) {
635
+ try {
636
+ const parsed = JSON.parse(safeRead(file));
637
+ if ((parsed.name ?? basename(dirname(file))) === name)
638
+ return true;
639
+ }
640
+ catch {
641
+ /* unreadable project.json — not a match */
642
+ }
643
+ }
644
+ return false;
597
645
  }
598
646
  function readPid(pidFile) {
599
647
  try {
@@ -867,6 +915,26 @@ function runTrust(ctx, args, out, err) {
867
915
  // that unused CA file (the papercut). So run HOSTS-ONLY (/etc/hosts + DNS) and report the REAL served-cert
868
916
  // trust instead.
869
917
  const guestOfDaemon = daemonRequest('GET', '/list')?.status === 200 && gatewayOnPort443(cwd) === undefined;
918
+ // No trust target anywhere in this workspace (never ran `lensmcp install`). Trust is workspace-scoped work
919
+ // — minting the CA and writing /etc/hosts for THIS workspace's cluster hosts — so there is nothing to run
920
+ // here. Say which workspace owns the CA instead of failing inside Nx with "Cannot find project 'gateway'".
921
+ if (!target) {
922
+ err(`This workspace has no trust target — no project declares the \`@lensmcp/cluster:trust\` executor.`);
923
+ err('');
924
+ if (guestOfDaemon) {
925
+ const owner = daemonWsKey();
926
+ err(` A shared gateway daemon${owner ? ` (owned by '${owner}')` : ''} already serves this workspace, and`);
927
+ err(' TLS rides ITS certificate authority. Run `lensmcp trust` in THAT workspace — it owns the CA —');
928
+ err(' and this workspace is trusted too (restart the browser once if it cached a cert error).');
929
+ }
930
+ else {
931
+ err(' • Should this workspace own its gateway (and its CA)? Scaffold it first: `lensmcp install`,');
932
+ err(' then re-run `lensmcp trust`.');
933
+ err(' • Joining a shared gateway? Run `lensmcp trust` in the workspace that owns the daemon.');
934
+ }
935
+ err(' • Already have a differently-named target? `lensmcp trust --project <p> --target <t>`.');
936
+ return { exitCode: 1 };
937
+ }
870
938
  if (!isInteractive()) {
871
939
  out(' note: trust runs `sudo` for the CA + /etc/hosts — run it in a real terminal if a step is needed.');
872
940
  }
@@ -920,8 +988,10 @@ function writeSetupState(cwd) {
920
988
  }
921
989
  /**
922
990
  * `lensmcp setup` — first-time bootstrap. Runs `install` (plugin + .mcp.json +
923
- * nx config + vite/nest wiring, no sudo) then `trust` (dev CA → keychain,
924
- * /etc/hosts, DNS flush, sudo). Both steps are idempotent, so re-running is safe.
991
+ * nx config + vite/nest wiring, no sudo), then `trust` (dev CA → keychain,
992
+ * /etc/hosts, DNS flush, sudo), then `pods` — which recycles this workspace's
993
+ * devservers and reconciles the gateway, so nothing keeps serving the wiring
994
+ * that setup just replaced. Every step is idempotent, so re-running is safe.
925
995
  * Flags are forwarded to whichever sub-command understands them (`--skip-format`,
926
996
  * `--register-host-config` → install; `--project`/`--target` → trust).
927
997
  */
@@ -929,9 +999,9 @@ function runSetup(ctx, args, out, err) {
929
999
  const opts = parseFlags(args, { string: ['--cwd'] });
930
1000
  const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
931
1001
  const subCtx = { ...ctx, cwd };
932
- out('lensmcp setup — first-time bootstrap (install → trust)');
1002
+ out('lensmcp setup — first-time bootstrap (install → trust → pods)');
933
1003
  out('');
934
- out('[1/2] install — plugin, .mcp.json, nx config, vite/nest wiring');
1004
+ out('[1/3] install — plugin, .mcp.json, nx config, vite/nest wiring');
935
1005
  const installRes = runInstall(subCtx, args, out, err);
936
1006
  if (installRes.exitCode !== 0) {
937
1007
  err('');
@@ -939,17 +1009,31 @@ function runSetup(ctx, args, out, err) {
939
1009
  return installRes;
940
1010
  }
941
1011
  out('');
942
- out('[2/2] trust — dev CA → System keychain, /etc/hosts, DNS flush (sudo)');
1012
+ out('[2/3] trust — dev CA → System keychain, /etc/hosts, DNS flush (sudo)');
943
1013
  const trustRes = runTrust(subCtx, args, out, err);
944
1014
  if (trustRes.exitCode !== 0) {
945
1015
  err('');
946
1016
  err('setup: the trust step failed — run `lensmcp setup` directly in a terminal so sudo can prompt.');
947
1017
  return trustRes;
948
1018
  }
949
- // Both steps succeeded → mark the workspace set up for this version (opens the MCP gate).
1019
+ // Install + trust rewrote this workspace's wiring, but a devserver that has been up since before it
1020
+ // changed goes on serving the OLD bundle from its pooled pods — the "setup said ✓ but the app is
1021
+ // unchanged" trap. Recycle them, then put the gateway back in line with reality WITHOUT restarting it
1022
+ // (the :443 door is shared: bouncing it would take every other workspace down with this one).
1023
+ out('');
1024
+ out("[3/3] pods — recycle this workspace's devservers, reconcile the gateway");
1025
+ const key = ensureLensConfig(cwd).key;
1026
+ const reaped = reapWorkspacePods(cwd, key, out);
1027
+ reconcileGatewayAfterSetup(subCtx, cwd, key, out);
1028
+ // All steps succeeded → mark the workspace set up for this version (opens the MCP gate).
950
1029
  writeSetupState(cwd);
951
1030
  out('');
952
1031
  out('✓ setup complete.');
1032
+ if (reaped.killed > 0 || reaped.stale > 0) {
1033
+ out(` recycled ${reaped.killed} pod${reaped.killed === 1 ? '' : 's'}` +
1034
+ (reaped.stale > 0 ? ` (+${reaped.stale} stale socket${reaped.stale === 1 ? '' : 's'} cleared)` : '') +
1035
+ ' — they come back on the new wiring.');
1036
+ }
953
1037
  out(' next: lensmcp gateway start (the :443 front door — needs sudo)');
954
1038
  out(' verify: lensmcp doctor');
955
1039
  return { exitCode: 0 };
@@ -1309,6 +1393,179 @@ function discoverDevserverSocks() {
1309
1393
  return found.sort((a, b) => `${a.wsKey}/${a.service}`.localeCompare(`${b.wsKey}/${b.service}`));
1310
1394
  }
1311
1395
  const fullServiceName = (d) => (d.wsKey ? `${d.wsKey}/${d.service}` : d.service);
1396
+ /* ───────── pod recycling (the `setup` reap) ─────────
1397
+ * A devserver is a PARENT process holding `parent.sock` plus N forked child pods. Killing the parent is
1398
+ * enough for the pods — they exit on the IPC `disconnect` (main.devserver.ts's ORPHAN GUARD) and unlink
1399
+ * their own sockets. But the gateway spawns each service `detached: true` with `NX_DAEMON=false`, so the
1400
+ * whole service — the `nx run` shim, the devserver, the pods — sits in ONE process group of its own; group-
1401
+ * killing that reaps the shim too, which a lone parent kill would leave behind (the documented "nx wrapper
1402
+ * outlives its child" leak). Hence: kill the group when we can prove it's the service's own, else the pid. */
1403
+ /** The pid holding a unix socket (best-effort via lsof; undefined = the socket is stale litter). */
1404
+ function pidOnSocket(sock) {
1405
+ try {
1406
+ const r = spawnSync('lsof', ['-t', sock], { encoding: 'utf8' });
1407
+ const pid = Number((r.stdout ?? '').split('\n')[0]?.trim());
1408
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
1409
+ }
1410
+ catch {
1411
+ return undefined;
1412
+ }
1413
+ }
1414
+ /** A process's group id (undefined when it's gone or `ps` is unavailable). */
1415
+ function pgidOf(pid) {
1416
+ try {
1417
+ const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
1418
+ const pgid = Number((r.stdout ?? '').trim());
1419
+ return Number.isInteger(pgid) && pgid > 1 ? pgid : undefined;
1420
+ }
1421
+ catch {
1422
+ return undefined;
1423
+ }
1424
+ }
1425
+ /** A process's working directory — how an UN-NAMESPACED devserver (a plain `nx serve`, no LENSMCP_WS_KEY)
1426
+ * gets attributed to a workspace. Undefined when it can't be read, and an unattributable pod is left
1427
+ * alone: it belongs to somebody else until proven otherwise. */
1428
+ function cwdOfPid(pid) {
1429
+ try {
1430
+ const r = spawnSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8' });
1431
+ const line = (r.stdout ?? '').split('\n').find((l) => l.startsWith('n'));
1432
+ return line ? line.slice(1).trim() || undefined : undefined;
1433
+ }
1434
+ catch {
1435
+ return undefined;
1436
+ }
1437
+ }
1438
+ /** Is `child` the same path as, or inside, `parent`? */
1439
+ function isInside(parent, child) {
1440
+ const rel = relative(resolve(parent), resolve(child));
1441
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
1442
+ }
1443
+ /** Every devserver under $TMPDIR that belongs to the workspace rooted at `cwd`. A namespaced sock dir
1444
+ * (`$TMPDIR/<wsKey>/…`) is attributed by its key; a legacy un-namespaced one by its owner's cwd. */
1445
+ function workspaceDevservers(cwd, key) {
1446
+ return discoverDevserverSocks().filter((d) => {
1447
+ if (d.wsKey)
1448
+ return d.wsKey === key;
1449
+ const pid = pidOnSocket(d.sock);
1450
+ const owner = pid !== undefined ? cwdOfPid(pid) : undefined;
1451
+ return owner !== undefined && isInside(cwd, owner);
1452
+ });
1453
+ }
1454
+ /**
1455
+ * Stop every devserver of THIS workspace and clear its socket dir, so the next boot comes up on the
1456
+ * freshly installed wiring instead of a pod that has been running since before it changed.
1457
+ *
1458
+ * Safe next to a live gateway: it marks a vanished service `down` and either self-heals it or scales it
1459
+ * from zero on the next request, so the front door keeps serving while the pods come back new. NEVER
1460
+ * group-kills a group it can't prove is the service's own — a mis-derived group would take down the
1461
+ * gateway (or this CLI) with it.
1462
+ */
1463
+ function reapWorkspacePods(cwd, key, out) {
1464
+ const mine = workspaceDevservers(cwd, key);
1465
+ if (mine.length === 0) {
1466
+ out(' no devserver pods running for this workspace — nothing to recycle.');
1467
+ return { killed: 0, stale: 0 };
1468
+ }
1469
+ // Groups we must never signal: our own, and whatever holds :443 (the gateway must survive this).
1470
+ const forbidden = new Set();
1471
+ for (const p of [process.pid, ...pidsOnPort443()]) {
1472
+ const g = pgidOf(p);
1473
+ if (g !== undefined)
1474
+ forbidden.add(g);
1475
+ }
1476
+ let killed = 0;
1477
+ let stale = 0;
1478
+ for (const d of mine) {
1479
+ const name = fullServiceName(d);
1480
+ const pid = pidOnSocket(d.sock);
1481
+ if (pid === undefined) {
1482
+ rmFile(d.sock);
1483
+ out(` ✓ ${name} — stale socket removed (no process held it)`);
1484
+ stale++;
1485
+ continue;
1486
+ }
1487
+ const group = pgidOf(pid);
1488
+ const groupKillable = group !== undefined && !forbidden.has(group);
1489
+ const signal = (sig) => {
1490
+ try {
1491
+ if (groupKillable)
1492
+ process.kill(-group, sig);
1493
+ else
1494
+ process.kill(pid, sig); // pods follow the parent out via the IPC disconnect guard
1495
+ }
1496
+ catch {
1497
+ /* already gone */
1498
+ }
1499
+ };
1500
+ signal('SIGTERM');
1501
+ // Give the tree a moment to unwind gracefully (the devserver closes its pods' IPC, they unlink their
1502
+ // own sockets), then escalate — a wedged child must not survive the recycle.
1503
+ if (!waitForPidGone(pid, 5000)) {
1504
+ signal('SIGKILL');
1505
+ waitForPidGone(pid, 2000);
1506
+ }
1507
+ rmFile(d.sock);
1508
+ out(` ✓ ${name} — stopped (pid ${pid}${groupKillable ? `, group ${group}` : ', parent only'})`);
1509
+ killed++;
1510
+ }
1511
+ return { killed, stale };
1512
+ }
1513
+ /** Poll until `pid` is gone or the timeout elapses. Synchronous — a setup step, not a hot path. */
1514
+ function waitForPidGone(pid, timeoutMs) {
1515
+ const deadline = Date.now() + timeoutMs;
1516
+ for (;;) {
1517
+ if (!isAlive(pid))
1518
+ return true;
1519
+ if (Date.now() >= deadline)
1520
+ return false;
1521
+ spawnSync('sleep', ['0.15']);
1522
+ }
1523
+ }
1524
+ /**
1525
+ * Bring the gateway back in line with reality WITHOUT restarting a running one — a `setup` must never take
1526
+ * the shared `:443` front door (and every other workspace on it) down. It:
1527
+ * • adopts an orphaned gateway the pid file lost track of (the desync self-heal);
1528
+ * • busts the nx project graph, so the projects `install` just wired resolve instead of 404ing out of a
1529
+ * daemon whose graph predates them;
1530
+ * • registers this workspace into a daemon someone else owns, when it isn't in there yet.
1531
+ * An already-registered workspace is left alone: re-registering makes the daemon tear down and respawn its
1532
+ * services and dashboard for nothing.
1533
+ */
1534
+ function reconcileGatewayAfterSetup(ctx, cwd, key, out) {
1535
+ const pidFile = join(cwd, '.lensmcp', 'gateway.pid');
1536
+ const registeredMarker = join(cwd, '.lensmcp', 'registered.json');
1537
+ const { pid, healed } = reconcileGateway(pidFile, cwd);
1538
+ if (healed)
1539
+ out(` healed: adopted a running gateway (pid ${pid}) the pid file had lost.`);
1540
+ refreshNxGraph(cwd, ctx.env, (l) => out(` ${l}`));
1541
+ const owned = gatewayOnPort443(cwd) !== undefined;
1542
+ if (owned) {
1543
+ out(` gateway: this workspace owns the :443 daemon (pid ${pid ?? '?'}) — left running, pods respawn fresh.`);
1544
+ return;
1545
+ }
1546
+ const list = daemonRequest('GET', '/list');
1547
+ if (list?.status !== 200) {
1548
+ out(' gateway: not running. Start it when you are ready: lensmcp gateway start');
1549
+ return;
1550
+ }
1551
+ const registered = list.json && typeof list.json === 'object'
1552
+ ? (list.json.workspaces ?? []).some((w) => w.key === key)
1553
+ : false;
1554
+ if (registered) {
1555
+ mkdirSync(dirname(registeredMarker), { recursive: true });
1556
+ writeFileSync(registeredMarker, JSON.stringify({ wsKey: key }));
1557
+ out(` gateway: '${key}' is already registered into the shared daemon — left serving.`);
1558
+ return;
1559
+ }
1560
+ const res = daemonRequest('POST', '/register', { wsKey: key, root: cwd, projects: buildProjectsMap(cwd) });
1561
+ if (res && res.status === 200) {
1562
+ mkdirSync(dirname(registeredMarker), { recursive: true });
1563
+ writeFileSync(registeredMarker, JSON.stringify({ wsKey: key }));
1564
+ out(` gateway: registered '${key}' into the shared daemon — it now hosts this workspace.`);
1565
+ return;
1566
+ }
1567
+ out(' gateway: a shared daemon is up but registering this workspace failed — run `lensmcp gateway start`.');
1568
+ }
1312
1569
  /** Resolve a service argument (`name` or `wsKey/name`) to ONE control socket. A bare
1313
1570
  * name running in several workspaces is disambiguated by the current workspace's key.
1314
1571
  * Prints the helpful error itself and returns null when nothing (or too much) matches. */
@@ -1340,7 +1597,7 @@ function resolveServiceSock(service, all, cwd, err) {
1340
1597
  async function runLogs(ctx, args, out, err) {
1341
1598
  const opts = parseFlags(args, {
1342
1599
  string: ['--tail', '--cwd'],
1343
- boolean: ['--no-follow', '--plain'],
1600
+ boolean: ['--no-follow', '--plain', '--color'],
1344
1601
  });
1345
1602
  const service = opts.positional[0];
1346
1603
  const all = discoverDevserverSocks();
@@ -1362,7 +1619,11 @@ async function runLogs(ctx, args, out, err) {
1362
1619
  const follow = opts.flags['--no-follow'] !== true;
1363
1620
  const tailRaw = Number(stringFlag(opts.flags['--tail']) ?? NaN);
1364
1621
  const tail = Number.isInteger(tailRaw) && tailRaw >= 0 ? tailRaw : 100; // 0 = live-only, no history
1365
- const plain = opts.flags['--plain'] === true || !process.stdout.isTTY;
1622
+ // ANSI is stripped when the output is piped — EXCEPT for a consumer that renders it and says so with
1623
+ // `--color` (the IDE plugin's Logs console runs through a pipe, and plain-stripped output is exactly the
1624
+ // unreadable wall of text the colored stream avoids). Explicit `--plain` still wins.
1625
+ const plain = opts.flags['--plain'] === true ||
1626
+ (opts.flags['--color'] !== true && !process.stdout.isTTY);
1366
1627
  const reqPath = `/webpack/logs?tail=${tail}&follow=${follow ? 1 : 0}${plain ? '&plain=1' : ''}`;
1367
1628
  return new Promise((done) => {
1368
1629
  const req = httpRequest({ socketPath: target.sock, path: reqPath, method: 'GET' }, (res) => {
package/lib/sweep.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Retention sweep at gateway start — the one moment we KNOW no gateway is writing these files.
3
+ * The bus rotates round-robin (`events.jsonl.1` … `.N`, 50 MB per generation, oldest dropped —
4
+ * emit.ts's `LENSMCP_EVENT_MAX_BYTES`/`LENSMCP_EVENT_MAX_FILES` contract); this prunes what that
5
+ * scheme can't reach: legacy `.old` files older schemes left behind (a tetros workspace carried an
6
+ * 8.6 GB `events.jsonl.old` from July), generations beyond the horizon, oversized generations from
7
+ * the pre-cap era, and a grown `gateway.log` (rotated to `.1` so the append-only log doesn't creep
8
+ * to hundreds of MB across restarts). Lives outside cli.ts so it stays CJS-jestable (cli.ts uses
9
+ * import.meta).
10
+ */
11
+ export declare function sweepLensRuntimeFiles(cwd: string, logFile: string): void;
12
+ //# sourceMappingURL=sweep.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sweep.d.ts","sourceRoot":"","sources":["../../src/lib/sweep.ts"],"names":[],"mappings":"AAWA;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAgCxE"}
package/lib/sweep.js ADDED
@@ -0,0 +1,60 @@
1
+ import { readdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const removeQuietly = (path) => {
4
+ try {
5
+ rmSync(path, { force: true });
6
+ }
7
+ catch {
8
+ /* best-effort — a vanished file or racing sweep must never block a start */
9
+ }
10
+ };
11
+ /**
12
+ * Retention sweep at gateway start — the one moment we KNOW no gateway is writing these files.
13
+ * The bus rotates round-robin (`events.jsonl.1` … `.N`, 50 MB per generation, oldest dropped —
14
+ * emit.ts's `LENSMCP_EVENT_MAX_BYTES`/`LENSMCP_EVENT_MAX_FILES` contract); this prunes what that
15
+ * scheme can't reach: legacy `.old` files older schemes left behind (a tetros workspace carried an
16
+ * 8.6 GB `events.jsonl.old` from July), generations beyond the horizon, oversized generations from
17
+ * the pre-cap era, and a grown `gateway.log` (rotated to `.1` so the append-only log doesn't creep
18
+ * to hundreds of MB across restarts). Lives outside cli.ts so it stays CJS-jestable (cli.ts uses
19
+ * import.meta).
20
+ */
21
+ export function sweepLensRuntimeFiles(cwd, logFile) {
22
+ const dir = join(cwd, '.lensmcp');
23
+ const eventFile = join(dir, 'events.jsonl');
24
+ removeQuietly(`${eventFile}.old`);
25
+ removeQuietly(`${logFile}.old`);
26
+ const maxFiles = (() => {
27
+ const n = Number(process.env['LENSMCP_EVENT_MAX_FILES']);
28
+ return Number.isFinite(n) && n >= 1 && n <= 20 ? Math.floor(n) : 5;
29
+ })();
30
+ const MAX_GENERATION_BYTES = 64 * 1024 * 1024; // legit generations are ≤ ~50 MB; bigger = pre-cap era litter
31
+ try {
32
+ for (const name of readdirSync(dir)) {
33
+ const m = /^events\.jsonl\.(\d+)$/.exec(name);
34
+ if (!m)
35
+ continue;
36
+ const gen = Number(m[1]);
37
+ const file = join(dir, name);
38
+ if (gen < 1 || gen > maxFiles)
39
+ removeQuietly(file);
40
+ else {
41
+ try {
42
+ if (statSync(file).size > MAX_GENERATION_BYTES)
43
+ removeQuietly(file);
44
+ }
45
+ catch { /* raced away */ }
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ /* no .lensmcp dir yet */
51
+ }
52
+ const MAX_GATEWAY_LOG_BYTES = 16 * 1024 * 1024;
53
+ try {
54
+ if (statSync(logFile).size > MAX_GATEWAY_LOG_BYTES)
55
+ renameSync(logFile, `${logFile}.1`);
56
+ }
57
+ catch {
58
+ /* no log yet */
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.16.29",
3
+ "version": "1.16.31",
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.29",
5
+ "version": "1.16.31",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"