stashes 0.1.10 → 0.1.12

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
@@ -312,7 +312,29 @@ class WorktreeManager {
312
312
  await this.git.raw(["worktree", "prune"]);
313
313
  }
314
314
  logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
315
- await this.git.raw(["worktree", "add", previewPath, branch]);
315
+ try {
316
+ await this.git.raw(["worktree", "add", previewPath, branch]);
317
+ } catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ if (msg.includes("is already used by worktree")) {
320
+ logger.warn("worktree", `branch ${branch} locked by stale worktree, cleaning up`);
321
+ const staleDir = join3(this.projectPath, ".stashes", "worktrees", stashId);
322
+ const legacyPreview = join3(this.projectPath, ".stashes", "preview");
323
+ for (const dir of [staleDir, legacyPreview]) {
324
+ if (existsSync3(dir)) {
325
+ try {
326
+ await this.git.raw(["worktree", "remove", "--force", dir]);
327
+ } catch {
328
+ rmSync(dir, { recursive: true, force: true });
329
+ }
330
+ }
331
+ }
332
+ await this.git.raw(["worktree", "prune"]);
333
+ await this.git.raw(["worktree", "add", previewPath, branch]);
334
+ } else {
335
+ throw err;
336
+ }
337
+ }
316
338
  this.symlinkDeps(previewPath);
317
339
  logger.info("worktree", `pool preview created: ${stashId}`);
318
340
  return previewPath;
@@ -711,11 +733,15 @@ async function generate(opts) {
711
733
  }
712
734
  }
713
735
  const completedStashes = [];
