stashes 0.1.27 → 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/cli.js CHANGED
@@ -20,7 +20,7 @@ var __require = import.meta.require;
20
20
 
21
21
  // src/index.ts
22
22
  import { readFileSync as readFileSync9 } from "fs";
23
- import { join as join12, dirname as dirname5 } from "path";
23
+ import { join as join13, dirname as dirname5 } from "path";
24
24
  import { fileURLToPath as fileURLToPath3 } from "url";
25
25
  import { Command } from "commander";
26
26
 
@@ -31,9 +31,9 @@ import open from "open";
31
31
  // ../server/dist/index.js
32
32
  import { Hono as Hono2 } from "hono";
33
33
  import { cors } from "hono/cors";
34
- import { join as join8, dirname as dirname2 } from "path";
34
+ import { join as join9, dirname as dirname2 } from "path";
35
35
  import { fileURLToPath } from "url";
36
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
36
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
37
37
  // ../shared/dist/constants/index.js
38
38
  var STASHES_PORT = 4000;
39
39
  var DEFAULT_STASH_COUNT = 3;
@@ -159,10 +159,10 @@ function ensureProject(persistence) {
159
159
  var apiRoutes = app;
160
160
 
161
161
  // ../core/dist/generation.js
162
- import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
163
- import { join as join6 } from "path";
162
+ import { readFileSync as readFileSync3, existsSync as existsSync7 } from "fs";
163
+ import { join as join7 } from "path";
164
164
  var {spawn: spawn3 } = globalThis.Bun;
165
- import simpleGit2 from "simple-git";
165
+ import simpleGit3 from "simple-git";
166
166
 
167
167
  // ../core/dist/worktree.js
168
168
  import simpleGit from "simple-git";
@@ -542,6 +542,14 @@ class PersistenceService {
542
542
  const filePath = join4(this.basePath, "projects", projectId, "stashes.json");
543
543
  writeJson(filePath, stashes);
544
544
  }
545
+ getProjectSettings(projectId) {
546
+ const filePath = join4(this.basePath, "projects", projectId, "settings.json");
547
+ return readJson(filePath, {});
548
+ }
549
+ saveProjectSettings(projectId, settings) {
550
+ const filePath = join4(this.basePath, "projects", projectId, "settings.json");
551
+ writeJson(filePath, settings);
552
+ }
545
553
  listChats(projectId) {
546
554
  const dir = join4(this.basePath, "projects", projectId, "chats");
547
555
  if (!existsSync4(dir))
@@ -622,18 +630,22 @@ class PersistenceService {
622
630
  var {spawn } = globalThis.Bun;
623
631
  var CLAUDE_BIN = "/opt/homebrew/bin/claude";
624
632
  var processes = new Map;
625
- function startAiProcess(id, prompt, cwd, resumeSessionId) {
633
+ function startAiProcess(id, prompt, cwd, resumeSessionId, model) {
626
634
  killAiProcess(id);
627
635
  logger.info("claude", `spawning process: ${id}`, {
628
636
  cwd,
629
637
  promptLength: prompt.length,
630
638
  promptPreview: prompt.substring(0, 100),
631
- resumeSessionId
639
+ resumeSessionId,
640
+ model
632
641
  });
633
642
  const cmd = [CLAUDE_BIN, "-p", prompt, "--output-format=stream-json", "--verbose", "--dangerously-skip-permissions"];
634
643
  if (resumeSessionId) {
635
644
  cmd.push("--resume", resumeSessionId);
636
645
  }
646
+ if (model) {
647
+ cmd.push("--model", model);
648
+ }
637
649
  const proc = spawn({
638
650
  cmd,
639
651
  stdin: "ignore",
@@ -757,6 +769,11 @@ async function* parseClaudeStream(proc) {
757
769
  }
758
770
  }
759
771
 
772
+ // ../core/dist/smart-screenshot.js
773
+ import { join as join6 } from "path";
774
+ import { mkdirSync as mkdirSync4, existsSync as existsSync6 } from "fs";
775
+ import simpleGit2 from "simple-git";
776
+
760
777
  // ../core/dist/screenshot.js
761
778
  var {spawn: spawn2 } = globalThis.Bun;
762
779
  import { join as join5 } from "path";
@@ -799,6 +816,171 @@ async function captureScreenshot(port, projectPath, stashId) {
799
816
  return `/api/screenshots/${filename}`;
800
817
  }
801
818
 
819
+ // ../core/dist/smart-screenshot.js
820
+ var SCREENSHOTS_DIR2 = ".stashes/screenshots";
821
+ var DEFAULT_TIMEOUT = 120000;
822
+ var DIFF_MAX_CHARS = 1e4;
823
+ async function getStashDiff(worktreePath, parentBranch) {
824
+ const git = simpleGit2(worktreePath);
825
+ try {
826
+ const diff = await git.diff([parentBranch, "HEAD"]);
827
+ if (diff.length <= DIFF_MAX_CHARS)
828
+ return diff;
829
+ const diffStat = await git.diff([parentBranch, "HEAD", "--stat"]);
830
+ const truncatedDiff = diff.substring(0, DIFF_MAX_CHARS);
831
+ return `## Changed files:
832
+ ${diffStat}
833
+
834
+ ## Partial diff (truncated):
835
+ ${truncatedDiff}`;
836
+ } catch (err) {
837
+ logger.warn("smart-screenshot", `git diff failed, falling back`, {
838
+ error: err instanceof Error ? err.message : String(err)
839
+ });
840
+ return "";
841
+ }
842
+ }
843
+ function buildScreenshotPrompt(port, diff, screenshotDir, stashId) {
844
+ return [
845
+ "You are a screenshot assistant. A developer made UI changes to a web app.",
846
+ "Your job: navigate the running app, find the pages affected by the changes, and take screenshots.",
847
+ "",
848
+ `## The app is running at: http://localhost:${port}`,
849
+ "",
850
+ "## Git diff of changes:",
851
+ "```",
852
+ diff,
853
+ "```",
854
+ "",
855
+ "## Instructions:",
856
+ "1. Analyze the diff to identify which components/pages changed",
857
+ "2. Determine the URL routes where these changes are visible",
858
+ "3. For each route:",
859
+ " - Navigate to it using browser_navigate",
860
+ " - If the change is behind an interaction (tab, modal, accordion), perform that interaction",
861
+ " - Wait for the page to settle",
862
+ " - Take a screenshot using browser_take_screenshot",
863
+ "4. Decide which screenshot shows the MOST visually significant change \u2014 mark it as primary",
864
+ "",
865
+ `## Screenshot save paths:`,
866
+ `- Primary: ${join6(screenshotDir, `${stashId}.png`)}`,
867
+ `- Additional: ${join6(screenshotDir, `${stashId}-1.png`)}, ${join6(screenshotDir, `${stashId}-2.png`)}, etc.`,
868
+ "",
869
+ "## Output format (after all screenshots are taken):",
870
+ "Respond with ONLY this JSON (no markdown fences):",
871
+ "{",
872
+ ' "screenshots": [',
873
+ " {",
874
+ ` "path": "${join6(screenshotDir, `${stashId}.png`)}",`,
875
+ ' "label": "Short description of what is shown",',
876
+ ' "route": "/the-url-path",',
877
+ ' "isPrimary": true',
878
+ " }",
879
+ " ]",
880
+ "}"
881
+ ].join(`
882
+ `);
883
+ }
884
+ function parseAiResult(text) {
885
+ try {
886
+ return JSON.parse(text);
887
+ } catch {
888
+ const jsonMatch = text.match(/\{[\s\S]*"screenshots"[\s\S]*\}/);
889
+ if (jsonMatch) {
890
+ try {
891
+ return JSON.parse(jsonMatch[0]);
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+ return null;
897
+ }
898
+ }
899
+ async function captureSmartScreenshots(opts) {
900
+ const { projectPath, stashId, stashBranch, parentBranch, worktreePath, port, model = "haiku", timeout = DEFAULT_TIMEOUT } = opts;
901
+ const screenshotDir = join6(projectPath, SCREENSHOTS_DIR2);
902
+ if (!existsSync6(screenshotDir)) {
903
+ mkdirSync4(screenshotDir, { recursive: true });
904
+ }
905
+ const diff = await getStashDiff(worktreePath, parentBranch);
906
+ if (!diff) {
907
+ logger.info("smart-screenshot", `No diff found for ${stashId}, using simple screenshot`);
908
+ const url = await captureScreenshot(port, projectPath, stashId);
909
+ return {
910
+ primary: url,
911
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
912
+ };
913
+ }
914
+ const processId = `screenshot-ai-${stashId}`;
915
+ const prompt = buildScreenshotPrompt(port, diff, screenshotDir, stashId);
916
+ const modelFlag = model === "sonnet" ? "sonnet" : "haiku";
917
+ const aiProcess = startAiProcess(processId, prompt, worktreePath, undefined, modelFlag);
918
+ let textOutput = "";
919
+ let timedOut = false;
920
+ const timeoutId = setTimeout(() => {
921
+ timedOut = true;
922
+ killAiProcess(processId);
923
+ }, timeout);
924
+ try {
925
+ for await (const chunk of parseClaudeStream(aiProcess.process)) {
926
+ if (chunk.type === "text") {
927
+ textOutput += chunk.content;
928
+ }
929
+ }
930
+ await aiProcess.process.exited;
931
+ } catch (err) {
932
+ logger.warn("smart-screenshot", `AI process error for ${stashId}`, {
933
+ error: err instanceof Error ? err.message : String(err),
934
+ timedOut
935
+ });
936
+ } finally {
937
+ clearTimeout(timeoutId);
938
+ killAiProcess(processId);
939
+ }
940
+ const result = parseAiResult(textOutput);
941
+ if (!result || !result.screenshots || result.screenshots.length === 0) {
942
+ logger.info("smart-screenshot", `AI returned no screenshots for ${stashId}, falling back`);
943
+ const url = await captureScreenshot(port, projectPath, stashId);
944
+ return {
945
+ primary: url,
946
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
947
+ };
948
+ }
949
+ const screenshots = [];
950
+ let primaryUrl = "";
951
+ for (const shot of result.screenshots) {
952
+ const filename = shot.path.split("/").pop() || "";
953
+ if (!existsSync6(shot.path)) {
954
+ logger.warn("smart-screenshot", `Screenshot file not found: ${shot.path}`);
955
+ continue;
956
+ }
957
+ const url = `/api/screenshots/${filename}`;
958
+ const screenshot = {
959
+ url,
960
+ label: shot.label,
961
+ route: shot.route,
962
+ isPrimary: shot.isPrimary
963
+ };
964
+ screenshots.push(screenshot);
965
+ if (shot.isPrimary)
966
+ primaryUrl = url;
967
+ }
968
+ if (screenshots.length === 0) {
969
+ logger.info("smart-screenshot", `No valid screenshots for ${stashId}, falling back`);
970
+ const url = await captureScreenshot(port, projectPath, stashId);
971
+ return {
972
+ primary: url,
973
+ screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
974
+ };
975
+ }
976
+ if (!primaryUrl) {
977
+ primaryUrl = screenshots[0].url;
978
+ screenshots[0] = { ...screenshots[0], isPrimary: true };
979
+ }
980
+ logger.info("smart-screenshot", `Captured ${screenshots.length} smart screenshots for ${stashId}`);
981
+ return { primary: primaryUrl, screenshots };
982
+ }
983
+
802
984
  // ../core/dist/prompt.js
803
985
  function buildStashPrompt(component, sourceCode, userPrompt, directive) {
804
986
  const parts = [
@@ -849,23 +1031,6 @@ async function waitForPort(port, timeout) {
849
1031
  }
850
1032
  throw new Error(`Port ${port} not ready within ${timeout}ms`);
851
1033
  }
852
- async function captureEphemeralScreenshot(worktreePath, projectPath, stashId, port) {
853
- const devServer = spawn3({
854
- cmd: ["npm", "run", "dev"],
855
- cwd: worktreePath,
856
- stdin: "ignore",
857
- stdout: "pipe",
858
- stderr: "pipe",
859
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
860
- });
861
- try {
862
- await waitForPort(port, 60000);
863
- await new Promise((r) => setTimeout(r, 2000));
864
- return await captureScreenshot(port, projectPath, stashId);
865
- } finally {
866
- devServer.kill();
867
- }
868
- }
869
1034
  async function allocatePort() {
870
1035
  for (let port = 4010;port <= 4030; port++) {
871
1036
  try {
@@ -883,8 +1048,8 @@ async function generate(opts) {
883
1048
  const selectedDirectives = directives.slice(0, count);
884
1049
  let sourceCode = "";
885
1050
  if (component?.filePath) {
886
- const sourceFile = join6(projectPath, component.filePath);
887
- if (existsSync6(sourceFile)) {
1051
+ const sourceFile = join7(projectPath, component.filePath);
1052
+ if (existsSync7(sourceFile)) {
888
1053
  sourceCode = readFileSync3(sourceFile, "utf-8");
889
1054
  }
890
1055
  }
@@ -906,6 +1071,7 @@ async function generate(opts) {
906
1071
  worktreePath: worktree.path,
907
1072
  port: null,
908
1073
  screenshotUrl: null,
1074
+ screenshots: [],
909
1075
  status: "generating",
910
1076
  error: null,
911
1077
  relatedTo: [],
@@ -930,7 +1096,7 @@ async function generate(opts) {
930
1096
  });
931
1097
  }
932
1098
  await aiProcess.process.exited;
933
- const wtGit = simpleGit2(worktree.path);
1099
+ const wtGit = simpleGit3(worktree.path);
934
1100
  try {
935
1101
  await wtGit.add("-A");
936
1102
  await wtGit.commit(`stashes: stash ${stashId}`);
@@ -953,15 +1119,40 @@ async function generate(opts) {
953
1119
  try {
954
1120
  const port = await allocatePort();
955
1121
  const worktree = await worktreeManager.createForGeneration(`screenshot-${stash.id}`);
956
- const screenshotGit = simpleGit2(worktree.path);
1122
+ const screenshotGit = simpleGit3(worktree.path);
957
1123
  await screenshotGit.checkout(["-f", stash.branch]);
958
- const screenshotPath = await captureEphemeralScreenshot(worktree.path, projectPath, stash.id, port);
1124
+ const devServer = spawn3({
1125
+ cmd: ["npm", "run", "dev"],
1126
+ cwd: worktree.path,
1127
+ stdin: "ignore",
1128
+ stdout: "pipe",
1129
+ stderr: "pipe",
1130
+ env: { ...process.env, PORT: String(port), BROWSER: "none" }
1131
+ });
1132
+ try {
1133
+ await waitForPort(port, 60000);
1134
+ await new Promise((r) => setTimeout(r, 2000));
1135
+ const mainGit = simpleGit3(projectPath);
1136
+ const parentBranch = (await mainGit.revparse(["HEAD"])).trim();
1137
+ const { primary, screenshots } = await captureSmartScreenshots({
1138
+ projectPath,
1139
+ stashId: stash.id,
1140
+ stashBranch: stash.branch,
1141
+ parentBranch,
1142
+ worktreePath: worktree.path,
1143
+ port,
1144
+ model: opts.screenshotModel,
1145
+ timeout: opts.screenshotTimeout
1146
+ });
1147
+ const updatedStash = { ...stash, screenshotUrl: primary, screenshots };
1148
+ persistence.saveStash(updatedStash);
1149
+ const idx = completedStashes.indexOf(stash);
1150
+ completedStashes[idx] = updatedStash;
1151
+ emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath: primary });
1152
+ } finally {
1153
+ devServer.kill();
1154
+ }
959
1155
  await worktreeManager.removeGeneration(`screenshot-${stash.id}`);
960
- const updatedStash = { ...stash, screenshotUrl: screenshotPath };
961
- persistence.saveStash(updatedStash);
962
- const idx = completedStashes.indexOf(stash);
963
- completedStashes[idx] = updatedStash;
964
- emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath });
965
1156
  } catch (err) {
966
1157
  logger.error("generation", `screenshot failed for ${stash.id}`, {
967
1158
  error: err instanceof Error ? err.message : String(err)
@@ -973,7 +1164,7 @@ async function generate(opts) {
973
1164
  }
974
1165
  // ../core/dist/vary.js
975
1166
  var {spawn: spawn4 } = globalThis.Bun;
976
- import simpleGit3 from "simple-git";
1167
+ import simpleGit4 from "simple-git";
977
1168
  function emit2(onProgress, event) {
978
1169
  if (onProgress)
979
1170
  onProgress(event);
@@ -1031,6 +1222,7 @@ async function vary(opts) {
1031
1222
  worktreePath: worktree.path,
1032
1223
  port: null,
1033
1224
  screenshotUrl: null,
1225
+ screenshots: [],
1034
1226
  status: "generating",
1035
1227
  error: null,
1036
1228
  relatedTo: [sourceStashId],
@@ -1050,7 +1242,7 @@ async function vary(opts) {
1050
1242
  });
1051
1243
  }
1052
1244
  await aiProcess.process.exited;
1053
- const wtGit = simpleGit3(worktree.path);
1245
+ const wtGit = simpleGit4(worktree.path);
1054
1246
  try {
1055
1247
  await wtGit.add("-A");
1056
1248
  await wtGit.commit(`stashes: vary ${stashId} from ${sourceStashId}`);
@@ -1058,10 +1250,11 @@ async function vary(opts) {
1058
1250
  await worktreeManager.removeGeneration(stashId);
1059
1251
  emit2(onProgress, { type: "screenshotting", stashId });
1060
1252
  let screenshotPath = "";
1253
+ let screenshots = [];
1061
1254
  try {
1062
1255
  const port = await allocatePort2();
1063
1256
  const screenshotWorktree = await worktreeManager.createForGeneration(`screenshot-${stashId}`);
1064
- const screenshotGit = simpleGit3(screenshotWorktree.path);
1257
+ const screenshotGit = simpleGit4(screenshotWorktree.path);
1065
1258
  await screenshotGit.checkout(["-f", stash.branch]);
1066
1259
  const devServer = spawn4({
1067
1260
  cmd: ["npm", "run", "dev"],
@@ -1074,7 +1267,18 @@ async function vary(opts) {
1074
1267
  try {
1075
1268
  await waitForPort2(port, 60000);
1076
1269
  await new Promise((r) => setTimeout(r, 2000));
1077
- screenshotPath = await captureScreenshot(port, projectPath, stashId);
1270
+ const result = await captureSmartScreenshots({
1271
+ projectPath,
1272
+ stashId,
1273
+ stashBranch: stash.branch,
1274
+ parentBranch: sourceStash.branch,
1275
+ worktreePath: screenshotWorktree.path,
1276
+ port,
1277
+ model: opts.screenshotModel,
1278
+ timeout: opts.screenshotTimeout
1279
+ });
1280
+ screenshotPath = result.primary;
1281
+ screenshots = [...result.screenshots];
1078
1282
  } finally {
1079
1283
  devServer.kill();
1080
1284
  }
@@ -1084,7 +1288,7 @@ async function vary(opts) {
1084
1288
  error: err instanceof Error ? err.message : String(err)
1085
1289
  });
1086
1290
  }
1087
- const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null };
1291
+ const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null, screenshots };
1088
1292
  persistence.saveStash(readyStash);
1089
1293
  emit2(onProgress, { type: "ready", stashId, screenshotPath });
1090
1294
  return readyStash;
@@ -1108,7 +1312,7 @@ async function apply(opts) {
1108
1312
  logger.info("apply", `stash ${stashId} applied and worktrees cleaned up`);
1109
1313
  }
1110
1314
  // ../core/dist/manage.js
1111
- import simpleGit4 from "simple-git";
1315
+ import simpleGit5 from "simple-git";
1112
1316
  async function list(projectPath) {
1113
1317
  const persistence = new PersistenceService(projectPath);
1114
1318
  const projects = persistence.listProjects();
@@ -1129,7 +1333,7 @@ async function remove(projectPath, stashId) {
1129
1333
  }
1130
1334
  }
1131
1335
  try {
1132
- const git = simpleGit4(projectPath);
1336
+ const git = simpleGit5(projectPath);
1133
1337
  await git.raw(["branch", "-D", `stashes/${stashId}`]);
1134
1338
  } catch {}
1135
1339
  logger.info("manage", `removed stash: ${stashId}`);
@@ -1140,8 +1344,8 @@ async function cleanup(projectPath) {
1140
1344
  logger.info("manage", "cleanup complete");
1141
1345
  }
1142
1346
  // ../server/dist/services/stash-service.js
1143
- import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
1144
- import { join as join7 } from "path";
1347
+ import { readFileSync as readFileSync4, existsSync as existsSync8 } from "fs";
1348
+ import { join as join8 } from "path";
1145
1349
 
1146
1350
  // ../server/dist/services/preview-pool.js
1147
1351
  class PreviewPool {
@@ -1404,8 +1608,8 @@ class StashService {
1404
1608
  let sourceCode = "";
1405
1609
  const filePath = component?.filePath || "";
1406
1610
  if (filePath && filePath !== "auto-detect") {
1407
- const sourceFile = join7(this.projectPath, filePath);
1408
- if (existsSync7(sourceFile)) {
1611
+ const sourceFile = join8(this.projectPath, filePath);
1612
+ if (existsSync8(sourceFile)) {
1409
1613
  sourceCode = readFileSync4(sourceFile, "utf-8");
1410
1614
  }
1411
1615
  }
@@ -1580,7 +1784,8 @@ ${sourceCode.substring(0, 3000)}
1580
1784
  this.broadcast({
1581
1785
  type: "stash:screenshot",
1582
1786
  stashId: stash.id,
1583
- url: stash.screenshotUrl
1787
+ url: stash.screenshotUrl,
1788
+ screenshots: stash.screenshots?.length ? [...stash.screenshots] : undefined
1584
1789
  });
1585
1790
  }
1586
1791
  }
@@ -1597,7 +1802,12 @@ ${sourceCode.substring(0, 3000)}
1597
1802
  ..."number" in event ? { number: event.number } : {}
1598
1803
  });
1599
1804
  if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
1600
- this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: event.screenshotPath });
1805
+ this.broadcast({
1806
+ type: "stash:screenshot",
1807
+ stashId: event.stashId,
1808
+ url: event.screenshotPath,
1809
+ screenshots: "screenshots" in event ? event.screenshots : undefined
1810
+ });
1601
1811
  }
1602
1812
  break;
1603
1813
  case "error":
@@ -1611,6 +1821,7 @@ ${sourceCode.substring(0, 3000)}
1611
1821
  }
1612
1822
  }
1613
1823
  async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
1824
+ const settings = this.persistence.getProjectSettings(projectId);
1614
1825
  let enrichedPrompt = prompt;
1615
1826
  if (referenceStashIds?.length) {
1616
1827
  const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
@@ -1628,14 +1839,27 @@ ${refDescriptions.join(`
1628
1839
  prompt: enrichedPrompt,
1629
1840
  component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
1630
1841
  count: stashCount,
1842
+ screenshotModel: settings.screenshotModel,
1843
+ screenshotTimeout: settings.screenshotTimeout,
1631
1844
  onProgress: (event) => this.progressToBroadcast(event)
1632
1845
  });
1633
1846
  }
1634
1847
  async vary(sourceStashId, prompt) {
1848
+ let projectId = "";
1849
+ for (const project of this.persistence.listProjects()) {
1850
+ const stashes = this.persistence.listStashes(project.id);
1851
+ if (stashes.find((s) => s.id === sourceStashId)) {
1852
+ projectId = project.id;
1853
+ break;
1854
+ }
1855
+ }
1856
+ const settings = projectId ? this.persistence.getProjectSettings(projectId) : {};
1635
1857
  await vary({
1636
1858
  projectPath: this.projectPath,
1637
1859
  sourceStashId,
1638
1860
  prompt,
1861
+ screenshotModel: settings.screenshotModel,
1862
+ screenshotTimeout: settings.screenshotTimeout,
1639
1863
  onProgress: (event) => this.progressToBroadcast(event)
1640
1864
  });
1641
1865
  }
@@ -1749,105 +1973,93 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1749
1973
  }
1750
1974
 
1751
1975
  // ../server/dist/services/app-proxy.js
1976
+ import { spawn as spawn5 } from "child_process";
1752
1977
  function startAppProxy(userDevPort, proxyPort, injectOverlay) {
1753
- const upstreamOrigin = `http://localhost:${userDevPort}`;
1754
- const proxyOrigin = `http://localhost:${proxyPort}`;
1755
- const server = Bun.serve({
1756
- port: proxyPort,
1757
- async fetch(req, server2) {
1758
- if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1759
- const url2 = new URL(req.url);
1760
- const success = server2.upgrade(req, {
1761
- data: {
1762
- path: url2.pathname + url2.search,
1763
- upstream: null,
1764
- ready: false,
1765
- buffer: []
1766
- }
1767
- });
1768
- return success ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
1769
- }
1770
- const url = new URL(req.url);
1771
- const targetUrl = `http://localhost:${userDevPort}${url.pathname}${url.search}`;
1772
- try {
1773
- const headers = new Headers;
1774
- for (const [key, value] of req.headers.entries()) {
1775
- if (!["host", "accept-encoding"].includes(key.toLowerCase())) {
1776
- headers.set(key, value);
1777
- }
1778
- }
1779
- headers.set("host", `localhost:${userDevPort}`);
1780
- const response = await fetch(targetUrl, {
1781
- method: req.method,
1782
- headers,
1783
- body: req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined,
1784
- redirect: "manual"
1785
- });
1786
- const contentType = response.headers.get("content-type") || "";
1787
- const respHeaders = new Headers(response.headers);
1788
- if (response.status >= 300 && response.status < 400) {
1789
- const location = respHeaders.get("location");
1790
- if (location?.startsWith(upstreamOrigin)) {
1791
- respHeaders.set("location", proxyOrigin + location.substring(upstreamOrigin.length));
1792
- }
1793
- }
1794
- respHeaders.delete("content-encoding");
1795
- return new Response(response.body, {
1796
- status: response.status,
1797
- headers: respHeaders
1798
- });
1799
- } catch (err) {
1800
- return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1801
- status: 502,
1802
- headers: { "content-type": "application/json" }
1803
- });
1804
- }
1805
- },
1806
- websocket: {
1807
- open(ws) {
1808
- const { data } = ws;
1809
- const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
1810
- upstream.addEventListener("open", () => {
1811
- data.upstream = upstream;
1812
- data.ready = true;
1813
- for (const msg of data.buffer) {
1814
- upstream.send(msg);
1815
- }
1816
- data.buffer = [];
1817
- });
1818
- upstream.addEventListener("message", (event) => {
1819
- try {
1820
- ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
1821
- } catch {}
1822
- });
1823
- upstream.addEventListener("close", () => {
1824
- try {
1825
- ws.close();
1826
- } catch {}
1827
- });
1828
- upstream.addEventListener("error", () => {
1829
- try {
1830
- ws.close();
1831
- } catch {}
1832
- });
1833
- },
1834
- message(ws, msg) {
1835
- const { data } = ws;
1836
- if (data.ready && data.upstream) {
1837
- data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
1838
- } else {
1839
- data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
1978
+ const overlayScript = injectOverlay("", userDevPort, proxyPort);
1979
+ const overlayEscaped = JSON.stringify(overlayScript);
1980
+ const proxyScript = `
1981
+ const http = require('http');
1982
+ const net = require('net');
1983
+ const zlib = require('zlib');
1984
+ const UPSTREAM = ${userDevPort};
1985
+ const OVERLAY = ${overlayEscaped};
1986
+
1987
+ const server = http.createServer((clientReq, clientRes) => {
1988
+ const opts = {
1989
+ hostname: 'localhost',
1990
+ port: UPSTREAM,
1991
+ path: clientReq.url,
1992
+ method: clientReq.method,
1993
+ headers: clientReq.headers,
1994
+ };
1995
+ const proxyReq = http.request(opts, (proxyRes) => {
1996
+ const ct = proxyRes.headers['content-type'] || '';
1997
+ if (ct.includes('text/html')) {
1998
+ // Buffer HTML to inject overlay
1999
+ const chunks = [];
2000
+ proxyRes.on('data', c => chunks.push(c));
2001
+ proxyRes.on('end', () => {
2002
+ let html = Buffer.concat(chunks);
2003
+ const enc = proxyRes.headers['content-encoding'];
2004
+ // Decompress if needed
2005
+ if (enc === 'gzip') {
2006
+ try { html = zlib.gunzipSync(html); } catch {}
2007
+ } else if (enc === 'br') {
2008
+ try { html = zlib.brotliDecompressSync(html); } catch {}
2009
+ } else if (enc === 'deflate') {
2010
+ try { html = zlib.inflateSync(html); } catch {}
1840
2011
  }
1841
- },
1842
- close(ws) {
1843
- try {
1844
- ws.data.upstream?.close();
1845
- } catch {}
1846
- }
2012
+ const hdrs = { ...proxyRes.headers };
2013
+ delete hdrs['content-length'];
2014
+ delete hdrs['content-encoding'];
2015
+ delete hdrs['transfer-encoding'];
2016
+ clientRes.writeHead(proxyRes.statusCode, hdrs);
2017
+ clientRes.write(html);
2018
+ clientRes.end(OVERLAY);
2019
+ });
2020
+ } else {
2021
+ // Non-HTML: stream through unchanged
2022
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
2023
+ proxyRes.pipe(clientRes);
1847
2024
  }
2025
+ proxyRes.on('error', () => clientRes.end());
1848
2026
  });
1849
- logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
1850
- return server;
2027
+ proxyReq.on('error', () => { try { clientRes.writeHead(502); clientRes.end(); } catch {} });
2028
+ clientReq.pipe(proxyReq);
2029
+ });
2030
+
2031
+ // WebSocket upgrades: raw TCP pipe
2032
+ server.on('upgrade', (req, socket, head) => {
2033
+ const upstream = net.createConnection(UPSTREAM, 'localhost', () => {
2034
+ const lines = [req.method + ' ' + req.url + ' HTTP/1.1'];
2035
+ for (const [k, v] of Object.entries(req.headers)) {
2036
+ lines.push(k + ': ' + (Array.isArray(v) ? v.join(', ') : v));
2037
+ }
2038
+ upstream.write(lines.join('\\r\\n') + '\\r\\n\\r\\n');
2039
+ if (head.length) upstream.write(head);
2040
+ socket.pipe(upstream);
2041
+ upstream.pipe(socket);
2042
+ });
2043
+ upstream.on('error', () => socket.destroy());
2044
+ socket.on('error', () => upstream.destroy());
2045
+ });
2046
+
2047
+ server.listen(${proxyPort}, () => {
2048
+ if (process.send) process.send('ready');
2049
+ });
2050
+ `;
2051
+ const child = spawn5("node", ["-e", proxyScript], {
2052
+ stdio: ["ignore", "inherit", "inherit", "ipc"]
2053
+ });
2054
+ child.on("error", (err) => {
2055
+ logger.error("proxy", `Failed to start proxy: ${err.message}`);
2056
+ });
2057
+ child.on("message", (msg) => {
2058
+ if (msg === "ready") {
2059
+ logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
2060
+ }
2061
+ });
2062
+ return child;
1851
2063
  }
1852
2064
 
1853
2065
  // ../server/dist/index.js
@@ -1864,12 +2076,12 @@ app2.route("/api", apiRoutes);
1864
2076
  app2.get("/*", async (c) => {
1865
2077
  const path = c.req.path;
1866
2078
  const selfDir = dirname2(fileURLToPath(import.meta.url));
1867
- const bundledWebDir = join8(selfDir, "web");
1868
- const monorepoWebDir = join8(selfDir, "../../web/dist");
1869
- const webDistDir = existsSync8(join8(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
2079
+ const bundledWebDir = join9(selfDir, "web");
2080
+ const monorepoWebDir = join9(selfDir, "../../web/dist");
2081
+ const webDistDir = existsSync9(join9(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
1870
2082
  const requestPath = path === "/" ? "/index.html" : path;
1871
- const filePath = join8(webDistDir, requestPath);
1872
- if (existsSync8(filePath) && !filePath.includes("..")) {
2083
+ const filePath = join9(webDistDir, requestPath);
2084
+ if (existsSync9(filePath) && !filePath.includes("..")) {
1873
2085
  const content = readFileSync5(filePath);
1874
2086
  const ext = filePath.split(".").pop() || "";
1875
2087
  const contentTypes = {
@@ -1888,8 +2100,8 @@ app2.get("/*", async (c) => {
1888
2100
  headers: { "content-type": contentTypes[ext] || "application/octet-stream" }
1889
2101
  });
1890
2102
  }
1891
- const indexPath = join8(webDistDir, "index.html");
1892
- if (existsSync8(indexPath)) {
2103
+ const indexPath = join9(webDistDir, "index.html");
2104
+ if (existsSync9(indexPath)) {
1893
2105
  const html = readFileSync5(indexPath, "utf-8");
1894
2106
  return new Response(html, {
1895
2107
  headers: { "content-type": "text/html; charset=utf-8" }
@@ -1925,7 +2137,7 @@ function startServer(projectPath, userDevPort, port = STASHES_PORT) {
1925
2137
  logger.info("server", `Project: ${projectPath}`);
1926
2138
  return server;
1927
2139
  }
1928
- function injectOverlayScript(html, upstreamPort, proxyPort) {
2140
+ function injectOverlayScript(html, _upstreamPort, _proxyPort) {
1929
2141
  const overlayScript = `
1930
2142
  <script data-stashes-overlay>
1931
2143
  (function() {
@@ -1933,35 +2145,6 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
1933
2145
  var pickerEnabled = false;
1934
2146
  var precisionMode = false;
1935
2147
 
1936
- // Rewrite cross-origin requests to the upstream dev server through the proxy
1937
- var upstreamOrigin = 'http://localhost:${upstreamPort}';
1938
- var proxyOrigin = window.location.origin;
1939
- if (proxyOrigin !== upstreamOrigin) {
1940
- function rewriteUrl(url) {
1941
- if (typeof url === 'string' && (url.startsWith(upstreamOrigin + '/') || url === upstreamOrigin)) {
1942
- return proxyOrigin + url.substring(upstreamOrigin.length);
1943
- }
1944
- return url;
1945
- }
1946
- var origFetch = window.fetch;
1947
- window.fetch = function(input, init) {
1948
- if (typeof input === 'string') {
1949
- input = rewriteUrl(input);
1950
- } else if (input instanceof Request) {
1951
- var rewritten = rewriteUrl(input.url);
1952
- if (rewritten !== input.url) { input = new Request(rewritten, input); }
1953
- }
1954
- return origFetch.call(window, input, init);
1955
- };
1956
- var origXhrOpen = XMLHttpRequest.prototype.open;
1957
- XMLHttpRequest.prototype.open = function() {
1958
- if (arguments.length >= 2 && typeof arguments[1] === 'string') {
1959
- arguments[1] = rewriteUrl(arguments[1]);
1960
- }
1961
- return origXhrOpen.apply(this, arguments);
1962
- };
1963
- }
1964
-
1965
2148
  function createOverlay() {
1966
2149
  var overlay = document.createElement('div');
1967
2150
  overlay.id = 'stashes-highlight';
@@ -2137,11 +2320,11 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
2137
2320
  }
2138
2321
 
2139
2322
  // ../server/dist/services/detector.js
2140
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
2141
- import { join as join9 } from "path";
2323
+ import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
2324
+ import { join as join10 } from "path";
2142
2325
  function detectFramework(projectPath) {
2143
- const packageJsonPath = join9(projectPath, "package.json");
2144
- if (!existsSync9(packageJsonPath)) {
2326
+ const packageJsonPath = join10(projectPath, "package.json");
2327
+ if (!existsSync10(packageJsonPath)) {
2145
2328
  return {
2146
2329
  framework: "unknown",
2147
2330
  devCommand: "npm run dev",
@@ -2203,7 +2386,7 @@ function getDevCommand(packageJson, fallback) {
2203
2386
  }
2204
2387
  function findConfig(projectPath, candidates) {
2205
2388
  for (const candidate of candidates) {
2206
- if (existsSync9(join9(projectPath, candidate))) {
2389
+ if (existsSync10(join10(projectPath, candidate))) {
2207
2390
  return candidate;
2208
2391
  }
2209
2392
  }
@@ -2392,8 +2575,8 @@ Cleaning up all stashes and worktrees...`);
2392
2575
  }
2393
2576
 
2394
2577
  // src/commands/setup.ts
2395
- import { existsSync as existsSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync2, mkdirSync as mkdirSync4 } from "fs";
2396
- import { dirname as dirname3, join as join10 } from "path";
2578
+ import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync2, mkdirSync as mkdirSync5 } from "fs";
2579
+ import { dirname as dirname3, join as join11 } from "path";
2397
2580
  import { homedir } from "os";
2398
2581
  import * as p from "@clack/prompts";
2399
2582
  import pc from "picocolors";
@@ -2412,60 +2595,60 @@ var MCP_ENTRY_ZED = {
2412
2595
  };
2413
2596
  function buildToolDefinitions() {
2414
2597
  const home = homedir();
2415
- const appSupport = join10(home, "Library", "Application Support");
2598
+ const appSupport = join11(home, "Library", "Application Support");
2416
2599
  return [
2417
2600
  {
2418
2601
  id: "claude-code",
2419
2602
  name: "Claude Code",
2420
- configPath: join10(home, ".claude.json"),
2603
+ configPath: join11(home, ".claude.json"),
2421
2604
  serversKey: "mcpServers",
2422
2605
  format: "standard",
2423
- detect: () => existsSync10(join10(home, ".claude.json")) || existsSync10(join10(home, ".claude"))
2606
+ detect: () => existsSync11(join11(home, ".claude.json")) || existsSync11(join11(home, ".claude"))
2424
2607
  },
2425
2608
  {
2426
2609
  id: "claude-desktop",
2427
2610
  name: "Claude Desktop",
2428
- configPath: join10(appSupport, "Claude", "claude_desktop_config.json"),
2611
+ configPath: join11(appSupport, "Claude", "claude_desktop_config.json"),
2429
2612
  serversKey: "mcpServers",
2430
2613
  format: "standard",
2431
- detect: () => existsSync10(join10(appSupport, "Claude")) || existsSync10("/Applications/Claude.app")
2614
+ detect: () => existsSync11(join11(appSupport, "Claude")) || existsSync11("/Applications/Claude.app")
2432
2615
  },
2433
2616
  {
2434
2617
  id: "vscode",
2435
2618
  name: "VS Code",
2436
- configPath: join10(appSupport, "Code", "User", "mcp.json"),
2619
+ configPath: join11(appSupport, "Code", "User", "mcp.json"),
2437
2620
  serversKey: "servers",
2438
2621
  format: "standard",
2439
- detect: () => existsSync10(join10(appSupport, "Code", "User"))
2622
+ detect: () => existsSync11(join11(appSupport, "Code", "User"))
2440
2623
  },
2441
2624
  {
2442
2625
  id: "cursor",
2443
2626
  name: "Cursor",
2444
- configPath: join10(home, ".cursor", "mcp.json"),
2627
+ configPath: join11(home, ".cursor", "mcp.json"),
2445
2628
  serversKey: "mcpServers",
2446
2629
  format: "standard",
2447
- detect: () => existsSync10(join10(home, ".cursor"))
2630
+ detect: () => existsSync11(join11(home, ".cursor"))
2448
2631
  },
2449
2632
  {
2450
2633
  id: "windsurf",
2451
2634
  name: "Windsurf",
2452
- configPath: join10(home, ".codeium", "windsurf", "mcp_config.json"),
2635
+ configPath: join11(home, ".codeium", "windsurf", "mcp_config.json"),
2453
2636
  serversKey: "mcpServers",
2454
2637
  format: "standard",
2455
- detect: () => existsSync10(join10(home, ".codeium", "windsurf"))
2638
+ detect: () => existsSync11(join11(home, ".codeium", "windsurf"))
2456
2639
  },
2457
2640
  {
2458
2641
  id: "zed",
2459
2642
  name: "Zed",
2460
- configPath: join10(appSupport, "Zed", "settings.json"),
2643
+ configPath: join11(appSupport, "Zed", "settings.json"),
2461
2644
  serversKey: "context_servers",
2462
2645
  format: "zed",
2463
- detect: () => existsSync10(join10(appSupport, "Zed"))
2646
+ detect: () => existsSync11(join11(appSupport, "Zed"))
2464
2647
  }
2465
2648
  ];
2466
2649
  }
2467
2650
  function readJsonFile(path) {
2468
- if (!existsSync10(path))
2651
+ if (!existsSync11(path))
2469
2652
  return {};
2470
2653
  try {
2471
2654
  const raw = readFileSync7(path, "utf-8").trim();
@@ -2477,7 +2660,7 @@ function readJsonFile(path) {
2477
2660
  }
2478
2661
  }
2479
2662
  function writeJsonFile(path, data) {
2480
- mkdirSync4(dirname3(path), { recursive: true });
2663
+ mkdirSync5(dirname3(path), { recursive: true });
2481
2664
  writeFileSync2(path, JSON.stringify(data, null, 2) + `
2482
2665
  `);
2483
2666
  }
@@ -2618,13 +2801,13 @@ async function setupCommand(options) {
2618
2801
  import { execFileSync, execSync } from "child_process";
2619
2802
  import { writeFileSync as writeFileSync3, unlinkSync, chmodSync, readFileSync as readFileSync8 } from "fs";
2620
2803
  import { tmpdir } from "os";
2621
- import { join as join11, dirname as dirname4 } from "path";
2804
+ import { join as join12, dirname as dirname4 } from "path";
2622
2805
  import { fileURLToPath as fileURLToPath2 } from "url";
2623
2806
  import * as p2 from "@clack/prompts";
2624
2807
  import pc2 from "picocolors";
2625
2808
  function getCurrentVersion() {
2626
2809
  const selfDir = dirname4(fileURLToPath2(import.meta.url));
2627
- const pkgPath = join11(selfDir, "..", "package.json");
2810
+ const pkgPath = join12(selfDir, "..", "package.json");
2628
2811
  return JSON.parse(readFileSync8(pkgPath, "utf-8")).version;
2629
2812
  }
2630
2813
  function fetchLatestVersion() {
@@ -2701,7 +2884,7 @@ async function updateCommand() {
2701
2884
  }
2702
2885
  s.stop(`Removed from ${configuredTools.length} tool${configuredTools.length === 1 ? "" : "s"}`);
2703
2886
  }
2704
- const scriptPath = join11(tmpdir(), `stashes-update-${Date.now()}.sh`);
2887
+ const scriptPath = join12(tmpdir(), `stashes-update-${Date.now()}.sh`);
2705
2888
  writeFileSync3(scriptPath, buildUpdateScript(), "utf-8");
2706
2889
  chmodSync(scriptPath, 493);
2707
2890
  try {
@@ -2720,7 +2903,7 @@ Update failed. Try manually:`);
2720
2903
 
2721
2904
  // src/index.ts
2722
2905
  var selfDir = dirname5(fileURLToPath3(import.meta.url));
2723
- var pkgPath = join12(selfDir, "..", "package.json");
2906
+ var pkgPath = join13(selfDir, "..", "package.json");
2724
2907
  var version = JSON.parse(readFileSync9(pkgPath, "utf-8")).version;
2725
2908
  var program = new Command;
2726
2909
  program.name("stashes").description("Generate AI-powered UI design explorations in your project").version(version, "-v, --version");