stashes 0.1.26 → 0.1.28

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/dist/mcp.js CHANGED
@@ -26,10 +26,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
26
26
  import { z } from "zod";
27
27
 
28
28
  // ../core/dist/generation.js
29
- import { readFileSync as readFileSync2, existsSync as existsSync5 } from "fs";
30
- import { join as join5 } from "path";
29
+ import { readFileSync as readFileSync2, existsSync as existsSync6 } from "fs";
30
+ import { join as join6 } from "path";
31
31
  var {spawn: spawn3 } = globalThis.Bun;
32
- import simpleGit2 from "simple-git";
32
+ import simpleGit3 from "simple-git";
33
33
  // ../shared/dist/constants/index.js
34
34
  var STASHES_PORT = 4000;
35
35
  var DEFAULT_STASH_COUNT = 3;
@@ -424,6 +424,14 @@ class PersistenceService {
424
424
  const filePath = join3(this.basePath, "projects", projectId, "stashes.json");
425
425
  writeJson(filePath, stashes);
426
426
  }
427
+ getProjectSettings(projectId) {
428
+ const filePath = join3(this.basePath, "projects", projectId, "settings.json");
429
+ return readJson(filePath, {});
430
+ }
431
+ saveProjectSettings(projectId, settings) {
432
+ const filePath = join3(this.basePath, "projects", projectId, "settings.json");
433
+ writeJson(filePath, settings);
434
+ }
427
435
  listChats(projectId) {
428
436
  const dir = join3(this.basePath, "projects", projectId, "chats");
429
437
  if (!existsSync3(dir))
@@ -504,18 +512,22 @@ class PersistenceService {
504
512
  var {spawn } = globalThis.Bun;
505
513
  var CLAUDE_BIN = "/opt/homebrew/bin/claude";
506
514
  var processes = new Map;
507
- function startAiProcess(id, prompt, cwd, resumeSessionId) {
515
+ function startAiProcess(id, prompt, cwd, resumeSessionId, model) {
508
516
  killAiProcess(id);
509
517
  logger.info("claude", `spawning process: ${id}`, {
510
518
  cwd,
511
519
  promptLength: prompt.length,
512
520
  promptPreview: prompt.substring(0, 100),
513
- resumeSessionId
521
+ resumeSessionId,
522
+ model
514
523
  });
515
524
  const cmd = [CLAUDE_BIN, "-p", prompt, "--output-format=stream-json", "--verbose", "--dangerously-skip-permissions"];
516
525
  if (resumeSessionId) {
517
526
  cmd.push("--resume", resumeSessionId);
518
527
  }
528
+ if (model) {
529
+ cmd.push("--model", model);
530
+ }
519
531
  const proc = spawn({
520
532
  cmd,
521
533
  stdin: "ignore",
@@ -639,6 +651,11 @@ async function* parseClaudeStream(proc) {
639
651
  }
640
652
  }
641
653
 
654
+ // ../core/dist/smart-screenshot.js
655
+ import { join as join5 } from "path";
656
+ import { mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
657
+ import simpleGit2 from "simple-git";
658
+
642
659
  // ../core/dist/screenshot.js
643
660
  var {spawn: spawn2 } = globalThis.Bun;
644
661
  import { join as join4 } from "path";
@@ -681,6 +698,171 @@ async function captureScreenshot(port, projectPath, stashId) {
681
698
  return `/api/screenshots/${filename}`;
682
699
  }
683
700
 
701
+ // ../core/dist/smart-screenshot.js
702
+ var SCREENSHOTS_DIR2 = ".stashes/screenshots";
703
+ var DEFAULT_TIMEOUT = 120000;
704
+ var DIFF_MAX_CHARS = 1e4;
705
+ async function getStashDiff(worktreePath, parentBranch) {
706
+ const git = simpleGit2(worktreePath);
707
+ try {
708
+ const diff = await git.diff([parentBranch, "HEAD"]);
709
+ if (diff.length <= DIFF_MAX_CHARS)
710
+ return diff;
711
+ const diffStat = await git.diff([parentBranch, "HEAD", "--stat"]);
712
+ const truncatedDiff = diff.substring(0, DIFF_MAX_CHARS);
713
+ return `## Changed files:
714
+ ${diffStat}
715
+
716
+ ## Partial diff (truncated):
717
+ ${truncatedDiff}`;
718
+ } catch (err) {
719
+ logger.warn("smart-screenshot", `git diff failed, falling back`, {
720
+ error: err instanceof Error ? err.message : String(err)
721
+ });
722
+ return "";
723
+ }
724
+ }
725
+ function buildScreenshotPrompt(port, diff, screenshotDir, stashId) {
726
+ return [
727
+ "You are a screenshot assistant. A developer made UI changes to a web app.",
728
+ "Your job: navigate the running app, find the pages affected by the changes, and take screenshots.",
729
+ "",
730
+ `## The app is running at: http://localhost:${port}`,
731
+ "",
732
+ "## Git diff of changes:",
733
+ "```",
734
+ diff,
735
+ "```",
736
+ "",
737
+ "## Instructions:",
738
+ "1. Analyze the diff to identify which components/pages changed",
739
+ "2. Determine the URL routes where these changes are visible",
740
+ "3. For each route:",
741
+ " - Navigate to it using browser_navigate",
742
+ " - If the change is behind an interaction (tab, modal, accordion), perform that interaction",
743
+ " - Wait for the page to settle",
744
+ " - Take a screenshot using browser_take_screenshot",
745
+ "4. Decide which screenshot shows the MOST visually significant change \u2014 mark it as primary",
746
+ "",
747
+ `## Screenshot save paths:`,
748
+ `- Primary: ${join5(screenshotDir, `${stashId}.png`)}`,
749
+ `- Additional: ${join5(screenshotDir, `${stashId}-1.png`)}, ${join5(screenshotDir, `${stashId}-2.png`)}, etc.`,
750
+ "",
751
+ "## Output format (after all screenshots are taken):",
752
+ "Respond with ONLY this JSON (no markdown fences):",
753
+ "{",
754
+ ' "screenshots": [',
755
+ " {",
756
+ ` "path": "${join5(screenshotDir, `${stashId}.png`)}",`,
757
+ ' "label": "Short description of what is shown",',
758
+ ' "route": "/the-url-path",',
759
+ ' "isPrimary": true',
760
+ " }",
761
+ " ]",
762
+ "}"
763
+ ].join(`
764
+ `);
765
+ }
766
+ function parseAiResult(text) {
767
+ try {
768
+ return JSON.parse(text);
769
+ } catch {
770
+ const jsonMatch = text.match(/\{[\s\S]*"screenshots"[\s\S]*\}/);
771
+ if (jsonMatch) {
772
+ try {
773
+ return JSON.parse(jsonMatch[0]);
774
+ } catch {
775
+ return null;
776
+ }
777
+ }
778
+ return null;
779
+ }
780
+ }
781
+ async function captureSmartScreenshots(opts) {
782
+ const { projectPath, stashId, stashBranch, parentBranch, worktreePath, port, model = "haiku", timeout = DEFAULT_TIMEOUT } = opts;
783
+ const screenshotDir = join5(projectPath, SCREENSHOTS_DIR2);
784
+ if (!existsSync5(screenshotDir)) {
785
+ mkdirSync4(screenshotDir, { recursive: true });
786
+ }
787
+ const diff = await getStashDiff(worktreePath, parentBranch);
788
+ if (!diff) {
789
+ logger.info("smart-screenshot", `No diff found for ${stashId}, using simple screenshot`);
790
+ const url = await captureScreenshot(port, projectPath, stashId);
791
+ return {
792
+ primary: url,
793
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
794
+ };
795
+ }
796
+ const processId = `screenshot-ai-${stashId}`;
797
+ const prompt = buildScreenshotPrompt(port, diff, screenshotDir, stashId);
798
+ const modelFlag = model === "sonnet" ? "sonnet" : "haiku";
799
+ const aiProcess = startAiProcess(processId, prompt, worktreePath, undefined, modelFlag);
800
+ let textOutput = "";
801
+ let timedOut = false;
802
+ const timeoutId = setTimeout(() => {
803
+ timedOut = true;
804
+ killAiProcess(processId);
805
+ }, timeout);
806
+ try {
807
+ for await (const chunk of parseClaudeStream(aiProcess.process)) {
808
+ if (chunk.type === "text") {
809
+ textOutput += chunk.content;
810
+ }
811
+ }
812
+ await aiProcess.process.exited;
813
+ } catch (err) {
814
+ logger.warn("smart-screenshot", `AI process error for ${stashId}`, {
815
+ error: err instanceof Error ? err.message : String(err),
816
+ timedOut
817
+ });
818
+ } finally {
819
+ clearTimeout(timeoutId);
820
+ killAiProcess(processId);
821
+ }
822
+ const result = parseAiResult(textOutput);
823
+ if (!result || !result.screenshots || result.screenshots.length === 0) {
824
+ logger.info("smart-screenshot", `AI returned no screenshots for ${stashId}, falling back`);
825
+ const url = await captureScreenshot(port, projectPath, stashId);
826
+ return {
827
+ primary: url,
828
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
829
+ };
830
+ }
831
+ const screenshots = [];
832
+ let primaryUrl = "";
833
+ for (const shot of result.screenshots) {
834
+ const filename = shot.path.split("/").pop() || "";
835
+ if (!existsSync5(shot.path)) {
836
+ logger.warn("smart-screenshot", `Screenshot file not found: ${shot.path}`);
837
+ continue;
838
+ }
839
+ const url = `/api/screenshots/${filename}`;
840
+ const screenshot = {
841
+ url,
842
+ label: shot.label,
843
+ route: shot.route,
844
+ isPrimary: shot.isPrimary
845
+ };
846
+ screenshots.push(screenshot);
847
+ if (shot.isPrimary)
848
+ primaryUrl = url;
849
+ }
850
+ if (screenshots.length === 0) {
851
+ logger.info("smart-screenshot", `No valid screenshots for ${stashId}, falling back`);
852
+ const url = await captureScreenshot(port, projectPath, stashId);
853
+ return {
854
+ primary: url,
855
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
856
+ };
857
+ }
858
+ if (!primaryUrl) {
859
+ primaryUrl = screenshots[0].url;
860
+ screenshots[0] = { ...screenshots[0], isPrimary: true };
861
+ }
862
+ logger.info("smart-screenshot", `Captured ${screenshots.length} smart screenshots for ${stashId}`);
863
+ return { primary: primaryUrl, screenshots };
864
+ }
865
+
684
866
  // ../core/dist/prompt.js
685
867
  function buildStashPrompt(component, sourceCode, userPrompt, directive) {
686
868
  const parts = [
@@ -731,23 +913,6 @@ async function waitForPort(port, timeout) {
731
913
  }
732
914
  throw new Error(`Port ${port} not ready within ${timeout}ms`);
733
915
  }
734
- async function captureEphemeralScreenshot(worktreePath, projectPath, stashId, port) {
735
- const devServer = spawn3({
736
- cmd: ["npm", "run", "dev"],
737
- cwd: worktreePath,
738
- stdin: "ignore",
739
- stdout: "pipe",
740
- stderr: "pipe",
741
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
742
- });
743
- try {
744
- await waitForPort(port, 60000);
745
- await new Promise((r) => setTimeout(r, 2000));
746
- return await captureScreenshot(port, projectPath, stashId);
747
- } finally {
748
- devServer.kill();
749
- }
750
- }
751
916
  async function allocatePort() {
752
917
  for (let port = 4010;port <= 4030; port++) {
753
918
  try {
@@ -765,8 +930,8 @@ async function generate(opts) {
765
930
  const selectedDirectives = directives.slice(0, count);
766
931
  let sourceCode = "";
767
932
  if (component?.filePath) {
768
- const sourceFile = join5(projectPath, component.filePath);
769
- if (existsSync5(sourceFile)) {
933
+ const sourceFile = join6(projectPath, component.filePath);
934
+ if (existsSync6(sourceFile)) {
770
935
  sourceCode = readFileSync2(sourceFile, "utf-8");
771
936
  }
772
937
  }
@@ -788,6 +953,7 @@ async function generate(opts) {
788
953
  worktreePath: worktree.path,
789
954
  port: null,
790
955
  screenshotUrl: null,
956
+ screenshots: [],
791
957
  status: "generating",
792
958
  error: null,
793
959
  relatedTo: [],
@@ -812,7 +978,7 @@ async function generate(opts) {
812
978
  });
813
979
  }
814
980
  await aiProcess.process.exited;
815
- const wtGit = simpleGit2(worktree.path);
981
+ const wtGit = simpleGit3(worktree.path);
816
982
  try {
817
983
  await wtGit.add("-A");
818
984
  await wtGit.commit(`stashes: stash ${stashId}`);
@@ -835,15 +1001,40 @@ async function generate(opts) {
835
1001
  try {
836
1002
  const port = await allocatePort();
837
1003
  const worktree = await worktreeManager.createForGeneration(`screenshot-${stash.id}`);
838
- const screenshotGit = simpleGit2(worktree.path);
1004
+ const screenshotGit = simpleGit3(worktree.path);
839
1005
  await screenshotGit.checkout(["-f", stash.branch]);
840
- const screenshotPath = await captureEphemeralScreenshot(worktree.path, projectPath, stash.id, port);
1006
+ const devServer = spawn3({
1007
+ cmd: ["npm", "run", "dev"],
1008
+ cwd: worktree.path,
1009
+ stdin: "ignore",
1010
+ stdout: "pipe",
1011
+ stderr: "pipe",
1012
+ env: { ...process.env, PORT: String(port), BROWSER: "none" }
1013
+ });
1014
+ try {
1015
+ await waitForPort(port, 60000);
1016
+ await new Promise((r) => setTimeout(r, 2000));
1017
+ const mainGit = simpleGit3(projectPath);
1018
+ const parentBranch = (await mainGit.revparse(["HEAD"])).trim();
1019
+ const { primary, screenshots } = await captureSmartScreenshots({
1020
+ projectPath,
1021
+ stashId: stash.id,
1022
+ stashBranch: stash.branch,
1023
+ parentBranch,
1024
+ worktreePath: worktree.path,
1025
+ port,
1026
+ model: opts.screenshotModel,
1027
+ timeout: opts.screenshotTimeout
1028
+ });
1029
+ const updatedStash = { ...stash, screenshotUrl: primary, screenshots };
1030
+ persistence.saveStash(updatedStash);
1031
+ const idx = completedStashes.indexOf(stash);
1032
+ completedStashes[idx] = updatedStash;
1033
+ emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath: primary });
1034
+ } finally {
1035
+ devServer.kill();
1036
+ }
841
1037
  await worktreeManager.removeGeneration(`screenshot-${stash.id}`);
842
- const updatedStash = { ...stash, screenshotUrl: screenshotPath };
843
- persistence.saveStash(updatedStash);
844
- const idx = completedStashes.indexOf(stash);
845
- completedStashes[idx] = updatedStash;
846
- emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath });
847
1038
  } catch (err) {
848
1039
  logger.error("generation", `screenshot failed for ${stash.id}`, {
849
1040
  error: err instanceof Error ? err.message : String(err)
@@ -855,7 +1046,7 @@ async function generate(opts) {
855
1046
  }
856
1047
  // ../core/dist/vary.js
857
1048
  var {spawn: spawn4 } = globalThis.Bun;
858
- import simpleGit3 from "simple-git";
1049
+ import simpleGit4 from "simple-git";
859
1050
  function emit2(onProgress, event) {
860
1051
  if (onProgress)
861
1052
  onProgress(event);
@@ -913,6 +1104,7 @@ async function vary(opts) {
913
1104
  worktreePath: worktree.path,
914
1105
  port: null,
915
1106
  screenshotUrl: null,
1107
+ screenshots: [],
916
1108
  status: "generating",
917
1109
  error: null,
918
1110
  relatedTo: [sourceStashId],
@@ -932,7 +1124,7 @@ async function vary(opts) {
932
1124
  });
933
1125
  }
934
1126
  await aiProcess.process.exited;
935
- const wtGit = simpleGit3(worktree.path);
1127
+ const wtGit = simpleGit4(worktree.path);
936
1128
  try {
937
1129
  await wtGit.add("-A");
938
1130
  await wtGit.commit(`stashes: vary ${stashId} from ${sourceStashId}`);
@@ -940,10 +1132,11 @@ async function vary(opts) {
940
1132
  await worktreeManager.removeGeneration(stashId);
941
1133
  emit2(onProgress, { type: "screenshotting", stashId });
942
1134
  let screenshotPath = "";
1135
+ let screenshots = [];
943
1136
  try {
944
1137
  const port = await allocatePort2();
945
1138
  const screenshotWorktree = await worktreeManager.createForGeneration(`screenshot-${stashId}`);
946
- const screenshotGit = simpleGit3(screenshotWorktree.path);
1139
+ const screenshotGit = simpleGit4(screenshotWorktree.path);
947
1140
  await screenshotGit.checkout(["-f", stash.branch]);
948
1141
  const devServer = spawn4({
949
1142
  cmd: ["npm", "run", "dev"],
@@ -956,7 +1149,18 @@ async function vary(opts) {
956
1149
  try {
957
1150
  await waitForPort2(port, 60000);
958
1151
  await new Promise((r) => setTimeout(r, 2000));
959
- screenshotPath = await captureScreenshot(port, projectPath, stashId);
1152
+ const result = await captureSmartScreenshots({
1153
+ projectPath,
1154
+ stashId,
1155
+ stashBranch: stash.branch,
1156
+ parentBranch: sourceStash.branch,
1157
+ worktreePath: screenshotWorktree.path,
1158
+ port,
1159
+ model: opts.screenshotModel,
1160
+ timeout: opts.screenshotTimeout
1161
+ });
1162
+ screenshotPath = result.primary;
1163
+ screenshots = [...result.screenshots];
960
1164
  } finally {
961
1165
  devServer.kill();
962
1166
  }
@@ -966,7 +1170,7 @@ async function vary(opts) {
966
1170
  error: err instanceof Error ? err.message : String(err)
967
1171
  });
968
1172
  }
969
- const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null };
1173
+ const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null, screenshots };
970
1174
  persistence.saveStash(readyStash);
971
1175
  emit2(onProgress, { type: "ready", stashId, screenshotPath });
972
1176
  return readyStash;
@@ -990,7 +1194,7 @@ async function apply(opts) {
990
1194
  logger.info("apply", `stash ${stashId} applied and worktrees cleaned up`);
991
1195
  }
992
1196
  // ../core/dist/manage.js
993
- import simpleGit4 from "simple-git";
1197
+ import simpleGit5 from "simple-git";
994
1198
  async function list(projectPath) {
995
1199
  const persistence = new PersistenceService(projectPath);
996
1200
  const projects = persistence.listProjects();
@@ -1011,7 +1215,7 @@ async function remove(projectPath, stashId) {
1011
1215
  }
1012
1216
  }
1013
1217
  try {
1014
- const git = simpleGit4(projectPath);
1218
+ const git = simpleGit5(projectPath);
1015
1219
  await git.raw(["branch", "-D", `stashes/${stashId}`]);
1016
1220
  } catch {}
1017
1221
  logger.info("manage", `removed stash: ${stashId}`);
@@ -1026,7 +1230,7 @@ async function show(projectPath, stashId) {
1026
1230
  }
1027
1231
  if (!found)
1028
1232
  return null;
1029
- const git = simpleGit4(projectPath);
1233
+ const git = simpleGit5(projectPath);
1030
1234
  const branch = found.branch || `stashes/${stashId}`;
1031
1235
  let diff = "";
1032
1236
  let files = [];
@@ -1223,14 +1427,14 @@ async function handleRemove(args, projectPath) {
1223
1427
  // ../server/dist/index.js
1224
1428
  import { Hono as Hono2 } from "hono";
1225
1429
  import { cors } from "hono/cors";
1226
- import { join as join8, dirname as dirname2 } from "path";
1430
+ import { join as join9, dirname as dirname2 } from "path";
1227
1431
  import { fileURLToPath } from "url";
1228
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
1432
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
1229
1433
 
1230
1434
  // ../server/dist/routes/api.js
1231
1435
  import { Hono } from "hono";
1232
- import { join as join6, basename } from "path";
1233
- import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
1436
+ import { join as join7, basename } from "path";
1437
+ import { existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
1234
1438
  var app = new Hono;
1235
1439
  app.get("/health", (c) => c.json({ status: "ok", service: "stashes" }));
1236
1440
  app.get("/projects", (c) => {
@@ -1311,8 +1515,8 @@ app.delete("/chats/:chatId", (c) => {
1311
1515
  });
1312
1516
  app.get("/screenshots/:filename", (c) => {
1313
1517
  const filename = c.req.param("filename");
1314
- const filePath = join6(serverState.projectPath, ".stashes", "screenshots", filename);
1315
- if (!existsSync6(filePath))
1518
+ const filePath = join7(serverState.projectPath, ".stashes", "screenshots", filename);
1519
+ if (!existsSync7(filePath))
1316
1520
  return c.json({ error: "Not found" }, 404);
1317
1521
  const content = readFileSync3(filePath);
1318
1522
  return new Response(content, {
@@ -1336,8 +1540,8 @@ function ensureProject(persistence) {
1336
1540
  var apiRoutes = app;
1337
1541
 
1338
1542
  // ../server/dist/services/stash-service.js
1339
- import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
1340
- import { join as join7 } from "path";
1543
+ import { readFileSync as readFileSync4, existsSync as existsSync8 } from "fs";
1544
+ import { join as join8 } from "path";
1341
1545
 
1342
1546
  // ../server/dist/services/preview-pool.js
1343
1547
  class PreviewPool {
@@ -1600,8 +1804,8 @@ class StashService {
1600
1804
  let sourceCode = "";
1601
1805
  const filePath = component?.filePath || "";
1602
1806
  if (filePath && filePath !== "auto-detect") {
1603
- const sourceFile = join7(this.projectPath, filePath);
1604
- if (existsSync7(sourceFile)) {
1807
+ const sourceFile = join8(this.projectPath, filePath);
1808
+ if (existsSync8(sourceFile)) {
1605
1809
  sourceCode = readFileSync4(sourceFile, "utf-8");
1606
1810
  }
1607
1811
  }
@@ -1751,6 +1955,7 @@ ${sourceCode.substring(0, 3000)}
1751
1955
  for (const msg of pendingMessages) {
1752
1956
  this.persistence.saveChatMessage(projectId, chatId, msg);
1753
1957
  }
1958
+ this.syncStashesFromDisk(projectId);
1754
1959
  } catch (err) {
1755
1960
  this.broadcast({
1756
1961
  type: "ai_stream",
@@ -1762,6 +1967,25 @@ ${sourceCode.substring(0, 3000)}
1762
1967
  killAiProcess("chat");
1763
1968
  }
1764
1969
  }
1970
+ syncStashesFromDisk(projectId) {
1971
+ const diskStashes = this.persistence.listStashes(projectId);
1972
+ for (const stash of diskStashes) {
1973
+ this.broadcast({
1974
+ type: "stash:status",
1975
+ stashId: stash.id,
1976
+ status: stash.status,
1977
+ number: stash.number
1978
+ });
1979
+ if (stash.screenshotUrl) {
1980
+ this.broadcast({
1981
+ type: "stash:screenshot",
1982
+ stashId: stash.id,
1983
+ url: stash.screenshotUrl,
1984
+ screenshots: stash.screenshots?.length ? [...stash.screenshots] : undefined
1985
+ });
1986
+ }
1987
+ }
1988
+ }
1765
1989
  progressToBroadcast(event) {
1766
1990
  switch (event.type) {
1767
1991
  case "generating":
@@ -1774,7 +1998,12 @@ ${sourceCode.substring(0, 3000)}
1774
1998
  ..."number" in event ? { number: event.number } : {}
1775
1999
  });
1776
2000
  if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
1777
- this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: event.screenshotPath });
2001
+ this.broadcast({
2002
+ type: "stash:screenshot",
2003
+ stashId: event.stashId,
2004
+ url: event.screenshotPath,
2005
+ screenshots: "screenshots" in event ? event.screenshots : undefined
2006
+ });
1778
2007
  }
1779
2008
  break;
1780
2009
  case "error":
@@ -1788,6 +2017,7 @@ ${sourceCode.substring(0, 3000)}
1788
2017
  }
1789
2018
  }
1790
2019
  async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
2020
+ const settings = this.persistence.getProjectSettings(projectId);
1791
2021
  let enrichedPrompt = prompt;
1792
2022
  if (referenceStashIds?.length) {
1793
2023
  const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
@@ -1805,14 +2035,27 @@ ${refDescriptions.join(`
1805
2035
  prompt: enrichedPrompt,
1806
2036
  component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
1807
2037
  count: stashCount,
2038
+ screenshotModel: settings.screenshotModel,
2039
+ screenshotTimeout: settings.screenshotTimeout,
1808
2040
  onProgress: (event) => this.progressToBroadcast(event)
1809
2041
  });
1810
2042
  }
1811
2043
  async vary(sourceStashId, prompt) {
2044
+ let projectId = "";
2045
+ for (const project of this.persistence.listProjects()) {
2046
+ const stashes = this.persistence.listStashes(project.id);
2047
+ if (stashes.find((s) => s.id === sourceStashId)) {
2048
+ projectId = project.id;
2049
+ break;
2050
+ }
2051
+ }
2052
+ const settings = projectId ? this.persistence.getProjectSettings(projectId) : {};
1812
2053
  await vary({
1813
2054
  projectPath: this.projectPath,
1814
2055
  sourceStashId,
1815
2056
  prompt,
2057
+ screenshotModel: settings.screenshotModel,
2058
+ screenshotTimeout: settings.screenshotTimeout,
1816
2059
  onProgress: (event) => this.progressToBroadcast(event)
1817
2060
  });
1818
2061
  }
@@ -1926,115 +2169,93 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1926
2169
  }
1927
2170
 
1928
2171
  // ../server/dist/services/app-proxy.js
2172
+ import { spawn as spawn5 } from "child_process";
1929
2173
  function startAppProxy(userDevPort, proxyPort, injectOverlay) {
1930
- const upstreamOrigin = `http://localhost:${userDevPort}`;
1931
- const proxyOrigin = `http://localhost:${proxyPort}`;
1932
- const server = Bun.serve({
1933
- port: proxyPort,
1934
- async fetch(req, server2) {
1935
- if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1936
- const url2 = new URL(req.url);
1937
- const success = server2.upgrade(req, {
1938
- data: {
1939
- path: url2.pathname + url2.search,
1940
- upstream: null,
1941
- ready: false,
1942
- buffer: []
1943
- }
1944
- });
1945
- return success ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
1946
- }
1947
- const url = new URL(req.url);
1948
- const targetUrl = `http://localhost:${userDevPort}${url.pathname}${url.search}`;
1949
- try {
1950
- const headers = new Headers;
1951
- for (const [key, value] of req.headers.entries()) {
1952
- if (!["host", "accept-encoding"].includes(key.toLowerCase())) {
1953
- headers.set(key, value);
1954
- }
1955
- }
1956
- headers.set("host", `localhost:${userDevPort}`);
1957
- const response = await fetch(targetUrl, {
1958
- method: req.method,
1959
- headers,
1960
- body: req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined,
1961
- redirect: "manual"
1962
- });
1963
- const contentType = response.headers.get("content-type") || "";
1964
- const respHeaders = new Headers(response.headers);
1965
- if (response.status >= 300 && response.status < 400) {
1966
- const location = respHeaders.get("location");
1967
- if (location?.startsWith(upstreamOrigin)) {
1968
- respHeaders.set("location", proxyOrigin + location.substring(upstreamOrigin.length));
1969
- }
1970
- }
1971
- if (contentType.includes("text/html")) {
1972
- const html = await response.text();
1973
- const injectedHtml = injectOverlay(html, userDevPort, proxyPort);
1974
- respHeaders.delete("content-encoding");
1975
- respHeaders.delete("content-length");
1976
- return new Response(injectedHtml, {
1977
- status: response.status,
1978
- headers: respHeaders
1979
- });
1980
- }
1981
- respHeaders.delete("content-encoding");
1982
- return new Response(response.body, {
1983
- status: response.status,
1984
- headers: respHeaders
1985
- });
1986
- } catch (err) {
1987
- return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1988
- status: 502,
1989
- headers: { "content-type": "application/json" }
1990
- });
1991
- }
1992
- },
1993
- websocket: {
1994
- open(ws) {
1995
- const { data } = ws;
1996
- const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
1997
- upstream.addEventListener("open", () => {
1998
- data.upstream = upstream;
1999
- data.ready = true;
2000
- for (const msg of data.buffer) {
2001
- upstream.send(msg);
2002
- }
2003
- data.buffer = [];
2004
- });
2005
- upstream.addEventListener("message", (event) => {
2006
- try {
2007
- ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
2008
- } catch {}
2009
- });
2010
- upstream.addEventListener("close", () => {
2011
- try {
2012
- ws.close();
2013
- } catch {}
2014
- });
2015
- upstream.addEventListener("error", () => {
2016
- try {
2017
- ws.close();
2018
- } catch {}
2019
- });
2020
- },
2021
- message(ws, msg) {
2022
- const { data } = ws;
2023
- if (data.ready && data.upstream) {
2024
- data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
2025
- } else {
2026
- data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
2174
+ const overlayScript = injectOverlay("", userDevPort, proxyPort);
2175
+ const overlayEscaped = JSON.stringify(overlayScript);
2176
+ const proxyScript = `
2177
+ const http = require('http');
2178
+ const net = require('net');
2179
+ const zlib = require('zlib');
2180
+ const UPSTREAM = ${userDevPort};
2181
+ const OVERLAY = ${overlayEscaped};
2182
+
2183
+ const server = http.createServer((clientReq, clientRes) => {
2184
+ const opts = {
2185
+ hostname: 'localhost',
2186
+ port: UPSTREAM,
2187
+ path: clientReq.url,
2188
+ method: clientReq.method,
2189
+ headers: clientReq.headers,
2190
+ };
2191
+ const proxyReq = http.request(opts, (proxyRes) => {
2192
+ const ct = proxyRes.headers['content-type'] || '';
2193
+ if (ct.includes('text/html')) {
2194
+ // Buffer HTML to inject overlay
2195
+ const chunks = [];
2196
+ proxyRes.on('data', c => chunks.push(c));
2197
+ proxyRes.on('end', () => {
2198
+ let html = Buffer.concat(chunks);
2199
+ const enc = proxyRes.headers['content-encoding'];
2200
+ // Decompress if needed
2201
+ if (enc === 'gzip') {
2202
+ try { html = zlib.gunzipSync(html); } catch {}
2203
+ } else if (enc === 'br') {
2204
+ try { html = zlib.brotliDecompressSync(html); } catch {}
2205
+ } else if (enc === 'deflate') {
2206
+ try { html = zlib.inflateSync(html); } catch {}
2027
2207
  }
2028
- },
2029
- close(ws) {
2030
- try {
2031
- ws.data.upstream?.close();
2032
- } catch {}
2033
- }
2208
+ const hdrs = { ...proxyRes.headers };
2209
+ delete hdrs['content-length'];
2210
+ delete hdrs['content-encoding'];
2211
+ delete hdrs['transfer-encoding'];
2212
+ clientRes.writeHead(proxyRes.statusCode, hdrs);
2213
+ clientRes.write(html);
2214
+ clientRes.end(OVERLAY);
2215
+ });
2216
+ } else {
2217
+ // Non-HTML: stream through unchanged
2218
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
2219
+ proxyRes.pipe(clientRes);
2034
2220
  }
2221
+ proxyRes.on('error', () => clientRes.end());
2035
2222
  });
2036
- logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
2037
- return server;
2223
+ proxyReq.on('error', () => { try { clientRes.writeHead(502); clientRes.end(); } catch {} });
2224
+ clientReq.pipe(proxyReq);
2225
+ });
2226
+
2227
+ // WebSocket upgrades: raw TCP pipe
2228
+ server.on('upgrade', (req, socket, head) => {
2229
+ const upstream = net.createConnection(UPSTREAM, 'localhost', () => {
2230
+ const lines = [req.method + ' ' + req.url + ' HTTP/1.1'];
2231
+ for (const [k, v] of Object.entries(req.headers)) {
2232
+ lines.push(k + ': ' + (Array.isArray(v) ? v.join(', ') : v));
2233
+ }
2234
+ upstream.write(lines.join('\\r\\n') + '\\r\\n\\r\\n');
2235
+ if (head.length) upstream.write(head);
2236
+ socket.pipe(upstream);
2237
+ upstream.pipe(socket);
2238
+ });
2239
+ upstream.on('error', () => socket.destroy());
2240
+ socket.on('error', () => upstream.destroy());
2241
+ });
2242
+
2243
+ server.listen(${proxyPort}, () => {
2244
+ if (process.send) process.send('ready');
2245
+ });
2246
+ `;
2247
+ const child = spawn5("node", ["-e", proxyScript], {
2248
+ stdio: ["ignore", "inherit", "inherit", "ipc"]
2249
+ });
2250
+ child.on("error", (err) => {
2251
+ logger.error("proxy", `Failed to start proxy: ${err.message}`);
2252
+ });
2253
+ child.on("message", (msg) => {
2254
+ if (msg === "ready") {
2255
+ logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
2256
+ }
2257
+ });
2258
+ return child;
2038
2259
  }
2039
2260
 
2040
2261
  // ../server/dist/index.js
@@ -2051,12 +2272,12 @@ app2.route("/api", apiRoutes);
2051
2272
  app2.get("/*", async (c) => {
2052
2273
  const path = c.req.path;
2053
2274
  const selfDir = dirname2(fileURLToPath(import.meta.url));
2054
- const bundledWebDir = join8(selfDir, "web");
2055
- const monorepoWebDir = join8(selfDir, "../../web/dist");
2056
- const webDistDir = existsSync8(join8(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
2275
+ const bundledWebDir = join9(selfDir, "web");
2276
+ const monorepoWebDir = join9(selfDir, "../../web/dist");
2277
+ const webDistDir = existsSync9(join9(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
2057
2278
  const requestPath = path === "/" ? "/index.html" : path;
2058
- const filePath = join8(webDistDir, requestPath);
2059
- if (existsSync8(filePath) && !filePath.includes("..")) {
2279
+ const filePath = join9(webDistDir, requestPath);
2280
+ if (existsSync9(filePath) && !filePath.includes("..")) {
2060
2281
  const content = readFileSync5(filePath);
2061
2282
  const ext = filePath.split(".").pop() || "";
2062
2283
  const contentTypes = {
@@ -2075,8 +2296,8 @@ app2.get("/*", async (c) => {
2075
2296
  headers: { "content-type": contentTypes[ext] || "application/octet-stream" }
2076
2297
  });
2077
2298
  }
2078
- const indexPath = join8(webDistDir, "index.html");
2079
- if (existsSync8(indexPath)) {
2299
+ const indexPath = join9(webDistDir, "index.html");
2300
+ if (existsSync9(indexPath)) {
2080
2301
  const html = readFileSync5(indexPath, "utf-8");
2081
2302
  return new Response(html, {
2082
2303
  headers: { "content-type": "text/html; charset=utf-8" }
@@ -2112,7 +2333,7 @@ function startServer(projectPath, userDevPort, port = STASHES_PORT) {
2112
2333
  logger.info("server", `Project: ${projectPath}`);
2113
2334
  return server;
2114
2335
  }
2115
- function injectOverlayScript(html, upstreamPort, proxyPort) {
2336
+ function injectOverlayScript(html, _upstreamPort, _proxyPort) {
2116
2337
  const overlayScript = `
2117
2338
  <script data-stashes-overlay>
2118
2339
  (function() {
@@ -2120,35 +2341,6 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
2120
2341
  var pickerEnabled = false;
2121
2342
  var precisionMode = false;
2122
2343
 
2123
- // Rewrite cross-origin requests to the upstream dev server through the proxy
2124
- var upstreamOrigin = 'http://localhost:${upstreamPort}';
2125
- var proxyOrigin = window.location.origin;
2126
- if (proxyOrigin !== upstreamOrigin) {
2127
- function rewriteUrl(url) {
2128
- if (typeof url === 'string' && (url.startsWith(upstreamOrigin + '/') || url === upstreamOrigin)) {
2129
- return proxyOrigin + url.substring(upstreamOrigin.length);
2130
- }
2131
- return url;
2132
- }
2133
- var origFetch = window.fetch;
2134
- window.fetch = function(input, init) {
2135
- if (typeof input === 'string') {
2136
- input = rewriteUrl(input);
2137
- } else if (input instanceof Request) {
2138
- var rewritten = rewriteUrl(input.url);
2139
- if (rewritten !== input.url) { input = new Request(rewritten, input); }
2140
- }
2141
- return origFetch.call(window, input, init);
2142
- };
2143
- var origXhrOpen = XMLHttpRequest.prototype.open;
2144
- XMLHttpRequest.prototype.open = function() {
2145
- if (arguments.length >= 2 && typeof arguments[1] === 'string') {
2146
- arguments[1] = rewriteUrl(arguments[1]);
2147
- }
2148
- return origXhrOpen.apply(this, arguments);
2149
- };
2150
- }
2151
-
2152
2344
  function createOverlay() {
2153
2345
  var overlay = document.createElement('div');
2154
2346
  overlay.id = 'stashes-highlight';
@@ -2162,13 +2354,18 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
2162
2354
  }
2163
2355
 
2164
2356
  var SEMANTIC_TAGS = ['header','nav','main','section','article','aside','footer','form','dialog'];
2357
+ var LEAF_TAGS = ['h1','h2','h3','h4','h5','h6','p','button','a','input','textarea','select','img','svg','video','label','li','td','th','figcaption','blockquote','pre','code','span'];
2165
2358
 
2166
2359
  function findTarget(el, precise) {
2167
2360
  if (precise) return el;
2361
+ // If the element itself is a meaningful leaf, select it directly
2362
+ var elTag = el.tagName ? el.tagName.toLowerCase() : '';
2363
+ if (LEAF_TAGS.indexOf(elTag) !== -1) return el;
2168
2364
  var current = el;
2169
2365
  var best = el;
2170
2366
  while (current && current !== document.body) {
2171
2367
  var tag = current.tagName.toLowerCase();
2368
+ if (LEAF_TAGS.indexOf(tag) !== -1) { best = current; break; }
2172
2369
  if (SEMANTIC_TAGS.indexOf(tag) !== -1) { best = current; break; }
2173
2370
  if (current.id) { best = current; break; }
2174
2371
  if (current.getAttribute('role')) { best = current; break; }
@@ -2322,11 +2519,11 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
2322
2519
  import open from "open";
2323
2520
 
2324
2521
  // ../server/dist/services/detector.js
2325
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
2326
- import { join as join9 } from "path";
2522
+ import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
2523
+ import { join as join10 } from "path";
2327
2524
  function detectFramework(projectPath) {
2328
- const packageJsonPath = join9(projectPath, "package.json");
2329
- if (!existsSync9(packageJsonPath)) {
2525
+ const packageJsonPath = join10(projectPath, "package.json");
2526
+ if (!existsSync10(packageJsonPath)) {
2330
2527
  return {
2331
2528
  framework: "unknown",
2332
2529
  devCommand: "npm run dev",
@@ -2388,7 +2585,7 @@ function getDevCommand(packageJson, fallback) {
2388
2585
  }
2389
2586
  function findConfig(projectPath, candidates) {
2390
2587
  for (const candidate of candidates) {
2391
- if (existsSync9(join9(projectPath, candidate))) {
2588
+ if (existsSync10(join10(projectPath, candidate))) {
2392
2589
  return candidate;
2393
2590
  }
2394
2591
  }