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/cli.js +234 -82
- package/dist/mcp.js +234 -82
- package/dist/web/assets/index-BMBumaom.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
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
|
|
1225
|
-
|
|
1226
|
-
if (
|
|
1227
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|