@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.
- package/assets/icon.png +0 -0
- package/assets/icon.svg +6 -0
- package/dist/main/agent-bridge.js +1 -3
- package/dist/main/main.js +236 -40
- package/dist/preload/preload.cjs +2 -0
- package/dist/preload/webui-preload.cjs +1 -0
- package/dist/renderer/assets/index-DgACI2f2.js +10 -0
- package/dist/renderer/assets/index-PckFmyB3.css +1 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +19 -6
- package/dist/renderer/assets/index-0PiIJ5rt.js +0 -371
- package/dist/renderer/assets/index-DNaesuTF.css +0 -1
package/assets/icon.png
ADDED
|
Binary file
|
package/assets/icon.svg
ADDED
|
@@ -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
|
|
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
|
|
43
|
+
const path8 = pendingOpenFilePath;
|
|
44
44
|
pendingOpenFilePath = null;
|
|
45
|
-
return
|
|
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((
|
|
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
|
-
|
|
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:
|
|
1041
|
-
runtimes: Array.from(this.runtimes.values()).map(
|
|
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
|
|
1124
|
-
if (!
|
|
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
|
|
1317
|
-
if (!
|
|
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((
|
|
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
|
-
|
|
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((
|
|
1560
|
+
await new Promise((resolve5) => {
|
|
1488
1561
|
let settled = false;
|
|
1489
1562
|
const finish = () => {
|
|
1490
1563
|
if (settled) return;
|
|
1491
1564
|
settled = true;
|
|
1492
|
-
|
|
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
|
-
|
|
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(-
|
|
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((
|
|
1712
|
+
return new Promise((resolve5) => {
|
|
1638
1713
|
const server = net.createServer();
|
|
1639
|
-
server.once("error", () =>
|
|
1714
|
+
server.once("error", () => resolve5(false));
|
|
1640
1715
|
server.once("listening", () => {
|
|
1641
|
-
server.close(() =>
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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:
|
|
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
|
-
|
|
2284
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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", () => {
|
package/dist/preload/preload.cjs
CHANGED
|
@@ -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",
|