714
- const stashPromises = selectedDirectives.map(async (directive) => {
736
+ const existingStashes = persistence.listStashes(projectId);
737
+ const maxNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0);
738
+ const stashPromises = selectedDirectives.map(async (directive, idx) => {
715
739
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
740
+ const stashNumber = maxNumber + idx + 1;
716
741
  const worktree = await worktreeManager.createForGeneration(stashId);
717
742
  const stash = {
718
743
  id: stashId,
744
+ number: stashNumber,
719
745
  projectId,
720
746
  prompt,
721
747
  componentPath: component?.filePath,
@@ -729,7 +755,7 @@ async function generate(opts) {
729
755
  createdAt: new Date().toISOString()
730
756
  };
731
757
  persistence.saveStash(stash);
732
- emit(onProgress, { type: "generating", stashId });
758
+ emit(onProgress, { type: "generating", stashId, number: stashNumber });
733
759
  let stashPrompt;
734
760
  if (component?.filePath) {
735
761
  stashPrompt = buildStashPrompt({ name: component.exportName || component.filePath, filePath: component.filePath, domSelector: "" }, sourceCode, prompt, directive);
@@ -834,9 +860,12 @@ async function vary(opts) {
834
860
  if (!sourceStash)
835
861
  throw new Error(`Source stash ${sourceStashId} not found`);
836
862
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
863
+ const existingStashes = persistence.listStashes(projectId);
864
+ const stashNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0) + 1;
837
865
  const worktree = await worktreeManager.createForVary(stashId, sourceStash.branch);
838
866
  const stash = {
839
867
  id: stashId,
868
+ number: stashNumber,
840
869
  projectId,
841
870
  prompt,
842
871
  componentPath: sourceStash.componentPath,
@@ -850,7 +879,7 @@ async function vary(opts) {
850
879
  createdAt: new Date().toISOString()
851
880
  };
852
881
  persistence.saveStash(stash);
853
- emit2(onProgress, { type: "generating", stashId });
882
+ emit2(onProgress, { type: "generating", stashId, number: stashNumber });
854
883
  const varyPrompt = `The user wants to vary the current UI. Apply this change: ${prompt}`;
855
884
  const aiProcess = startAiProcess(stashId, varyPrompt, worktree.path);
856
885
  try {
@@ -1195,8 +1224,8 @@ class StashService {
1195
1224
  });
1196
1225
  }
1197
1226
  }
1198
- async chat(projectId, message, referenceStashIds) {
1199
- const component = this.selectedComponent;
1227
+ async message(projectId, message, referenceStashIds, componentContext) {
1228
+ const component = componentContext ? { name: componentContext.name, filePath: this.selectedComponent?.filePath || "" } : this.selectedComponent;
1200
1229
  let sourceCode = "";
1201
1230
  const filePath = component?.filePath || "";
1202
1231
  if (filePath && filePath !== "auto-detect") {
@@ -1216,8 +1245,11 @@ ${refs.join(`
1216
1245
  }
1217
1246
  }
1218
1247
  const chatPrompt = [
1219
- "The user is asking about their UI project. Answer concisely.",
1220
- "Do NOT modify any files.",
1248
+ "You are helping the user explore UI design variations for their project.",
1249
+ "You have access to stashes MCP tools to generate, list, browse, vary, and apply stashes.",
1250
+ "If the user asks you to generate, create, or make variations, use the stashes_generate tool.",
1251
+ "If the user asks to vary an existing stash, use the stashes_vary tool.",
1252
+ "Otherwise, respond conversationally about their project and stashes.",
1221
1253
  "",
1222
1254
  component ? `Component: ${component.name}` : "",
1223
1255
  filePath !== "auto-detect" ? `File: ${filePath}` : "",
@@ -1228,7 +1260,7 @@ ${sourceCode.substring(0, 3000)}
1228
1260
  \`\`\`` : "",
1229
1261
  stashContext,
1230
1262
  "",
1231
- `User question: ${message}`
1263
+ `User: ${message}`
1232
1264
  ].filter(Boolean).join(`
1233
1265
  `);
1234
1266
  const aiProcess = startAiProcess("chat", chatPrompt, this.projectPath);
@@ -1237,7 +1269,55 @@ ${sourceCode.substring(0, 3000)}
1237
1269
  for await (const chunk of parseClaudeStream(aiProcess.process)) {
1238
1270
  if (chunk.type === "text") {
1239
1271
  fullResponse += chunk.content;
1240
- this.broadcast({ type: "ai_stream", content: chunk.content, streamType: "text", source: "chat" });
1272
+ this.broadcast({
1273
+ type: "ai_stream",
1274
+ content: chunk.content,
1275
+ streamType: "text",
1276
+ source: "chat"
1277
+ });
1278
+ } else if (chunk.type === "thinking") {
1279
+ this.broadcast({
1280
+ type: "ai_stream",
1281
+ content: chunk.content,
1282
+ streamType: "thinking",
1283
+ source: "chat"
1284
+ });
1285
+ } else if (chunk.type === "tool_use") {
1286
+ let toolName = "unknown";
1287
+ let toolParams = {};
1288
+ try {
1289
+ const parsed = JSON.parse(chunk.content);
1290
+ toolName = parsed.tool || parsed.name || "unknown";
1291
+ toolParams = parsed.input || parsed.params || {};
1292
+ } catch {}
1293
+ this.broadcast({
1294
+ type: "ai_stream",
1295
+ content: chunk.content,
1296
+ streamType: "tool_start",
1297
+ source: "chat",
1298
+ toolName,
1299
+ toolParams,
1300
+ toolStatus: "running"
1301
+ });
1302
+ } else if (chunk.type === "tool_result") {
1303
+ let toolName = "unknown";
1304
+ let toolResult = "";
1305
+ try {
1306
+ const parsed = JSON.parse(chunk.content);
1307
+ toolName = parsed.tool || parsed.name || "unknown";
1308
+ toolResult = typeof parsed.result === "string" ? parsed.result.substring(0, 200) : JSON.stringify(parsed.result).substring(0, 200);
1309
+ } catch {
1310
+ toolResult = chunk.content.substring(0, 200);
1311
+ }
1312
+ this.broadcast({
1313
+ type: "ai_stream",
1314
+ content: chunk.content,
1315
+ streamType: "tool_end",
1316
+ source: "chat",
1317
+ toolName,
1318
+ toolStatus: "completed",
1319
+ toolResult
1320
+ });
1241
1321
  }
1242
1322
  }
1243
1323
  await aiProcess.process.exited;
@@ -1253,7 +1333,7 @@ ${sourceCode.substring(0, 3000)}
1253
1333
  } catch (err) {
1254
1334
  this.broadcast({
1255
1335
  type: "ai_stream",
1256
- content: `Chat error: ${err instanceof Error ? err.message : String(err)}`,
1336
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
1257
1337
  streamType: "text",
1258
1338
  source: "chat"
1259
1339
  });
@@ -1266,7 +1346,12 @@ ${sourceCode.substring(0, 3000)}
1266
1346
  case "generating":
1267
1347
  case "screenshotting":
1268
1348
  case "ready":
1269
- this.broadcast({ type: "stash:status", stashId: event.stashId, status: event.type === "ready" ? "ready" : event.type });
1349
+ this.broadcast({
1350
+ type: "stash:status",
1351
+ stashId: event.stashId,
1352
+ status: event.type === "ready" ? "ready" : event.type,
1353
+ ..."number" in event ? { number: event.number } : {}
1354
+ });
1270
1355
  if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
1271
1356
  this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: event.screenshotPath });
1272
1357
  }
@@ -1274,9 +1359,11 @@ ${sourceCode.substring(0, 3000)}
1274
1359
  case "error":
1275
1360
  this.broadcast({ type: "stash:error", stashId: event.stashId, error: event.error });
1276
1361
  break;
1277
- case "ai_stream":
1278
- this.broadcast({ type: "ai_stream", content: event.content, streamType: event.streamType });
1362
+ case "ai_stream": {
1363
+ const streamType = event.streamType === "tool_use" ? "tool_start" : event.streamType;
1364
+ this.broadcast({ type: "ai_stream", content: event.content, streamType });
1279
1365
  break;
1366
+ }
1280
1367
  }
1281
1368
  }
1282
1369
  async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
@@ -1370,28 +1457,17 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1370
1457
  case "select_component":
1371
1458
  stashService.setSelectedComponent(event.component);
1372
1459
  break;
1373
- case "chat":
1460
+ case "message":
1374
1461
  persistence.saveChatMessage(event.projectId, {
1375
1462
  id: crypto.randomUUID(),
1376
1463
  role: "user",
1377
1464
  content: event.message,
1378
1465
  type: "text",
1379
- createdAt: new Date().toISOString()
1380
- });
1381
- await stashService.chat(event.projectId, event.message, event.referenceStashIds);
1382
- break;
1383
- case "generate":
1384
- persistence.saveChatMessage(event.projectId, {
1385
- id: crypto.randomUUID(),
1386
- role: "user",
1387
- content: event.prompt,
1388
- type: "text",
1389
- createdAt: new Date().toISOString()
1466
+ createdAt: new Date().toISOString(),
1467
+ referenceStashIds: event.referenceStashIds,
1468
+ componentContext: event.componentContext
1390
1469
  });
1391
- await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
1392
- break;
1393
- case "vary":
1394
- await stashService.vary(event.sourceStashId, event.prompt);
1470
+ await stashService.message(event.projectId, event.message, event.referenceStashIds, event.componentContext);
1395
1471
  break;
1396
1472
  case "interact":
1397
1473
  await stashService.switchPreview(event.stashId, event.sortedStashIds);
@@ -1407,9 +1483,11 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1407
1483
  break;
1408
1484
  }
1409
1485
  } catch (err) {
1410
- logger.error("ws", `handler failed for ${event.type}`, {
1411
- error: err instanceof Error ? err.message : String(err)
1412
- });
1486
+ const errorMsg = err instanceof Error ? err.message : String(err);
1487
+ logger.error("ws", `handler failed for ${event.type}`, { error: errorMsg });
1488
+ if ("stashId" in event && event.stashId) {
1489
+ broadcast({ type: "stash:error", stashId: event.stashId, error: errorMsg });
1490
+ }
1413
1491
  }
1414
1492
  },
