stashes 0.1.8 → 0.1.9

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
@@ -35,6 +35,10 @@ var STASHES_PORT = 4000;
35
35
  var DEFAULT_STASH_COUNT = 3;
36
36
  var APP_PROXY_PORT = STASHES_PORT + 1;
37
37
  var STASH_PORT_START = 4010;
38
+ var STASH_PORT_END = 4030;
39
+ var MAX_PREVIEW_SERVERS = 5;
40
+ var PREVIEW_TTL_MS = 300000;
41
+ var PREVIEW_REAPER_INTERVAL = 30000;
38
42
  var DEFAULT_DIRECTIVES = [
39
43
  "Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
40
44
  "Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
@@ -232,6 +236,34 @@ class WorktreeManager {
232
236
  getPreviewPort() {
233
237
  return PREVIEW_PORT;
234
238
  }
239
+ async createPreviewForPool(stashId) {
240
+ const previewPath = join2(this.projectPath, ".stashes", "previews", stashId);
241
+ const branch = `stashes/${stashId}`;
242
+ if (existsSync2(previewPath)) {
243
+ try {
244
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
245
+ } catch {
246
+ rmSync(previewPath, { recursive: true, force: true });
247
+ }
248
+ await this.git.raw(["worktree", "prune"]);
249
+ }
250
+ logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
251
+ await this.git.raw(["worktree", "add", previewPath, branch]);
252
+ this.symlinkDeps(previewPath);
253
+ logger.info("worktree", `pool preview created: ${stashId}`);
254
+ return previewPath;
255
+ }
256
+ async removePreviewForPool(stashId) {
257
+ const previewPath = join2(this.projectPath, ".stashes", "previews", stashId);
258
+ logger.info("worktree", `removing pool preview: ${stashId}`);
259
+ try {
260
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
261
+ } catch {
262
+ if (existsSync2(previewPath)) {
263
+ rmSync(previewPath, { recursive: true, force: true });
264
+ }
265
+ }
266
+ }
235
267
  async apply(stashId) {
236
268
  const branch = `stashes/${stashId}`;
237
269
  logger.info("worktree", `merging: ${stashId}`, { branch });
@@ -272,6 +304,10 @@ class WorktreeManager {
272
304
  if (existsSync2(previewDir)) {
273
305
  rmSync(previewDir, { recursive: true, force: true });
274
306
  }
307
+ const previewsDir = join2(this.projectPath, ".stashes", "previews");
308
+ if (existsSync2(previewsDir)) {
309
+ rmSync(previewsDir, { recursive: true, force: true });
310
+ }
275
311
  logger.info("worktree", `cleanup complete`);
276
312
  }
277
313
  symlinkDeps(worktreePath) {
@@ -1049,19 +1085,201 @@ var apiRoutes = app;
1049
1085
  // ../server/dist/services/stash-service.js
1050
1086
  import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
1051
1087
  import { join as join7 } from "path";
1088
+
1089
+ // ../server/dist/services/preview-pool.js
1090
+ class PreviewPool {
1091
+ entries = new Map;
1092
+ usedPorts = new Set;
1093
+ maxSize;
1094
+ ttlMs;
1095
+ worktreeManager;
1096
+ broadcast;
1097
+ reaperInterval;
1098
+ constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
1099
+ this.worktreeManager = worktreeManager;
1100
+ this.broadcast = broadcast;
1101
+ this.maxSize = maxSize;
1102
+ this.ttlMs = ttlMs;
1103
+ this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
1104
+ }
1105
+ async getOrStart(stashId) {
1106
+ const existing = this.entries.get(stashId);
1107
+ if (existing) {
1108
+ existing.lastHeartbeat = Date.now();
1109
+ logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
1110
+ return existing.port;
1111
+ }
1112
+ if (this.entries.size >= this.maxSize) {
1113
+ this.evictOldest();
1114
+ }
1115
+ const port = this.allocatePort();
1116
+ const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
1117
+ const process2 = Bun.spawn({
1118
+ cmd: ["npm", "run", "dev"],
1119
+ cwd: worktreePath,
1120
+ stdin: "ignore",
1121
+ stdout: "pipe",
1122
+ stderr: "pipe",
1123
+ env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
1124
+ });
1125
+ const entry = {
1126
+ stashId,
1127
+ port,
1128
+ process: process2,
1129
+ worktreePath,
1130
+ lastHeartbeat: Date.now()
1131
+ };
1132
+ this.entries.set(stashId, entry);
1133
+ this.usedPorts.add(port);
1134
+ logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
1135
+ await this.waitForPort(port, 60000);
1136
+ return port;
1137
+ }
1138
+ heartbeat(stashId) {
1139
+ const entry = this.entries.get(stashId);
1140
+ if (entry) {
1141
+ entry.lastHeartbeat = Date.now();
1142
+ }
1143
+ }
1144
+ isWarm(stashId) {
1145
+ return this.entries.has(stashId);
1146
+ }
1147
+ getPort(stashId) {
1148
+ return this.entries.get(stashId)?.port ?? null;
1149
+ }
1150
+ async stop(stashId) {
1151
+ const entry = this.entries.get(stashId);
1152
+ if (!entry)
1153
+ return;
1154
+ logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1155
+ this.killEntry(entry);
1156
+ this.entries.delete(stashId);
1157
+ this.usedPorts.delete(entry.port);
1158
+ try {
1159
+ await this.worktreeManager.removePreviewForPool(stashId);
1160
+ } catch (err) {
1161
+ logger.warn("pool", `worktree removal failed for ${stashId}`, {
1162
+ error: err instanceof Error ? err.message : String(err)
1163
+ });
1164
+ }
1165
+ }
1166
+ prefetchNeighbors(currentStashId, sortedStashIds) {
1167
+ const currentIndex = sortedStashIds.indexOf(currentStashId);
1168
+ if (currentIndex === -1 || sortedStashIds.length < 2)
1169
+ return;
1170
+ const neighbors = [];
1171
+ const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1172
+ const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1173
+ if (!this.entries.has(sortedStashIds[prevIndex])) {
1174
+ neighbors.push(sortedStashIds[prevIndex]);
1175
+ }
1176
+ if (!this.entries.has(sortedStashIds[nextIndex])) {
1177
+ neighbors.push(sortedStashIds[nextIndex]);
1178
+ }
1179
+ for (const stashId of neighbors) {
1180
+ if (this.entries.size >= this.maxSize)
1181
+ break;
1182
+ logger.info("pool", `prefetching neighbor: ${stashId}`);
1183
+ this.getOrStart(stashId).then((port) => {
1184
+ this.broadcast({ type: "stash:port", stashId, port });
1185
+ }).catch((err) => {
1186
+ logger.warn("pool", `prefetch failed for ${stashId}`, {
1187
+ error: err instanceof Error ? err.message : String(err)
1188
+ });
1189
+ });
1190
+ }
1191
+ }
1192
+ async shutdown() {
1193
+ logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1194
+ clearInterval(this.reaperInterval);
1195
+ const stashIds = [...this.entries.keys()];
1196
+ for (const stashId of stashIds) {
1197
+ await this.stop(stashId);
1198
+ }
1199
+ }
1200
+ reap() {
1201
+ const now = Date.now();
1202
+ const expired = [];
1203
+ for (const [stashId, entry] of this.entries) {
1204
+ if (now - entry.lastHeartbeat > this.ttlMs) {
1205
+ expired.push(stashId);
1206
+ }
1207
+ }
1208
+ for (const stashId of expired) {
1209
+ logger.info("pool", `reaping inactive: ${stashId}`);
1210
+ const entry = this.entries.get(stashId);
1211
+ this.killEntry(entry);
1212
+ this.entries.delete(stashId);
1213
+ this.usedPorts.delete(entry.port);
1214
+ this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1215
+ logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1216
+ error: err instanceof Error ? err.message : String(err)
1217
+ });
1218
+ });
1219
+ this.broadcast({ type: "stash:preview_stopped", stashId });
1220
+ }
1221
+ }
1222
+ evictOldest() {
1223
+ let oldest = null;
1224
+ for (const entry of this.entries.values()) {
1225
+ if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
1226
+ oldest = entry;
1227
+ }
1228
+ }
1229
+ if (oldest) {
1230
+ logger.info("pool", `evicting: ${oldest.stashId} (oldest heartbeat)`);
1231
+ this.killEntry(oldest);
1232
+ this.entries.delete(oldest.stashId);
1233
+ this.usedPorts.delete(oldest.port);
1234
+ this.worktreeManager.removePreviewForPool(oldest.stashId).catch((err) => {
1235
+ logger.warn("pool", `evict worktree cleanup failed for ${oldest.stashId}`, {
1236
+ error: err instanceof Error ? err.message : String(err)
1237
+ });
1238
+ });
1239
+ this.broadcast({ type: "stash:preview_stopped", stashId: oldest.stashId });
1240
+ }
1241
+ }
1242
+ allocatePort() {
1243
+ for (let port = STASH_PORT_START;port <= STASH_PORT_END; port++) {
1244
+ if (!this.usedPorts.has(port)) {
1245
+ return port;
1246
+ }
1247
+ }
1248
+ throw new Error(`No available ports in range ${STASH_PORT_START}-${STASH_PORT_END}`);
1249
+ }
1250
+ killEntry(entry) {
1251
+ try {
1252
+ entry.process.kill();
1253
+ } catch {}
1254
+ }
1255
+ async waitForPort(port, timeout) {
1256
+ const start = Date.now();
1257
+ while (Date.now() - start < timeout) {
1258
+ try {
1259
+ const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1260
+ if (res.ok || res.status < 500)
1261
+ return;
1262
+ } catch {}
1263
+ await new Promise((r) => setTimeout(r, 1000));
1264
+ }
1265
+ throw new Error(`Port ${port} not ready within ${timeout}ms`);
1266
+ }
1267
+ }
1268
+
1269
+ // ../server/dist/services/stash-service.js
1052
1270
  class StashService {
1053
1271
  projectPath;
1054
1272
  worktreeManager;
1055
1273
  persistence;
1056
1274
  broadcast;
1275
+ previewPool;
1057
1276
  selectedComponent = null;
1058
- previewServer = null;
1059
- activePreviewStashId = null;
1060
1277
  constructor(projectPath, worktreeManager, persistence, broadcast) {
1061
1278
  this.projectPath = projectPath;
1062
1279
  this.worktreeManager = worktreeManager;
1063
1280
  this.persistence = persistence;
1064
1281
  this.broadcast = broadcast;
1282
+ this.previewPool = new PreviewPool(worktreeManager, broadcast);
1065
1283
  }
1066
1284
  setSelectedComponent(component) {
1067
1285
  this.selectedComponent = component;
@@ -1220,94 +1438,25 @@ ${refDescriptions.join(`
1220
1438
  onProgress: (event) => this.progressToBroadcast(event)
1221
1439
  });
1222
1440
  }
1223
- async switchPreview(stashId) {
1224
- const previewPort = this.worktreeManager.getPreviewPort();
1225
- let previewPath = this.worktreeManager.getPreviewPath();
1226
- if (!previewPath) {
1227
- previewPath = await this.worktreeManager.createPreview();
1228
- }
1229
- await this.ensurePreviewServer(previewPath);
1230
- if (this.activePreviewStashId === stashId) {
1231
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1232
- return;
1441
+ async switchPreview(stashId, sortedStashIds) {
1442
+ const port = await this.previewPool.getOrStart(stashId);
1443
+ this.broadcast({ type: "stash:port", stashId, port });
1444
+ if (sortedStashIds && sortedStashIds.length > 1) {
1445
+ this.previewPool.prefetchNeighbors(stashId, sortedStashIds);
1233
1446
  }
1234
- await this.worktreeManager.switchPreviewTo(stashId);
1235
- this.activePreviewStashId = stashId;
1236
- await this.waitForRecompile(previewPort, 15000);
1237
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1447
+ }
1448
+ previewHeartbeat(stashId) {
1449
+ this.previewPool.heartbeat(stashId);
1238
1450
  }
1239
1451
  async applyStash(stashId) {
1240
- this.stopPreviewServer();
1452
+ await this.previewPool.shutdown();
1241
1453
  await apply({ projectPath: this.projectPath, stashId });
1242
- this.activePreviewStashId = null;
1243
1454
  this.broadcast({ type: "stash:applied", stashId });
1244
1455
  }
1245
1456
  async deleteStash(stashId) {
1246
- if (this.activePreviewStashId === stashId) {
1247
- this.activePreviewStashId = null;
1248
- }
1457
+ await this.previewPool.stop(stashId);
1249
1458
  await remove(this.projectPath, stashId);
1250
1459
  }
1251
- async ensurePreviewServer(previewPath) {
1252
- if (this.previewServer) {
1253
- const port = this.worktreeManager.getPreviewPort();
1254
- try {
1255
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1256
- if (res.ok || res.status < 500)
1257
- return;
1258
- } catch {
1259
- this.previewServer = null;
1260
- }
1261
- }
1262
- await this.startPreviewServer(previewPath);
1263
- }
1264
- async startPreviewServer(previewPath) {
1265
- const port = this.worktreeManager.getPreviewPort();
1266
- this.previewServer = Bun.spawn({
1267
- cmd: ["npm", "run", "dev"],
1268
- cwd: previewPath,
1269
- stdin: "ignore",
1270
- stdout: "pipe",
1271
- stderr: "pipe",
1272
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
1273
- });
1274
- await this.waitForPort(port, 60000);
1275
- }
1276
- stopPreviewServer() {
1277
- if (this.previewServer) {
1278
- this.previewServer.kill();
1279
- this.previewServer = null;
1280
- }
1281
- }
1282
- async waitForRecompile(port, timeout) {
1283
- const start = Date.now();
1284
- await new Promise((r) => setTimeout(r, 500));
1285
- while (Date.now() - start < timeout) {
1286
- try {
1287
- const res = await fetch(`http://localhost:${port}`, {
1288
- signal: AbortSignal.timeout(3000),
1289
- headers: { "cache-control": "no-cache" }
1290
- });
1291
- if (res.ok) {
1292
- await new Promise((r) => setTimeout(r, 1000));
1293
- return;
1294
- }
1295
- } catch {}
1296
- await new Promise((r) => setTimeout(r, 500));
1297
- }
1298
- }
1299
- async waitForPort(port, timeout) {
1300
- const start = Date.now();
1301
- while (Date.now() - start < timeout) {
1302
- try {
1303
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1304
- if (res.ok || res.status < 500)
1305
- return;
1306
- } catch {}
1307
- await new Promise((r) => setTimeout(r, 1000));
1308
- }
1309
- throw new Error(`Port ${port} not ready within ${timeout}ms`);
1310
- }
1311
1460
  }
1312
1461
 
1313
1462
  // ../server/dist/services/websocket.js
@@ -1375,7 +1524,10 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1375
1524
  await stashService.vary(event.sourceStashId, event.prompt);
1376
1525
  break;
1377
1526
  case "interact":
1378
- await stashService.switchPreview(event.stashId);
1527
+ await stashService.switchPreview(event.stashId, event.sortedStashIds);
1528
+ break;
1529
+ case "preview_heartbeat":
1530
+ stashService.previewHeartbeat(event.stashId);
1379
1531
  break;
1380
1532
  case "apply_stash":
1381
1533
  await stashService.applyStash(event.stashId);