faux-studio 0.3.11 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +21 -0
  2. package/dist/index.js +360 -46
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -67,6 +67,27 @@ faux-studio runs locally and bridges your AI client to Figma Desktop:
67
67
  - Setup guide: [faux.design/docs/setup](https://faux.design/docs/setup)
68
68
  - Issues: [github.com/uxfreak/faux-studio/issues](https://github.com/uxfreak/faux-studio/issues)
69
69
 
70
+ ## Publishing a New Version
71
+
72
+ To publish a new version to npm:
73
+
74
+ 1. Bump the version in `package.json`:
75
+ ```bash
76
+ npm version patch # 0.4.0 → 0.4.1
77
+ npm version minor # 0.4.0 → 0.5.0
78
+ npm version major # 0.4.0 → 1.0.0
79
+ ```
80
+ This updates `package.json` and creates a git commit + tag automatically.
81
+
82
+ 2. Push the commit and tag:
83
+ ```bash
84
+ git push && git push --tags
85
+ ```
86
+
87
+ 3. The [publish workflow](.github/workflows/publish.yml) will build and publish to npm automatically on any `v*` tag push.
88
+
89
+ > **Note:** Ensure the `NPM_TOKEN` secret is configured in the repo's GitHub Settings → Secrets and variables → Actions.
90
+
70
91
  ## License
71
92
 
72
93
  MIT
package/dist/index.js CHANGED
@@ -10451,7 +10451,7 @@ function error(message) {
10451
10451
  // src/auth.ts
10452
10452
  var FAUX_DIR = join(homedir(), ".faux");
10453
10453
  var CREDENTIALS_PATH = join(FAUX_DIR, "credentials.json");
10454
- var AUTH_BASE = "https://auth.faux.design";
10454
+ var AUTH_BASE = process.env.FAUX_AUTH_URL || "https://auth.faux.design";
10455
10455
  var POLL_INTERVAL_MS = 2e3;
10456
10456
  var POLL_TIMEOUT_MS = 5 * 60 * 1e3;
10457
10457
  var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
@@ -10630,7 +10630,7 @@ import { join as join2 } from "path";
10630
10630
  import { homedir as homedir2 } from "os";
10631
10631
  var FAUX_DIR2 = join2(homedir2(), ".faux");
10632
10632
  var CACHE_PATH = join2(FAUX_DIR2, "tool-cache.json");
10633
- var API_BASE = "https://mcp.faux.design";
10633
+ var API_BASE = process.env.FAUX_API_URL || "https://api.faux.design";
10634
10634
  async function loadCachedTools() {
10635
10635
  try {
10636
10636
  const raw = await readFile2(CACHE_PATH, "utf-8");
@@ -10756,10 +10756,12 @@ var CdpClient = class {
10756
10756
  pending = /* @__PURE__ */ new Map();
10757
10757
  eventHandlers = /* @__PURE__ */ new Map();
10758
10758
  figmaContextId = null;
10759
+ _targetWsUrl = null;
10759
10760
  // -------------------------------------------------------------------------
10760
10761
  // Connection
10761
10762
  // -------------------------------------------------------------------------
10762
10763
  async connect(wsUrl) {
10764
+ this._targetWsUrl = wsUrl;
10763
10765
  return new Promise((resolve, reject) => {
10764
10766
  const ws = new wrapper_default(wsUrl);
10765
10767
  const timeout = setTimeout(() => {
@@ -10865,7 +10867,7 @@ var CdpClient = class {
10865
10867
  this.on("Runtime.executionContextCreated", handler);
10866
10868
  try {
10867
10869
  await this.send("Runtime.enable");
10868
- await new Promise((r) => setTimeout(r, 500));
10870
+ await new Promise((r) => setTimeout(r, 150));
10869
10871
  } finally {
10870
10872
  this.off("Runtime.executionContextCreated", handler);
10871
10873
  }
@@ -10928,6 +10930,15 @@ ${script}
10928
10930
  })()`);
10929
10931
  }
10930
10932
  // -------------------------------------------------------------------------
10933
+ // Reconnection
10934
+ // -------------------------------------------------------------------------
10935
+ /** Close current connection and reconnect to a different CDP target. */
10936
+ async reconnectToTarget(wsUrl) {
10937
+ this.close();
10938
+ await this.connect(wsUrl);
10939
+ await this.discoverFigmaContext();
10940
+ }
10941
+ // -------------------------------------------------------------------------
10931
10942
  // State
10932
10943
  // -------------------------------------------------------------------------
10933
10944
  get connected() {
@@ -10936,6 +10947,10 @@ ${script}
10936
10947
  get hasContext() {
10937
10948
  return this.figmaContextId !== null;
10938
10949
  }
10950
+ /** The WebSocket URL this client is currently connected to. */
10951
+ get targetWsUrl() {
10952
+ return this._targetWsUrl;
10953
+ }
10939
10954
  resetContext() {
10940
10955
  this.figmaContextId = null;
10941
10956
  }
@@ -10946,6 +10961,7 @@ ${script}
10946
10961
  this.ws?.close();
10947
10962
  this.ws = null;
10948
10963
  this.figmaContextId = null;
10964
+ this._targetWsUrl = null;
10949
10965
  }
10950
10966
  };
10951
10967
 
@@ -11057,10 +11073,20 @@ async function listTargets(port) {
11057
11073
  if (!res.ok) throw new Error(`Failed to list CDP targets (HTTP ${res.status})`);
11058
11074
  return await res.json();
11059
11075
  }
11060
- var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)\//;
11076
+ var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)\/([a-zA-Z0-9]+)/;
11061
11077
  function findFigmaDesignTarget(targets) {
11062
11078
  return targets.find((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)) || targets.find((t) => t.type === "page") || null;
11063
11079
  }
11080
+ function findShellTarget(targets) {
11081
+ return targets.find((t) => t.type === "page" && t.url?.includes("shell.html")) || null;
11082
+ }
11083
+ function listDesignTargets(targets) {
11084
+ return targets.filter((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)).map((t) => {
11085
+ const fileKey = t.url.match(FIGMA_DESIGN_URL_RE)[2];
11086
+ const fileName = t.title.replace(/\s[–—-]\s*Figma\s*$/u, "").trim();
11087
+ return { ...t, fileKey, fileName };
11088
+ });
11089
+ }
11064
11090
  async function probeCdpPorts() {
11065
11091
  for (const port of KNOWN_CDP_PORTS) {
11066
11092
  const { alive, isFigma } = await isCdpAlive(port);
@@ -11152,7 +11178,6 @@ async function ensureFigma() {
11152
11178
  }
11153
11179
 
11154
11180
  // src/plugin-ws.ts
11155
- import { execSync as execSync2 } from "child_process";
11156
11181
  import { randomUUID } from "crypto";
11157
11182
  import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
11158
11183
  import { join as join4 } from "path";
@@ -11192,8 +11217,6 @@ var PluginWsServer = class {
11192
11217
  // Server Lifecycle
11193
11218
  // -------------------------------------------------------------------------
11194
11219
  async start() {
11195
- this.killStaleInstances();
11196
- await new Promise((r) => setTimeout(r, 300));
11197
11220
  for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_RANGE; port++) {
11198
11221
  try {
11199
11222
  await this.listen(port);
@@ -11209,35 +11232,6 @@ var PluginWsServer = class {
11209
11232
  `No available port in range ${DEFAULT_PORT}-${DEFAULT_PORT + PORT_RANGE - 1}. Another faux-studio instance may be running.`
11210
11233
  );
11211
11234
  }
11212
- /**
11213
- * Kill any node processes listening on our port range that are running
11214
- * faux-studio. Uses OS-level detection — works regardless of the old
11215
- * instance's version or protocol support.
11216
- */
11217
- killStaleInstances() {
11218
- const myPid = process.pid;
11219
- for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_RANGE; port++) {
11220
- try {
11221
- let pid;
11222
- if (process.platform === "win32") {
11223
- const output = execSync2(
11224
- `netstat -ano | findstr "LISTENING" | findstr ":${port} "`,
11225
- { encoding: "utf-8" }
11226
- );
11227
- pid = output.trim().split(/\s+/).pop();
11228
- } else {
11229
- pid = execSync2(`lsof -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8" }).trim().split("\n")[0];
11230
- }
11231
- if (!pid || Number(pid) === myPid) continue;
11232
- const cmd = process.platform === "win32" ? execSync2(`wmic process where "ProcessId=${pid}" get CommandLine /format:list`, { encoding: "utf-8" }) : execSync2(`ps -p ${pid} -o command=`, { encoding: "utf-8" });
11233
- if (cmd.includes("faux-studio")) {
11234
- log(`Killing stale faux-studio on port ${port} (pid ${pid})`);
11235
- process.kill(Number(pid), "SIGTERM");
11236
- }
11237
- } catch {
11238
- }
11239
- }
11240
- }
11241
11235
  listen(port) {
11242
11236
  return new Promise((resolve, reject) => {
11243
11237
  const wss = new import_websocket_server.default({ port, host: "127.0.0.1" });
@@ -11470,6 +11464,229 @@ var PluginWsServer = class {
11470
11464
  }
11471
11465
  };
11472
11466
 
11467
+ // src/file-tracker.ts
11468
+ var FileChangedError = class extends Error {
11469
+ previousFileName;
11470
+ currentFileName;
11471
+ currentFileKey;
11472
+ allFiles;
11473
+ constructor(previousFileName, currentFileName, currentFileKey, allFiles) {
11474
+ super(`Active Figma file changed from "${previousFileName}" to "${currentFileName}".`);
11475
+ this.name = "FileChangedError";
11476
+ this.previousFileName = previousFileName;
11477
+ this.currentFileName = currentFileName;
11478
+ this.currentFileKey = currentFileKey;
11479
+ this.allFiles = allFiles;
11480
+ }
11481
+ };
11482
+ var FileUnknownError = class extends Error {
11483
+ allFiles;
11484
+ constructor(allFiles) {
11485
+ super("Cannot detect which Figma file is active. Please confirm the target file.");
11486
+ this.name = "FileUnknownError";
11487
+ this.allFiles = allFiles;
11488
+ }
11489
+ };
11490
+ var LAST_KNOWN_STALE_MS = 5 * 6e4;
11491
+ var SHELL_DETECT_TIMEOUT_MS = 500;
11492
+ var CdpFileTracker = class {
11493
+ cdpPort;
11494
+ shellWsUrl = null;
11495
+ _activeFileKey = null;
11496
+ _lastKnownFileKey = null;
11497
+ _lastKnownFileName = null;
11498
+ _lastAcknowledgedAt = 0;
11499
+ _files = /* @__PURE__ */ new Map();
11500
+ _lastDetectTime = 0;
11501
+ _cachedResult = null;
11502
+ constructor(cdpPort) {
11503
+ this.cdpPort = cdpPort;
11504
+ }
11505
+ // -------------------------------------------------------------------------
11506
+ // Public API
11507
+ // -------------------------------------------------------------------------
11508
+ /**
11509
+ * Detect the currently active file. Uses cached result if fresh.
11510
+ * @param cacheTtlMs How long to trust cached results (0 = always re-detect).
11511
+ */
11512
+ async detectActiveFile(cacheTtlMs = 2e3) {
11513
+ if (this._cachedResult && cacheTtlMs > 0 && Date.now() - this._lastDetectTime < cacheTtlMs) {
11514
+ return this._cachedResult;
11515
+ }
11516
+ const result = await this.detect();
11517
+ this._cachedResult = result;
11518
+ this._lastDetectTime = Date.now();
11519
+ if (result.activeFile) {
11520
+ this._activeFileKey = result.activeFile.fileKey;
11521
+ }
11522
+ return result;
11523
+ }
11524
+ /** List all open design files from /json/list. */
11525
+ async listOpenFiles() {
11526
+ try {
11527
+ const targets = await listTargets(this.cdpPort);
11528
+ return this.parseDesignTargets(targets);
11529
+ } catch {
11530
+ return Array.from(this._files.values());
11531
+ }
11532
+ }
11533
+ /** Get cached file info by fileKey. */
11534
+ getFileTarget(fileKey) {
11535
+ return this._files.get(fileKey) || null;
11536
+ }
11537
+ /** Whether the detected active file differs from the last acknowledged file. */
11538
+ hasFileChanged() {
11539
+ return this._activeFileKey !== null && this._lastKnownFileKey !== null && this._activeFileKey !== this._lastKnownFileKey;
11540
+ }
11541
+ /**
11542
+ * Acknowledge the current active file as the intended target.
11543
+ * Call AFTER successful execution (AR-3).
11544
+ */
11545
+ acknowledgeActiveFile() {
11546
+ if (this._activeFileKey) {
11547
+ this._lastKnownFileKey = this._activeFileKey;
11548
+ this._lastKnownFileName = this._files.get(this._activeFileKey)?.fileName ?? null;
11549
+ this._lastAcknowledgedAt = Date.now();
11550
+ }
11551
+ }
11552
+ get activeFileKey() {
11553
+ return this._activeFileKey;
11554
+ }
11555
+ get lastKnownFileKey() {
11556
+ return this._lastKnownFileKey;
11557
+ }
11558
+ get lastKnownFileName() {
11559
+ return this._lastKnownFileName;
11560
+ }
11561
+ get activeFileName() {
11562
+ return this._activeFileKey ? this._files.get(this._activeFileKey)?.fileName ?? null : null;
11563
+ }
11564
+ // -------------------------------------------------------------------------
11565
+ // Private: Core Detection
11566
+ // -------------------------------------------------------------------------
11567
+ async detect() {
11568
+ let targets;
11569
+ try {
11570
+ targets = await listTargets(this.cdpPort);
11571
+ if (!Array.isArray(targets)) targets = [];
11572
+ } catch {
11573
+ return this.fallbackToLastKnown([]);
11574
+ }
11575
+ const designFiles = this.parseDesignTargets(targets);
11576
+ this._files.clear();
11577
+ for (const f of designFiles) {
11578
+ this._files.set(f.fileKey, f);
11579
+ }
11580
+ if (designFiles.length === 0) {
11581
+ return { activeFile: null, allFiles: [], method: "none", confidence: "none" };
11582
+ }
11583
+ if (designFiles.length === 1) {
11584
+ this._activeFileKey = designFiles[0].fileKey;
11585
+ return { activeFile: designFiles[0], allFiles: designFiles, method: "single-file", confidence: "high" };
11586
+ }
11587
+ const shellTabName = await this.detectViaShellTabBar(targets);
11588
+ if (shellTabName) {
11589
+ const matched = this.matchTabNameToFile(shellTabName, designFiles);
11590
+ if (matched) {
11591
+ this._activeFileKey = matched.fileKey;
11592
+ log(`File tracker: active file "${matched.fileName}" (${matched.fileKey}) via shell tab bar`);
11593
+ return { activeFile: matched, allFiles: designFiles, method: "shell-tab-bar", confidence: "high" };
11594
+ }
11595
+ }
11596
+ return this.fallbackToLastKnown(designFiles);
11597
+ }
11598
+ // -------------------------------------------------------------------------
11599
+ // Private: Shell.html Tab Bar Detection (PRIMARY)
11600
+ // -------------------------------------------------------------------------
11601
+ /**
11602
+ * Read the active tab name from Figma's shell.html tab bar.
11603
+ * Security: uses a fixed literal expression — never interpolate user input (SR-1).
11604
+ */
11605
+ async detectViaShellTabBar(targets) {
11606
+ if (!this.shellWsUrl) {
11607
+ const shell = findShellTarget(targets);
11608
+ if (!shell?.webSocketDebuggerUrl) return null;
11609
+ if (!shell.webSocketDebuggerUrl.startsWith("ws://127.0.0.1:")) return null;
11610
+ this.shellWsUrl = shell.webSocketDebuggerUrl;
11611
+ }
11612
+ const client = new CdpClient();
11613
+ try {
11614
+ const connectPromise = client.connect(this.shellWsUrl);
11615
+ const timeoutPromise = new Promise(
11616
+ (_, reject) => setTimeout(() => reject(new Error("shell detect timeout")), SHELL_DETECT_TIMEOUT_MS)
11617
+ );
11618
+ await Promise.race([connectPromise, timeoutPromise]);
11619
+ const result = await client.send("Runtime.evaluate", {
11620
+ expression: `document.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute('aria-label')`,
11621
+ returnByValue: true
11622
+ });
11623
+ const raw = result?.result?.value;
11624
+ return this.parseTabNameFromCdpResult(raw);
11625
+ } catch {
11626
+ this.shellWsUrl = null;
11627
+ return null;
11628
+ } finally {
11629
+ client.close();
11630
+ }
11631
+ }
11632
+ // -------------------------------------------------------------------------
11633
+ // Private: Fallback — Last Known
11634
+ // -------------------------------------------------------------------------
11635
+ fallbackToLastKnown(designFiles) {
11636
+ if (this._lastKnownFileKey && !this.isLastKnownStale()) {
11637
+ const existing = designFiles.find((f) => f.fileKey === this._lastKnownFileKey);
11638
+ if (existing) {
11639
+ this._activeFileKey = existing.fileKey;
11640
+ log(`File tracker: using last-known file "${existing.fileName}" (low confidence)`);
11641
+ return { activeFile: existing, allFiles: designFiles, method: "last-known", confidence: "low" };
11642
+ }
11643
+ }
11644
+ log("File tracker: cannot detect active file \u2014 shell detection failed and last-known is stale or missing");
11645
+ return { activeFile: null, allFiles: designFiles, method: "none", confidence: "none" };
11646
+ }
11647
+ /** True if the last acknowledged execution was more than 5 minutes ago. */
11648
+ isLastKnownStale() {
11649
+ if (this._lastAcknowledgedAt === 0) return false;
11650
+ return Date.now() - this._lastAcknowledgedAt > LAST_KNOWN_STALE_MS;
11651
+ }
11652
+ // -------------------------------------------------------------------------
11653
+ // Private: Matching & Validation
11654
+ // -------------------------------------------------------------------------
11655
+ /**
11656
+ * Match a tab name from shell.html against design file targets.
11657
+ * Returns null if ambiguous (multiple files with same name) — AR-9.
11658
+ */
11659
+ matchTabNameToFile(tabName, designFiles) {
11660
+ const matches = designFiles.filter((f) => f.fileName === tabName);
11661
+ if (matches.length === 1) return matches[0];
11662
+ if (matches.length > 1) {
11663
+ warn(`File tracker: ${matches.length} files named "${tabName}" \u2014 cannot disambiguate`);
11664
+ return null;
11665
+ }
11666
+ return null;
11667
+ }
11668
+ /**
11669
+ * Validate and clean the CDP result from shell.html evaluation (SR-3).
11670
+ * Rejects non-string, empty, oversized, or control-character values.
11671
+ */
11672
+ parseTabNameFromCdpResult(raw) {
11673
+ if (typeof raw !== "string") return null;
11674
+ const trimmed = raw.trim();
11675
+ if (trimmed.length === 0 || trimmed.length > 512) return null;
11676
+ if (/[\x00-\x1f\x7f]/.test(trimmed)) return null;
11677
+ return trimmed;
11678
+ }
11679
+ /** Parse design targets from /json/list into FigmaFileInfo[]. */
11680
+ parseDesignTargets(targets) {
11681
+ return listDesignTargets(targets).map((t) => ({
11682
+ fileKey: t.fileKey,
11683
+ fileName: t.fileName,
11684
+ targetId: t.id,
11685
+ wsUrl: t.webSocketDebuggerUrl
11686
+ }));
11687
+ }
11688
+ };
11689
+
11473
11690
  // node_modules/zod/v3/helpers/util.js
11474
11691
  var util;
11475
11692
  (function(util2) {
@@ -25602,7 +25819,7 @@ Resources provide quick read-only access to Figma state without tool calls:
25602
25819
  - Create components for reusable UI patterns.`;
25603
25820
  function createMcpServer(deps) {
25604
25821
  const server2 = new Server(
25605
- { name: "faux-studio", version: "0.3.11" },
25822
+ { name: "faux-studio", version: "0.3.12" },
25606
25823
  {
25607
25824
  capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
25608
25825
  instructions: INSTRUCTIONS
@@ -25617,7 +25834,7 @@ function createMcpServer(deps) {
25617
25834
  },
25618
25835
  {
25619
25836
  name: "setup_figma",
25620
- description: "Ensure Figma Desktop is running and the plugin is connected. Call this before any design work. Launches Figma if needed, waits for the plugin to connect, and provides setup guidance. Idempotent \u2014 safe to call multiple times. Returns connection status and active file info.",
25837
+ description: "Ensure Figma Desktop is running and connected. Call this before any design work. Launches Figma if needed, detects open files and the active tab, and provides setup guidance. Returns connection status, active file, and list of all open design files. Idempotent \u2014 safe to call multiple times.",
25621
25838
  inputSchema: {
25622
25839
  type: "object",
25623
25840
  properties: {},
@@ -25707,7 +25924,10 @@ function createMcpServer(deps) {
25707
25924
  macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
25708
25925
  };
25709
25926
  const script = await deps.generateScript(name, params);
25710
- const result = await deps.executeScript(script, intents);
25927
+ const annotation = TOOL_ANNOTATIONS[name];
25928
+ const isReadOnly = annotation?.readOnlyHint === true;
25929
+ const cacheTtlMs = isReadOnly ? 2e3 : 0;
25930
+ const result = await deps.executeScript(script, intents, { cacheTtlMs });
25711
25931
  const imageData = extractImageData(result);
25712
25932
  if (imageData) {
25713
25933
  const content = [
@@ -25723,6 +25943,37 @@ function createMcpServer(deps) {
25723
25943
  content: [{ type: "text", text }]
25724
25944
  };
25725
25945
  } catch (err) {
25946
+ if (err instanceof FileChangedError) {
25947
+ const annotation = TOOL_ANNOTATIONS[name];
25948
+ const isReadOnly = annotation?.readOnlyHint === true;
25949
+ const payload = JSON.stringify({
25950
+ status: "confirmation_required",
25951
+ reason: "ACTIVE_FILE_CHANGED",
25952
+ previousFile: err.previousFileName,
25953
+ currentFile: err.currentFileName,
25954
+ currentFileKey: err.currentFileKey,
25955
+ message: `The active Figma file changed from "${err.previousFileName}" to "${err.currentFileName}". Please confirm which file you want to work on.`,
25956
+ availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
25957
+ }, null, 2);
25958
+ if (isReadOnly) {
25959
+ return { content: [{ type: "text", text: payload }] };
25960
+ }
25961
+ return { content: [{ type: "text", text: payload }], isError: true };
25962
+ }
25963
+ if (err instanceof FileUnknownError) {
25964
+ return {
25965
+ content: [{
25966
+ type: "text",
25967
+ text: JSON.stringify({
25968
+ status: "confirmation_required",
25969
+ reason: "ACTIVE_FILE_UNKNOWN",
25970
+ message: "Cannot detect which Figma file is active. Please confirm the target file.",
25971
+ availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
25972
+ }, null, 2)
25973
+ }],
25974
+ isError: true
25975
+ };
25976
+ }
25726
25977
  const message = err instanceof Error ? err.message : String(err);
25727
25978
  const isAuthError = message.includes("auth") || message.includes("401") || message.includes("AUTH_EXPIRED");
25728
25979
  return {
@@ -25772,6 +26023,7 @@ async function startServer(server2) {
25772
26023
  // src/index.ts
25773
26024
  var auth;
25774
26025
  var cdpClient = null;
26026
+ var fileTracker = null;
25775
26027
  var pluginServer = new PluginWsServer();
25776
26028
  var forceTransport = process.env.FAUX_TRANSPORT;
25777
26029
  async function tryConnectCdp() {
@@ -25779,20 +26031,23 @@ async function tryConnectCdp() {
25779
26031
  if (cdpClient?.connected && cdpClient.hasContext) return cdpClient;
25780
26032
  cdpClient?.close();
25781
26033
  cdpClient = null;
25782
- const { targets } = await ensureFigma();
25783
- const target = findFigmaDesignTarget(targets);
26034
+ const connection = await ensureFigma();
26035
+ const target = findFigmaDesignTarget(connection.targets);
25784
26036
  if (!target) return null;
25785
26037
  const client = new CdpClient();
25786
26038
  await client.connect(target.webSocketDebuggerUrl);
25787
26039
  await client.discoverFigmaContext();
25788
26040
  log(`CDP connected: ${target.title}`);
25789
26041
  cdpClient = client;
26042
+ if (!fileTracker) {
26043
+ fileTracker = new CdpFileTracker(connection.port);
26044
+ }
25790
26045
  return client;
25791
26046
  } catch {
25792
26047
  return null;
25793
26048
  }
25794
26049
  }
25795
- async function executeScript(script, intents) {
26050
+ async function executeScript(script, intents, opts) {
25796
26051
  if (forceTransport !== "cdp" && pluginServer.hasConnections) {
25797
26052
  log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
25798
26053
  return pluginServer.executeScript(script, void 0, intents);
@@ -25800,12 +26055,34 @@ async function executeScript(script, intents) {
25800
26055
  if (forceTransport !== "plugin") {
25801
26056
  const client = await tryConnectCdp();
25802
26057
  if (client) {
26058
+ if (fileTracker) {
26059
+ const detection = await fileTracker.detectActiveFile(opts?.cacheTtlMs);
26060
+ if (!detection.activeFile && detection.allFiles.length > 1) {
26061
+ throw new FileUnknownError(detection.allFiles);
26062
+ }
26063
+ if (fileTracker.hasFileChanged()) {
26064
+ throw new FileChangedError(
26065
+ fileTracker.lastKnownFileName,
26066
+ fileTracker.activeFileName,
26067
+ fileTracker.activeFileKey,
26068
+ detection.allFiles
26069
+ );
26070
+ }
26071
+ if (detection.activeFile && client.targetWsUrl !== detection.activeFile.wsUrl) {
26072
+ log(`Switching CDP target to "${detection.activeFile.fileName}" (${detection.activeFile.fileKey})`);
26073
+ await client.reconnectToTarget(detection.activeFile.wsUrl);
26074
+ }
26075
+ }
25803
26076
  log("Executing via CDP");
25804
26077
  try {
25805
- return await client.executeScript(script);
26078
+ const result = await client.executeScript(script);
26079
+ fileTracker?.acknowledgeActiveFile();
26080
+ return result;
25806
26081
  } catch (err) {
25807
26082
  if (err instanceof ContextDestroyedError) {
25808
- return await recoverCdp(client, script);
26083
+ const result = await recoverCdp(client, script);
26084
+ fileTracker?.acknowledgeActiveFile();
26085
+ return result;
25809
26086
  }
25810
26087
  throw err;
25811
26088
  }
@@ -25873,6 +26150,16 @@ async function setupFigma(params) {
25873
26150
  return pluginReadyResult();
25874
26151
  }
25875
26152
  if (forceTransport !== "plugin" && cdpClient?.connected && cdpClient.hasContext) {
26153
+ if (fileTracker) {
26154
+ const detection = await fileTracker.detectActiveFile();
26155
+ return {
26156
+ status: "ready",
26157
+ transport: "cdp",
26158
+ message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? "unknown"}". ${detection.allFiles.length} file(s) open. Ready to design.`,
26159
+ activeFile: detection.activeFile?.fileName,
26160
+ openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
26161
+ };
26162
+ }
25876
26163
  return {
25877
26164
  status: "ready",
25878
26165
  transport: "cdp",
@@ -25892,11 +26179,17 @@ async function setupFigma(params) {
25892
26179
  await client.discoverFigmaContext();
25893
26180
  cdpClient = client;
25894
26181
  log(`CDP connected: ${target.title}`);
26182
+ if (!fileTracker) {
26183
+ fileTracker = new CdpFileTracker(existing.port);
26184
+ }
26185
+ const detection = await fileTracker.detectActiveFile();
26186
+ fileTracker.acknowledgeActiveFile();
25895
26187
  return {
25896
26188
  status: "ready",
25897
26189
  transport: "cdp",
25898
- message: `Connected via CDP. Active file: ${target.title}. Ready to design.`,
25899
- activeFile: target.title,
26190
+ message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
26191
+ activeFile: detection.activeFile?.fileName ?? target.title,
26192
+ openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
25900
26193
  port: existing.port
25901
26194
  };
25902
26195
  } catch {
@@ -26032,10 +26325,31 @@ async function main() {
26032
26325
  function shutdown() {
26033
26326
  pluginServer.close();
26034
26327
  cdpClient?.close();
26328
+ fileTracker = null;
26035
26329
  process.exit(0);
26036
26330
  }
26037
26331
  process.on("SIGINT", shutdown);
26038
26332
  process.on("SIGTERM", shutdown);
26333
+ var ORPHAN_CHECK_MS = 5e3;
26334
+ var originalPpid = process.ppid;
26335
+ function isParentAlive() {
26336
+ try {
26337
+ process.kill(originalPpid, 0);
26338
+ return true;
26339
+ } catch {
26340
+ return false;
26341
+ }
26342
+ }
26343
+ process.stdin.on("end", () => {
26344
+ log("stdin closed \u2014 parent process gone, shutting down");
26345
+ shutdown();
26346
+ });
26347
+ setInterval(() => {
26348
+ if (!isParentAlive()) {
26349
+ log(`Parent process (PID ${originalPpid}) gone \u2014 shutting down`);
26350
+ shutdown();
26351
+ }
26352
+ }, ORPHAN_CHECK_MS).unref();
26039
26353
  main().catch((err) => {
26040
26354
  error(err instanceof Error ? err.message : String(err));
26041
26355
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faux-studio",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
4
4
  "description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "build": "tsup",
17
+ "build:staging": "tsup --env.FAUX_API_URL https://staging-api.faux.design --env.FAUX_AUTH_URL https://staging-auth.faux.design",
17
18
  "build:plugin": "cd plugin && npm run build",
18
19
  "build:all": "npm run build && npm run build:plugin",
19
20
  "dev": "tsup --watch",