1415
1493
  close(ws) {
package/dist/mcp.js CHANGED
@@ -248,7 +248,29 @@ class WorktreeManager {
248
248
  await this.git.raw(["worktree", "prune"]);
249
249
  }
250
250
  logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
251
- await this.git.raw(["worktree", "add", previewPath, branch]);
251
+ try {
252
+ await this.git.raw(["worktree", "add", previewPath, branch]);
253
+ } catch (err) {
254
+ const msg = err instanceof Error ? err.message : String(err);
255
+ if (msg.includes("is already used by worktree")) {
256
+ logger.warn("worktree", `branch ${branch} locked by stale worktree, cleaning up`);
257
+ const staleDir = join2(this.projectPath, ".stashes", "worktrees", stashId);
258
+ const legacyPreview = join2(this.projectPath, ".stashes", "preview");
259
+ for (const dir of [staleDir, legacyPreview]) {
260
+ if (existsSync2(dir)) {
261
+ try {
262
+ await this.git.raw(["worktree", "remove", "--force", dir]);
263
+ } catch {
264
+ rmSync(dir, { recursive: true, force: true });
265
+ }
266
+ }
267
+ }
268
+ await this.git.raw(["worktree", "prune"]);
269
+ await this.git.raw(["worktree", "add", previewPath, branch]);
270
+ } else {
271
+ throw err;
272
+ }
273
+ }
252
274
  this.symlinkDeps(previewPath);
253
275
  logger.info("worktree", `pool preview created: ${stashId}`);
254
276
  return previewPath;
@@ -647,11 +669,15 @@ async function generate(opts) {
647
669
  }
648
670
  }
