stashes 0.1.38 → 0.1.40

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
@@ -102,18 +102,35 @@ app.get("/chats", (c) => {
102
102
  app.post("/chats", async (c) => {
103
103
  const persistence = getPersistence();
104
104
  const project = ensureProject(persistence);
105
- const { title } = await c.req.json();
105
+ const { title, referencedStashIds } = await c.req.json();
106
106
  const chatCount = persistence.listChats(project.id).length;
107
107
  const chat = {
108
108
  id: `chat_${crypto.randomUUID().substring(0, 8)}`,
109
109
  projectId: project.id,
110
110
  title: title?.trim() || `Chat ${chatCount + 1}`,
111
+ referencedStashIds: referencedStashIds ?? [],
111
112
  createdAt: new Date().toISOString(),
112
113
  updatedAt: new Date().toISOString()
113
114
  };
114
115
  persistence.saveChat(chat);
115
116
  return c.json({ data: chat }, 201);
116
117
  });
118
+ app.patch("/chats/:chatId", async (c) => {
119
+ const persistence = getPersistence();
120
+ const project = ensureProject(persistence);
121
+ const chatId = c.req.param("chatId");
122
+ const chat = persistence.getChat(project.id, chatId);
123
+ if (!chat)
124
+ return c.json({ error: "Chat not found" }, 404);
125
+ const body = await c.req.json();
126
+ const updated = {
127
+ ...chat,
128
+ ...body.referencedStashIds !== undefined ? { referencedStashIds: body.referencedStashIds } : {},
129
+ updatedAt: new Date().toISOString()
130
+ };
131
+ persistence.saveChat(updated);
132
+ return c.json({ data: updated });
133
+ });
117
134
  app.get("/chats/:chatId", (c) => {
118
135
  const persistence = getPersistence();
119
136
  const project = ensureProject(persistence);
@@ -122,7 +139,8 @@ app.get("/chats/:chatId", (c) => {
122
139
  if (!chat)
123
140
  return c.json({ error: "Chat not found" }, 404);
124
141
  const messages = persistence.getChatMessages(project.id, chatId);
125
- const stashes = persistence.listStashes(project.id).filter((s) => s.originChatId === chatId);
142
+ const refIds = new Set(chat.referencedStashIds ?? []);
143
+ const stashes = persistence.listStashes(project.id).filter((s) => s.originChatId === chatId || refIds.has(s.id));
126
144
  return c.json({ data: { ...chat, messages, stashes } });
127
145
  });
128
146
  app.delete("/chats/:chatId", (c) => {
@@ -1362,141 +1380,428 @@ async function cleanup(projectPath) {
1362
1380
  import { readFileSync as readFileSync4, existsSync as existsSync8 } from "fs";
1363
1381
  import { join as join8 } from "path";
1364
1382
 
1365
- // ../server/dist/services/preview-pool.js
1366
- class PreviewPool {
1367
- entries = new Map;
1368
- usedPorts = new Set;
1369
- maxSize;
1370
- ttlMs;
1371
- worktreeManager;
1372
- broadcast;
1373
- reaperInterval;
1374
- constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
1375
- this.worktreeManager = worktreeManager;
1376
- this.broadcast = broadcast;
1377
- this.maxSize = maxSize;
1378
- this.ttlMs = ttlMs;
1379
- this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
1380
- }
1381
- async getOrStart(stashId) {
1382
- const existing = this.entries.get(stashId);
1383
- if (existing) {
1384
- existing.lastHeartbeat = Date.now();
1385
- logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
1386
- return existing.port;
1387
- }
1388
- if (this.entries.size >= this.maxSize) {
1389
- this.evictOldest();
1390
- }
1391
- const port = this.allocatePort();
1392
- const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
1393
- const process2 = Bun.spawn({
1394
- cmd: ["npm", "run", "dev"],
1395
- cwd: worktreePath,
1396
- stdin: "ignore",
1397
- stdout: "pipe",
1398
- stderr: "pipe",
1399
- env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
1400
- });
1401
- const entry = {
1402
- stashId,
1403
- port,
1404
- process: process2,
1405
- worktreePath,
1406
- lastHeartbeat: Date.now()
1407
- };
1408
- this.entries.set(stashId, entry);
1409
- this.usedPorts.add(port);
1410
- logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
1411
- await this.waitForPort(port, 60000);
1412
- return port;
1413
- }
1414
- heartbeat(stashId) {
1415
- const entry = this.entries.get(stashId);
1416
- if (entry) {
1417
- entry.lastHeartbeat = Date.now();
1418
- }
1419
- }
1420
- isWarm(stashId) {
1421
- return this.entries.has(stashId);
1422
- }
1423
- getPort(stashId) {
1424
- return this.entries.get(stashId)?.port ?? null;
1425
- }
1426
- async stop(stashId) {
1427
- const entry = this.entries.get(stashId);
1428
- if (!entry)
1429
- return;
1430
- logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1431
- this.killEntry(entry);
1432
- this.entries.delete(stashId);
1433
- this.usedPorts.delete(entry.port);
1434
- try {
1435
- await this.worktreeManager.removePreviewForPool(stashId);
1436
- } catch (err) {
1437
- logger.warn("pool", `worktree removal failed for ${stashId}`, {
1438
- error: err instanceof Error ? err.message : String(err)
1383
+ // ../server/dist/services/app-proxy.js
1384
+ import { spawn as spawn5 } from "child_process";
1385
+ function startAppProxy(userDevPort, proxyPort, injectOverlay) {
1386
+ const overlayScript = injectOverlay("", userDevPort, proxyPort);
1387
+ const overlayEscaped = JSON.stringify(overlayScript);
1388
+ const proxyScript = `
1389
+ const http = require('http');
1390
+ const net = require('net');
1391
+ const zlib = require('zlib');
1392
+ const UPSTREAM = ${userDevPort};
1393
+ const OVERLAY = ${overlayEscaped};
1394
+
1395
+ const server = http.createServer((clientReq, clientRes) => {
1396
+ const opts = {
1397
+ hostname: 'localhost',
1398
+ port: UPSTREAM,
1399
+ path: clientReq.url,
1400
+ method: clientReq.method,
1401
+ headers: clientReq.headers,
1402
+ };
1403
+ const proxyReq = http.request(opts, (proxyRes) => {
1404
+ const ct = proxyRes.headers['content-type'] || '';
1405
+ if (ct.includes('text/html')) {
1406
+ // Buffer HTML to inject overlay
1407
+ const chunks = [];
1408
+ proxyRes.on('data', c => chunks.push(c));
1409
+ proxyRes.on('end', () => {
1410
+ let html = Buffer.concat(chunks);
1411
+ const enc = proxyRes.headers['content-encoding'];
1412
+ // Decompress if needed
1413
+ if (enc === 'gzip') {
1414
+ try { html = zlib.gunzipSync(html); } catch {}
1415
+ } else if (enc === 'br') {
1416
+ try { html = zlib.brotliDecompressSync(html); } catch {}
1417
+ } else if (enc === 'deflate') {
1418
+ try { html = zlib.inflateSync(html); } catch {}
1419
+ }
1420
+ const hdrs = { ...proxyRes.headers };
1421
+ delete hdrs['content-length'];
1422
+ delete hdrs['content-encoding'];
1423
+ delete hdrs['transfer-encoding'];
1424
+ clientRes.writeHead(proxyRes.statusCode, hdrs);
1425
+ clientRes.write(html);
1426
+ clientRes.end(OVERLAY);
1439
1427
  });
1428
+ } else {
1429
+ // Non-HTML: stream through unchanged
1430
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
1431
+ proxyRes.pipe(clientRes);
1440
1432
  }
1441
- }
1442
- prefetchNeighbors(currentStashId, sortedStashIds) {
1443
- const currentIndex = sortedStashIds.indexOf(currentStashId);
1444
- if (currentIndex === -1 || sortedStashIds.length < 2)
1445
- return;
1446
- const neighbors = [];
1447
- const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1448
- const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1449
- if (!this.entries.has(sortedStashIds[prevIndex])) {
1450
- neighbors.push(sortedStashIds[prevIndex]);
1451
- }
1452
- if (!this.entries.has(sortedStashIds[nextIndex])) {
1453
- neighbors.push(sortedStashIds[nextIndex]);
1454
- }
1455
- for (const stashId of neighbors) {
1456
- if (this.entries.size >= this.maxSize)
1457
- break;
1458
- logger.info("pool", `prefetching neighbor: ${stashId}`);
1459
- this.getOrStart(stashId).then((port) => {
1460
- this.broadcast({ type: "stash:port", stashId, port });
1461
- }).catch((err) => {
1462
- logger.warn("pool", `prefetch failed for ${stashId}`, {
1463
- error: err instanceof Error ? err.message : String(err)
1464
- });
1465
- });
1433
+ proxyRes.on('error', () => clientRes.end());
1434
+ });
1435
+ proxyReq.on('error', () => { try { clientRes.writeHead(502); clientRes.end(); } catch {} });
1436
+ clientReq.pipe(proxyReq);
1437
+ });
1438
+
1439
+ // WebSocket upgrades: raw TCP pipe
1440
+ server.on('upgrade', (req, socket, head) => {
1441
+ const upstream = net.createConnection(UPSTREAM, 'localhost', () => {
1442
+ const lines = [req.method + ' ' + req.url + ' HTTP/1.1'];
1443
+ for (const [k, v] of Object.entries(req.headers)) {
1444
+ lines.push(k + ': ' + (Array.isArray(v) ? v.join(', ') : v));
1466
1445
  }
1467
- }
1468
- async shutdown() {
1469
- logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1470
- clearInterval(this.reaperInterval);
1471
- const stashIds = [...this.entries.keys()];
1472
- for (const stashId of stashIds) {
1473
- await this.stop(stashId);
1446
+ upstream.write(lines.join('\\r\\n') + '\\r\\n\\r\\n');
1447
+ if (head.length) upstream.write(head);
1448
+ socket.pipe(upstream);
1449
+ upstream.pipe(socket);
1450
+ });
1451
+ upstream.on('error', () => socket.destroy());
1452
+ socket.on('error', () => upstream.destroy());
1453
+ });
1454
+
1455
+ server.listen(${proxyPort}, () => {
1456
+ if (process.send) process.send('ready');
1457
+ });
1458
+ `;
1459
+ const child = spawn5("node", ["-e", proxyScript], {
1460
+ stdio: ["ignore", "inherit", "inherit", "ipc"]
1461
+ });
1462
+ child.on("error", (err) => {
1463
+ logger.error("proxy", `Failed to start proxy: ${err.message}`);
1464
+ });
1465
+ child.on("message", (msg) => {
1466
+ if (msg === "ready") {
1467
+ logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
1474
1468
  }
1469
+ });
1470
+ return child;
1471
+ }
1472
+
1473
+ // ../server/dist/services/overlay-script.js
1474
+ function injectOverlayScript(html, _upstreamPort, _proxyPort) {
1475
+ const overlayScript = `
1476
+ <script data-stashes-overlay>
1477
+ (function() {
1478
+ var highlightOverlay = null;
1479
+ var pickerEnabled = false;
1480
+ var precisionMode = false;
1481
+
1482
+ function createOverlay() {
1483
+ var overlay = document.createElement('div');
1484
+ overlay.id = 'stashes-highlight';
1485
+ overlay.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #6366f1;background:rgba(99,102,241,0.1);z-index:99999;transition:all 0.1s ease;display:none;border-radius:4px;';
1486
+ var tooltip = document.createElement('div');
1487
+ tooltip.id = 'stashes-tooltip';
1488
+ tooltip.style.cssText = 'position:fixed;background:#1e1b4b;color:#e0e7ff;padding:4px 10px;border-radius:6px;font-size:11px;font-family:ui-monospace,monospace;z-index:100000;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:400px;overflow:hidden;text-overflow:ellipsis;';
1489
+ document.body.appendChild(overlay);
1490
+ document.body.appendChild(tooltip);
1491
+ return overlay;
1475
1492
  }
1476
- reap() {
1477
- const now = Date.now();
1478
- const expired = [];
1479
- for (const [stashId, entry] of this.entries) {
1480
- if (now - entry.lastHeartbeat > this.ttlMs) {
1481
- expired.push(stashId);
1493
+
1494
+ var SEMANTIC_TAGS = ['header','nav','main','section','article','aside','footer','form','dialog'];
1495
+ 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'];
1496
+
1497
+ function findTarget(el, precise) {
1498
+ if (precise) return el;
1499
+ // If the element itself is a meaningful leaf, select it directly
1500
+ var elTag = el.tagName ? el.tagName.toLowerCase() : '';
1501
+ if (LEAF_TAGS.indexOf(elTag) !== -1) return el;
1502
+ var current = el;
1503
+ var best = el;
1504
+ while (current && current !== document.body) {
1505
+ var tag = current.tagName.toLowerCase();
1506
+ if (LEAF_TAGS.indexOf(tag) !== -1) { best = current; break; }
1507
+ if (SEMANTIC_TAGS.indexOf(tag) !== -1) { best = current; break; }
1508
+ if (current.id) { best = current; break; }
1509
+ if (current.getAttribute('role')) { best = current; break; }
1510
+ if (current.getAttribute('data-testid')) { best = current; break; }
1511
+ if (current.children && current.children.length > 1 && current.getBoundingClientRect().height > 50) {
1512
+ best = current;
1513
+ break;
1482
1514
  }
1515
+ current = current.parentElement;
1483
1516
  }
1484
- for (const stashId of expired) {
1485
- logger.info("pool", `reaping inactive: ${stashId}`);
1486
- const entry = this.entries.get(stashId);
1487
- this.killEntry(entry);
1488
- this.entries.delete(stashId);
1489
- this.usedPorts.delete(entry.port);
1490
- this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1491
- logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1492
- error: err instanceof Error ? err.message : String(err)
1493
- });
1494
- });
1495
- this.broadcast({ type: "stash:preview_stopped", stashId });
1496
- }
1517
+ return best;
1497
1518
  }
1498
- evictOldest() {
1499
- let oldest = null;
1519
+
1520
+ function describeElement(el) {
1521
+ var tag = el.tagName.toLowerCase();
1522
+ var id = el.id ? '#' + el.id : '';
1523
+ var role = el.getAttribute('role') || '';
1524
+ var testId = el.getAttribute('data-testid') || '';
1525
+ var cls = (el.className && typeof el.className === 'string') ? el.className.trim().split(/[ ]+/).slice(0, 2).join('.') : '';
1526
+ var text = (el.textContent || '').trim().substring(0, 40);
1527
+ var label = tag;
1528
+ if (SEMANTIC_TAGS.indexOf(tag) !== -1) label = tag.toUpperCase();
1529
+ if (id) label += ' ' + id;
1530
+ else if (cls) label += '.' + cls;
1531
+ if (role) label += ' [' + role + ']';
1532
+ if (testId) label += ' [' + testId + ']';
1533
+ if (!id && !cls && text) label += ' "' + text.substring(0, 25) + '"';
1534
+ return label;
1535
+ }
1536
+
1537
+ function generateSelector(el) {
1538
+ if (el.id) return '#' + el.id;
1539
+ var parts = [];
1540
+ var current = el;
1541
+ var depth = 0;
1542
+ while (current && current !== document.body && depth < 5) {
1543
+ var sel = current.tagName.toLowerCase();
1544
+ if (current.id) { parts.unshift('#' + current.id); break; }
1545
+ if (current.className && typeof current.className === 'string') {
1546
+ var c = current.className.trim().split(/[ ]+/).slice(0, 2).join('.');
1547
+ if (c) sel += '.' + c;
1548
+ }
1549
+ parts.unshift(sel);
1550
+ current = current.parentElement;
1551
+ depth++;
1552
+ }
1553
+ return parts.join(' > ');
1554
+ }
1555
+
1556
+ function onMouseMove(e) {
1557
+ if (!pickerEnabled) return;
1558
+ if (!highlightOverlay) highlightOverlay = createOverlay();
1559
+ precisionMode = e.shiftKey;
1560
+ var target = findTarget(e.target, precisionMode);
1561
+ var overlay = document.getElementById('stashes-highlight');
1562
+ var tooltip = document.getElementById('stashes-tooltip');
1563
+ if (target) {
1564
+ var rect = target.getBoundingClientRect();
1565
+ overlay.style.display = 'block';
1566
+ overlay.style.top = rect.top + 'px';
1567
+ overlay.style.left = rect.left + 'px';
1568
+ overlay.style.width = rect.width + 'px';
1569
+ overlay.style.height = rect.height + 'px';
1570
+ overlay.style.borderColor = precisionMode ? '#f59e0b' : '#6366f1';
1571
+ tooltip.style.display = 'block';
1572
+ tooltip.style.top = Math.max(0, rect.top - 30) + 'px';
1573
+ tooltip.style.left = Math.max(0, rect.left) + 'px';
1574
+ tooltip.textContent = (precisionMode ? '[precise] ' : '') + describeElement(target);
1575
+ } else {
1576
+ overlay.style.display = 'none';
1577
+ tooltip.style.display = 'none';
1578
+ }
1579
+ }
1580
+
1581
+ function onClick(e) {
1582
+ if (!pickerEnabled) return;
1583
+ e.preventDefault();
1584
+ e.stopPropagation();
1585
+ var target = findTarget(e.target, e.shiftKey);
1586
+ if (target) {
1587
+ var desc = describeElement(target);
1588
+ var selector = generateSelector(target);
1589
+ var tag = target.tagName.toLowerCase();
1590
+ var outerSnippet = target.outerHTML.substring(0, 500);
1591
+ window.parent.postMessage({
1592
+ type: 'stashes:component_selected',
1593
+ component: {
1594
+ name: desc,
1595
+ filePath: 'auto-detect',
1596
+ domSelector: selector,
1597
+ htmlSnippet: outerSnippet,
1598
+ tag: tag
1599
+ }
1600
+ }, '*');
1601
+ var overlay = document.getElementById('stashes-highlight');
1602
+ if (overlay) {
1603
+ overlay.style.borderColor = '#22c55e';
1604
+ overlay.style.background = 'rgba(34,197,94,0.1)';
1605
+ setTimeout(function() {
1606
+ overlay.style.borderColor = '#6366f1';
1607
+ overlay.style.background = 'rgba(99,102,241,0.1)';
1608
+ }, 500);
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ window.addEventListener('message', function(e) {
1614
+ if (!e.data || !e.data.type) return;
1615
+ if (e.data.type === 'stashes:toggle_picker') {
1616
+ pickerEnabled = e.data.enabled;
1617
+ if (!pickerEnabled) {
1618
+ var ov = document.getElementById('stashes-highlight');
1619
+ var tp = document.getElementById('stashes-tooltip');
1620
+ if (ov) ov.style.display = 'none';
1621
+ if (tp) tp.style.display = 'none';
1622
+ }
1623
+ } else if (e.data.type === 'stashes:navigate_back') {
1624
+ history.back();
1625
+ } else if (e.data.type === 'stashes:navigate_forward') {
1626
+ history.forward();
1627
+ } else if (e.data.type === 'stashes:refresh') {
1628
+ location.reload();
1629
+ } else if (e.data.type === 'stashes:navigate_to') {
1630
+ window.location.href = e.data.url;
1631
+ }
1632
+ });
1633
+
1634
+ // Report current URL to parent for status bar display
1635
+ function reportUrl() {
1636
+ window.parent.postMessage({
1637
+ type: 'stashes:url_change',
1638
+ url: window.location.pathname + window.location.search + window.location.hash
1639
+ }, '*');
1640
+ }
1641
+ reportUrl();
1642
+ var origPush = history.pushState;
1643
+ history.pushState = function() {
1644
+ origPush.apply(this, arguments);
1645
+ setTimeout(reportUrl, 0);
1646
+ };
1647
+ var origReplace = history.replaceState;
1648
+ history.replaceState = function() {
1649
+ origReplace.apply(this, arguments);
1650
+ setTimeout(reportUrl, 0);
1651
+ };
1652
+ window.addEventListener('popstate', reportUrl);
1653
+
1654
+ document.addEventListener('mousemove', onMouseMove, { passive: true });
1655
+ document.addEventListener('click', onClick, true);
1656
+ })();
1657
+ </script>`;
1658
+ if (html.includes("</body>")) {
1659
+ return html.replace("</body>", () => overlayScript + `
1660
+ </body>`);
1661
+ }
1662
+ return html + overlayScript;
1663
+ }
1664
+
1665
+ // ../server/dist/services/preview-pool.js
1666
+ var DEV_PORT_OFFSET = 1000;
1667
+
1668
+ class PreviewPool {
1669
+ entries = new Map;
1670
+ usedPorts = new Set;
1671
+ maxSize;
1672
+ ttlMs;
1673
+ worktreeManager;
1674
+ broadcast;
1675
+ reaperInterval;
1676
+ constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
1677
+ this.worktreeManager = worktreeManager;
1678
+ this.broadcast = broadcast;
1679
+ this.maxSize = maxSize;
1680
+ this.ttlMs = ttlMs;
1681
+ this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
1682
+ }
1683
+ async getOrStart(stashId) {
1684
+ const existing = this.entries.get(stashId);
1685
+ if (existing) {
1686
+ existing.lastHeartbeat = Date.now();
1687
+ logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
1688
+ return existing.port;
1689
+ }
1690
+ if (this.entries.size >= this.maxSize) {
1691
+ this.evictOldest();
1692
+ }
1693
+ const proxyPort = this.allocatePort();
1694
+ const devPort = proxyPort + DEV_PORT_OFFSET;
1695
+ const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
1696
+ const process2 = Bun.spawn({
1697
+ cmd: ["npm", "run", "dev"],
1698
+ cwd: worktreePath,
1699
+ stdin: "ignore",
1700
+ stdout: "pipe",
1701
+ stderr: "pipe",
1702
+ env: { ...Bun.env, PORT: String(devPort), BROWSER: "none" }
1703
+ });
1704
+ const proxyProcess = startAppProxy(devPort, proxyPort, injectOverlayScript);
1705
+ const entry = {
1706
+ stashId,
1707
+ port: proxyPort,
1708
+ process: process2,
1709
+ proxyProcess,
1710
+ worktreePath,
1711
+ lastHeartbeat: Date.now()
1712
+ };
1713
+ this.entries.set(stashId, entry);
1714
+ this.usedPorts.add(proxyPort);
1715
+ logger.info("pool", `cold start: ${stashId} dev=:${devPort} proxy=:${proxyPort}`, { poolSize: this.entries.size });
1716
+ await this.waitForPort(proxyPort, 60000);
1717
+ return proxyPort;
1718
+ }
1719
+ heartbeat(stashId) {
1720
+ const entry = this.entries.get(stashId);
1721
+ if (entry) {
1722
+ entry.lastHeartbeat = Date.now();
1723
+ }
1724
+ }
1725
+ isWarm(stashId) {
1726
+ return this.entries.has(stashId);
1727
+ }
1728
+ getPort(stashId) {
1729
+ return this.entries.get(stashId)?.port ?? null;
1730
+ }
1731
+ async stop(stashId) {
1732
+ const entry = this.entries.get(stashId);
1733
+ if (!entry)
1734
+ return;
1735
+ logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1736
+ this.killEntry(entry);
1737
+ this.entries.delete(stashId);
1738
+ this.usedPorts.delete(entry.port);
1739
+ try {
1740
+ await this.worktreeManager.removePreviewForPool(stashId);
1741
+ } catch (err) {
1742
+ logger.warn("pool", `worktree removal failed for ${stashId}`, {
1743
+ error: err instanceof Error ? err.message : String(err)
1744
+ });
1745
+ }
1746
+ }
1747
+ prefetchNeighbors(currentStashId, sortedStashIds) {
1748
+ const currentIndex = sortedStashIds.indexOf(currentStashId);
1749
+ if (currentIndex === -1 || sortedStashIds.length < 2)
1750
+ return;
1751
+ const neighbors = [];
1752
+ const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1753
+ const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1754
+ if (!this.entries.has(sortedStashIds[prevIndex])) {
1755
+ neighbors.push(sortedStashIds[prevIndex]);
1756
+ }
1757
+ if (!this.entries.has(sortedStashIds[nextIndex])) {
1758
+ neighbors.push(sortedStashIds[nextIndex]);
1759
+ }
1760
+ for (const stashId of neighbors) {
1761
+ if (this.entries.size >= this.maxSize)
1762
+ break;
1763
+ logger.info("pool", `prefetching neighbor: ${stashId}`);
1764
+ this.getOrStart(stashId).then((port) => {
1765
+ this.broadcast({ type: "stash:port", stashId, port });
1766
+ }).catch((err) => {
1767
+ logger.warn("pool", `prefetch failed for ${stashId}`, {
1768
+ error: err instanceof Error ? err.message : String(err)
1769
+ });
1770
+ });
1771
+ }
1772
+ }
1773
+ async shutdown() {
1774
+ logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1775
+ clearInterval(this.reaperInterval);
1776
+ const stashIds = [...this.entries.keys()];
1777
+ for (const stashId of stashIds) {
1778
+ await this.stop(stashId);
1779
+ }
1780
+ }
1781
+ reap() {
1782
+ const now = Date.now();
1783
+ const expired = [];
1784
+ for (const [stashId, entry] of this.entries) {
1785
+ if (now - entry.lastHeartbeat > this.ttlMs) {
1786
+ expired.push(stashId);
1787
+ }
1788
+ }
1789
+ for (const stashId of expired) {
1790
+ logger.info("pool", `reaping inactive: ${stashId}`);
1791
+ const entry = this.entries.get(stashId);
1792
+ this.killEntry(entry);
1793
+ this.entries.delete(stashId);
1794
+ this.usedPorts.delete(entry.port);
1795
+ this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1796
+ logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1797
+ error: err instanceof Error ? err.message : String(err)
1798
+ });
1799
+ });
1800
+ this.broadcast({ type: "stash:preview_stopped", stashId });
1801
+ }
1802
+ }
1803
+ evictOldest() {
1804
+ let oldest = null;
1500
1805
  for (const entry of this.entries.values()) {
1501
1806
  if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
1502
1807
  oldest = entry;
@@ -1527,6 +1832,9 @@ class PreviewPool {
1527
1832
  try {
1528
1833
  entry.process.kill();
1529
1834
  } catch {}
1835
+ try {
1836
+ entry.proxyProcess.kill();
1837
+ } catch {}
1530
1838
  }
1531
1839
  async waitForPort(port, timeout) {
1532
1840
  const start = Date.now();
@@ -2031,124 +2339,34 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
2031
2339
  }
2032
2340
  }
2033
2341
  await stashService.message(event.projectId, event.chatId, event.message, event.referenceStashIds, event.componentContext);
2034
- break;
2035
- }
2036
- case "interact":
2037
- await stashService.switchPreview(event.stashId, event.sortedStashIds);
2038
- break;
2039
- case "preview_heartbeat":
2040
- stashService.previewHeartbeat(event.stashId);
2041
- break;
2042
- case "apply_stash":
2043
- await stashService.applyStash(event.stashId);
2044
- break;
2045
- case "delete_stash":
2046
- await stashService.deleteStash(event.stashId);
2047
- break;
2048
- }
2049
- } catch (err) {
2050
- const errorMsg = err instanceof Error ? err.message : String(err);
2051
- logger.error("ws", `handler failed for ${event.type}`, { error: errorMsg });
2052
- if ("stashId" in event && event.stashId) {
2053
- broadcast({ type: "stash:error", stashId: event.stashId, error: errorMsg });
2054
- }
2055
- }
2056
- },
2057
- close(ws) {
2058
- clients.delete(ws);
2059
- logger.info("ws", "client disconnected", { remaining: clients.size });
2060
- }
2061
- };
2062
- }
2063
-
2064
- // ../server/dist/services/app-proxy.js
2065
- import { spawn as spawn5 } from "child_process";
2066
- function startAppProxy(userDevPort, proxyPort, injectOverlay) {
2067
- const overlayScript = injectOverlay("", userDevPort, proxyPort);
2068
- const overlayEscaped = JSON.stringify(overlayScript);
2069
- const proxyScript = `
2070
- const http = require('http');
2071
- const net = require('net');
2072
- const zlib = require('zlib');
2073
- const UPSTREAM = ${userDevPort};
2074
- const OVERLAY = ${overlayEscaped};
2075
-
2076
- const server = http.createServer((clientReq, clientRes) => {
2077
- const opts = {
2078
- hostname: 'localhost',
2079
- port: UPSTREAM,
2080
- path: clientReq.url,
2081
- method: clientReq.method,
2082
- headers: clientReq.headers,
2083
- };
2084
- const proxyReq = http.request(opts, (proxyRes) => {
2085
- const ct = proxyRes.headers['content-type'] || '';
2086
- if (ct.includes('text/html')) {
2087
- // Buffer HTML to inject overlay
2088
- const chunks = [];
2089
- proxyRes.on('data', c => chunks.push(c));
2090
- proxyRes.on('end', () => {
2091
- let html = Buffer.concat(chunks);
2092
- const enc = proxyRes.headers['content-encoding'];
2093
- // Decompress if needed
2094
- if (enc === 'gzip') {
2095
- try { html = zlib.gunzipSync(html); } catch {}
2096
- } else if (enc === 'br') {
2097
- try { html = zlib.brotliDecompressSync(html); } catch {}
2098
- } else if (enc === 'deflate') {
2099
- try { html = zlib.inflateSync(html); } catch {}
2342
+ break;
2343
+ }
2344
+ case "interact":
2345
+ await stashService.switchPreview(event.stashId, event.sortedStashIds);
2346
+ break;
2347
+ case "preview_heartbeat":
2348
+ stashService.previewHeartbeat(event.stashId);
2349
+ break;
2350
+ case "apply_stash":
2351
+ await stashService.applyStash(event.stashId);
2352
+ break;
2353
+ case "delete_stash":
2354
+ await stashService.deleteStash(event.stashId);
2355
+ break;
2100
2356
  }
2101
- const hdrs = { ...proxyRes.headers };
2102
- delete hdrs['content-length'];
2103
- delete hdrs['content-encoding'];
2104
- delete hdrs['transfer-encoding'];
2105
- clientRes.writeHead(proxyRes.statusCode, hdrs);
2106
- clientRes.write(html);
2107
- clientRes.end(OVERLAY);
2108
- });
2109
- } else {
2110
- // Non-HTML: stream through unchanged
2111
- clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
2112
- proxyRes.pipe(clientRes);
2113
- }
2114
- proxyRes.on('error', () => clientRes.end());
2115
- });
2116
- proxyReq.on('error', () => { try { clientRes.writeHead(502); clientRes.end(); } catch {} });
2117
- clientReq.pipe(proxyReq);
2118
- });
2119
-
2120
- // WebSocket upgrades: raw TCP pipe
2121
- server.on('upgrade', (req, socket, head) => {
2122
- const upstream = net.createConnection(UPSTREAM, 'localhost', () => {
2123
- const lines = [req.method + ' ' + req.url + ' HTTP/1.1'];
2124
- for (const [k, v] of Object.entries(req.headers)) {
2125
- lines.push(k + ': ' + (Array.isArray(v) ? v.join(', ') : v));
2126
- }
2127
- upstream.write(lines.join('\\r\\n') + '\\r\\n\\r\\n');
2128
- if (head.length) upstream.write(head);
2129
- socket.pipe(upstream);
2130
- upstream.pipe(socket);
2131
- });
2132
- upstream.on('error', () => socket.destroy());
2133
- socket.on('error', () => upstream.destroy());
2134
- });
2135
-
2136
- server.listen(${proxyPort}, () => {
2137
- if (process.send) process.send('ready');
2138
- });
2139
- `;
2140
- const child = spawn5("node", ["-e", proxyScript], {
2141
- stdio: ["ignore", "inherit", "inherit", "ipc"]
2142
- });
2143
- child.on("error", (err) => {
2144
- logger.error("proxy", `Failed to start proxy: ${err.message}`);
2145
- });
2146
- child.on("message", (msg) => {
2147
- if (msg === "ready") {
2148
- logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
2357
+ } catch (err) {
2358
+ const errorMsg = err instanceof Error ? err.message : String(err);
2359
+ logger.error("ws", `handler failed for ${event.type}`, { error: errorMsg });
2360
+ if ("stashId" in event && event.stashId) {
2361
+ broadcast({ type: "stash:error", stashId: event.stashId, error: errorMsg });
2362
+ }
2363
+ }
2364
+ },
2365
+ close(ws) {
2366
+ clients.delete(ws);
2367
+ logger.info("ws", "client disconnected", { remaining: clients.size });
2149
2368
  }
2150
- });
2151
- return child;
2369
+ };
2152
2370
  }
