@wrongstack/desktop 1.0.3 → 1.0.4

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.
Binary file
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none" role="img" aria-labelledby="wrongstack-logo-title" shape-rendering="crispEdges">
2
+ <title id="wrongstack-logo-title">WrongStack</title>
3
+ <rect width="400" height="400" fill="#121210"/>
4
+ <path fill="#FD9F02" d="M30 170h60v60H30zM170 170h60v60h-60zM240 170h60v60h-60zM310 170h60v60h-60z"/>
5
+ <path fill="#FE2E5F" d="M100 200h60v60h-60z"/>
6
+ </svg>
@@ -175,9 +175,7 @@ var DesktopAgentBridge = class extends EventEmitter {
175
175
  conversation.reconnectTimer = null;
176
176
  if (!conversation.reconnectUrl) return;
177
177
  if (conversation.ws?.readyState !== WebSocket.OPEN) {
178
- void this.connect(conversation.runtimeId, conversation.reconnectUrl).catch(
179
- () => void 0
180
- );
178
+ void this.connect(conversation.runtimeId, conversation.reconnectUrl).catch(() => void 0);
181
179
  }
182
180
  }, reconnect.plan.delayMs);
183
181
  }
package/dist/main/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/main/main.ts
2
- import * as path6 from "node:path";
2
+ import * as path7 from "node:path";
3
3
  import { installCrashShield, resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
4
4
  import {
5
5
  app as app2,
@@ -40,9 +40,9 @@ function handleFileOpen(filePath) {
40
40
  }
41
41
  var pendingOpenFilePath = null;
42
42
  function drainPendingOpenFilePath() {
43
- const path7 = pendingOpenFilePath;
43
+ const path8 = pendingOpenFilePath;
44
44
  pendingOpenFilePath = null;
45
- return path7;
45
+ return path8;
46
46
  }
47
47
  function firstOpenFileArg(argv) {
48
48
  if (process.platform !== "darwin") return null;
@@ -147,7 +147,7 @@ var DesktopAgentBridge = class extends EventEmitter {
147
147
  conversation.error = void 0;
148
148
  this.emitChanged(conversation);
149
149
  this.emitReconnectEvent(conversation, "connecting");
150
- const promise = new Promise((resolve4, reject) => {
150
+ const promise = new Promise((resolve5, reject) => {
151
151
  const ws = new WebSocket(wsUrl);
152
152
  conversation.ws = ws;
153
153
  const myGeneration = ++conversation.socketGeneration;
@@ -172,7 +172,7 @@ var DesktopAgentBridge = class extends EventEmitter {
172
172
  conversation.connectionState = markConnectionOpen(conversation.connectionState);
173
173
  this.emitChanged(conversation);
174
174
  this.emitReconnectEvent(conversation, "connected");
175
- resolve4();
175
+ resolve5();
176
176
  });
177
177
  ws.once("error", (err) => {
178
178
  clearTimeout(timeout);
@@ -233,9 +233,7 @@ var DesktopAgentBridge = class extends EventEmitter {
233
233
  conversation.reconnectTimer = null;
234
234
  if (!conversation.reconnectUrl) return;
235
235
  if (conversation.ws?.readyState !== WebSocket.OPEN) {
236
- void this.connect(conversation.runtimeId, conversation.reconnectUrl).catch(
237
- () => void 0
238
- );
236
+ void this.connect(conversation.runtimeId, conversation.reconnectUrl).catch(() => void 0);
239
237
  }
240
238
  }, reconnect.plan.delayMs);
241
239
  }
@@ -531,6 +529,7 @@ var IPC = {
531
529
  getState: "desktop:get-state",
532
530
  getConversation: "desktop:get-conversation",
533
531
  getWebuiStatus: "desktop:get-webui-status",
532
+ listProjectSessions: "desktop:list-project-sessions",
534
533
  openProject: "desktop:open-project",
535
534
  registerProject: "desktop:register-project",
536
535
  unregisterProject: "desktop:unregister-project",
@@ -1004,6 +1003,24 @@ var WS_PORT_START = 34660;
1004
1003
  var START_TIMEOUT_MS = 3e4;
1005
1004
  var MIN_WINDOW_WIDTH = 760;
1006
1005
  var MIN_WINDOW_HEIGHT = 520;
1006
+ var DEFAULT_IDLE_MINUTES = 15;
1007
+ var IDLE_SWEEP_INTERVAL_MS = 6e4;
1008
+ function resolveIdleTimeoutMs(env = process.env) {
1009
+ const raw = Number.parseFloat(env.WRONGSTACK_DESKTOP_IDLE_MINUTES ?? "");
1010
+ if (Number.isFinite(raw)) return raw > 0 ? raw * 6e4 : 0;
1011
+ return DEFAULT_IDLE_MINUTES * 6e4;
1012
+ }
1013
+ function reclaimableRuntimeIds(runtimes, options) {
1014
+ if (options.idleTimeoutMs <= 0) return [];
1015
+ const out = [];
1016
+ for (const [id, runtime] of runtimes) {
1017
+ if (id === options.activeRuntimeId) continue;
1018
+ if (runtime.status !== "running") continue;
1019
+ if (options.now - runtime.lastActivityAt < options.idleTimeoutMs) continue;
1020
+ out.push(id);
1021
+ }
1022
+ return out;
1023
+ }
1007
1024
  var DesktopRuntimeManager = class extends EventEmitter2 {
1008
1025
  constructor(trustBoundary = desktopCompatibilityTrustBoundary) {
1009
1026
  super();
@@ -1025,6 +1042,8 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1025
1042
  activeRuntimeId = null;
1026
1043
  restoring = false;
1027
1044
  workspaceRestoreCompleted = false;
1045
+ idleSweepTimer = null;
1046
+ idleTimeoutMs = 0;
1028
1047
  async init() {
1029
1048
  const state = await this.loadDesktopState();
1030
1049
  this.recentProjects = state.recentProjects;
@@ -1036,9 +1055,12 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1036
1055
  this.windowState = state.window;
1037
1056
  }
1038
1057
  snapshot() {
1058
+ const activeId = this.activeRuntimeId;
1039
1059
  return {
1040
- activeRuntimeId: this.activeRuntimeId,
1041
- runtimes: Array.from(this.runtimes.values()).map(publicRuntime),
1060
+ activeRuntimeId: activeId,
1061
+ runtimes: Array.from(this.runtimes.values()).map(
1062
+ (runtime) => publicRuntime(runtime, runtime.id === activeId)
1063
+ ),
1042
1064
  recentProjects: [...this.recentProjects],
1043
1065
  registeredProjects: [...this.registeredProjects],
1044
1066
  restoring: this.restoring
@@ -1101,7 +1123,7 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1101
1123
  }
1102
1124
  getRuntime(id) {
1103
1125
  const runtime = this.runtimes.get(id);
1104
- return runtime ? publicRuntime(runtime) : void 0;
1126
+ return runtime ? publicRuntime(runtime, true) : void 0;
1105
1127
  }
1106
1128
  getRuntimeUrlWithToken(id) {
1107
1129
  const runtime = this.runtimes.get(id);
@@ -1120,8 +1142,8 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1120
1142
  }
1121
1143
  async openProject(projectRoot, options = {}) {
1122
1144
  const resolved = path3.resolve(projectRoot);
1123
- const stat3 = await fs2.stat(resolved).catch(() => null);
1124
- if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1145
+ const stat4 = await fs2.stat(resolved).catch(() => null);
1146
+ if (!stat4?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1125
1147
  const kind = options.kind ?? "project";
1126
1148
  const touchRecent = options.touchRecent ?? kind === "project";
1127
1149
  const forceNew = options.forceNew === true;
@@ -1142,7 +1164,7 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1142
1164
  await this.persistWorkspaceState();
1143
1165
  }
1144
1166
  this.emitChanged();
1145
- return publicRuntime(existing);
1167
+ return publicRuntime(existing, true);
1146
1168
  }
1147
1169
  const staleSameRoot = Array.from(this.runtimes.values()).filter(
1148
1170
  (runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind
@@ -1175,7 +1197,8 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1175
1197
  token,
1176
1198
  child: null,
1177
1199
  logs: [],
1178
- logNotifyTimer: null
1200
+ logNotifyTimer: null,
1201
+ lastActivityAt: Date.now()
1179
1202
  };
1180
1203
  this.runtimes.set(runtimeId, runtime);
1181
1204
  this.activeRuntimeId = runtimeId;
@@ -1229,12 +1252,14 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1229
1252
  child.stdout?.on("data", (chunk) => {
1230
1253
  const text = chunk.toString();
1231
1254
  appendRuntimeLog(runtime, "stdout", text);
1255
+ runtime.lastActivityAt = Date.now();
1232
1256
  this.scheduleLogChanged(runtime);
1233
1257
  process.stdout.write(`[desktop:${runtime.id}] ${text}`);
1234
1258
  });
1235
1259
  child.stderr?.on("data", (chunk) => {
1236
1260
  const text = chunk.toString();
1237
1261
  appendRuntimeLog(runtime, "stderr", text);
1262
+ runtime.lastActivityAt = Date.now();
1238
1263
  this.scheduleLogChanged(runtime);
1239
1264
  process.stderr.write(`[desktop:${runtime.id}] ${text}`);
1240
1265
  });
@@ -1264,7 +1289,7 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1264
1289
  runtime.status = "running";
1265
1290
  await this.persistWorkspaceState();
1266
1291
  this.emitChanged();
1267
- return publicRuntime(runtime);
1292
+ return publicRuntime(runtime, true);
1268
1293
  } catch (err) {
1269
1294
  if (runtime.status !== "stopped" && runtime.status !== "error") {
1270
1295
  runtime.status = "error";
@@ -1285,6 +1310,7 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1285
1310
  const runtime = this.runtimes.get(id);
1286
1311
  if (!runtime) throw new Error(`Runtime not found: ${id}`);
1287
1312
  this.activeRuntimeId = id;
1313
+ runtime.lastActivityAt = Date.now();
1288
1314
  if (runtime.kind === "project") this.lastActiveProjectRoot = runtime.root;
1289
1315
  if (runtime.kind === "project") {
1290
1316
  await this.touchProject(runtime.root);
@@ -1313,8 +1339,8 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1313
1339
  }
1314
1340
  async registerProject(projectRoot) {
1315
1341
  const resolved = path3.resolve(projectRoot);
1316
- const stat3 = await fs2.stat(resolved).catch(() => null);
1317
- if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1342
+ const stat4 = await fs2.stat(resolved).catch(() => null);
1343
+ if (!stat4?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1318
1344
  const now = (/* @__PURE__ */ new Date()).toISOString();
1319
1345
  const entry = {
1320
1346
  name: path3.basename(resolved) || resolved,
@@ -1436,11 +1462,58 @@ var DesktopRuntimeManager = class extends EventEmitter2 {
1436
1462
  emitChanged() {
1437
1463
  this.emit("changed");
1438
1464
  }
1465
+ /**
1466
+ * Notify the shell that a runtime produced output.
1467
+ *
1468
+ * Only the ACTIVE runtime's logs reach the renderer (see `publicRuntime`), so
1469
+ * output from a background project has nothing to show and must not cost a
1470
+ * broadcast. Before this guard, every project writing to stdout scheduled its
1471
+ * own 250 ms timer, and each one fired a FULL snapshot: N chatty projects
1472
+ * produced 4N broadcasts per second carrying N x 40 log lines each, for a
1473
+ * panel that displays one runtime's output.
1474
+ *
1475
+ * The 250 ms debounce is per-runtime by construction (the timer lives on the
1476
+ * runtime record) but only one runtime can be active, so at most one such
1477
+ * timer is ever armed now.
1478
+ */
1479
+ /**
1480
+ * Begin reclaiming idle project servers.
1481
+ *
1482
+ * Idempotent, and a no-op when the timeout is disabled. The interval is
1483
+ * unref'd so a pending sweep never holds the process open during quit.
1484
+ */
1485
+ startIdleSweep(options = {}) {
1486
+ if (this.idleSweepTimer) return;
1487
+ this.idleTimeoutMs = options.idleTimeoutMs ?? resolveIdleTimeoutMs();
1488
+ if (this.idleTimeoutMs <= 0) return;
1489
+ this.idleSweepTimer = setInterval(() => {
1490
+ void this.sweepIdleRuntimes();
1491
+ }, IDLE_SWEEP_INTERVAL_MS);
1492
+ this.idleSweepTimer.unref?.();
1493
+ }
1494
+ stopIdleSweep() {
1495
+ if (!this.idleSweepTimer) return;
1496
+ clearInterval(this.idleSweepTimer);
1497
+ this.idleSweepTimer = null;
1498
+ }
1499
+ /** One pass. Exposed so a test can drive it without waiting on the interval. */
1500
+ async sweepIdleRuntimes(now = Date.now()) {
1501
+ const ids = reclaimableRuntimeIds(this.runtimes, {
1502
+ activeRuntimeId: this.activeRuntimeId,
1503
+ idleTimeoutMs: this.idleTimeoutMs,
1504
+ now
1505
+ });
1506
+ for (const id of ids) {
1507
+ await this.closeRuntimeInternal(id, { persistWorkspace: false });
1508
+ }
1509
+ return ids;
1510
+ }
1439
1511
  scheduleLogChanged(runtime) {
1512
+ if (runtime.id !== this.activeRuntimeId) return;
1440
1513
  if (runtime.logNotifyTimer) return;
1441
1514
  runtime.logNotifyTimer = setTimeout(() => {
1442
1515
  runtime.logNotifyTimer = null;
1443
- if (this.runtimes.get(runtime.id) === runtime) {
1516
+ if (this.runtimes.get(runtime.id) === runtime && runtime.id === this.activeRuntimeId) {
1444
1517
  this.emitChanged();
1445
1518
  }
1446
1519
  }, 250);
@@ -1451,14 +1524,14 @@ function hasChildExited(child) {
1451
1524
  }
1452
1525
  function waitForChildExit(child, timeoutMs) {
1453
1526
  if (hasChildExited(child)) return Promise.resolve(true);
1454
- return new Promise((resolve4) => {
1527
+ return new Promise((resolve5) => {
1455
1528
  let settled = false;
1456
1529
  const finish = (exited) => {
1457
1530
  if (settled) return;
1458
1531
  settled = true;
1459
1532
  clearTimeout(timer);
1460
1533
  child.off("exit", onExit);
1461
- resolve4(exited);
1534
+ resolve5(exited);
1462
1535
  };
1463
1536
  const onExit = () => finish(true);
1464
1537
  const timer = setTimeout(() => finish(hasChildExited(child)), timeoutMs);
@@ -1484,12 +1557,12 @@ async function terminateProcessTree(child) {
1484
1557
  }
1485
1558
  return;
1486
1559
  }
1487
- await new Promise((resolve4) => {
1560
+ await new Promise((resolve5) => {
1488
1561
  let settled = false;
1489
1562
  const finish = () => {
1490
1563
  if (settled) return;
1491
1564
  settled = true;
1492
- resolve4();
1565
+ resolve5();
1493
1566
  };
1494
1567
  const timer = setTimeout(finish, 3e3);
1495
1568
  timer.unref?.();
@@ -1508,7 +1581,8 @@ async function terminateProcessTree(child) {
1508
1581
  });
1509
1582
  });
1510
1583
  }
1511
- function publicRuntime(runtime) {
1584
+ var SNAPSHOT_LOG_LINES = 40;
1585
+ function publicRuntime(runtime, includeLogs) {
1512
1586
  const {
1513
1587
  child: _child,
1514
1588
  token: _token,
@@ -1519,9 +1593,10 @@ function publicRuntime(runtime) {
1519
1593
  void _child;
1520
1594
  void _token;
1521
1595
  void _logNotifyTimer;
1596
+ if (!includeLogs) return record;
1522
1597
  return {
1523
1598
  ...record,
1524
- recentLogs: logs.slice(-40)
1599
+ recentLogs: logs.slice(-SNAPSHOT_LOG_LINES)
1525
1600
  };
1526
1601
  }
1527
1602
  function runtimeToSessionState(runtime) {
@@ -1634,11 +1709,11 @@ async function findFreePort(startPort, exclude) {
1634
1709
  throw new Error(`No free local port found near ${startPort}`);
1635
1710
  }
1636
1711
  function isPortFree(port) {
1637
- return new Promise((resolve4) => {
1712
+ return new Promise((resolve5) => {
1638
1713
  const server = net.createServer();
1639
- server.once("error", () => resolve4(false));
1714
+ server.once("error", () => resolve5(false));
1640
1715
  server.once("listening", () => {
1641
- server.close(() => resolve4(true));
1716
+ server.close(() => resolve5(true));
1642
1717
  });
1643
1718
  server.listen(port, "127.0.0.1");
1644
1719
  });
@@ -1648,7 +1723,7 @@ function waitForHttpReady(baseUrl, token, timeoutMs) {
1648
1723
  const url = new URL(baseUrl);
1649
1724
  url.searchParams.set("token", token);
1650
1725
  url.searchParams.set("shell", "desktop");
1651
- return new Promise((resolve4, reject) => {
1726
+ return new Promise((resolve5, reject) => {
1652
1727
  let probeTimer;
1653
1728
  const cleanup = () => {
1654
1729
  if (probeTimer) {
@@ -1673,7 +1748,7 @@ function waitForHttpReady(baseUrl, token, timeoutMs) {
1673
1748
  if (!done) {
1674
1749
  done = true;
1675
1750
  cleanup();
1676
- resolve4();
1751
+ resolve5();
1677
1752
  }
1678
1753
  return;
1679
1754
  }
@@ -2101,12 +2176,31 @@ var DesktopWebuiController = class {
2101
2176
  findBySenderId(senderId) {
2102
2177
  return [...this.views.values()].find((entry) => entry.view.webContents.id === senderId);
2103
2178
  }
2179
+ /**
2180
+ * Point the window at the active runtime's WebUI, and keep no other view.
2181
+ *
2182
+ * Only views whose runtime had STOPPED were released here. A view for a
2183
+ * running-but-background project stayed alive for the life of the app — a
2184
+ * full Chromium renderer process each, hidden purely by setting its width to
2185
+ * zero in `layoutViews`. Ten open projects meant ten renderer processes to
2186
+ * show one, and memory grew with every project ever visited.
2187
+ *
2188
+ * Now exactly one view exists: the active one. Switching projects releases
2189
+ * the previous view and loads the next, which costs a page load on the way
2190
+ * back — the deliberate trade for a footprint that does not grow with how
2191
+ * many projects are open. The shell covers that load with its own
2192
+ * `loading` state, so the gap is visible as progress rather than as a blank
2193
+ * window.
2194
+ *
2195
+ * Queued WebUI commands are not at risk: `dispatch` only ever targets
2196
+ * `activeEntry()`, so a disposed background view cannot have had any.
2197
+ */
2104
2198
  syncActive() {
2105
2199
  if (!this.ctx.getMainWindow()) return;
2106
2200
  const snapshot = this.ctx.manager.snapshot();
2107
- const live = new Set(snapshot.runtimes.filter((r) => r.status === "running").map((r) => r.id));
2108
- for (const [id, entry2] of this.views) if (!live.has(id)) this.dispose(entry2);
2109
2201
  const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
2202
+ const keep = active?.status === "running" ? active.id : null;
2203
+ for (const [id, entry2] of this.views) if (id !== keep) this.dispose(entry2);
2110
2204
  if (active?.status !== "running") {
2111
2205
  this.activeRuntimeId = active?.id ?? null;
2112
2206
  this.publishStatus({ runtimeId: active?.id ?? null, status: "idle" });
@@ -2187,7 +2281,7 @@ var DesktopWebuiController = class {
2187
2281
  if (this.views.get(entry.runtimeId) !== entry || !entry.url) return Promise.resolve(false);
2188
2282
  const requestId = `${entry.runtimeId}:${Date.now()}:${++this.commandSequence}`;
2189
2283
  const outbound = { ...command, requestId };
2190
- return new Promise((resolve4) => {
2284
+ return new Promise((resolve5) => {
2191
2285
  const fallbackTimer = setTimeout(() => {
2192
2286
  if (!this.pendingAcks.has(requestId)) return;
2193
2287
  if (this.views.get(entry.runtimeId) !== entry || entry.view.webContents.isDestroyed())
@@ -2202,7 +2296,7 @@ var DesktopWebuiController = class {
2202
2296
  runtimeId: entry.runtimeId,
2203
2297
  timer,
2204
2298
  fallbackTimer,
2205
- resolve: resolve4
2299
+ resolve: resolve5
2206
2300
  });
2207
2301
  try {
2208
2302
  entry.view.webContents.send(IPC.webuiCommand, outbound);
@@ -2269,6 +2363,7 @@ var DesktopWebuiController = class {
2269
2363
  import * as fs3 from "node:fs/promises";
2270
2364
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2271
2365
  import { nativeImage } from "electron";
2366
+ var ICON_CANDIDATES = ["../../assets/icon.png", "../../assets/icon.icns"];
2272
2367
  async function readIcon(relativePath) {
2273
2368
  try {
2274
2369
  const iconPath = fileURLToPath2(new URL(relativePath, import.meta.url));
@@ -2280,8 +2375,11 @@ async function readIcon(relativePath) {
2280
2375
  }
2281
2376
  }
2282
2377
  async function loadDesktopAppIcon() {
2283
- if (process.platform !== "darwin") return readIcon("../../assets/icon.svg");
2284
- return await readIcon("../../assets/icon.png") ?? readIcon("../../assets/icon.icns");
2378
+ for (const candidate of ICON_CANDIDATES) {
2379
+ const icon = await readIcon(candidate);
2380
+ if (icon) return icon;
2381
+ }
2382
+ return void 0;
2285
2383
  }
2286
2384
 
2287
2385
  // src/main/window-state-controller.ts
@@ -2754,6 +2852,94 @@ function configureApplicationMenu(ctx) {
2754
2852
  // src/main/ipc-handlers/index.ts
2755
2853
  import { ipcMain } from "electron";
2756
2854
 
2855
+ // src/main/session-index.ts
2856
+ import * as fs5 from "node:fs/promises";
2857
+ import * as path5 from "node:path";
2858
+ import { projectSlug as projectSlug2, wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
2859
+ var SHARD_RE = /^\d{4}-\d{2}-\d{2}$/;
2860
+ var SUMMARY_SUFFIX = ".summary.json";
2861
+ var DEFAULT_SESSION_LIMIT = 25;
2862
+ var CACHE_TTL_MS = 4e3;
2863
+ var cache = /* @__PURE__ */ new Map();
2864
+ function projectStoreDir(projectRoot) {
2865
+ return path5.join(wstackGlobalRoot3(), "projects", projectSlug2(path5.resolve(projectRoot)));
2866
+ }
2867
+ function toEntry(raw, fallbackId, mtimeMs) {
2868
+ if (typeof raw !== "object" || raw === null) return null;
2869
+ const r = raw;
2870
+ const id = typeof r.id === "string" && r.id !== "" ? r.id : fallbackId;
2871
+ if (!id) return null;
2872
+ const title = typeof r.title === "string" ? r.title.trim() : "";
2873
+ const startedAt = typeof r.startedAt === "string" ? r.startedAt : new Date(mtimeMs).toISOString();
2874
+ const lastActivityAt = typeof r.lastActivityAt === "string" ? r.lastActivityAt : typeof r.endedAt === "string" ? r.endedAt : startedAt;
2875
+ return {
2876
+ id,
2877
+ // A session that never got a first user message has no title. Showing the
2878
+ // id is better than an empty row the user cannot identify.
2879
+ title: title || id.split("/").pop() || id,
2880
+ startedAt,
2881
+ lastActivityAt,
2882
+ ...typeof r.messageCount === "number" ? { messageCount: r.messageCount } : {},
2883
+ ...typeof r.model === "string" ? { model: r.model } : {},
2884
+ ...typeof r.provider === "string" ? { provider: r.provider } : {}
2885
+ };
2886
+ }
2887
+ async function listProjectSessions(projectRoot, options = {}) {
2888
+ const limit = options.limit ?? DEFAULT_SESSION_LIMIT;
2889
+ if (limit <= 0) return [];
2890
+ const sessionsDir = path5.join(projectStoreDir(projectRoot), "sessions");
2891
+ const dirStat = await fs5.stat(sessionsDir).catch(() => null);
2892
+ if (!dirStat?.isDirectory()) return [];
2893
+ const cached = cache.get(sessionsDir);
2894
+ if (cached && Date.now() - cached.readAt < CACHE_TTL_MS && cached.limit >= limit) {
2895
+ return cached.sessions.slice(0, limit);
2896
+ }
2897
+ let shards;
2898
+ try {
2899
+ const entries = await fs5.readdir(sessionsDir, { withFileTypes: true });
2900
+ shards = entries.filter((entry) => entry.isDirectory() && SHARD_RE.test(entry.name)).map((entry) => entry.name).sort((a, b) => b.localeCompare(a));
2901
+ } catch {
2902
+ return [];
2903
+ }
2904
+ const sessions = [];
2905
+ for (const shard of shards) {
2906
+ if (sessions.length >= limit) break;
2907
+ const shardDir = path5.join(sessionsDir, shard);
2908
+ let files;
2909
+ try {
2910
+ files = (await fs5.readdir(shardDir)).filter((name) => name.endsWith(SUMMARY_SUFFIX));
2911
+ } catch {
2912
+ continue;
2913
+ }
2914
+ files.sort((a, b) => b.localeCompare(a));
2915
+ for (const file of files) {
2916
+ if (sessions.length >= limit) break;
2917
+ const full = path5.join(shardDir, file);
2918
+ let text;
2919
+ let mtimeMs;
2920
+ try {
2921
+ const [content, stat4] = await Promise.all([fs5.readFile(full, "utf8"), fs5.stat(full)]);
2922
+ text = content;
2923
+ mtimeMs = stat4.mtimeMs;
2924
+ } catch {
2925
+ continue;
2926
+ }
2927
+ let parsed;
2928
+ try {
2929
+ parsed = JSON.parse(text);
2930
+ } catch {
2931
+ continue;
2932
+ }
2933
+ const fallbackId = `${shard}/${file.slice(0, -SUMMARY_SUFFIX.length)}`;
2934
+ const entry = toEntry(parsed, fallbackId, mtimeMs);
2935
+ if (entry) sessions.push(entry);
2936
+ }
2937
+ }
2938
+ sessions.sort((a, b) => (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? ""));
2939
+ cache.set(sessionsDir, { readAt: Date.now(), limit, sessions });
2940
+ return sessions;
2941
+ }
2942
+
2757
2943
  // src/main/validation/index.ts
2758
2944
  function validate(schema, data) {
2759
2945
  try {
@@ -2801,7 +2987,7 @@ function createValidationLogger(prefix) {
2801
2987
 
2802
2988
  // src/main/validation/schemas.ts
2803
2989
  import { statSync } from "node:fs";
2804
- import * as path5 from "node:path";
2990
+ import * as path6 from "node:path";
2805
2991
  import { z } from "zod";
2806
2992
  var RUNTIME_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,120}$/;
2807
2993
  var runtimeIdSchema = z.string().regex(RUNTIME_ID_PATTERN, "Invalid runtime ID format");
@@ -2811,7 +2997,7 @@ var projectRootSchema = pathSchema.refine((value) => !value.includes("\0"), {
2811
2997
  }).refine(
2812
2998
  (value) => {
2813
2999
  try {
2814
- return statSync(path5.resolve(value)).isDirectory();
3000
+ return statSync(path6.resolve(value)).isDirectory();
2815
3001
  } catch {
2816
3002
  return false;
2817
3003
  }
@@ -2888,6 +3074,14 @@ function registerIpcHandlers(ctx) {
2888
3074
  return ctx.getAgentBridge().snapshot(result.data);
2889
3075
  });
2890
3076
  ipcMain.handle(IPC.getWebuiStatus, () => ctx.getWebuiStatus());
3077
+ ipcMain.handle(IPC.listProjectSessions, async (_event, root) => {
3078
+ const result = validate(pathSchema, root);
3079
+ if (!result.success) {
3080
+ validationLogger.log(`listProjectSessions: ${result.error}`);
3081
+ return [];
3082
+ }
3083
+ return listProjectSessions(result.data);
3084
+ });
2891
3085
  ipcMain.handle(IPC.navigateWebui, async (_event, command) => {
2892
3086
  return ctx.dispatchWebuiCommand(command);
2893
3087
  });
@@ -3059,7 +3253,7 @@ if (process.platform === "win32") {
3059
3253
  }
3060
3254
  app2.setPath(
3061
3255
  "userData",
3062
- path6.join(
3256
+ path7.join(
3063
3257
  resolveWstackPaths3({ projectRoot: process.cwd() }).configDir,
3064
3258
  "desktop",
3065
3259
  "electron-profile"
@@ -3113,7 +3307,7 @@ function revealInExplorer(root) {
3113
3307
  if (!authorization.allowed) return;
3114
3308
  return shell2.openPath(root).catch((err) => {
3115
3309
  if (process.platform === "darwin") {
3116
- void shell2.openPath(path6.dirname(root)).catch(() => void 0);
3310
+ void shell2.openPath(path7.dirname(root)).catch(() => void 0);
3117
3311
  }
3118
3312
  console.error(
3119
3313
  JSON.stringify({
@@ -3382,6 +3576,7 @@ async function boot() {
3382
3576
  void windowStateController.save().finally(() => app2.exit(0));
3383
3577
  });
3384
3578
  await restoreLastWorkspace2();
3579
+ manager.startIdleSweep();
3385
3580
  const argvOpenPath = firstOpenFileArg(process.argv);
3386
3581
  const queuedOpenPath = drainPendingOpenFilePath();
3387
3582
  const openPath = argvOpenPath ?? queuedOpenPath;
@@ -3409,6 +3604,7 @@ app2.on("before-quit", () => {
3409
3604
  }
3410
3605
  bridge.closeAll();
3411
3606
  webuiController.disposeAll();
3607
+ manager.stopIdleSweep();
3412
3608
  void manager.closeAll({ persistWorkspace: false });
3413
3609
  });
3414
3610
  app2.on("activate", () => {
@@ -8,6 +8,7 @@ var IPC = {
8
8
  getState: "desktop:get-state",
9
9
  getConversation: "desktop:get-conversation",
10
10
  getWebuiStatus: "desktop:get-webui-status",
11
+ listProjectSessions: "desktop:list-project-sessions",
11
12
  openProject: "desktop:open-project",
12
13
  registerProject: "desktop:register-project",
13
14
  unregisterProject: "desktop:unregister-project",
@@ -47,6 +48,7 @@ var api = {
47
48
  getState: () => import_electron.ipcRenderer.invoke(IPC.getState),
48
49
  getConversation: (runtimeId) => import_electron.ipcRenderer.invoke(IPC.getConversation, runtimeId),
49
50
  getWebuiStatus: () => import_electron.ipcRenderer.invoke(IPC.getWebuiStatus),
51
+ listProjectSessions: (root) => import_electron.ipcRenderer.invoke(IPC.listProjectSessions, root),
50
52
  openProject: (root) => import_electron.ipcRenderer.invoke(IPC.openProject, root),
51
53
  registerProject: (root) => import_electron.ipcRenderer.invoke(IPC.registerProject, root),
52
54
  unregisterProject: (root) => import_electron.ipcRenderer.invoke(IPC.unregisterProject, root),
@@ -8,6 +8,7 @@ var IPC = {
8
8
  getState: "desktop:get-state",
9
9
  getConversation: "desktop:get-conversation",
10
10
  getWebuiStatus: "desktop:get-webui-status",
11
+ listProjectSessions: "desktop:list-project-sessions",
11
12
  openProject: "desktop:open-project",
12
13
  registerProject: "desktop:register-project",
13
14
  unregisterProject: "desktop:unregister-project",