649
671
  const completedStashes = [];
650
- const stashPromises = selectedDirectives.map(async (directive) => {
672
+ const existingStashes = persistence.listStashes(projectId);
673
+ const maxNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0);
674
+ const stashPromises = selectedDirectives.map(async (directive, idx) => {
651
675
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
676
+ const stashNumber = maxNumber + idx + 1;
652
677
  const worktree = await worktreeManager.createForGeneration(stashId);
653
678
  const stash = {
654
679
  id: stashId,
680
+ number: stashNumber,
655
681
  projectId,
656
682
  prompt,
657
683
  componentPath: component?.filePath,
@@ -665,7 +691,7 @@ async function generate(opts) {
665
691
  createdAt: new Date().toISOString()
666
692
  };
667
693
  persistence.saveStash(stash);
668
- emit(onProgress, { type: "generating", stashId });
694
+ emit(onProgress, { type: "generating", stashId, number: stashNumber });
669
695
  let stashPrompt;
670
696
  if (component?.filePath) {
671
697
  stashPrompt = buildStashPrompt({ name: component.exportName || component.filePath, filePath: component.filePath, domSelector: "" }, sourceCode, prompt, directive);
@@ -770,9 +796,12 @@ async function vary(opts) {
770
796
  if (!sourceStash)
771
797
  throw new Error(`Source stash ${sourceStashId} not found`);
772
798
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
799
+ const existingStashes = persistence.listStashes(projectId);
800
+ const stashNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0) + 1;
773
801
  const worktree = await worktreeManager.createForVary(stashId, sourceStash.branch);
774
802
  const stash = {
775
803
  id: stashId,
804
+ number: stashNumber,
776
805
  projectId,
777
806
  prompt,
778
807
  componentPath: sourceStash.componentPath,
@@ -786,7 +815,7 @@ async function vary(opts) {
786
815
  createdAt: new Date().toISOString()
787
816
  };
788
817
  persistence.saveStash(stash);
789
- emit2(onProgress, { type: "generating", stashId });
818
+ emit2(onProgress, { type: "generating", stashId, number: stashNumber });
790
819
  const varyPrompt = `The user wants to vary the current UI. Apply this change: ${prompt}`;
791
820
  const aiProcess = startAiProcess(stashId, varyPrompt, worktree.path);
792
821
  try {
@@ -1325,8 +1354,8 @@ class StashService {
1325
1354
  });
1326
1355
  }
1327
1356
  }
1328
- async chat(projectId, message, referenceStashIds) {
1329
- const component = this.selectedComponent;
1357
+ async message(projectId, message, referenceStashIds, componentContext) {
1358
+ const component = componentContext ? { name: componentContext.name, filePath: this.selectedComponent?.filePath || "" } : this.selectedComponent;
1330
1359
  let sourceCode = "";
1331
1360
  const filePath = component?.filePath || "";
1332
1361
  if (filePath && filePath !== "auto-detect") {
@@ -1346,8 +1375,11 @@ ${refs.join(`
1346
1375
  }
1347
1376
  }
1348
1377
  const chatPrompt = [
1349
- "The user is asking about their UI project. Answer concisely.",
1350
- "Do NOT modify any files.",
1378
+ "You are helping the user explore UI design variations for their project.",
1379
+ "You have access to stashes MCP tools to generate, list, browse, vary, and apply stashes.",
1380
+ "If the user asks you to generate, create, or make variations, use the stashes_generate tool.",
1381
+ "If the user asks to vary an existing stash, use the stashes_vary tool.",
1382
+ "Otherwise, respond conversationally about their project and stashes.",
1351
1383
  "",
1352
1384
  component ? `Component: ${component.name}` : "",
1353
1385
  filePath !== "auto-detect" ? `File: ${filePath}` : "",
@@ -1358,7 +1390,7 @@ ${sourceCode.substring(0, 3000)}
1358
1390
  \`\`\`` : "",
1359
1391
  stashContext,
1360
1392
  "",
1361
- `User question: ${message}`
1393
+ `User: ${message}`
1362
1394
  ].filter(Boolean).join(`
1363
1395
  `);
1364
1396
  const aiProcess = startAiProcess("chat", chatPrompt, this.projectPath);
@@ -1367,7 +1399,55 @@ ${sourceCode.substring(0, 3000)}
1367
1399
  for await (const chunk of parseClaudeStream(aiProcess.process)) {
1368
1400
  if (chunk.type === "text") {
1369
1401
  fullResponse += chunk.content;
1370
- this.broadcast({ type: "ai_stream", content: chunk.content, streamType: "text", source: "chat" });
1402
+ this.broadcast({
1403
+ type: "ai_stream",
1404
+ content: chunk.content,
1405
+ streamType: "text",
1406
+ source: "chat"
1407
+ });
1408
+ } else if (chunk.type === "thinking") {
1409
+ this.broadcast({
1410
+ type: "ai_stream",
1411
+ content: chunk.content,
1412
+ streamType: "thinking",
1413
+ source: "chat"
1414
+ });
1415
+ } else if (chunk.type === "tool_use") {
1416
+ let toolName = "unknown";
1417
+ let toolParams = {};
1418
+ try {
1419
+ const parsed = JSON.parse(chunk.content);
1420
+ toolName = parsed.tool || parsed.name || "unknown";
1421
+ toolParams = parsed.input || parsed.params || {};
1422
+ } catch {}
1423
+ this.broadcast({
1424
+ type: "ai_stream",
1425
+ content: chunk.content,
1426
+ streamType: "tool_start",
1427
+ source: "chat",
1428
+ toolName,
1429
+ toolParams,
1430
+ toolStatus: "running"
1431
+ });
1432
+ } else if (chunk.type === "tool_result") {
1433
+ let toolName = "unknown";
1434
+ let toolResult = "";
1435
+ try {
1436
+ const parsed = JSON.parse(chunk.content);
1437
+ toolName = parsed.tool || parsed.name || "unknown";
1438
+ toolResult = typeof parsed.result === "string" ? parsed.result.substring(0, 200) : JSON.stringify(parsed.result).substring(0, 200);
1439
+ } catch {
1440
+ toolResult = chunk.content.substring(0, 200);
1441
+ }
1442
+ this.broadcast({
1443
+ type: "ai_stream",
1444
+ content: chunk.content,
1445
+ streamType: "tool_end",
1446
+ source: "chat",
1447
+ toolName,
1448
+ toolStatus: "completed",
1449
+ toolResult
1450
+ });
1371
1451
  }
1372
1452
  }
1373
1453
  await aiProcess.process.exited;
@@ -1383,7 +1463,7 @@ ${sourceCode.substring(0, 3000)}
1383
1463
  } catch (err) {
1384
1464
  this.broadcast({
1385
1465
  type: "ai_stream",
1386
- content: `Chat error: ${err instanceof Error ? err.message : String(err)}`,
1466
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
1387
1467
  streamType: "text",
1388
1468
  source: "chat"
1389
1469
  });
@@ -1396,7 +1476,12 @@ ${sourceCode.substring(0, 3000)}
1396
1476
  case "generating":
1397
1477
  case "screenshotting":
1398
1478
  case "ready":
1399
- this.broadcast({ type: "stash:status", stashId: event.stashId, status: event.type === "ready" ? "ready" : event.type });
1479
+ this.broadcast({
1480
+ type: "stash:status",
1481
+ stashId: event.stashId,
1482
+ status: event.type === "ready" ? "ready" : event.type,
1483
+ ..."number" in event ? { number: event.number } : {}
1484
+ });
1400
1485
  if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
1401
1486
  this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: event.screenshotPath });
1402
1487
  }
@@ -1404,9 +1489,11 @@ ${sourceCode.substring(0, 3000)}
1404
1489
  case "error":
1405
1490
  this.broadcast({ type: "stash:error", stashId: event.stashId, error: event.error });
1406
1491
  break;
1407
- case "ai_stream":
1408
- this.broadcast({ type: "ai_stream", content: event.content, streamType: event.streamType });
1492
+ case "ai_stream": {
1493
+ const streamType = event.streamType === "tool_use" ? "tool_start" : event.streamType;
1494
+ this.broadcast({ type: "ai_stream", content: event.content, streamType });
1409
1495
  break;
1496
+ }
1410
1497
  }
1411
1498
  }
1412
1499
  async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
@@ -1500,28 +1587,17 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1500
1587
  case "select_component":
1501
1588
  stashService.setSelectedComponent(event.component);
1502
1589
  break;
1503
- case "chat":
1590
+ case "message":
1504
1591
  persistence.saveChatMessage(event.projectId, {
1505
1592
  id: crypto.randomUUID(),
1506
1593
  role: "user",
1507
1594
  content: event.message,
1508
1595
  type: "text",
1509
- createdAt: new Date().toISOString()
1510
- });
1511
- await stashService.chat(event.projectId, event.message, event.referenceStashIds);
1512
- break;
1513
- case "generate":
1514
- persistence.saveChatMessage(event.projectId, {
1515
- id: crypto.randomUUID(),
1516
- role: "user",
1517
- content: event.prompt,
1518
- type: "text",
1519
- createdAt: new Date().toISOString()
1596
+ createdAt: new Date().toISOString(),
1597
+ referenceStashIds: event.referenceStashIds,
1598
+ componentContext: event.componentContext
1520
1599
  });
1521
- await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
1522
- break;
1523
- case "vary":
1524
- await stashService.vary(event.sourceStashId, event.prompt);
1600
+ await stashService.message(event.projectId, event.message, event.referenceStashIds, event.componentContext);
1525
1601
  break;
1526
1602
  case "interact":
1527
1603
  await stashService.switchPreview(event.stashId, event.sortedStashIds);
@@ -1537,9 +1613,11 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1537
1613
  break;
1538
1614
  }
1539
1615
  } catch (err) {
1540
- logger.error("ws", `handler failed for ${event.type}`, {
1541
- error: err instanceof Error ? err.message : String(err)
1542
- });
1616
+ const errorMsg = err instanceof Error ? err.message : String(err);
1617
+ logger.error("ws", `handler failed for ${event.type}`, { error: errorMsg });
1618
+ if ("stashId" in event && event.stashId) {
1619
+ broadcast({ type: "stash:error", stashId: event.stashId, error: errorMsg });
1620
+ }
1543
1621
  }
1544
1622
  },
1545
1623
  close(ws) {