2153
2371
 
2154
2372
  // ../server/dist/index.js
@@ -2226,187 +2444,6 @@ function startServer(projectPath, userDevPort, port = STASHES_PORT) {
2226
2444
  logger.info("server", `Project: ${projectPath}`);
2227
2445
  return server;
2228
2446
  }
2229
- function injectOverlayScript(html, _upstreamPort, _proxyPort) {
2230
- const overlayScript = `
2231
- <script data-stashes-overlay>
2232
- (function() {
2233
- var highlightOverlay = null;
2234
- var pickerEnabled = false;
2235
- var precisionMode = false;
2236
-
2237
- function createOverlay() {
2238
- var overlay = document.createElement('div');
2239
- overlay.id = 'stashes-highlight';
2240
- overlay.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #6366f1;background:rgba(99,102,241,0.1);z-index:99999;transition:all 0.1s ease;display:none;border-radius:4px;';
2241
- var tooltip = document.createElement('div');
2242
- tooltip.id = 'stashes-tooltip';
2243
- tooltip.style.cssText = 'position:fixed;background:#1e1b4b;color:#e0e7ff;padding:4px 10px;border-radius:6px;font-size:11px;font-family:ui-monospace,monospace;z-index:100000;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:400px;overflow:hidden;text-overflow:ellipsis;';
2244
- document.body.appendChild(overlay);
2245
- document.body.appendChild(tooltip);
2246
- return overlay;
2247
- }
2248
-
2249
- var SEMANTIC_TAGS = ['header','nav','main','section','article','aside','footer','form','dialog'];
2250
- 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'];
2251
-
2252
- function findTarget(el, precise) {
2253
- if (precise) return el;
2254
- // If the element itself is a meaningful leaf, select it directly
2255
- var elTag = el.tagName ? el.tagName.toLowerCase() : '';
2256
- if (LEAF_TAGS.indexOf(elTag) !== -1) return el;
2257
- var current = el;
2258
- var best = el;
2259
- while (current && current !== document.body) {
2260
- var tag = current.tagName.toLowerCase();
2261
- if (LEAF_TAGS.indexOf(tag) !== -1) { best = current; break; }
2262
- if (SEMANTIC_TAGS.indexOf(tag) !== -1) { best = current; break; }
2263
- if (current.id) { best = current; break; }
2264
- if (current.getAttribute('role')) { best = current; break; }
2265
- if (current.getAttribute('data-testid')) { best = current; break; }
2266
- if (current.children && current.children.length > 1 && current.getBoundingClientRect().height > 50) {
2267
- best = current;
2268
- break;
2269
- }
2270
- current = current.parentElement;
2271
- }
2272
- return best;
2273
- }
2274
-
2275
- function describeElement(el) {
2276
- var tag = el.tagName.toLowerCase();
2277
- var id = el.id ? '#' + el.id : '';
2278
- var role = el.getAttribute('role') || '';
2279
- var testId = el.getAttribute('data-testid') || '';
2280
- var cls = (el.className && typeof el.className === 'string') ? el.className.trim().split(/[ ]+/).slice(0, 2).join('.') : '';
2281
- var text = (el.textContent || '').trim().substring(0, 40);
2282
- var label = tag;
2283
- if (SEMANTIC_TAGS.indexOf(tag) !== -1) label = tag.toUpperCase();
2284
- if (id) label += ' ' + id;
2285
- else if (cls) label += '.' + cls;
2286
- if (role) label += ' [' + role + ']';
2287
- if (testId) label += ' [' + testId + ']';
2288
- if (!id && !cls && text) label += ' "' + text.substring(0, 25) + '"';
2289
- return label;
2290
- }
2291
-
2292
- function generateSelector(el) {
2293
- if (el.id) return '#' + el.id;
2294
- var parts = [];
2295
- var current = el;
2296
- var depth = 0;
2297
- while (current && current !== document.body && depth < 5) {
2298
- var sel = current.tagName.toLowerCase();
2299
- if (current.id) { parts.unshift('#' + current.id); break; }
2300
- if (current.className && typeof current.className === 'string') {
2301
- var c = current.className.trim().split(/[ ]+/).slice(0, 2).join('.');
2302
- if (c) sel += '.' + c;
2303
- }
2304
- parts.unshift(sel);
2305
- current = current.parentElement;
2306
- depth++;
2307
- }
2308
- return parts.join(' > ');
2309
- }
2310
-
2311
- function onMouseMove(e) {
2312
- if (!pickerEnabled) return;
2313
- if (!highlightOverlay) highlightOverlay = createOverlay();
2314
- precisionMode = e.shiftKey;
2315
- var target = findTarget(e.target, precisionMode);
2316
- var overlay = document.getElementById('stashes-highlight');
2317
- var tooltip = document.getElementById('stashes-tooltip');
2318
- if (target) {
2319
- var rect = target.getBoundingClientRect();
2320
- overlay.style.display = 'block';
2321
- overlay.style.top = rect.top + 'px';
2322
- overlay.style.left = rect.left + 'px';
2323
- overlay.style.width = rect.width + 'px';
2324
- overlay.style.height = rect.height + 'px';
2325
- overlay.style.borderColor = precisionMode ? '#f59e0b' : '#6366f1';
2326
- tooltip.style.display = 'block';
2327
- tooltip.style.top = Math.max(0, rect.top - 30) + 'px';
2328
- tooltip.style.left = Math.max(0, rect.left) + 'px';
2329
- tooltip.textContent = (precisionMode ? '[precise] ' : '') + describeElement(target);
2330
- } else {
2331
- overlay.style.display = 'none';
2332
- tooltip.style.display = 'none';
2333
- }
2334
- }
2335
-
2336
- function onClick(e) {
2337
- if (!pickerEnabled) return;
2338
- e.preventDefault();
2339
- e.stopPropagation();
2340
- var target = findTarget(e.target, e.shiftKey);
2341
- if (target) {
2342
- var desc = describeElement(target);
2343
- var selector = generateSelector(target);
2344
- var tag = target.tagName.toLowerCase();
2345
- var outerSnippet = target.outerHTML.substring(0, 500);
2346
- window.parent.postMessage({
2347
- type: 'stashes:component_selected',
2348
- component: {
2349
- name: desc,
2350
- filePath: 'auto-detect',
2351
- domSelector: selector,
2352
- htmlSnippet: outerSnippet,
2353
- tag: tag
2354
- }
2355
- }, '*');
2356
- var overlay = document.getElementById('stashes-highlight');
2357
- if (overlay) {
2358
- overlay.style.borderColor = '#22c55e';
2359
- overlay.style.background = 'rgba(34,197,94,0.1)';
2360
- setTimeout(function() {
2361
- overlay.style.borderColor = '#6366f1';
2362
- overlay.style.background = 'rgba(99,102,241,0.1)';
2363
- }, 500);
2364
- }
2365
- }
2366
- }
2367
-
2368
- window.addEventListener('message', function(e) {
2369
- if (e.data && e.data.type === 'stashes:toggle_picker') {
2370
- pickerEnabled = e.data.enabled;
2371
- if (!pickerEnabled) {
2372
- var ov = document.getElementById('stashes-highlight');
2373
- var tp = document.getElementById('stashes-tooltip');
2374
- if (ov) ov.style.display = 'none';
2375
- if (tp) tp.style.display = 'none';
2376
- }
2377
- }
2378
- });
2379
-
2380
- // Report current URL to parent for status bar display
2381
- function reportUrl() {
2382
- window.parent.postMessage({
2383
- type: 'stashes:url_change',
2384
- url: window.location.pathname + window.location.search + window.location.hash
2385
- }, '*');
2386
- }
2387
- reportUrl();
2388
- var origPush = history.pushState;
2389
- history.pushState = function() {
2390
- origPush.apply(this, arguments);
2391
- setTimeout(reportUrl, 0);
2392
- };
2393
- var origReplace = history.replaceState;
2394
- history.replaceState = function() {
2395
- origReplace.apply(this, arguments);
2396
- setTimeout(reportUrl, 0);
2397
- };
2398
- window.addEventListener('popstate', reportUrl);
2399
-
2400
- document.addEventListener('mousemove', onMouseMove, { passive: true });
2401
- document.addEventListener('click', onClick, true);
2402
- })();
2403
- </script>`;
2404
- if (html.includes("</body>")) {
2405
- return html.replace("</body>", () => overlayScript + `
2406
- </body>`);
2407
- }
2408
- return html + overlayScript;
2409
- }
2410
2447
 
2411
2448
  // ../server/dist/services/detector.js
2412
2449
  import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";