@wrongstack/desktop 1.0.4 → 1.0.7

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/main/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/main/main.ts
2
- import * as path7 from "node:path";
2
+ import * as path6 from "node:path";
3
+ import { watchProviderConfig } from "@wrongstack/core/storage";
3
4
  import { installCrashShield, resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
4
5
  import {
5
6
  app as app2,
@@ -10,52 +11,6 @@ import {
10
11
  WebContentsView as WebContentsView2
11
12
  } from "electron";
12
13
 
13
- // src/main/macos-platform.ts
14
- import { app, Menu } from "electron";
15
- function initMacOS() {
16
- if (process.platform !== "darwin") return;
17
- app.setActivationPolicy("regular");
18
- app.on("open-file", (_event, filePath) => {
19
- if (app.isReady()) {
20
- handleFileOpen(filePath);
21
- }
22
- });
23
- app.dock?.setMenu(
24
- Menu.buildFromTemplate([
25
- {
26
- label: "New Window",
27
- click: () => {
28
- }
29
- }
30
- ])
31
- );
32
- app.setAboutPanelOptions({
33
- applicationName: "WrongStack Desktop",
34
- applicationVersion: app.getVersion(),
35
- version: process.versions.electron ? `Electron ${process.versions.electron}` : ""
36
- });
37
- }
38
- function handleFileOpen(filePath) {
39
- pendingOpenFilePath = filePath;
40
- }
41
- var pendingOpenFilePath = null;
42
- function drainPendingOpenFilePath() {
43
- const path8 = pendingOpenFilePath;
44
- pendingOpenFilePath = null;
45
- return path8;
46
- }
47
- function firstOpenFileArg(argv) {
48
- if (process.platform !== "darwin") return null;
49
- for (let index = 1; index < argv.length; index++) {
50
- const arg = argv[index];
51
- if (arg == null) continue;
52
- if (arg.startsWith("-")) continue;
53
- if (arg === "." || arg === "--") continue;
54
- return arg;
55
- }
56
- return null;
57
- }
58
-
59
14
  // src/main/agent-bridge.ts
60
15
  import { randomUUID } from "node:crypto";
61
16
  import { EventEmitter } from "node:events";
@@ -147,7 +102,7 @@ var DesktopAgentBridge = class extends EventEmitter {
147
102
  conversation.error = void 0;
148
103
  this.emitChanged(conversation);
149
104
  this.emitReconnectEvent(conversation, "connecting");
150
- const promise = new Promise((resolve5, reject) => {
105
+ const promise = new Promise((resolve4, reject) => {
151
106
  const ws = new WebSocket(wsUrl);
152
107
  conversation.ws = ws;
153
108
  const myGeneration = ++conversation.socketGeneration;
@@ -172,7 +127,7 @@ var DesktopAgentBridge = class extends EventEmitter {
172
127
  conversation.connectionState = markConnectionOpen(conversation.connectionState);
173
128
  this.emitChanged(conversation);
174
129
  this.emitReconnectEvent(conversation, "connected");
175
- resolve5();
130
+ resolve4();
176
131
  });
177
132
  ws.once("error", (err) => {
178
133
  clearTimeout(timeout);
@@ -455,6 +410,99 @@ function stringValue(value) {
455
410
  return typeof value === "string" ? value : void 0;
456
411
  }
457
412
 
413
+ // src/main/app-icon.ts
414
+ import * as fs from "node:fs/promises";
415
+ import { fileURLToPath } from "node:url";
416
+ import { nativeImage } from "electron";
417
+ var ICON_CANDIDATES = ["../../assets/icon.png", "../../assets/icon.icns"];
418
+ async function readIcon(relativePath) {
419
+ try {
420
+ const iconPath = fileURLToPath(new URL(relativePath, import.meta.url));
421
+ await fs.stat(iconPath);
422
+ const icon = nativeImage.createFromPath(iconPath);
423
+ return icon.isEmpty() ? void 0 : icon;
424
+ } catch {
425
+ return void 0;
426
+ }
427
+ }
428
+ async function loadDesktopAppIcon() {
429
+ for (const candidate of ICON_CANDIDATES) {
430
+ const icon = await readIcon(candidate);
431
+ if (icon) return icon;
432
+ }
433
+ return void 0;
434
+ }
435
+
436
+ // src/main/desktop-config-io.ts
437
+ import * as fs2 from "node:fs/promises";
438
+ import * as path from "node:path";
439
+ import {
440
+ decryptConfigSecrets,
441
+ DefaultSecretVault,
442
+ encryptConfigSecrets
443
+ } from "@wrongstack/core/security";
444
+ import { atomicWrite, wstackGlobalRoot } from "@wrongstack/core/utils";
445
+ var globalRoot = wstackGlobalRoot();
446
+ var bootstrapConfigPath = path.join(globalRoot, "config.json");
447
+ var defaultProfileConfigPath = path.join(globalRoot, "profiles", "default", "config.json");
448
+ var vault = new DefaultSecretVault({
449
+ keyFile: path.join(globalRoot, ".key")
450
+ });
451
+ function safeProfileName(value) {
452
+ if (typeof value !== "string" || !value.trim()) return "default";
453
+ return value.replace(/[/\\:]/g, "_").replace(/\.\./g, "_") || "default";
454
+ }
455
+ async function resolveActiveProfileConfigPath() {
456
+ try {
457
+ const raw = await fs2.readFile(bootstrapConfigPath, "utf8");
458
+ const parsed = JSON.parse(raw);
459
+ return path.join(globalRoot, "profiles", safeProfileName(parsed.activeProfile), "config.json");
460
+ } catch {
461
+ return defaultProfileConfigPath;
462
+ }
463
+ }
464
+ async function readUiLocale() {
465
+ const profileConfigPath = await resolveActiveProfileConfigPath();
466
+ let raw;
467
+ try {
468
+ raw = await fs2.readFile(profileConfigPath, "utf8");
469
+ } catch {
470
+ return void 0;
471
+ }
472
+ try {
473
+ const decrypted = decryptConfigSecrets(JSON.parse(raw), vault);
474
+ const value = decrypted.uiLocale;
475
+ return typeof value === "string" && value ? value : void 0;
476
+ } catch {
477
+ return void 0;
478
+ }
479
+ }
480
+ var desktopConfigPaths = {
481
+ bootstrapConfigPath,
482
+ profileConfigPath: defaultProfileConfigPath,
483
+ vault
484
+ };
485
+ async function writeUiLocale(code) {
486
+ const profileConfigPath = await resolveActiveProfileConfigPath();
487
+ let raw;
488
+ try {
489
+ raw = await fs2.readFile(profileConfigPath, "utf8");
490
+ } catch {
491
+ raw = "{}";
492
+ }
493
+ let parsed;
494
+ try {
495
+ parsed = JSON.parse(raw);
496
+ } catch {
497
+ return;
498
+ }
499
+ const decrypted = decryptConfigSecrets(parsed, vault);
500
+ decrypted.uiLocale = code;
501
+ const encrypted = encryptConfigSecrets(decrypted, vault);
502
+ await fs2.mkdir(path.dirname(profileConfigPath), { recursive: true });
503
+ await atomicWrite(profileConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
504
+ }
505
+
458
506
  // src/main/desktop-privileged-actions.ts
459
507
  import { randomUUID as randomUUID2 } from "node:crypto";
460
508
  import {
@@ -462,19 +510,26 @@ import {
462
510
  isTrustDecisionAllowed
463
511
  } from "@wrongstack/core/security";
464
512
  var desktopCompatibilityTrustBoundary = createCompatibilityTrustBoundary({
465
- policyId: "desktop-trusted-host-compat-v1"
513
+ policyId: "desktop-trusted-host-compat-v1",
514
+ // WS-SEC-03: the desktop shell hosts a renderer that loads *remote* content
515
+ // (the WebUI runtime over http://127.0.0.1). Actions originating there are
516
+ // attributed to `remote-client`, so refuse the high/critical tiers from it —
517
+ // spawning or terminating a runtime is a shell-menu action, never something
518
+ // page content should be able to drive.
519
+ denyHighRiskRemoteClient: true
466
520
  });
467
521
  async function authorizeDesktopAction(boundary, action, logger) {
522
+ const principalId = action.origin === "user" ? "desktop-user" : "desktop-webui-view";
468
523
  const request = {
469
524
  version: 1,
470
525
  requestId: randomUUID2(),
471
- actor: { kind: "user", id: "desktop-user" },
526
+ actor: { kind: action.origin, id: principalId },
472
527
  surface: "desktop",
473
528
  capability: action.capability,
474
529
  subject: action.subject,
475
530
  risk: action.risk,
476
531
  scope: action.cwd ? { cwd: action.cwd } : {},
477
- authContext: { method: "local-process", principalId: "desktop-user" },
532
+ authContext: { method: "local-process", principalId },
478
533
  ...action.metadata ? { metadata: action.metadata } : {}
479
534
  };
480
535
  const decision = await boundary.evaluate(request);
@@ -482,6 +537,10 @@ async function authorizeDesktopAction(boundary, action, logger) {
482
537
  event: "desktop.trust_boundary.decision",
483
538
  requestId: request.requestId,
484
539
  capability: request.capability,
540
+ // The origin is the whole point of the decision now, so an audit line that
541
+ // omits it cannot be used to tell an allowed shell action from an allowed
542
+ // renderer-driven one.
543
+ actor: request.actor.kind,
485
544
  decision: decision.kind,
486
545
  policyId: decision.policyId
487
546
  };
@@ -497,6 +556,8 @@ async function authorizeDesktopAction(boundary, action, logger) {
497
556
  function authorizeDesktopRuntimeStart(boundary, cwd, runtimeKind) {
498
557
  return authorizeDesktopAction(boundary, {
499
558
  capability: "process.spawn",
559
+ // Shell-driven: reached from the app menu / shell renderer's open-project.
560
+ origin: "user",
500
561
  subject: {
501
562
  kind: "command",
502
563
  id: "wrongstack-webui-runtime",
@@ -510,6 +571,7 @@ function authorizeDesktopRuntimeStart(boundary, cwd, runtimeKind) {
510
571
  function authorizeDesktopRuntimeStop(boundary, runtime) {
511
572
  return authorizeDesktopAction(boundary, {
512
573
  capability: "process.terminate",
574
+ origin: "user",
513
575
  subject: {
514
576
  kind: "process",
515
577
  id: runtime.id,
@@ -524,46 +586,6 @@ function authorizeDesktopRuntimeStop(boundary, runtime) {
524
586
  });
525
587
  }
526
588
 
527
- // src/main/ipc.ts
528
- var IPC = {
529
- getState: "desktop:get-state",
530
- getConversation: "desktop:get-conversation",
531
- getWebuiStatus: "desktop:get-webui-status",
532
- listProjectSessions: "desktop:list-project-sessions",
533
- openProject: "desktop:open-project",
534
- registerProject: "desktop:register-project",
535
- unregisterProject: "desktop:unregister-project",
536
- openProjectSession: "desktop:open-project-session",
537
- activateRuntime: "desktop:activate-runtime",
538
- closeRuntime: "desktop:close-runtime",
539
- navigateWebui: "desktop:navigate-webui",
540
- reloadWebui: "desktop:reload-webui",
541
- setShellSidebarCollapsed: "desktop:set-shell-sidebar-collapsed",
542
- openSettings: "desktop:open-settings",
543
- sendMessage: "desktop:send-message",
544
- abortRuntime: "desktop:abort-runtime",
545
- openRuntimeInBrowser: "desktop:open-runtime-in-browser",
546
- revealRuntimeRoot: "desktop:reveal-runtime-root",
547
- stateChanged: "desktop:state-changed",
548
- conversationChanged: "desktop:conversation-changed",
549
- webuiStatusChanged: "desktop:webui-status-changed",
550
- webuiReadyChanged: "desktop:webui-ready-changed",
551
- webuiPrefsChanged: "desktop:webui-prefs-changed",
552
- webuiCommandAck: "desktop:webui-command-ack",
553
- webuiCommand: "desktop:webui-command",
554
- shellSidebarCollapsedChanged: "desktop:shell-sidebar-collapsed-changed",
555
- setLocale: "desktop:set-locale",
556
- localeChanged: "desktop:locale-changed",
557
- // Embedded WebUI view side — the desktop shell pushes locale changes here
558
- // so the React WebUI inside Electron can swap i18n instantly, without waiting
559
- // for the config-file watcher → WS prefs.updated round-trip.
560
- webuiLocaleChanged: "desktop:webui-locale-changed",
561
- // macOS open-file event forwarded from main process to shell renderer.
562
- // The shell uses this to decide whether to open a dragged/double-clicked
563
- // path as a project directory.
564
- openFile: "desktop:open-file"
565
- };
566
-
567
589
  // src/main/i18n-main.ts
568
590
  var en = {
569
591
  windowTitle: "WrongStack Desktop",
@@ -864,2386 +886,2370 @@ function tMain(key) {
864
886
  return CATALOGS[mainLocale]?.[key] ?? CATALOGS.en[key] ?? key;
865
887
  }
866
888
 
867
- // src/main/desktop-config-io.ts
868
- import * as fs from "node:fs/promises";
869
- import * as path from "node:path";
870
- import {
871
- decryptConfigSecrets,
872
- DefaultSecretVault,
873
- encryptConfigSecrets
874
- } from "@wrongstack/core/security";
875
- import { atomicWrite, wstackGlobalRoot } from "@wrongstack/core/utils";
876
- var globalRoot = wstackGlobalRoot();
877
- var bootstrapConfigPath = path.join(globalRoot, "config.json");
878
- var defaultProfileConfigPath = path.join(globalRoot, "profiles", "default", "config.json");
879
- var vault = new DefaultSecretVault({
880
- keyFile: path.join(globalRoot, ".key")
881
- });
882
- function safeProfileName(value) {
883
- if (typeof value !== "string" || !value.trim()) return "default";
884
- return value.replace(/[/\\:]/g, "_").replace(/\.\./g, "_") || "default";
885
- }
886
- async function resolveActiveProfileConfigPath() {
889
+ // src/main/ipc.ts
890
+ var IPC = {
891
+ getState: "desktop:get-state",
892
+ getConversation: "desktop:get-conversation",
893
+ getWebuiStatus: "desktop:get-webui-status",
894
+ getOpenSessions: "desktop:get-open-sessions",
895
+ openProject: "desktop:open-project",
896
+ registerProject: "desktop:register-project",
897
+ unregisterProject: "desktop:unregister-project",
898
+ openProjectSession: "desktop:open-project-session",
899
+ activateRuntime: "desktop:activate-runtime",
900
+ closeRuntime: "desktop:close-runtime",
901
+ navigateWebui: "desktop:navigate-webui",
902
+ reloadWebui: "desktop:reload-webui",
903
+ setShellSidebarCollapsed: "desktop:set-shell-sidebar-collapsed",
904
+ openSettings: "desktop:open-settings",
905
+ sendMessage: "desktop:send-message",
906
+ abortRuntime: "desktop:abort-runtime",
907
+ openRuntimeInBrowser: "desktop:open-runtime-in-browser",
908
+ revealRuntimeRoot: "desktop:reveal-runtime-root",
909
+ stateChanged: "desktop:state-changed",
910
+ conversationChanged: "desktop:conversation-changed",
911
+ webuiStatusChanged: "desktop:webui-status-changed",
912
+ webuiReadyChanged: "desktop:webui-ready-changed",
913
+ webuiPrefsChanged: "desktop:webui-prefs-changed",
914
+ webuiOpenSessionsChanged: "desktop:webui-open-sessions-changed",
915
+ openSessionsChanged: "desktop:open-sessions-changed",
916
+ webuiCommandAck: "desktop:webui-command-ack",
917
+ webuiCommand: "desktop:webui-command",
918
+ shellSidebarCollapsedChanged: "desktop:shell-sidebar-collapsed-changed",
919
+ setLocale: "desktop:set-locale",
920
+ localeChanged: "desktop:locale-changed",
921
+ // Embedded WebUI view side — the desktop shell pushes locale changes here
922
+ // so the React WebUI inside Electron can swap i18n instantly, without waiting
923
+ // for the config-file watcher → WS prefs.updated round-trip.
924
+ webuiLocaleChanged: "desktop:webui-locale-changed",
925
+ // macOS open-file event forwarded from main process to shell renderer.
926
+ // The shell uses this to decide whether to open a dragged/double-clicked
927
+ // path as a project directory.
928
+ openFile: "desktop:open-file"
929
+ };
930
+
931
+ // src/main/ipc-handlers/index.ts
932
+ import { ipcMain } from "electron";
933
+
934
+ // src/main/validation/index.ts
935
+ function validate(schema, data) {
887
936
  try {
888
- const raw = await fs.readFile(bootstrapConfigPath, "utf8");
889
- const parsed = JSON.parse(raw);
890
- return path.join(globalRoot, "profiles", safeProfileName(parsed.activeProfile), "config.json");
891
- } catch {
892
- return defaultProfileConfigPath;
937
+ const result = schema.safeParse(data);
938
+ if (result.success) {
939
+ return { success: true, data: result.data };
940
+ }
941
+ return { success: false, error: formatZodError(result.error) };
942
+ } catch (err) {
943
+ return { success: false, error: String(err) };
893
944
  }
894
945
  }
895
- async function readUiLocale() {
896
- const profileConfigPath = await resolveActiveProfileConfigPath();
897
- let raw;
898
- try {
899
- raw = await fs.readFile(profileConfigPath, "utf8");
900
- } catch {
901
- return void 0;
902
- }
903
- try {
904
- const decrypted = decryptConfigSecrets(JSON.parse(raw), vault);
905
- const value = decrypted.uiLocale;
906
- return typeof value === "string" && value ? value : void 0;
907
- } catch {
908
- return void 0;
909
- }
946
+ function validateOrDefault(schema, data, defaultValue) {
947
+ const result = validate(schema, data);
948
+ return result.success ? result.data : defaultValue;
910
949
  }
911
- var desktopConfigPaths = {
912
- bootstrapConfigPath,
913
- profileConfigPath: defaultProfileConfigPath,
914
- vault
915
- };
916
- async function writeUiLocale(code) {
917
- const profileConfigPath = await resolveActiveProfileConfigPath();
918
- let raw;
919
- try {
920
- raw = await fs.readFile(profileConfigPath, "utf8");
921
- } catch {
922
- raw = "{}";
923
- }
924
- let parsed;
925
- try {
926
- parsed = JSON.parse(raw);
927
- } catch {
928
- return;
929
- }
930
- const decrypted = decryptConfigSecrets(parsed, vault);
931
- decrypted.uiLocale = code;
932
- const encrypted = encryptConfigSecrets(decrypted, vault);
933
- await fs.mkdir(path.dirname(profileConfigPath), { recursive: true });
934
- await atomicWrite(profileConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
950
+ function formatZodError(error) {
951
+ return error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
952
+ }
953
+ function createValidationLogger(prefix) {
954
+ const loggedErrors = /* @__PURE__ */ new Set();
955
+ const maxErrors = 100;
956
+ return {
957
+ log(error) {
958
+ if (loggedErrors.size >= maxErrors) return;
959
+ const key = `${prefix}:${error}`;
960
+ if (!loggedErrors.has(key)) {
961
+ loggedErrors.add(key);
962
+ console.warn(
963
+ JSON.stringify({
964
+ level: "warn",
965
+ event: "desktop.validation_error",
966
+ prefix,
967
+ message: error,
968
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
969
+ })
970
+ );
971
+ }
972
+ },
973
+ reset() {
974
+ loggedErrors.clear();
975
+ }
976
+ };
935
977
  }
936
978
 
937
- // src/main/runtime-manager.ts
938
- import { spawn } from "node:child_process";
939
- import { randomBytes } from "node:crypto";
940
- import { EventEmitter as EventEmitter2 } from "node:events";
941
- import * as fs2 from "node:fs/promises";
942
- import * as http from "node:http";
943
- import * as net from "node:net";
944
- import * as os from "node:os";
945
- import * as path3 from "node:path";
946
- import {
947
- atomicWrite as atomicWrite2,
948
- buildChildEnv,
949
- projectSlug,
950
- resolveWstackPaths as resolveWstackPaths2,
951
- toErrorMessage,
952
- wstackGlobalRoot as wstackGlobalRoot2
953
- } from "@wrongstack/core/utils";
954
-
955
- // src/main/runtime-manager-paths.ts
956
- import { existsSync } from "node:fs";
957
- import { createRequire } from "node:module";
979
+ // src/main/validation/schemas.ts
980
+ import { statSync } from "node:fs";
958
981
  import * as path2 from "node:path";
959
- import { fileURLToPath } from "node:url";
960
- import { resolveWstackPaths } from "@wrongstack/core/utils";
961
- function resolveWebUiEntry() {
962
- if (process.env["WRONGSTACK_WEBUI_ENTRY"]) {
963
- return path2.resolve(process.env["WRONGSTACK_WEBUI_ENTRY"]);
964
- }
965
- const require2 = createRequire(import.meta.url);
966
- try {
967
- const serverPkgPath = require2.resolve("@wrongstack/webui-server/package.json");
968
- const candidate = path2.join(path2.dirname(serverPkgPath), "dist", "server", "entry.js");
969
- if (existsSync(candidate)) return candidate;
970
- } catch {
971
- }
972
- const serverIndex = require2.resolve("@wrongstack/webui-server");
973
- return path2.join(path2.dirname(serverIndex), "server", "entry.js");
974
- }
975
- function resolveWebUiDistDir() {
976
- if (process.env["WRONGSTACK_WEBUI_DIST"]) {
977
- return path2.resolve(process.env["WRONGSTACK_WEBUI_DIST"]);
982
+ import { z } from "zod";
983
+ var RUNTIME_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,120}$/;
984
+ var runtimeIdSchema = z.string().regex(RUNTIME_ID_PATTERN, "Invalid runtime ID format");
985
+ var pathSchema = z.string().min(1).max(1e4);
986
+ var projectRootSchema = pathSchema.refine((value) => !value.includes("\0"), {
987
+ message: "path must not contain a NUL byte"
988
+ }).refine(
989
+ (value) => {
990
+ try {
991
+ return statSync(path2.resolve(value)).isDirectory();
992
+ } catch {
993
+ return false;
994
+ }
995
+ },
996
+ { message: "path must be an existing directory" }
997
+ );
998
+ var booleanSchema = z.boolean();
999
+ var numberSchema = z.number().int().finite();
1000
+ var openProjectSchema = z.object({
1001
+ requestedRoot: pathSchema.optional()
1002
+ });
1003
+ var registerProjectSchema = z.object({
1004
+ requestedRoot: pathSchema.optional()
1005
+ });
1006
+ var unregisterProjectSchema = z.object({
1007
+ root: pathSchema
1008
+ });
1009
+ var openProjectSessionSchema = z.object({
1010
+ runtimeId: runtimeIdSchema.optional()
1011
+ });
1012
+ var activateRuntimeSchema = z.object({
1013
+ id: runtimeIdSchema
1014
+ });
1015
+ var closeRuntimeSchema = z.object({
1016
+ id: runtimeIdSchema
1017
+ });
1018
+ var sendMessageSchema = z.object({
1019
+ id: runtimeIdSchema,
1020
+ content: z.string()
1021
+ });
1022
+ var abortRuntimeSchema = z.object({
1023
+ id: runtimeIdSchema
1024
+ });
1025
+ var openRuntimeInBrowserSchema = z.object({
1026
+ id: runtimeIdSchema
1027
+ });
1028
+ var revealRuntimeRootSchema = z.object({
1029
+ id: runtimeIdSchema
1030
+ });
1031
+ var setShellSidebarCollapsedSchema = z.object({
1032
+ collapsed: booleanSchema
1033
+ });
1034
+ var navigateWebuiSchema = z.object({
1035
+ command: z.unknown()
1036
+ });
1037
+ var getConversationSchema = z.object({
1038
+ runtimeId: runtimeIdSchema
1039
+ });
1040
+ var webuiReadyChangedSchema = z.object({
1041
+ ready: booleanSchema
1042
+ });
1043
+ var webuiPrefsChangedSchema = z.object({
1044
+ prefs: z.record(z.string(), z.unknown())
1045
+ });
1046
+ var webuiCommandAckSchema = z.object({
1047
+ requestId: z.string(),
1048
+ handled: z.boolean(),
1049
+ message: z.string().optional()
1050
+ });
1051
+ var setLocaleSchema = z.object({
1052
+ locale: z.string().min(2).max(10)
1053
+ });
1054
+
1055
+ // src/main/webui/open-sessions.ts
1056
+ function sanitizeOpenSessions(value) {
1057
+ if (!Array.isArray(value)) return [];
1058
+ const sessions = [];
1059
+ const ids = /* @__PURE__ */ new Set();
1060
+ const slots = /* @__PURE__ */ new Set();
1061
+ for (const raw of value) {
1062
+ if (!isRecord(raw)) continue;
1063
+ const id = raw["id"];
1064
+ const title = raw["title"];
1065
+ const slot = raw["slot"];
1066
+ if (typeof id !== "string" || id.length === 0 || id.length > 512 || typeof title !== "string" || !Number.isInteger(slot) || slot < 0 || slot > 3 || typeof raw["active"] !== "boolean" || typeof raw["running"] !== "boolean" || ids.has(id) || slots.has(slot)) {
1067
+ continue;
1068
+ }
1069
+ ids.add(id);
1070
+ slots.add(slot);
1071
+ sessions.push({
1072
+ id,
1073
+ title: title.trim().slice(0, 200) || id.slice(0, 8),
1074
+ slot,
1075
+ active: raw["active"],
1076
+ running: raw["running"]
1077
+ });
1078
+ if (sessions.length === 4) break;
978
1079
  }
979
- const require2 = createRequire(import.meta.url);
980
- const serverEntry = require2.resolve("@wrongstack/webui");
981
- const candidate = path2.dirname(serverEntry);
982
- if (existsSync(candidate)) return candidate;
983
- throw new Error(
984
- `WebUI frontend assets not found at ${candidate}. Build @wrongstack/webui or set WRONGSTACK_WEBUI_DIST.`
985
- );
986
- }
987
- function rendererIndexPath() {
988
- return new URL("../renderer/index.html", import.meta.url).href;
989
- }
990
- function preloadPath() {
991
- return fileURLToPath(new URL("../preload/preload.cjs", import.meta.url));
992
- }
993
- function webuiPreloadPath() {
994
- return fileURLToPath(new URL("../preload/webui-preload.cjs", import.meta.url));
1080
+ return sessions.sort((a, b) => a.slot - b.slot);
995
1081
  }
996
- function desktopSettingsWorkspaceRoot() {
997
- return path2.join(resolveWstackPaths({ projectRoot: process.cwd() }).configDir, "settings");
1082
+ function isRecord(value) {
1083
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
998
1084
  }
999
1085
 
1000
- // src/main/runtime-manager.ts
1001
- var HTTP_PORT_START = 34560;
1002
- var WS_PORT_START = 34660;
1003
- var START_TIMEOUT_MS = 3e4;
1004
- var MIN_WINDOW_WIDTH = 760;
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;
1086
+ // src/main/ipc-handlers/index.ts
1087
+ var validationLogger = createValidationLogger("IPC");
1088
+ function isShellSender(ctx, senderId) {
1089
+ const shell3 = ctx.getShellView();
1090
+ if (shell3 === null) return false;
1091
+ const contents = shell3.webContents;
1092
+ return !contents.isDestroyed() && contents.id === senderId;
1093
+ }
1094
+ function handleShellOnly(ctx, channel, handler) {
1095
+ ipcMain.handle(channel, (event, ...args) => {
1096
+ if (!isShellSender(ctx, event.sender.id)) {
1097
+ validationLogger.log(`${channel}: rejected invoke from sender ${event.sender.id}`);
1098
+ throw new Error(`IPC channel ${channel} is restricted to the shell renderer`);
1099
+ }
1100
+ return handler(event, ...args);
1101
+ });
1023
1102
  }
1024
- var DesktopRuntimeManager = class extends EventEmitter2 {
1025
- constructor(trustBoundary = desktopCompatibilityTrustBoundary) {
1026
- super();
1027
- this.trustBoundary = trustBoundary;
1028
- }
1029
- trustBoundary;
1030
- runtimes = /* @__PURE__ */ new Map();
1031
- stateFile = path3.join(
1032
- resolveWstackPaths2({ projectRoot: process.cwd() }).configDir,
1033
- "desktop.json"
1034
- );
1035
- recentProjects = [];
1036
- registeredProjects = [];
1037
- restoreProjectSessions = [];
1038
- restoreActiveRuntimeId = null;
1039
- restoreActiveProjectRoot = null;
1040
- lastActiveProjectRoot = null;
1041
- windowState = null;
1042
- activeRuntimeId = null;
1043
- restoring = false;
1044
- workspaceRestoreCompleted = false;
1045
- idleSweepTimer = null;
1046
- idleTimeoutMs = 0;
1047
- async init() {
1048
- const state = await this.loadDesktopState();
1049
- this.recentProjects = state.recentProjects;
1050
- this.registeredProjects = await readGlobalProjectManifest();
1051
- this.restoreProjectSessions = state.openProjectSessions;
1052
- this.restoreActiveRuntimeId = state.activeRuntimeId;
1053
- this.restoreActiveProjectRoot = state.activeProjectRoot;
1054
- this.lastActiveProjectRoot = state.activeProjectRoot;
1055
- this.windowState = state.window;
1056
- }
1057
- snapshot() {
1058
- const activeId = this.activeRuntimeId;
1059
- return {
1060
- activeRuntimeId: activeId,
1061
- runtimes: Array.from(this.runtimes.values()).map(
1062
- (runtime) => publicRuntime(runtime, runtime.id === activeId)
1063
- ),
1064
- recentProjects: [...this.recentProjects],
1065
- registeredProjects: [...this.registeredProjects],
1066
- restoring: this.restoring
1067
- };
1068
- }
1069
- getWindowState() {
1070
- return this.windowState ? { ...this.windowState } : null;
1071
- }
1072
- async saveWindowState(window) {
1073
- this.windowState = { ...window };
1074
- await this.saveDesktopState();
1075
- }
1076
- async restoreLastWorkspace() {
1077
- const sessions = this.restoreProjectSessions.filter(
1078
- (session) => typeof session.root === "string" && session.root.trim()
1103
+ function registerIpcHandlers(ctx) {
1104
+ handleShellOnly(ctx, IPC.getState, () => ctx.getRuntimeManager().snapshot());
1105
+ handleShellOnly(ctx, IPC.getConversation, (_event, runtimeId) => {
1106
+ const result = validate(runtimeIdSchema, runtimeId);
1107
+ if (!result.success) {
1108
+ validationLogger.log(`getConversation: ${result.error}`);
1109
+ return ctx.getAgentBridge().snapshot("");
1110
+ }
1111
+ return ctx.getAgentBridge().snapshot(result.data);
1112
+ });
1113
+ handleShellOnly(ctx, IPC.getWebuiStatus, () => ctx.getWebuiStatus());
1114
+ handleShellOnly(ctx, IPC.getOpenSessions, () => ctx.getOpenSessions());
1115
+ handleShellOnly(ctx, IPC.navigateWebui, async (_event, command) => {
1116
+ return ctx.dispatchWebuiCommand(command);
1117
+ });
1118
+ handleShellOnly(ctx, IPC.reloadWebui, async () => ctx.reloadActiveWebuiView());
1119
+ handleShellOnly(ctx, IPC.setShellSidebarCollapsed, (_event, collapsed) => {
1120
+ const result = validateOrDefault(booleanSchema, collapsed, true);
1121
+ ctx.setShellSidebarCollapsed(result);
1122
+ return true;
1123
+ });
1124
+ handleShellOnly(ctx, IPC.openSettings, async () => ctx.openSettings());
1125
+ handleShellOnly(ctx, IPC.openProjectSession, async (_event, runtimeId) => {
1126
+ const validated = validateOptional(runtimeIdSchema, runtimeId);
1127
+ return ctx.openProjectSession(validated);
1128
+ });
1129
+ handleShellOnly(ctx, IPC.openProject, async (_event, requestedRoot) => {
1130
+ const validated = validateOptional(projectRootSchema, requestedRoot);
1131
+ return ctx.openProject(validated);
1132
+ });
1133
+ handleShellOnly(ctx, IPC.registerProject, async (_event, requestedRoot) => {
1134
+ const validated = validateOptional(projectRootSchema, requestedRoot);
1135
+ return ctx.registerProject(validated);
1136
+ });
1137
+ handleShellOnly(ctx, IPC.unregisterProject, async (_event, root) => {
1138
+ const result = validate(pathSchema, root);
1139
+ if (!result.success) {
1140
+ validationLogger.log(`unregisterProject: ${result.error}`);
1141
+ return ctx.getRuntimeManager().snapshot();
1142
+ }
1143
+ return ctx.unregisterProject(result.data);
1144
+ });
1145
+ handleShellOnly(ctx, IPC.activateRuntime, async (_event, id) => {
1146
+ const result = validate(runtimeIdSchema, id);
1147
+ if (!result.success) {
1148
+ validationLogger.log(`activateRuntime: ${result.error}`);
1149
+ return ctx.getRuntimeManager().snapshot();
1150
+ }
1151
+ return ctx.activateRuntime(result.data);
1152
+ });
1153
+ handleShellOnly(ctx, IPC.closeRuntime, async (_event, id) => {
1154
+ const result = validate(runtimeIdSchema, id);
1155
+ if (!result.success) {
1156
+ validationLogger.log(`closeRuntime: ${result.error}`);
1157
+ return ctx.getRuntimeManager().snapshot();
1158
+ }
1159
+ return ctx.closeRuntime(result.data);
1160
+ });
1161
+ handleShellOnly(ctx, IPC.sendMessage, async (_event, id, content) => {
1162
+ const idResult = validate(runtimeIdSchema, id);
1163
+ if (!idResult.success) {
1164
+ validationLogger.log(`sendMessage (id): ${idResult.error}`);
1165
+ return ctx.sendMessage("", "", "");
1166
+ }
1167
+ const contentResult = validate(
1168
+ pathSchema.transform(() => String(content ?? "")),
1169
+ content
1079
1170
  );
1080
- if (sessions.length === 0 || this.restoring || this.runtimes.size > 0) {
1081
- this.workspaceRestoreCompleted = true;
1171
+ const safeContent = contentResult.success ? contentResult.data : String(content ?? "");
1172
+ return ctx.sendMessage(
1173
+ idResult.data,
1174
+ ctx.getRuntimeManager().getRuntimeWsUrlWithToken(idResult.data) ?? "",
1175
+ safeContent
1176
+ );
1177
+ });
1178
+ handleShellOnly(ctx, IPC.abortRuntime, async (_event, id) => {
1179
+ const result = validate(runtimeIdSchema, id);
1180
+ if (!result.success) {
1181
+ validationLogger.log(`abortRuntime: ${result.error}`);
1182
+ return ctx.abortRuntime("", "");
1183
+ }
1184
+ return ctx.abortRuntime(
1185
+ result.data,
1186
+ ctx.getRuntimeManager().getRuntimeWsUrlWithToken(result.data) ?? ""
1187
+ );
1188
+ });
1189
+ handleShellOnly(ctx, IPC.openRuntimeInBrowser, async (_event, id) => {
1190
+ const result = validate(runtimeIdSchema, id);
1191
+ if (!result.success) {
1192
+ validationLogger.log(`openRuntimeInBrowser: ${result.error}`);
1082
1193
  return;
1083
1194
  }
1084
- this.restoring = true;
1085
- this.emitChanged();
1086
- try {
1087
- const seen = /* @__PURE__ */ new Map();
1088
- for (const session of sessions) {
1089
- const key = pathKey(session.root);
1090
- const seenCount = seen.get(key) ?? 0;
1091
- seen.set(key, seenCount + 1);
1092
- await this.openProject(session.root, {
1093
- forceNew: seenCount > 0,
1094
- name: session.name,
1095
- runtimeId: session.runtimeId
1096
- }).catch((err) => {
1097
- process.stderr.write(
1098
- `[desktop:restore] Failed to restore ${session.root}: ${toErrorMessage(err)}
1099
- `
1100
- );
1101
- });
1102
- }
1103
- let restoredActive = false;
1104
- if (this.restoreActiveRuntimeId) {
1105
- const active = this.runtimes.get(this.restoreActiveRuntimeId);
1106
- if (active) {
1107
- await this.activateRuntime(active.id);
1108
- restoredActive = true;
1109
- }
1110
- }
1111
- if (!restoredActive && this.restoreActiveProjectRoot) {
1112
- const active = Array.from(this.runtimes.values()).find(
1113
- (runtime) => samePath(runtime.root, this.restoreActiveProjectRoot ?? "")
1114
- );
1115
- if (active) await this.activateRuntime(active.id);
1116
- }
1117
- } finally {
1118
- this.restoring = false;
1119
- this.workspaceRestoreCompleted = true;
1120
- this.emitChanged();
1121
- await this.saveDesktopState();
1195
+ const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(result.data);
1196
+ if (url) ctx.openExternal(url);
1197
+ });
1198
+ handleShellOnly(ctx, IPC.revealRuntimeRoot, async (_event, id) => {
1199
+ const result = validate(runtimeIdSchema, id);
1200
+ if (!result.success) {
1201
+ validationLogger.log(`revealRuntimeRoot: ${result.error}`);
1202
+ return;
1122
1203
  }
1123
- }
1124
- getRuntime(id) {
1125
- const runtime = this.runtimes.get(id);
1126
- return runtime ? publicRuntime(runtime, true) : void 0;
1127
- }
1128
- getRuntimeUrlWithToken(id) {
1129
- const runtime = this.runtimes.get(id);
1130
- if (!runtime) return void 0;
1131
- const url = new URL(runtime.url);
1132
- url.searchParams.set("token", runtime.token);
1133
- url.searchParams.set("shell", "desktop");
1134
- return url.toString();
1135
- }
1136
- getRuntimeWsUrlWithToken(id) {
1137
- const runtime = this.runtimes.get(id);
1138
- if (!runtime) return void 0;
1139
- const url = new URL(`ws://127.0.0.1:${runtime.wsPort}`);
1140
- url.searchParams.set("token", runtime.token);
1141
- return url.toString();
1142
- }
1143
- async openProject(projectRoot, options = {}) {
1144
- const resolved = path3.resolve(projectRoot);
1145
- const stat4 = await fs2.stat(resolved).catch(() => null);
1146
- if (!stat4?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1147
- const kind = options.kind ?? "project";
1148
- const touchRecent = options.touchRecent ?? kind === "project";
1149
- const forceNew = options.forceNew === true;
1150
- const authorization = await authorizeDesktopRuntimeStart(this.trustBoundary, resolved, kind);
1151
- if (!authorization.allowed) {
1152
- throw new Error(`Desktop runtime start denied: ${authorization.reason}`);
1204
+ const runtime = ctx.getRuntimeManager().getRuntime(result.data);
1205
+ if (runtime) ctx.revealInExplorer(runtime.root);
1206
+ });
1207
+ ipcMain.on(IPC.webuiReadyChanged, (event, ready) => {
1208
+ const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
1209
+ if (!entry) return;
1210
+ const isReady = ready === true;
1211
+ entry.bridgeReady = isReady;
1212
+ if (entry.bridgeReady) {
1213
+ ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "ready" });
1214
+ ctx.schedulePendingWebuiFlush(entry);
1215
+ } else if (entry.status.status === "ready") {
1216
+ ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "loading" });
1153
1217
  }
1154
- if (!forceNew) {
1155
- const existing = Array.from(this.runtimes.values()).find(
1156
- (runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind && (runtime2.status === "starting" || runtime2.status === "running")
1157
- );
1158
- if (existing) {
1159
- this.activeRuntimeId = existing.id;
1160
- if (existing.kind === "project") this.lastActiveProjectRoot = existing.root;
1161
- if (touchRecent) {
1162
- await this.touchProject(existing.root);
1163
- } else {
1164
- await this.persistWorkspaceState();
1165
- }
1166
- this.emitChanged();
1167
- return publicRuntime(existing, true);
1168
- }
1169
- const staleSameRoot = Array.from(this.runtimes.values()).filter(
1170
- (runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind
1171
- );
1172
- for (const stale of staleSameRoot) {
1173
- await this.closeRuntimeInternal(stale.id, { persistWorkspace: false });
1218
+ });
1219
+ ipcMain.on(IPC.webuiPrefsChanged, (event, prefs) => {
1220
+ const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
1221
+ if (!entry) return;
1222
+ const sanitized = sanitizeWebuiPrefs(prefs);
1223
+ if (Object.keys(sanitized).length === 0) return;
1224
+ ctx.setEntryWebuiStatus(entry, {
1225
+ ...entry.status,
1226
+ prefs: { ...entry.status.prefs ?? {}, ...sanitized }
1227
+ });
1228
+ });
1229
+ ipcMain.on(IPC.webuiOpenSessionsChanged, (event, sessions) => {
1230
+ const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
1231
+ if (!entry) return;
1232
+ ctx.setOpenSessions(entry.runtimeId, sanitizeOpenSessions(sessions));
1233
+ });
1234
+ ipcMain.on(
1235
+ IPC.webuiCommandAck,
1236
+ (event, requestId, handled, _message) => {
1237
+ const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
1238
+ if (!entry) return;
1239
+ const result = validate(webuiCommandAckSchema, {
1240
+ requestId,
1241
+ handled,
1242
+ message: _message
1243
+ });
1244
+ if (!result.success) {
1245
+ validationLogger.log(`webuiCommandAck: ${result.error}`);
1246
+ return;
1174
1247
  }
1248
+ const pending = ctx.getPendingWebuiCommandAcks().get(result.data.requestId);
1249
+ if (!pending || pending.runtimeId !== entry.runtimeId) return;
1250
+ ctx.settlePendingWebuiCommandAck(result.data.requestId, result.data.handled);
1175
1251
  }
1176
- const slug = projectSlug(resolved);
1177
- const requestedRuntimeId = normalizeRuntimeId(options.runtimeId);
1178
- const runtimeId = requestedRuntimeId && !this.runtimes.has(requestedRuntimeId) ? requestedRuntimeId : `${slug}-${randomBytes(3).toString("hex")}`;
1179
- const name = options.name ?? nextRuntimeName(this.runtimes, resolved, kind);
1180
- const httpPort = await findFreePort(HTTP_PORT_START, usedPorts(this.runtimes));
1181
- const wsPort = await findFreePort(
1182
- WS_PORT_START,
1183
- /* @__PURE__ */ new Set([...usedPorts(this.runtimes), httpPort])
1184
- );
1185
- const token = randomBytes(24).toString("hex");
1186
- const runtime = {
1187
- id: runtimeId,
1188
- name,
1189
- root: resolved,
1190
- slug,
1191
- kind,
1192
- status: "starting",
1193
- httpPort,
1194
- wsPort,
1195
- url: `http://127.0.0.1:${httpPort}`,
1196
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1197
- token,
1198
- child: null,
1199
- logs: [],
1200
- logNotifyTimer: null,
1201
- lastActivityAt: Date.now()
1202
- };
1203
- this.runtimes.set(runtimeId, runtime);
1204
- this.activeRuntimeId = runtimeId;
1205
- if (kind === "project") this.lastActiveProjectRoot = resolved;
1206
- if (touchRecent) {
1207
- await this.touchProject(resolved);
1208
- } else {
1209
- await this.persistWorkspaceState();
1210
- }
1211
- this.emitChanged();
1212
- try {
1213
- const entry = resolveWebUiEntry();
1214
- const distDir = resolveWebUiDistDir();
1215
- const child = spawn(
1216
- process.execPath,
1217
- [
1218
- entry,
1219
- "--host",
1220
- "127.0.0.1",
1221
- "--port",
1222
- String(httpPort),
1223
- "--ws-port",
1224
- String(wsPort),
1225
- "--dist-dir",
1226
- distDir,
1227
- "--require-token"
1228
- ],
1229
- {
1230
- cwd: resolved,
1231
- env: {
1232
- ...buildChildEnv(),
1233
- ELECTRON_RUN_AS_NODE: "1",
1234
- WEBUI_STRICT_PORT: "1",
1235
- WRONGSTACK_DESKTOP: "1",
1236
- // Passed by environment, not argv. A process command line is
1237
- // world-readable on every platform this ships to — `ps -ef` on
1238
- // POSIX, Task Manager's command-line column or a plain WMI query on
1239
- // Windows — so `--token <secret>` handed the WebUI access token to
1240
- // any local process that cared to look. The token grants full agent
1241
- // control, so that is a credential disclosure, not a nuisance.
1242
- // entry.ts already reads WEBUI_TOKEN; argv only took precedence
1243
- // over it (WS-087).
1244
- WEBUI_TOKEN: token
1245
- },
1246
- stdio: ["ignore", "pipe", "pipe"],
1247
- windowsHide: true
1248
- }
1249
- );
1250
- runtime.child = child;
1251
- runtime.pid = child.pid;
1252
- child.stdout?.on("data", (chunk) => {
1253
- const text = chunk.toString();
1254
- appendRuntimeLog(runtime, "stdout", text);
1255
- runtime.lastActivityAt = Date.now();
1256
- this.scheduleLogChanged(runtime);
1257
- process.stdout.write(`[desktop:${runtime.id}] ${text}`);
1258
- });
1259
- child.stderr?.on("data", (chunk) => {
1260
- const text = chunk.toString();
1261
- appendRuntimeLog(runtime, "stderr", text);
1262
- runtime.lastActivityAt = Date.now();
1263
- this.scheduleLogChanged(runtime);
1264
- process.stderr.write(`[desktop:${runtime.id}] ${text}`);
1265
- });
1266
- child.once("error", (err) => {
1267
- runtime.status = "error";
1268
- runtime.error = toErrorMessage(err);
1269
- runtime.child = null;
1270
- if (this.activeRuntimeId === runtime.id) {
1271
- this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
1272
- }
1273
- this.emitChanged();
1274
- });
1275
- child.once("exit", (code, signal) => {
1276
- if (runtime.status === "error") return;
1277
- runtime.status = "stopped";
1278
- runtime.error = code === 0 ? void 0 : `Exited with ${signal ?? `code ${code ?? "unknown"}`}`;
1279
- runtime.child = null;
1280
- if (this.activeRuntimeId === runtime.id) {
1281
- this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
1282
- }
1283
- this.emitChanged();
1284
- });
1285
- await waitForHttpReady(runtime.url, token, START_TIMEOUT_MS);
1286
- if (runtime.status !== "starting") {
1287
- throw new Error(runtime.error ?? "WebUI process exited during startup");
1288
- }
1289
- runtime.status = "running";
1290
- await this.persistWorkspaceState();
1291
- this.emitChanged();
1292
- return publicRuntime(runtime, true);
1293
- } catch (err) {
1294
- if (runtime.status !== "stopped" && runtime.status !== "error") {
1295
- runtime.status = "error";
1296
- }
1297
- if (runtime.error === void 0) {
1298
- runtime.error = toErrorMessage(err);
1299
- }
1300
- await terminateProcessTree(runtime.child);
1301
- runtime.child = null;
1302
- if (this.activeRuntimeId === runtime.id) {
1303
- this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
1304
- }
1305
- this.emitChanged();
1306
- throw err;
1252
+ );
1253
+ ipcMain.on(IPC.setLocale, (_event, locale) => {
1254
+ const result = validate(setLocaleSchema, { locale });
1255
+ if (!result.success) {
1256
+ validationLogger.log(`setLocale: ${result.error}`);
1257
+ return;
1307
1258
  }
1259
+ ctx.getI18n().setMainLocale(result.data.locale);
1260
+ ctx.configureApplicationMenu();
1261
+ ctx.broadcastLocaleToEmbeddedWebuis(result.data.locale);
1262
+ void ctx.getConfigIo().writeUiLocale(result.data.locale);
1263
+ });
1264
+ }
1265
+ function validateOptional(schema, value) {
1266
+ if (value === void 0 || value === null) return void 0;
1267
+ const result = schema.safeParse(value);
1268
+ return result.success ? result.data : void 0;
1269
+ }
1270
+ function sanitizeWebuiPrefs(prefs) {
1271
+ const next = {};
1272
+ if (!isRecord2(prefs)) return next;
1273
+ if (typeof prefs["yolo"] === "boolean") next.yolo = prefs["yolo"];
1274
+ if (typeof prefs["nextPrediction"] === "boolean") next.nextPrediction = prefs["nextPrediction"];
1275
+ if (typeof prefs["contextAutoCompact"] === "boolean") {
1276
+ next.contextAutoCompact = prefs["contextAutoCompact"];
1308
1277
  }
1309
- async activateRuntime(id) {
1310
- const runtime = this.runtimes.get(id);
1311
- if (!runtime) throw new Error(`Runtime not found: ${id}`);
1312
- this.activeRuntimeId = id;
1313
- runtime.lastActivityAt = Date.now();
1314
- if (runtime.kind === "project") this.lastActiveProjectRoot = runtime.root;
1315
- if (runtime.kind === "project") {
1316
- await this.touchProject(runtime.root);
1317
- } else {
1318
- await this.persistWorkspaceState();
1278
+ return next;
1279
+ }
1280
+ function isRecord2(value) {
1281
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1282
+ }
1283
+
1284
+ // src/main/state/constants.ts
1285
+ var OPEN_EXTERNAL_ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
1286
+ var SIDEBAR_WIDTH_WIDE = 292;
1287
+ var SIDEBAR_WIDTH_MEDIUM = 276;
1288
+ var SIDEBAR_WIDTH_NARROW = 252;
1289
+ var SIDEBAR_WIDTH_COLLAPSED = 56;
1290
+ var MIN_WINDOW_WIDTH = 760;
1291
+ var MIN_WINDOW_HEIGHT = 520;
1292
+ var MAX_PENDING_WEBUI_COMMANDS = 50;
1293
+ var MAX_PENDING_FLUSH_ATTEMPTS = 80;
1294
+ var WEBUI_COMMAND_FALLBACK_MS = 350;
1295
+ var WEBUI_COMMAND_ACK_TIMEOUT_MS = 2e3;
1296
+
1297
+ // src/main/layout/sidebar.ts
1298
+ function getSidebarWidth(windowWidth, collapsed) {
1299
+ if (collapsed) return SIDEBAR_WIDTH_COLLAPSED;
1300
+ if (windowWidth < 900) return SIDEBAR_WIDTH_NARROW;
1301
+ if (windowWidth < 1180) return SIDEBAR_WIDTH_MEDIUM;
1302
+ return SIDEBAR_WIDTH_WIDE;
1303
+ }
1304
+
1305
+ // src/main/macos-platform.ts
1306
+ import { app, Menu } from "electron";
1307
+ function initMacOS() {
1308
+ if (process.platform !== "darwin") return;
1309
+ app.setActivationPolicy("regular");
1310
+ app.on("open-file", (_event, filePath) => {
1311
+ if (app.isReady()) {
1312
+ handleFileOpen(filePath);
1319
1313
  }
1320
- this.emitChanged();
1321
- }
1322
- async closeRuntime(id) {
1323
- const runtime = this.runtimes.get(id);
1324
- if (runtime) {
1325
- const authorization = await authorizeDesktopRuntimeStop(this.trustBoundary, runtime);
1326
- if (!authorization.allowed) {
1327
- throw new Error(`Desktop runtime stop denied: ${authorization.reason}`);
1314
+ });
1315
+ app.dock?.setMenu(
1316
+ Menu.buildFromTemplate([
1317
+ {
1318
+ label: "New Window",
1319
+ click: () => {
1320
+ }
1328
1321
  }
1329
- }
1330
- await this.closeRuntimeInternal(id, { persistWorkspace: true });
1331
- }
1332
- async closeAll(options = {}) {
1333
- const persistWorkspace = options.persistWorkspace ?? true;
1334
- await Promise.all(
1335
- Array.from(this.runtimes.keys()).map(
1336
- (id) => this.closeRuntimeInternal(id, { persistWorkspace })
1337
- )
1338
- );
1339
- }
1340
- async registerProject(projectRoot) {
1341
- const resolved = path3.resolve(projectRoot);
1342
- const stat4 = await fs2.stat(resolved).catch(() => null);
1343
- if (!stat4?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1344
- const now = (/* @__PURE__ */ new Date()).toISOString();
1345
- const entry = {
1346
- name: path3.basename(resolved) || resolved,
1347
- root: resolved,
1348
- slug: projectSlug(resolved),
1349
- lastSeen: now,
1350
- lastWorkingDir: resolved
1351
- };
1352
- this.registeredProjects = await touchGlobalProjectManifest(entry);
1353
- this.emitChanged();
1322
+ ])
1323
+ );
1324
+ app.setAboutPanelOptions({
1325
+ applicationName: "WrongStack Desktop",
1326
+ applicationVersion: app.getVersion(),
1327
+ version: process.versions.electron ? `Electron ${process.versions.electron}` : ""
1328
+ });
1329
+ }
1330
+ function handleFileOpen(filePath) {
1331
+ pendingOpenFilePath = filePath;
1332
+ }
1333
+ var pendingOpenFilePath = null;
1334
+ function drainPendingOpenFilePath() {
1335
+ const path7 = pendingOpenFilePath;
1336
+ pendingOpenFilePath = null;
1337
+ return path7;
1338
+ }
1339
+ function firstOpenFileArg(argv) {
1340
+ if (process.platform !== "darwin") return null;
1341
+ for (let index = 1; index < argv.length; index++) {
1342
+ const arg = argv[index];
1343
+ if (arg == null) continue;
1344
+ if (arg.startsWith("-")) continue;
1345
+ if (arg === "." || arg === "--") continue;
1346
+ return arg;
1354
1347
  }
1355
- async unregisterProject(projectRoot) {
1356
- const resolved = path3.resolve(projectRoot);
1357
- this.registeredProjects = await removeGlobalProjectManifest(resolved);
1358
- await this.saveDesktopState();
1359
- this.emitChanged();
1348
+ return null;
1349
+ }
1350
+
1351
+ // src/main/menu/index.ts
1352
+ import { Menu as Menu2 } from "electron";
1353
+
1354
+ // src/main/menu/projects-menu.ts
1355
+ import path3 from "node:path";
1356
+ function buildProjectsMenu(runtimes, actions, t) {
1357
+ const projectGroups = groupProjectRuntimesForMenu(runtimes);
1358
+ const menu = [
1359
+ {
1360
+ label: t("openProjectEllipsis"),
1361
+ accelerator: "CmdOrCtrl+O",
1362
+ click: () => actions.newSession("")
1363
+ },
1364
+ {
1365
+ label: t("registerProjectEllipsis"),
1366
+ click: () => actions.registerProject?.()
1367
+ },
1368
+ { type: "separator" }
1369
+ ];
1370
+ if (projectGroups.length === 0) {
1371
+ menu.push({ label: t("noOpenProjectSessions"), enabled: false });
1372
+ return menu;
1360
1373
  }
1361
- async closeRuntimeInternal(id, options) {
1362
- const runtime = this.runtimes.get(id);
1363
- if (!runtime) return;
1364
- runtime.status = "stopped";
1365
- const child = runtime.child;
1366
- runtime.child = null;
1367
- await terminateProcessTree(child);
1368
- if (runtime.logNotifyTimer) {
1369
- clearTimeout(runtime.logNotifyTimer);
1370
- runtime.logNotifyTimer = null;
1371
- }
1372
- this.runtimes.delete(id);
1373
- if (this.activeRuntimeId === id) this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
1374
- if (this.lastActiveProjectRoot && samePath(this.lastActiveProjectRoot, runtime.root)) {
1375
- this.lastActiveProjectRoot = firstProjectRuntimeRoot(this.runtimes);
1376
- }
1377
- if (options.persistWorkspace) {
1378
- await this.persistWorkspaceState();
1379
- }
1380
- this.emitChanged();
1381
- }
1382
- async touchProject(projectRoot) {
1383
- const resolved = path3.resolve(projectRoot);
1384
- const now = (/* @__PURE__ */ new Date()).toISOString();
1385
- const entry = {
1386
- name: path3.basename(resolved) || resolved,
1387
- root: resolved,
1388
- slug: projectSlug(resolved),
1389
- lastSeen: now,
1390
- lastWorkingDir: resolved
1391
- };
1392
- this.recentProjects = [
1393
- entry,
1394
- ...this.recentProjects.filter((p) => !samePath(p.root, resolved))
1395
- ].slice(0, 24);
1396
- const [, registeredProjects] = await Promise.all([
1397
- this.saveDesktopState(),
1398
- touchGlobalProjectManifest(entry)
1399
- ]);
1400
- this.registeredProjects = registeredProjects;
1401
- }
1402
- async persistWorkspaceState() {
1403
- await this.saveDesktopState();
1404
- }
1405
- async loadDesktopState() {
1406
- try {
1407
- const raw = await fs2.readFile(this.stateFile, "utf8");
1408
- const parsed = JSON.parse(raw);
1409
- const openProjects = normalizePathList(parsed.openProjects);
1410
- const openProjectSessions = normalizeSessionStateList(
1411
- parsed.openProjectSessions,
1412
- openProjects
1413
- );
1414
- return {
1415
- recentProjects: normalizeProjectEntries(parsed.recentProjects),
1416
- openProjects,
1417
- openProjectSessions,
1418
- activeRuntimeId: normalizeRuntimeId(parsed.activeRuntimeId) ?? null,
1419
- activeProjectRoot: typeof parsed.activeProjectRoot === "string" && parsed.activeProjectRoot.trim() ? path3.resolve(parsed.activeProjectRoot) : null,
1420
- window: normalizeWindowState(parsed.window)
1421
- };
1422
- } catch {
1423
- return {
1424
- recentProjects: [],
1425
- openProjects: [],
1426
- openProjectSessions: [],
1427
- activeRuntimeId: null,
1428
- activeProjectRoot: null,
1429
- window: null
1430
- };
1431
- }
1432
- }
1433
- async saveDesktopState() {
1434
- await fs2.mkdir(path3.dirname(this.stateFile), { recursive: true });
1435
- const liveProjectSessions = Array.from(this.runtimes.values()).filter((runtime) => runtime.status !== "stopped" && runtime.kind === "project").map((runtime) => runtimeToSessionState(runtime));
1436
- const openProjectSessions = liveProjectSessions.length === 0 && !this.workspaceRestoreCompleted ? [...this.restoreProjectSessions] : liveProjectSessions;
1437
- const openProjects = openProjectSessions.map((session) => session.root);
1438
- const activeRuntime = this.activeRuntimeId ? this.runtimes.get(this.activeRuntimeId) : null;
1439
- const lastActiveProjectRoot = this.lastActiveProjectRoot;
1440
- const fallbackSession = lastActiveProjectRoot ? openProjectSessions.find((session) => samePath(session.root, lastActiveProjectRoot)) : void 0;
1441
- const activeSession = activeRuntime?.kind === "project" ? runtimeToSessionState(activeRuntime) : fallbackSession ?? openProjectSessions[0];
1442
- const activeRoot = activeSession?.root;
1443
- const activeRuntimeId = activeSession?.runtimeId ?? null;
1444
- await atomicWrite2(
1445
- this.stateFile,
1446
- `${JSON.stringify(
1374
+ for (const group of projectGroups) {
1375
+ menu.push({
1376
+ label: group.name,
1377
+ submenu: [
1447
1378
  {
1448
- recentProjects: this.recentProjects,
1449
- openProjects,
1450
- openProjectSessions,
1451
- activeRuntimeId,
1452
- activeProjectRoot: activeRoot ?? null,
1453
- window: this.windowState
1379
+ label: t("newSession"),
1380
+ click: () => actions.newSession(group.sessions[0]?.id ?? ""),
1381
+ enabled: Boolean(group.sessions[0])
1454
1382
  },
1455
- null,
1456
- 2
1457
- )}
1458
- `,
1459
- { mode: 384 }
1460
- );
1461
- }
1462
- emitChanged() {
1463
- this.emit("changed");
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
1383
+ {
1384
+ label: t("revealProjectFolder"),
1385
+ click: () => actions.reveal(group.sessions[0]?.id ?? ""),
1386
+ enabled: Boolean(group.sessions[0])
1387
+ },
1388
+ { type: "separator" },
1389
+ ...group.sessions.map((runtime, index) => buildSessionMenu(runtime, index + 1, actions, t))
1390
+ ]
1505
1391
  });
1506
- for (const id of ids) {
1507
- await this.closeRuntimeInternal(id, { persistWorkspace: false });
1508
- }
1509
- return ids;
1510
- }
1511
- scheduleLogChanged(runtime) {
1512
- if (runtime.id !== this.activeRuntimeId) return;
1513
- if (runtime.logNotifyTimer) return;
1514
- runtime.logNotifyTimer = setTimeout(() => {
1515
- runtime.logNotifyTimer = null;
1516
- if (this.runtimes.get(runtime.id) === runtime && runtime.id === this.activeRuntimeId) {
1517
- this.emitChanged();
1518
- }
1519
- }, 250);
1520
- }
1521
- };
1522
- function hasChildExited(child) {
1523
- return child.exitCode !== null || child.signalCode !== null;
1524
- }
1525
- function waitForChildExit(child, timeoutMs) {
1526
- if (hasChildExited(child)) return Promise.resolve(true);
1527
- return new Promise((resolve5) => {
1528
- let settled = false;
1529
- const finish = (exited) => {
1530
- if (settled) return;
1531
- settled = true;
1532
- clearTimeout(timer);
1533
- child.off("exit", onExit);
1534
- resolve5(exited);
1535
- };
1536
- const onExit = () => finish(true);
1537
- const timer = setTimeout(() => finish(hasChildExited(child)), timeoutMs);
1538
- timer.unref?.();
1539
- child.once("exit", onExit);
1540
- if (hasChildExited(child)) finish(true);
1541
- });
1542
- }
1543
- async function terminateProcessTree(child) {
1544
- if (!child?.pid || hasChildExited(child)) return;
1545
- if (process.platform !== "win32") {
1546
- const pid = child.pid;
1547
- const exited = waitForChildExit(child, 5e3);
1548
- child.kill("SIGTERM");
1549
- if (await exited) return;
1550
- if (!hasChildExited(child)) {
1551
- try {
1552
- process.kill(-pid, "SIGKILL");
1553
- } catch {
1554
- child.kill("SIGKILL");
1555
- }
1556
- await waitForChildExit(child, 1e3);
1557
- }
1558
- return;
1559
1392
  }
1560
- await new Promise((resolve5) => {
1561
- let settled = false;
1562
- const finish = () => {
1563
- if (settled) return;
1564
- settled = true;
1565
- resolve5();
1566
- };
1567
- const timer = setTimeout(finish, 3e3);
1568
- timer.unref?.();
1569
- const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
1570
- stdio: "ignore",
1571
- windowsHide: true
1572
- });
1573
- killer.once("exit", () => {
1574
- clearTimeout(timer);
1575
- finish();
1576
- });
1577
- killer.once("error", () => {
1578
- clearTimeout(timer);
1579
- child.kill();
1580
- finish();
1581
- });
1582
- });
1583
- }
1584
- var SNAPSHOT_LOG_LINES = 40;
1585
- function publicRuntime(runtime, includeLogs) {
1586
- const {
1587
- child: _child,
1588
- token: _token,
1589
- logs,
1590
- logNotifyTimer: _logNotifyTimer,
1591
- ...record
1592
- } = runtime;
1593
- void _child;
1594
- void _token;
1595
- void _logNotifyTimer;
1596
- if (!includeLogs) return record;
1597
- return {
1598
- ...record,
1599
- recentLogs: logs.slice(-SNAPSHOT_LOG_LINES)
1600
- };
1393
+ return menu;
1601
1394
  }
1602
- function runtimeToSessionState(runtime) {
1395
+ function buildSessionMenu(runtime, index, actions, t) {
1396
+ const running = runtime.status === "running";
1397
+ const label = `${t("session")} ${index} \xB7 ${runtime.status}`;
1603
1398
  return {
1604
- runtimeId: runtime.id,
1605
- name: runtime.name,
1606
- root: runtime.root,
1607
- startedAt: runtime.startedAt
1608
- };
1609
- }
1610
- function appendRuntimeLog(runtime, stream, text) {
1611
- for (const rawLine of text.split(/\r?\n/)) {
1612
- const line = rawLine.trimEnd();
1613
- if (!line) continue;
1614
- runtime.logs.push(`[${stream}] ${line}`);
1615
- }
1616
- if (runtime.logs.length > 120) {
1617
- runtime.logs.splice(0, runtime.logs.length - 120);
1618
- }
1619
- }
1620
- function normalizePathList(value) {
1621
- if (!Array.isArray(value)) return [];
1622
- const roots = [];
1623
- for (const item of value) {
1624
- if (typeof item !== "string" || !item.trim()) continue;
1625
- const resolved = path3.resolve(item);
1626
- roots.push(resolved);
1627
- }
1628
- return roots.slice(0, 12);
1399
+ label,
1400
+ submenu: [
1401
+ {
1402
+ label: t("quickView"),
1403
+ click: () => actions.activate(runtime.id)
1404
+ },
1405
+ {
1406
+ label: "WebUI",
1407
+ enabled: running,
1408
+ submenu: [
1409
+ {
1410
+ label: t("chat"),
1411
+ click: () => actions.activateAndNavigate(runtime.id, { activity: "chat", view: "chat" })
1412
+ },
1413
+ {
1414
+ label: t("focusPrompt"),
1415
+ click: () => actions.activateAndNavigate(runtime.id, { action: "focus-chat" })
1416
+ },
1417
+ {
1418
+ label: t("terminal"),
1419
+ click: () => actions.activateAndNavigate(runtime.id, { terminal: "toggle" })
1420
+ },
1421
+ {
1422
+ label: t("newTerminal"),
1423
+ click: () => actions.activateAndNavigate(runtime.id, { terminal: "new" })
1424
+ },
1425
+ { type: "separator" },
1426
+ {
1427
+ label: t("files"),
1428
+ click: () => actions.activateAndNavigate(runtime.id, { activity: "files", view: "files" })
1429
+ },
1430
+ {
1431
+ label: t("changes"),
1432
+ click: () => actions.activateAndNavigate(runtime.id, { activity: "changes", view: "changes" })
1433
+ },
1434
+ {
1435
+ label: t("sessions"),
1436
+ click: () => actions.activateAndNavigate(runtime.id, { view: "sessions" })
1437
+ },
1438
+ {
1439
+ label: t("fleetHQ"),
1440
+ click: () => actions.activateAndNavigate(runtime.id, { view: "roster" })
1441
+ },
1442
+ {
1443
+ label: t("settings"),
1444
+ click: () => actions.activateAndNavigate(runtime.id, { view: "settings" })
1445
+ },
1446
+ { type: "separator" },
1447
+ {
1448
+ label: t("commandPalette"),
1449
+ click: () => actions.activateAndNavigate(runtime.id, { action: "open-command-palette" })
1450
+ },
1451
+ {
1452
+ label: t("modelSwitcher"),
1453
+ click: () => actions.activateAndNavigate(runtime.id, { action: "open-model-switcher" })
1454
+ }
1455
+ ]
1456
+ },
1457
+ { type: "separator" },
1458
+ {
1459
+ label: t("openInBrowser"),
1460
+ enabled: running,
1461
+ click: () => actions.openBrowser(runtime.id)
1462
+ },
1463
+ {
1464
+ label: t("reloadWebui"),
1465
+ enabled: running,
1466
+ click: () => actions.reload(runtime.id)
1467
+ },
1468
+ {
1469
+ label: t("closeSession"),
1470
+ click: () => actions.close(runtime.id)
1471
+ }
1472
+ ]
1473
+ };
1629
1474
  }
1630
- function normalizeSessionStateList(value, fallbackRoots) {
1631
- if (!Array.isArray(value)) {
1632
- return fallbackRoots.map((root) => ({ root })).slice(0, 12);
1633
- }
1634
- const sessions = [];
1635
- for (const item of value) {
1636
- if (!item || typeof item !== "object") continue;
1637
- const candidate = item;
1638
- if (typeof candidate.root !== "string" || !candidate.root.trim()) continue;
1639
- const session = {
1640
- root: path3.resolve(candidate.root)
1641
- };
1642
- const runtimeId = normalizeRuntimeId(candidate.runtimeId);
1643
- if (runtimeId) session.runtimeId = runtimeId;
1644
- if (typeof candidate.name === "string" && candidate.name.trim()) {
1645
- session.name = candidate.name.trim().slice(0, 120);
1646
- }
1647
- if (typeof candidate.startedAt === "string" && candidate.startedAt.trim()) {
1648
- session.startedAt = candidate.startedAt.trim();
1475
+ function groupProjectRuntimesForMenu(runtimes) {
1476
+ const groups = /* @__PURE__ */ new Map();
1477
+ for (const runtime of runtimes) {
1478
+ if (runtime.kind !== "project") continue;
1479
+ const key = normalizeMenuRoot(runtime.root);
1480
+ const existing = groups.get(key);
1481
+ if (existing) {
1482
+ existing.sessions.push(runtime);
1483
+ continue;
1649
1484
  }
1650
- sessions.push(session);
1485
+ groups.set(key, {
1486
+ key,
1487
+ name: path3.basename(runtime.root) || runtime.name,
1488
+ root: runtime.root,
1489
+ sessions: [runtime]
1490
+ });
1651
1491
  }
1652
- return sessions.slice(0, 12);
1492
+ return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
1653
1493
  }
1654
- function normalizeRuntimeId(value) {
1655
- if (typeof value !== "string") return void 0;
1656
- const trimmed = value.trim();
1657
- if (!/^[a-zA-Z0-9._:-]{3,120}$/.test(trimmed)) return void 0;
1658
- return trimmed;
1494
+ function normalizeMenuRoot(root) {
1495
+ return root.replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
1659
1496
  }
1660
- function normalizeWindowState(value) {
1661
- if (!value || typeof value !== "object") return null;
1662
- const candidate = value;
1663
- const width = Number(candidate.width);
1664
- const height = Number(candidate.height);
1665
- if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
1666
- if (width < MIN_WINDOW_WIDTH || height < MIN_WINDOW_HEIGHT) return null;
1667
- const state = {
1668
- width: Math.round(width),
1669
- height: Math.round(height),
1670
- maximized: Boolean(candidate.maximized)
1497
+
1498
+ // src/main/menu/sections.ts
1499
+ function buildFileMenu(ctx, _actions, hasActiveRuntime, hasActiveProjectWebui, active, navigate, getActiveRuntimeId) {
1500
+ return {
1501
+ label: ctx.t("file"),
1502
+ submenu: [
1503
+ {
1504
+ label: ctx.t("openProjectEllipsis"),
1505
+ accelerator: "CmdOrCtrl+O",
1506
+ click: () => void ctx.openProject()
1507
+ },
1508
+ { label: ctx.t("registerProjectEllipsis"), click: () => void ctx.registerProject() },
1509
+ {
1510
+ label: ctx.t("removeActiveFromRegistry"),
1511
+ enabled: hasActiveProjectWebui,
1512
+ click: () => {
1513
+ if (active?.kind === "project") void ctx.unregisterProject(active.root);
1514
+ }
1515
+ },
1516
+ { type: "separator" },
1517
+ {
1518
+ label: ctx.t("newSessionForActive"),
1519
+ accelerator: "CmdOrCtrl+N",
1520
+ enabled: hasActiveProjectWebui,
1521
+ click: () => void ctx.openProjectSession(active?.id)
1522
+ },
1523
+ {
1524
+ label: ctx.t("settings"),
1525
+ accelerator: "CmdOrCtrl+,",
1526
+ click: () => {
1527
+ if (hasActiveRuntime) navigate({ view: "settings" });
1528
+ else void ctx.openSettings();
1529
+ }
1530
+ },
1531
+ { type: "separator" },
1532
+ {
1533
+ label: ctx.t("closeActiveRuntime"),
1534
+ accelerator: "CmdOrCtrl+W",
1535
+ enabled: hasActiveRuntime,
1536
+ click: () => {
1537
+ const id = getActiveRuntimeId();
1538
+ if (id) void ctx.closeRuntime(id);
1539
+ }
1540
+ },
1541
+ { type: "separator" },
1542
+ {
1543
+ role: process.platform === "darwin" ? "close" : "quit"
1544
+ }
1545
+ ]
1671
1546
  };
1672
- if (Number.isFinite(Number(candidate.x))) state.x = Math.round(Number(candidate.x));
1673
- if (Number.isFinite(Number(candidate.y))) state.y = Math.round(Number(candidate.y));
1674
- return state;
1675
- }
1676
- function firstRunningRuntimeId(runtimes) {
1677
- return Array.from(runtimes.values()).find((runtime) => runtime.status === "running")?.id ?? null;
1678
1547
  }
1679
- function firstProjectRuntimeRoot(runtimes) {
1680
- return Array.from(runtimes.values()).find(
1681
- (runtime) => runtime.status === "running" && runtime.kind === "project"
1682
- )?.root ?? null;
1683
- }
1684
- function usedPorts(runtimes) {
1685
- const ports = /* @__PURE__ */ new Set();
1686
- for (const runtime of runtimes.values()) {
1687
- ports.add(runtime.httpPort);
1688
- ports.add(runtime.wsPort);
1689
- }
1690
- return ports;
1691
- }
1692
- function nextRuntimeName(runtimes, root, kind) {
1693
- const baseName = path3.basename(root) || root;
1694
- if (kind !== "project") return baseName;
1695
- const liveSameRoot = Array.from(runtimes.values()).filter(
1696
- (runtime) => runtime.kind === "project" && samePath(runtime.root, root) && runtime.status !== "stopped"
1697
- ).length;
1698
- return liveSameRoot === 0 ? baseName : `${baseName} #${liveSameRoot + 1}`;
1699
- }
1700
- function pathKey(value) {
1701
- const resolved = path3.resolve(value);
1702
- return os.platform() === "win32" ? resolved.toLowerCase() : resolved;
1703
- }
1704
- async function findFreePort(startPort, exclude) {
1705
- for (let port = startPort; port < startPort + 200; port++) {
1706
- if (exclude.has(port)) continue;
1707
- if (await isPortFree(port)) return port;
1708
- }
1709
- throw new Error(`No free local port found near ${startPort}`);
1710
- }
1711
- function isPortFree(port) {
1712
- return new Promise((resolve5) => {
1713
- const server = net.createServer();
1714
- server.once("error", () => resolve5(false));
1715
- server.once("listening", () => {
1716
- server.close(() => resolve5(true));
1717
- });
1718
- server.listen(port, "127.0.0.1");
1719
- });
1720
- }
1721
- function waitForHttpReady(baseUrl, token, timeoutMs) {
1722
- const deadline = Date.now() + timeoutMs;
1723
- const url = new URL(baseUrl);
1724
- url.searchParams.set("token", token);
1725
- url.searchParams.set("shell", "desktop");
1726
- return new Promise((resolve5, reject) => {
1727
- let probeTimer;
1728
- const cleanup = () => {
1729
- if (probeTimer) {
1730
- clearTimeout(probeTimer);
1731
- probeTimer = void 0;
1732
- }
1733
- };
1734
- const probe = () => {
1735
- let done = false;
1736
- const triggerRetry = () => {
1737
- if (done) return;
1738
- done = true;
1739
- if (Date.now() >= deadline) {
1740
- reject(new Error(`WebUI did not become ready at ${baseUrl}`));
1741
- return;
1742
- }
1743
- probeTimer = setTimeout(probe, 250);
1744
- };
1745
- const req = http.get(url, (res) => {
1746
- res.resume();
1747
- if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
1748
- if (!done) {
1749
- done = true;
1750
- cleanup();
1751
- resolve5();
1752
- }
1753
- return;
1754
- }
1755
- triggerRetry();
1756
- });
1757
- req.once("error", () => {
1758
- triggerRetry();
1759
- });
1760
- req.setTimeout(1e3, () => {
1761
- req.destroy();
1762
- triggerRetry();
1763
- });
1764
- };
1765
- probe();
1548
+ function buildWorkspaceMenu(ctx, _actions, hasActiveWebui, prefs, navigate) {
1549
+ const yoloChecked = prefs?.yolo === true;
1550
+ const nextPredictionChecked = prefs?.nextPrediction === true;
1551
+ const contextAutoCompactChecked = prefs?.contextAutoCompact === true;
1552
+ const webuiItem = (item) => ({
1553
+ ...item,
1554
+ enabled: item.enabled ?? hasActiveWebui
1766
1555
  });
1556
+ return {
1557
+ label: ctx.t("workspace"),
1558
+ submenu: [
1559
+ webuiItem({
1560
+ label: ctx.t("openChat"),
1561
+ accelerator: "CmdOrCtrl+1",
1562
+ click: () => navigate({ activity: "chat", view: "chat" })
1563
+ }),
1564
+ webuiItem({
1565
+ label: ctx.t("focusPrompt"),
1566
+ accelerator: "CmdOrCtrl+/",
1567
+ click: () => navigate({ action: "focus-chat" })
1568
+ }),
1569
+ webuiItem({
1570
+ label: ctx.t("toggleTerminal"),
1571
+ accelerator: "CmdOrCtrl+`",
1572
+ click: () => navigate({ terminal: "toggle" })
1573
+ }),
1574
+ webuiItem({ label: ctx.t("newTerminal"), click: () => navigate({ terminal: "new" }) }),
1575
+ { type: "separator" },
1576
+ webuiItem({
1577
+ label: ctx.t("commandPalette"),
1578
+ accelerator: "CmdOrCtrl+K",
1579
+ click: () => navigate({ action: "open-command-palette" })
1580
+ }),
1581
+ webuiItem({
1582
+ label: ctx.t("quickModelSwitcher"),
1583
+ accelerator: "CmdOrCtrl+M",
1584
+ click: () => navigate({ action: "open-model-switcher" })
1585
+ }),
1586
+ webuiItem({
1587
+ type: "checkbox",
1588
+ label: ctx.t("yoloMode"),
1589
+ checked: yoloChecked,
1590
+ accelerator: "CmdOrCtrl+Shift+Y",
1591
+ click: () => navigate({ pref: { key: "yolo", toggle: true } })
1592
+ }),
1593
+ webuiItem({
1594
+ type: "checkbox",
1595
+ label: ctx.t("nextPrediction"),
1596
+ checked: nextPredictionChecked,
1597
+ click: () => navigate({ pref: { key: "nextPrediction", toggle: true } })
1598
+ }),
1599
+ webuiItem({
1600
+ type: "checkbox",
1601
+ label: ctx.t("contextAutoCompact"),
1602
+ checked: contextAutoCompactChecked,
1603
+ click: () => navigate({ pref: { key: "contextAutoCompact", toggle: true } })
1604
+ }),
1605
+ { type: "separator" },
1606
+ webuiItem({
1607
+ label: ctx.t("reloadActiveWebui"),
1608
+ accelerator: "CmdOrCtrl+Shift+R",
1609
+ click: () => void ctx.reloadActiveWebuiView()
1610
+ })
1611
+ ]
1612
+ };
1767
1613
  }
1768
- async function readGlobalProjectManifest() {
1769
- const manifestFile = path3.join(wstackGlobalRoot2(), "projects.json");
1614
+ function buildViewMenu(ctx) {
1615
+ return {
1616
+ label: ctx.t("view"),
1617
+ submenu: [
1618
+ {
1619
+ type: "checkbox",
1620
+ label: ctx.t("compactDesktopSidebar"),
1621
+ accelerator: "CmdOrCtrl+B",
1622
+ checked: ctx.getShellSidebarCollapsed(),
1623
+ click: () => ctx.setShellSidebarCollapsed(!ctx.getShellSidebarCollapsed())
1624
+ },
1625
+ { type: "separator" },
1626
+ { role: "reload" },
1627
+ { role: "toggleDevTools" },
1628
+ { type: "separator" },
1629
+ { role: "resetZoom" },
1630
+ { role: "zoomIn" },
1631
+ { role: "zoomOut" },
1632
+ { type: "separator" },
1633
+ { role: "togglefullscreen" }
1634
+ ]
1635
+ };
1636
+ }
1637
+
1638
+ // src/main/menu/index.ts
1639
+ function configureApplicationMenu(ctx) {
1640
+ const snapshot = ctx.getSnapshot();
1641
+ const active = ctx.getActiveRuntime();
1642
+ const hasActiveRuntime = Boolean(active);
1643
+ const hasActiveWebui = active?.status === "running";
1644
+ const hasActiveProjectWebui = hasActiveWebui && active?.kind === "project";
1645
+ const activeWebuiPrefs = ctx.getActiveWebuiPrefs();
1646
+ const navigate = (command) => {
1647
+ void ctx.dispatchWebuiCommand(command);
1648
+ };
1649
+ const activateAndNavigate = (runtimeId, command) => {
1650
+ void ctx.activateRuntime(runtimeId).then(() => ctx.dispatchWebuiCommand(command));
1651
+ };
1652
+ const reloadRuntimeWebui = (runtimeId) => {
1653
+ void ctx.activateRuntime(runtimeId).then(() => ctx.reloadActiveWebuiView());
1654
+ };
1655
+ const actions = {
1656
+ activate: (runtimeId) => void ctx.activateRuntime(runtimeId),
1657
+ activateAndNavigate,
1658
+ registerProject: () => void ctx.registerProject(),
1659
+ newSession: (runtimeId) => {
1660
+ if (runtimeId) void ctx.openProjectSession(runtimeId);
1661
+ else void ctx.openProject();
1662
+ },
1663
+ openBrowser: (runtimeId) => {
1664
+ const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(runtimeId);
1665
+ if (url) ctx.openExternal(url);
1666
+ },
1667
+ reload: reloadRuntimeWebui,
1668
+ close: (runtimeId) => void ctx.closeRuntime(runtimeId),
1669
+ reveal: (runtimeId) => {
1670
+ const runtime = ctx.getRuntimeManager().getRuntime(runtimeId);
1671
+ if (runtime) ctx.revealInExplorer(runtime.root);
1672
+ }
1673
+ };
1674
+ const template = [
1675
+ buildFileMenu(
1676
+ ctx,
1677
+ actions,
1678
+ hasActiveRuntime,
1679
+ hasActiveProjectWebui,
1680
+ active,
1681
+ navigate,
1682
+ ctx.getActiveRuntimeId
1683
+ ),
1684
+ {
1685
+ label: ctx.t("projects"),
1686
+ submenu: buildProjectsMenu(snapshot.runtimes, actions, ctx.t)
1687
+ },
1688
+ buildWorkspaceMenu(ctx, actions, hasActiveWebui, activeWebuiPrefs, navigate),
1689
+ buildViewMenu(ctx)
1690
+ ];
1691
+ Menu2.setApplicationMenu(Menu2.buildFromTemplate(template));
1692
+ }
1693
+
1694
+ // src/main/runtime/operations.ts
1695
+ import * as fs4 from "node:fs/promises";
1696
+
1697
+ // src/main/runtime-manager.ts
1698
+ import { spawn } from "node:child_process";
1699
+ import { randomBytes } from "node:crypto";
1700
+ import { EventEmitter as EventEmitter2 } from "node:events";
1701
+ import * as fs3 from "node:fs/promises";
1702
+ import * as http from "node:http";
1703
+ import * as net from "node:net";
1704
+ import * as os from "node:os";
1705
+ import * as path5 from "node:path";
1706
+ import {
1707
+ atomicWrite as atomicWrite2,
1708
+ buildChildEnv,
1709
+ projectSlug,
1710
+ resolveWstackPaths as resolveWstackPaths2,
1711
+ toErrorMessage,
1712
+ wstackGlobalRoot as wstackGlobalRoot2
1713
+ } from "@wrongstack/core/utils";
1714
+
1715
+ // src/main/runtime-manager-paths.ts
1716
+ import { existsSync } from "node:fs";
1717
+ import { createRequire } from "node:module";
1718
+ import * as path4 from "node:path";
1719
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1720
+ import { resolveWstackPaths } from "@wrongstack/core/utils";
1721
+ function resolveWebUiEntry() {
1722
+ if (process.env["WRONGSTACK_WEBUI_ENTRY"]) {
1723
+ return path4.resolve(process.env["WRONGSTACK_WEBUI_ENTRY"]);
1724
+ }
1725
+ const require2 = createRequire(import.meta.url);
1770
1726
  try {
1771
- const raw = await fs2.readFile(manifestFile, "utf8");
1772
- return normalizeProjectManifest(JSON.parse(raw));
1727
+ const serverPkgPath = require2.resolve("@wrongstack/webui-server/package.json");
1728
+ const candidate = path4.join(path4.dirname(serverPkgPath), "dist", "server", "entry.js");
1729
+ if (existsSync(candidate)) return candidate;
1773
1730
  } catch {
1774
- return [];
1775
1731
  }
1732
+ const serverIndex = require2.resolve("@wrongstack/webui-server");
1733
+ return path4.join(path4.dirname(serverIndex), "server", "entry.js");
1776
1734
  }
1777
- function normalizeProjectManifest(value) {
1778
- if (Array.isArray(value)) return normalizeProjectEntries(value).slice(0, 80);
1779
- if (!value || typeof value !== "object") return [];
1780
- const manifest = value;
1781
- const source = Array.isArray(manifest.projects) ? manifest.projects : Array.isArray(manifest.recentProjects) ? manifest.recentProjects : Array.isArray(manifest.recents) ? manifest.recents : [];
1782
- return normalizeProjectEntries(source).slice(0, 80);
1783
- }
1784
- function normalizeProjectEntries(value) {
1785
- if (!Array.isArray(value)) return [];
1786
- const seen = /* @__PURE__ */ new Set();
1787
- const projects = [];
1788
- for (const item of value) {
1789
- const project = normalizeProjectEntry(item);
1790
- if (!project) continue;
1791
- const key = pathKey(project.root);
1792
- if (seen.has(key)) continue;
1793
- seen.add(key);
1794
- projects.push(project);
1735
+ function resolveWebUiDistDir() {
1736
+ if (process.env["WRONGSTACK_WEBUI_DIST"]) {
1737
+ return path4.resolve(process.env["WRONGSTACK_WEBUI_DIST"]);
1795
1738
  }
1796
- return projects.sort(
1797
- (a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
1739
+ const require2 = createRequire(import.meta.url);
1740
+ const serverEntry = require2.resolve("@wrongstack/webui");
1741
+ const candidate = path4.dirname(serverEntry);
1742
+ if (existsSync(candidate)) return candidate;
1743
+ throw new Error(
1744
+ `WebUI frontend assets not found at ${candidate}. Build @wrongstack/webui or set WRONGSTACK_WEBUI_DIST.`
1798
1745
  );
1799
1746
  }
1800
- function normalizeProjectEntry(value) {
1801
- if (!value || typeof value !== "object") return null;
1802
- const candidate = value;
1803
- if (typeof candidate.root !== "string" || !candidate.root.trim()) return null;
1804
- const root = path3.resolve(candidate.root);
1805
- const name = typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim() : path3.basename(root) || root;
1806
- const entry = {
1807
- name,
1808
- root,
1809
- slug: typeof candidate.slug === "string" && candidate.slug.trim() ? candidate.slug.trim() : projectSlug(root)
1810
- };
1811
- if (typeof candidate.lastSeen === "string" && candidate.lastSeen.trim()) {
1812
- entry.lastSeen = candidate.lastSeen.trim();
1813
- }
1814
- if (typeof candidate.createdAt === "string" && candidate.createdAt.trim()) {
1815
- entry.createdAt = candidate.createdAt.trim();
1816
- }
1817
- if (typeof candidate.lastWorkingDir === "string" && candidate.lastWorkingDir.trim()) {
1818
- entry.lastWorkingDir = path3.resolve(candidate.lastWorkingDir);
1819
- }
1820
- return entry;
1747
+ function rendererIndexPath() {
1748
+ return new URL("../renderer/index.html", import.meta.url).href;
1821
1749
  }
1822
- async function touchGlobalProjectManifest(entry) {
1823
- const manifestFile = path3.join(wstackGlobalRoot2(), "projects.json");
1824
- const projects = await readGlobalProjectManifest();
1825
- const existing = projects.find((p) => samePath(p.root, entry.root));
1826
- if (existing) {
1827
- existing.name = entry.name;
1828
- existing.slug = entry.slug;
1829
- existing.lastSeen = entry.lastSeen;
1830
- existing.lastWorkingDir = entry.lastWorkingDir;
1831
- } else {
1832
- projects.push({ ...entry, createdAt: entry.lastSeen });
1833
- }
1834
- const sorted = projects.sort(
1835
- (a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
1836
- ).slice(0, 80);
1837
- await fs2.mkdir(path3.dirname(manifestFile), { recursive: true });
1838
- await atomicWrite2(manifestFile, `${JSON.stringify({ projects: sorted }, null, 2)}
1839
- `, {
1840
- mode: 384
1841
- });
1842
- return sorted;
1750
+ function preloadPath() {
1751
+ return fileURLToPath2(new URL("../preload/preload.cjs", import.meta.url));
1843
1752
  }
1844
- async function removeGlobalProjectManifest(projectRoot) {
1845
- const manifestFile = path3.join(wstackGlobalRoot2(), "projects.json");
1846
- const resolved = path3.resolve(projectRoot);
1847
- const projects = (await readGlobalProjectManifest()).filter(
1848
- (project) => !samePath(project.root, resolved)
1849
- );
1850
- await fs2.mkdir(path3.dirname(manifestFile), { recursive: true });
1851
- await atomicWrite2(manifestFile, `${JSON.stringify({ projects }, null, 2)}
1852
- `, { mode: 384 });
1853
- return projects;
1753
+ function webuiPreloadPath() {
1754
+ return fileURLToPath2(new URL("../preload/webui-preload.cjs", import.meta.url));
1854
1755
  }
1855
- function samePath(left, right) {
1856
- const a = path3.resolve(left);
1857
- const b = path3.resolve(right);
1858
- return os.platform() === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
1756
+ function desktopSettingsWorkspaceRoot() {
1757
+ return path4.join(resolveWstackPaths({ projectRoot: process.cwd() }).configDir, "settings");
1859
1758
  }
1860
1759
 
1861
- // src/main/main.ts
1862
- import { watchProviderConfig } from "@wrongstack/core/storage";
1863
-
1864
- // src/main/webui/controller.ts
1865
- import { shell, WebContentsView } from "electron";
1866
-
1867
- // src/main/state/constants.ts
1868
- var OPEN_EXTERNAL_ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
1869
- var SIDEBAR_WIDTH_WIDE = 292;
1870
- var SIDEBAR_WIDTH_MEDIUM = 276;
1871
- var SIDEBAR_WIDTH_NARROW = 252;
1872
- var SIDEBAR_WIDTH_COLLAPSED = 56;
1760
+ // src/main/runtime-manager.ts
1761
+ var HTTP_PORT_START = 34560;
1762
+ var WS_PORT_START = 34660;
1763
+ var START_TIMEOUT_MS = 3e4;
1873
1764
  var MIN_WINDOW_WIDTH2 = 760;
1874
1765
  var MIN_WINDOW_HEIGHT2 = 520;
1875
- var MAX_PENDING_WEBUI_COMMANDS = 50;
1876
- var MAX_PENDING_FLUSH_ATTEMPTS = 80;
1877
- var WEBUI_COMMAND_FALLBACK_MS = 350;
1878
- var WEBUI_COMMAND_ACK_TIMEOUT_MS = 2e3;
1879
-
1880
- // src/main/webui-command-bridge.ts
1881
- var DESKTOP_WEBUI_ACTIONS = /* @__PURE__ */ new Set([
1882
- "new-session",
1883
- "clear-context",
1884
- "compact-context",
1885
- "repair-context",
1886
- "download-chat",
1887
- "focus-chat",
1888
- "open-command-palette",
1889
- "open-shortcuts",
1890
- "search-chat",
1891
- "open-model-switcher",
1892
- "open-prompt-library"
1893
- ]);
1894
- var DESKTOP_WEBUI_VIEWS = /* @__PURE__ */ new Set([
1895
- "chat",
1896
- "settings",
1897
- "goal",
1898
- "sddhub",
1899
- "files",
1900
- "changes",
1901
- "sessions",
1902
- "setup",
1903
- "skill",
1904
- "roster",
1905
- "mailbox",
1906
- "debug",
1907
- "design-gallery",
1908
- "refresh-debug",
1909
- "analytics"
1910
- ]);
1911
- var DESKTOP_WEBUI_ACTIVITIES = /* @__PURE__ */ new Set([
1912
- "chat",
1913
- "agents",
1914
- "history",
1915
- "files",
1916
- "changes",
1917
- "mailbox",
1918
- "skills",
1919
- "design"
1920
- ]);
1921
- var DESKTOP_WEBUI_OVERLAYS = /* @__PURE__ */ new Set([
1922
- "fleet",
1923
- "agents-monitor",
1924
- "processes",
1925
- "queue"
1926
- ]);
1927
- var DESKTOP_WEBUI_DOCKS = /* @__PURE__ */ new Set([
1928
- "goal",
1929
- "fleet",
1930
- "work",
1931
- "worktrees",
1932
- "collab"
1933
- ]);
1934
- var DESKTOP_WEBUI_WORK_TABS = /* @__PURE__ */ new Set(["todos", "tasks", "plan"]);
1935
- var DESKTOP_WEBUI_PREF_KEYS = /* @__PURE__ */ new Set([
1936
- "yolo",
1937
- "nextPrediction",
1938
- "contextAutoCompact"
1939
- ]);
1940
- function normalizeDesktopWebuiCommand(value) {
1941
- if (!isRecord(value)) return null;
1942
- const command = {};
1943
- let hasCommand = false;
1944
- const action = value["action"];
1945
- if (action !== void 0) {
1946
- if (typeof action !== "string" || !DESKTOP_WEBUI_ACTIONS.has(action)) {
1947
- return null;
1948
- }
1949
- command.action = action;
1950
- hasCommand = true;
1951
- }
1952
- const view = value["view"];
1953
- if (view !== void 0) {
1954
- if (typeof view !== "string" || !DESKTOP_WEBUI_VIEWS.has(view)) {
1955
- return null;
1956
- }
1957
- command.view = view;
1958
- hasCommand = true;
1766
+ var DEFAULT_IDLE_MINUTES = 15;
1767
+ var IDLE_SWEEP_INTERVAL_MS = 6e4;
1768
+ function resolveIdleTimeoutMs(env = process.env) {
1769
+ const raw = Number.parseFloat(env.WRONGSTACK_DESKTOP_IDLE_MINUTES ?? "");
1770
+ if (Number.isFinite(raw)) return raw > 0 ? raw * 6e4 : 0;
1771
+ return DEFAULT_IDLE_MINUTES * 6e4;
1772
+ }
1773
+ function reclaimableRuntimeIds(runtimes, options) {
1774
+ if (options.idleTimeoutMs <= 0) return [];
1775
+ const out = [];
1776
+ for (const [id, runtime] of runtimes) {
1777
+ if (id === options.activeRuntimeId) continue;
1778
+ if (runtime.status !== "running") continue;
1779
+ if (options.now - runtime.lastActivityAt < options.idleTimeoutMs) continue;
1780
+ out.push(id);
1959
1781
  }
1960
- const activity = value["activity"];
1961
- if (activity !== void 0) {
1962
- if (typeof activity !== "string" || !DESKTOP_WEBUI_ACTIVITIES.has(activity)) {
1963
- return null;
1964
- }
1965
- command.activity = activity;
1966
- hasCommand = true;
1782
+ return out;
1783
+ }
1784
+ var DesktopRuntimeManager = class extends EventEmitter2 {
1785
+ constructor(trustBoundary = desktopCompatibilityTrustBoundary) {
1786
+ super();
1787
+ this.trustBoundary = trustBoundary;
1967
1788
  }
1968
- const overlay = value["overlay"];
1969
- if (overlay !== void 0) {
1970
- if (typeof overlay !== "string" || !DESKTOP_WEBUI_OVERLAYS.has(overlay)) {
1971
- return null;
1972
- }
1973
- command.overlay = overlay;
1974
- hasCommand = true;
1789
+ trustBoundary;
1790
+ runtimes = /* @__PURE__ */ new Map();
1791
+ stateFile = path5.join(
1792
+ resolveWstackPaths2({ projectRoot: process.cwd() }).configDir,
1793
+ "desktop.json"
1794
+ );
1795
+ recentProjects = [];
1796
+ registeredProjects = [];
1797
+ restoreProjectSessions = [];
1798
+ restoreActiveRuntimeId = null;
1799
+ restoreActiveProjectRoot = null;
1800
+ lastActiveProjectRoot = null;
1801
+ windowState = null;
1802
+ activeRuntimeId = null;
1803
+ restoring = false;
1804
+ workspaceRestoreCompleted = false;
1805
+ idleSweepTimer = null;
1806
+ idleTimeoutMs = 0;
1807
+ async init() {
1808
+ const state = await this.loadDesktopState();
1809
+ this.recentProjects = state.recentProjects;
1810
+ this.registeredProjects = await readGlobalProjectManifest();
1811
+ this.restoreProjectSessions = state.openProjectSessions;
1812
+ this.restoreActiveRuntimeId = state.activeRuntimeId;
1813
+ this.restoreActiveProjectRoot = state.activeProjectRoot;
1814
+ this.lastActiveProjectRoot = state.activeProjectRoot;
1815
+ this.windowState = state.window;
1975
1816
  }
1976
- const dockSection = value["dockSection"];
1977
- if (dockSection !== void 0) {
1978
- if (typeof dockSection !== "string" || !DESKTOP_WEBUI_DOCKS.has(dockSection)) {
1979
- return null;
1980
- }
1981
- command.dockSection = dockSection;
1982
- hasCommand = true;
1817
+ snapshot() {
1818
+ const activeId = this.activeRuntimeId;
1819
+ return {
1820
+ activeRuntimeId: activeId,
1821
+ runtimes: Array.from(this.runtimes.values()).map(
1822
+ (runtime) => publicRuntime(runtime, runtime.id === activeId)
1823
+ ),
1824
+ recentProjects: [...this.recentProjects],
1825
+ registeredProjects: [...this.registeredProjects],
1826
+ restoring: this.restoring
1827
+ };
1983
1828
  }
1984
- const workTab = value["workTab"];
1985
- if (workTab !== void 0) {
1986
- if (typeof workTab !== "string" || !DESKTOP_WEBUI_WORK_TABS.has(workTab)) {
1987
- return null;
1988
- }
1989
- command.workTab = workTab;
1990
- hasCommand = true;
1829
+ getWindowState() {
1830
+ return this.windowState ? { ...this.windowState } : null;
1991
1831
  }
1992
- const terminal = value["terminal"];
1993
- if (terminal !== void 0) {
1994
- if (terminal !== true && terminal !== false && terminal !== "toggle" && terminal !== "new") {
1995
- return null;
1996
- }
1997
- command.terminal = terminal;
1998
- hasCommand = true;
1832
+ async saveWindowState(window) {
1833
+ this.windowState = { ...window };
1834
+ await this.saveDesktopState();
1999
1835
  }
2000
- const pref = value["pref"];
2001
- if (pref !== void 0) {
2002
- if (!isRecord(pref)) return null;
2003
- const key = pref["key"];
2004
- if (typeof key !== "string" || !DESKTOP_WEBUI_PREF_KEYS.has(key)) {
2005
- return null;
1836
+ async restoreLastWorkspace() {
1837
+ const sessions = this.restoreProjectSessions.filter(
1838
+ (session) => typeof session.root === "string" && session.root.trim()
1839
+ );
1840
+ if (sessions.length === 0 || this.restoring || this.runtimes.size > 0) {
1841
+ this.workspaceRestoreCompleted = true;
1842
+ return;
2006
1843
  }
2007
- const toggle = pref["toggle"];
2008
- const prefValue = pref["value"];
2009
- if (toggle !== void 0 && typeof toggle !== "boolean") return null;
2010
- if (prefValue !== void 0 && typeof prefValue !== "boolean") return null;
2011
- if (toggle === void 0 && prefValue === void 0) return null;
2012
- command.pref = {
2013
- key,
2014
- ...typeof prefValue === "boolean" ? { value: prefValue } : {},
2015
- ...typeof toggle === "boolean" ? { toggle } : {}
2016
- };
2017
- hasCommand = true;
2018
- }
2019
- return hasCommand ? command : null;
2020
- }
2021
- function buildWebuiCommandFallbackScript(command) {
2022
- const payload = JSON.stringify(command).replace(/</g, "\\u003c");
2023
- return `window.dispatchEvent(new CustomEvent('wrongstack:desktop-command', { detail: ${payload} })); true;`;
2024
- }
2025
- function isRecord(value) {
2026
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2027
- }
2028
-
2029
- // src/main/webui/navigation.ts
2030
- function allowedExternalProtocol(target) {
2031
- try {
2032
- const protocol = new URL(target).protocol;
2033
- return OPEN_EXTERNAL_ALLOWED_PROTOCOLS.has(protocol) ? protocol : void 0;
2034
- } catch {
2035
- return void 0;
2036
- }
2037
- }
2038
- function sameOrigin(candidate, base) {
2039
- if (!base) return false;
2040
- try {
2041
- return new URL(candidate).origin === new URL(base).origin;
2042
- } catch {
2043
- return false;
2044
- }
2045
- }
2046
-
2047
- // src/main/webui/controller.ts
2048
- var DesktopWebuiController = class {
2049
- constructor(ctx) {
2050
- this.ctx = ctx;
2051
- }
2052
- ctx;
2053
- views = /* @__PURE__ */ new Map();
2054
- pendingAcks = /* @__PURE__ */ new Map();
2055
- activeRuntimeId = null;
2056
- status = { runtimeId: null, status: "idle" };
2057
- commandSequence = 0;
2058
- openExternal(target) {
2059
- const protocol = allowedExternalProtocol(target);
2060
- if (!protocol) return;
2061
- void authorizeDesktopAction(this.ctx.trustBoundary, {
2062
- capability: "url.open-external",
2063
- subject: { kind: "url", id: target, attributes: { protocol } },
2064
- risk: "elevated",
2065
- metadata: { operation: "webui-navigation" }
2066
- }).then((decision) => decision.allowed ? void shell.openExternal(target) : void 0).catch(() => void 0);
2067
- }
2068
- publishStatus(next) {
2069
- this.status = next;
2070
- const shellView2 = this.ctx.getShellView();
2071
- if (!shellView2 || shellView2.webContents.isDestroyed()) return;
2072
- shellView2.webContents.send(IPC.webuiStatusChanged, next);
2073
- }
2074
- setEntryStatus(entry, next) {
2075
- const previousPrefs = entry.status.prefs;
2076
- entry.status = {
2077
- ...next,
2078
- ...next.prefs === void 0 && previousPrefs !== void 0 ? { prefs: previousPrefs } : {},
2079
- // `pendingCommands` is always derived from the entry's own array (source of truth).
2080
- // Any `next.pendingCommands` value is intentionally overwritten.
2081
- pendingCommands: entry.pendingCommands.length
2082
- };
2083
- if (this.activeRuntimeId === entry.runtimeId) {
2084
- this.publishStatus(entry.status);
2085
- this.ctx.onPrefsChanged?.(previousPrefs, entry.status.prefs);
2086
- }
2087
- }
2088
- ensure(runtimeId) {
2089
- const mainWindow2 = this.ctx.getMainWindow();
2090
- if (!mainWindow2) return null;
2091
- const existing = this.views.get(runtimeId);
2092
- if (existing) return existing;
2093
- const view = new WebContentsView({
2094
- webPreferences: {
2095
- preload: webuiPreloadPath(),
2096
- contextIsolation: true,
2097
- nodeIntegration: false,
2098
- // This is the view that renders agent output, tool results, file
2099
- // contents, and fetched pages — i.e. the most attacker-influenceable
2100
- // surface in the app, and the one that most needs process-level
2101
- // containment rather than only bridge-level. webui-preload.ts imports
2102
- // just electron's contextBridge/ipcRenderer and a constants map, so it
2103
- // is already within the sandboxed preload subset (WS-093).
2104
- sandbox: true
1844
+ this.restoring = true;
1845
+ this.emitChanged();
1846
+ try {
1847
+ const seen = /* @__PURE__ */ new Map();
1848
+ for (const session of sessions) {
1849
+ const key = pathKey(session.root);
1850
+ const seenCount = seen.get(key) ?? 0;
1851
+ seen.set(key, seenCount + 1);
1852
+ await this.openProject(session.root, {
1853
+ forceNew: seenCount > 0,
1854
+ name: session.name,
1855
+ runtimeId: session.runtimeId
1856
+ }).catch((err) => {
1857
+ process.stderr.write(
1858
+ `[desktop:restore] Failed to restore ${session.root}: ${toErrorMessage(err)}
1859
+ `
1860
+ );
1861
+ });
2105
1862
  }
2106
- });
2107
- const entry = {
2108
- runtimeId,
2109
- view,
2110
- url: null,
2111
- status: { runtimeId, status: "idle" },
2112
- bridgeReady: false,
2113
- attached: false,
2114
- pendingCommands: [],
2115
- pendingFlushTimer: null,
2116
- pendingFlushAttempts: 0
2117
- };
2118
- view.webContents.setWindowOpenHandler(({ url }) => {
2119
- this.openExternal(url);
2120
- return { action: "deny" };
2121
- });
2122
- view.webContents.on("will-navigate", (event, url) => {
2123
- if (sameOrigin(url, entry.url)) return;
2124
- event.preventDefault();
2125
- this.openExternal(url);
2126
- });
2127
- view.webContents.on("did-start-loading", () => {
2128
- if (this.views.get(runtimeId) !== entry) return;
2129
- entry.bridgeReady = false;
2130
- this.setEntryStatus(entry, { runtimeId, status: "loading" });
2131
- });
2132
- view.webContents.on("did-finish-load", () => {
2133
- if (this.views.get(runtimeId) !== entry) return;
2134
- this.scheduleFlush(entry);
2135
- try {
2136
- entry.view.webContents.send(IPC.webuiLocaleChanged, this.ctx.getLocale());
2137
- } catch {
1863
+ let restoredActive = false;
1864
+ if (this.restoreActiveRuntimeId) {
1865
+ const active = this.runtimes.get(this.restoreActiveRuntimeId);
1866
+ if (active) {
1867
+ await this.activateRuntime(active.id);
1868
+ restoredActive = true;
1869
+ }
2138
1870
  }
2139
- });
2140
- view.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
2141
- if (this.views.get(runtimeId) !== entry || errorCode === -3) return;
2142
- this.setEntryStatus(entry, { runtimeId, status: "error", error: errorDescription });
2143
- });
2144
- view.webContents.on("render-process-gone", (_event, details) => {
2145
- if (this.views.get(runtimeId) !== entry) return;
2146
- this.setEntryStatus(entry, {
2147
- runtimeId,
2148
- status: "error",
2149
- error: `WebUI renderer exited: ${details.reason}`
2150
- });
2151
- });
2152
- this.views.set(runtimeId, entry);
2153
- return entry;
2154
- }
2155
- attach(entry) {
2156
- const mainWindow2 = this.ctx.getMainWindow();
2157
- if (!mainWindow2 || entry.attached) return;
2158
- mainWindow2.contentView.addChildView(entry.view);
2159
- entry.attached = true;
1871
+ if (!restoredActive && this.restoreActiveProjectRoot) {
1872
+ const active = Array.from(this.runtimes.values()).find(
1873
+ (runtime) => samePath(runtime.root, this.restoreActiveProjectRoot ?? "")
1874
+ );
1875
+ if (active) await this.activateRuntime(active.id);
1876
+ }
1877
+ } finally {
1878
+ this.restoring = false;
1879
+ this.workspaceRestoreCompleted = true;
1880
+ this.emitChanged();
1881
+ await this.saveDesktopState();
1882
+ }
2160
1883
  }
2161
- dispose(entry) {
2162
- this.views.delete(entry.runtimeId);
2163
- entry.pendingCommands.length = 0;
2164
- this.settleRuntimeAcks(entry.runtimeId, false);
2165
- if (entry.pendingFlushTimer) clearTimeout(entry.pendingFlushTimer);
2166
- entry.pendingFlushTimer = null;
2167
- const mainWindow2 = this.ctx.getMainWindow();
2168
- if (mainWindow2 && entry.attached) mainWindow2.contentView.removeChildView(entry.view);
2169
- entry.attached = false;
2170
- if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
2171
- if (this.activeRuntimeId === entry.runtimeId) this.activeRuntimeId = null;
1884
+ getRuntime(id) {
1885
+ const runtime = this.runtimes.get(id);
1886
+ return runtime ? publicRuntime(runtime, true) : void 0;
2172
1887
  }
2173
- disposeAll() {
2174
- for (const entry of [...this.views.values()]) this.dispose(entry);
1888
+ getRuntimeUrlWithToken(id) {
1889
+ const runtime = this.runtimes.get(id);
1890
+ if (!runtime) return void 0;
1891
+ const url = new URL(runtime.url);
1892
+ url.searchParams.set("token", runtime.token);
1893
+ url.searchParams.set("shell", "desktop");
1894
+ return url.toString();
2175
1895
  }
2176
- findBySenderId(senderId) {
2177
- return [...this.views.values()].find((entry) => entry.view.webContents.id === senderId);
1896
+ getRuntimeWsUrlWithToken(id) {
1897
+ const runtime = this.runtimes.get(id);
1898
+ if (!runtime) return void 0;
1899
+ const url = new URL(`ws://127.0.0.1:${runtime.wsPort}`);
1900
+ url.searchParams.set("token", runtime.token);
1901
+ return url.toString();
2178
1902
  }
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
- */
2198
- syncActive() {
2199
- if (!this.ctx.getMainWindow()) return;
2200
- const snapshot = this.ctx.manager.snapshot();
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);
2204
- if (active?.status !== "running") {
2205
- this.activeRuntimeId = active?.id ?? null;
2206
- this.publishStatus({ runtimeId: active?.id ?? null, status: "idle" });
2207
- this.ctx.layoutViews();
2208
- return;
1903
+ async openProject(projectRoot, options = {}) {
1904
+ const resolved = path5.resolve(projectRoot);
1905
+ const stat3 = await fs3.stat(resolved).catch(() => null);
1906
+ if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
1907
+ const kind = options.kind ?? "project";
1908
+ const touchRecent = options.touchRecent ?? kind === "project";
1909
+ const forceNew = options.forceNew === true;
1910
+ const authorization = await authorizeDesktopRuntimeStart(this.trustBoundary, resolved, kind);
1911
+ if (!authorization.allowed) {
1912
+ throw new Error(`Desktop runtime start denied: ${authorization.reason}`);
2209
1913
  }
2210
- const url = this.ctx.manager.getRuntimeUrlWithToken(active.id);
2211
- if (!url) {
2212
- this.activeRuntimeId = active.id;
2213
- this.publishStatus({ runtimeId: active.id, status: "idle" });
2214
- this.ctx.layoutViews();
2215
- return;
1914
+ if (!forceNew) {
1915
+ const existing = Array.from(this.runtimes.values()).find(
1916
+ (runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind && (runtime2.status === "starting" || runtime2.status === "running")
1917
+ );
1918
+ if (existing) {
1919
+ this.activeRuntimeId = existing.id;
1920
+ if (existing.kind === "project") this.lastActiveProjectRoot = existing.root;
1921
+ if (touchRecent) {
1922
+ await this.touchProject(existing.root);
1923
+ } else {
1924
+ await this.persistWorkspaceState();
1925
+ }
1926
+ this.emitChanged();
1927
+ return publicRuntime(existing, true);
1928
+ }
1929
+ const staleSameRoot = Array.from(this.runtimes.values()).filter(
1930
+ (runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind
1931
+ );
1932
+ for (const stale of staleSameRoot) {
1933
+ await this.closeRuntimeInternal(stale.id, { persistWorkspace: false });
1934
+ }
2216
1935
  }
2217
- const entry = this.ensure(active.id);
2218
- if (!entry) return;
2219
- this.activeRuntimeId = active.id;
2220
- this.attach(entry);
2221
- this.ctx.layoutViews();
2222
- this.publishStatus(entry.status);
2223
- if (entry.url === url) return;
2224
- entry.url = url;
2225
- entry.bridgeReady = false;
2226
- this.setEntryStatus(entry, { runtimeId: active.id, status: "loading" });
2227
- void entry.view.webContents.loadURL(url).catch((error) => {
2228
- this.setEntryStatus(entry, {
2229
- runtimeId: active.id,
2230
- status: "error",
2231
- error: error instanceof Error ? error.message : String(error)
2232
- });
2233
- });
2234
- }
2235
- broadcastLocale(locale) {
2236
- for (const entry of this.views.values()) {
2237
- if (!entry.view.webContents.isDestroyed())
2238
- entry.view.webContents.send(IPC.webuiLocaleChanged, locale);
2239
- }
2240
- }
2241
- activeEntry() {
2242
- const id = this.ctx.manager.snapshot().activeRuntimeId;
2243
- return id ? this.views.get(id) : void 0;
2244
- }
2245
- async dispatch(commandInput) {
2246
- const command = normalizeDesktopWebuiCommand(commandInput);
2247
- if (!command) return false;
2248
- const entry = this.activeEntry();
2249
- if (!entry?.url) return false;
2250
- if (entry.status.status !== "ready" || !entry.bridgeReady) {
2251
- if (!entry.view.webContents.isLoading() && entry.status.status !== "error")
2252
- return this.dispatchNow(entry, command);
2253
- this.queue(entry, command);
2254
- this.scheduleFlush(entry);
2255
- return true;
1936
+ const slug = projectSlug(resolved);
1937
+ const requestedRuntimeId = normalizeRuntimeId(options.runtimeId);
1938
+ const runtimeId = requestedRuntimeId && !this.runtimes.has(requestedRuntimeId) ? requestedRuntimeId : `${slug}-${randomBytes(3).toString("hex")}`;
1939
+ const name = options.name ?? nextRuntimeName(this.runtimes, resolved, kind);
1940
+ const httpPort = await findFreePort(HTTP_PORT_START, usedPorts(this.runtimes));
1941
+ const wsPort = await findFreePort(
1942
+ WS_PORT_START,
1943
+ /* @__PURE__ */ new Set([...usedPorts(this.runtimes), httpPort])
1944
+ );
1945
+ const token = randomBytes(24).toString("hex");
1946
+ const runtime = {
1947
+ id: runtimeId,
1948
+ name,
1949
+ root: resolved,
1950
+ slug,
1951
+ kind,
1952
+ status: "starting",
1953
+ httpPort,
1954
+ wsPort,
1955
+ url: `http://127.0.0.1:${httpPort}`,
1956
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1957
+ token,
1958
+ child: null,
1959
+ logs: [],
1960
+ logNotifyTimer: null,
1961
+ lastActivityAt: Date.now()
1962
+ };
1963
+ this.runtimes.set(runtimeId, runtime);
1964
+ this.activeRuntimeId = runtimeId;
1965
+ if (kind === "project") this.lastActiveProjectRoot = resolved;
1966
+ if (touchRecent) {
1967
+ await this.touchProject(resolved);
1968
+ } else {
1969
+ await this.persistWorkspaceState();
2256
1970
  }
2257
- return this.dispatchNow(entry, command);
2258
- }
2259
- async reload() {
2260
- const entry = this.activeEntry();
2261
- if (!entry?.url) return false;
2262
- entry.bridgeReady = false;
2263
- this.setEntryStatus(entry, { runtimeId: entry.runtimeId, status: "loading" });
2264
- return entry.view.webContents.loadURL(entry.url).then(() => true).catch((error) => {
2265
- this.setEntryStatus(entry, {
2266
- runtimeId: entry.runtimeId,
2267
- status: "error",
2268
- error: error instanceof Error ? error.message : String(error)
2269
- });
2270
- return false;
2271
- });
2272
- }
2273
- queue(entry, command) {
2274
- entry.pendingCommands.push(command);
2275
- if (entry.pendingCommands.length > MAX_PENDING_WEBUI_COMMANDS)
2276
- entry.pendingCommands.splice(0, entry.pendingCommands.length - MAX_PENDING_WEBUI_COMMANDS);
2277
- entry.pendingFlushAttempts = 0;
2278
- this.setEntryStatus(entry, entry.status);
2279
- }
2280
- dispatchNow(entry, command) {
2281
- if (this.views.get(entry.runtimeId) !== entry || !entry.url) return Promise.resolve(false);
2282
- const requestId = `${entry.runtimeId}:${Date.now()}:${++this.commandSequence}`;
2283
- const outbound = { ...command, requestId };
2284
- return new Promise((resolve5) => {
2285
- const fallbackTimer = setTimeout(() => {
2286
- if (!this.pendingAcks.has(requestId)) return;
2287
- if (this.views.get(entry.runtimeId) !== entry || entry.view.webContents.isDestroyed())
2288
- return;
2289
- void entry.view.webContents.executeJavaScript(buildWebuiCommandFallbackScript(outbound), true).catch(() => void 0);
2290
- }, WEBUI_COMMAND_FALLBACK_MS);
2291
- const timer = setTimeout(
2292
- () => this.settleAck(requestId, false),
2293
- WEBUI_COMMAND_ACK_TIMEOUT_MS
1971
+ this.emitChanged();
1972
+ try {
1973
+ const entry = resolveWebUiEntry();
1974
+ const distDir = resolveWebUiDistDir();
1975
+ const child = spawn(
1976
+ process.execPath,
1977
+ [
1978
+ entry,
1979
+ "--host",
1980
+ "127.0.0.1",
1981
+ "--port",
1982
+ String(httpPort),
1983
+ "--ws-port",
1984
+ String(wsPort),
1985
+ "--dist-dir",
1986
+ distDir,
1987
+ "--require-token"
1988
+ ],
1989
+ {
1990
+ cwd: resolved,
1991
+ env: {
1992
+ ...buildChildEnv(),
1993
+ ELECTRON_RUN_AS_NODE: "1",
1994
+ WEBUI_STRICT_PORT: "1",
1995
+ WRONGSTACK_DESKTOP: "1",
1996
+ // Passed by environment, not argv. A process command line is
1997
+ // world-readable on every platform this ships to — `ps -ef` on
1998
+ // POSIX, Task Manager's command-line column or a plain WMI query on
1999
+ // Windows so `--token <secret>` handed the WebUI access token to
2000
+ // any local process that cared to look. The token grants full agent
2001
+ // control, so that is a credential disclosure, not a nuisance.
2002
+ // entry.ts already reads WEBUI_TOKEN; argv only took precedence
2003
+ // over it (WS-087).
2004
+ WEBUI_TOKEN: token
2005
+ },
2006
+ stdio: ["ignore", "pipe", "pipe"],
2007
+ windowsHide: true
2008
+ }
2294
2009
  );
2295
- this.pendingAcks.set(requestId, {
2296
- runtimeId: entry.runtimeId,
2297
- timer,
2298
- fallbackTimer,
2299
- resolve: resolve5
2010
+ runtime.child = child;
2011
+ runtime.pid = child.pid;
2012
+ child.stdout?.on("data", (chunk) => {
2013
+ const text = chunk.toString();
2014
+ appendRuntimeLog(runtime, "stdout", text);
2015
+ runtime.lastActivityAt = Date.now();
2016
+ this.scheduleLogChanged(runtime);
2017
+ process.stdout.write(`[desktop:${runtime.id}] ${text}`);
2300
2018
  });
2301
- try {
2302
- entry.view.webContents.send(IPC.webuiCommand, outbound);
2303
- if (this.activeRuntimeId === entry.runtimeId) entry.view.webContents.focus();
2304
- } catch {
2305
- this.settleAck(requestId, false);
2019
+ child.stderr?.on("data", (chunk) => {
2020
+ const text = chunk.toString();
2021
+ appendRuntimeLog(runtime, "stderr", text);
2022
+ runtime.lastActivityAt = Date.now();
2023
+ this.scheduleLogChanged(runtime);
2024
+ process.stderr.write(`[desktop:${runtime.id}] ${text}`);
2025
+ });
2026
+ child.once("error", (err) => {
2027
+ runtime.status = "error";
2028
+ runtime.error = toErrorMessage(err);
2029
+ runtime.child = null;
2030
+ if (this.activeRuntimeId === runtime.id) {
2031
+ this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
2032
+ }
2033
+ this.emitChanged();
2034
+ });
2035
+ child.once("exit", (code, signal) => {
2036
+ if (runtime.status === "error") return;
2037
+ runtime.status = "stopped";
2038
+ runtime.error = code === 0 ? void 0 : `Exited with ${signal ?? `code ${code ?? "unknown"}`}`;
2039
+ runtime.child = null;
2040
+ if (this.activeRuntimeId === runtime.id) {
2041
+ this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
2042
+ }
2043
+ this.emitChanged();
2044
+ });
2045
+ await waitForHttpReady(runtime.url, token, START_TIMEOUT_MS);
2046
+ if (runtime.status !== "starting") {
2047
+ throw new Error(runtime.error ?? "WebUI process exited during startup");
2306
2048
  }
2307
- });
2308
- }
2309
- settleAck(requestId, handled) {
2310
- const pending = this.pendingAcks.get(requestId);
2311
- if (!pending) return;
2312
- this.pendingAcks.delete(requestId);
2313
- clearTimeout(pending.timer);
2314
- if (pending.fallbackTimer) clearTimeout(pending.fallbackTimer);
2315
- if (handled) {
2316
- const entry = this.views.get(pending.runtimeId);
2317
- if (entry) {
2318
- entry.bridgeReady = true;
2319
- this.setEntryStatus(entry, { ...entry.status, status: "ready" });
2049
+ runtime.status = "running";
2050
+ await this.persistWorkspaceState();
2051
+ this.emitChanged();
2052
+ return publicRuntime(runtime, true);
2053
+ } catch (err) {
2054
+ if (runtime.status !== "stopped" && runtime.status !== "error") {
2055
+ runtime.status = "error";
2320
2056
  }
2321
- }
2322
- pending.resolve(handled);
2323
- }
2324
- settleRuntimeAcks(runtimeId, handled) {
2325
- for (const [id, pending] of [...this.pendingAcks])
2326
- if (pending.runtimeId === runtimeId) this.settleAck(id, handled);
2327
- }
2328
- scheduleFlush(entry) {
2329
- if (entry.pendingFlushTimer) return;
2330
- entry.pendingFlushTimer = setTimeout(() => {
2331
- entry.pendingFlushTimer = null;
2332
- void this.flush(entry);
2333
- }, 250);
2334
- }
2335
- async flush(entry) {
2336
- if (this.views.get(entry.runtimeId) !== entry || entry.pendingCommands.length === 0) return;
2337
- if (!entry.bridgeReady) {
2338
- entry.pendingFlushAttempts += 1;
2339
- const shouldExecuteFallback = !entry.view.webContents.isLoading() && entry.pendingFlushAttempts >= 4;
2340
- if (!shouldExecuteFallback && entry.pendingFlushAttempts <= MAX_PENDING_FLUSH_ATTEMPTS) {
2341
- this.scheduleFlush(entry);
2342
- this.setEntryStatus(entry, entry.status);
2343
- return;
2057
+ if (runtime.error === void 0) {
2058
+ runtime.error = toErrorMessage(err);
2344
2059
  }
2345
- if (!shouldExecuteFallback) {
2346
- entry.pendingCommands.length = 0;
2347
- this.setEntryStatus(entry, {
2348
- runtimeId: entry.runtimeId,
2349
- status: "error",
2350
- error: "WebUI command bridge did not become ready."
2351
- });
2352
- return;
2060
+ await terminateProcessTree(runtime.child);
2061
+ runtime.child = null;
2062
+ if (this.activeRuntimeId === runtime.id) {
2063
+ this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
2353
2064
  }
2065
+ this.emitChanged();
2066
+ throw err;
2354
2067
  }
2355
- entry.pendingFlushAttempts = 0;
2356
- const commands = entry.pendingCommands.splice(0);
2357
- this.setEntryStatus(entry, entry.status);
2358
- for (const command of commands) await this.dispatchNow(entry, command).catch(() => void 0);
2359
2068
  }
2360
- };
2361
-
2362
- // src/main/app-icon.ts
2363
- import * as fs3 from "node:fs/promises";
2364
- import { fileURLToPath as fileURLToPath2 } from "node:url";
2365
- import { nativeImage } from "electron";
2366
- var ICON_CANDIDATES = ["../../assets/icon.png", "../../assets/icon.icns"];
2367
- async function readIcon(relativePath) {
2368
- try {
2369
- const iconPath = fileURLToPath2(new URL(relativePath, import.meta.url));
2370
- await fs3.stat(iconPath);
2371
- const icon = nativeImage.createFromPath(iconPath);
2372
- return icon.isEmpty() ? void 0 : icon;
2373
- } catch {
2374
- return void 0;
2069
+ async activateRuntime(id) {
2070
+ const runtime = this.runtimes.get(id);
2071
+ if (!runtime) throw new Error(`Runtime not found: ${id}`);
2072
+ this.activeRuntimeId = id;
2073
+ runtime.lastActivityAt = Date.now();
2074
+ if (runtime.kind === "project") this.lastActiveProjectRoot = runtime.root;
2075
+ if (runtime.kind === "project") {
2076
+ await this.touchProject(runtime.root);
2077
+ } else {
2078
+ await this.persistWorkspaceState();
2079
+ }
2080
+ this.emitChanged();
2375
2081
  }
2376
- }
2377
- async function loadDesktopAppIcon() {
2378
- for (const candidate of ICON_CANDIDATES) {
2379
- const icon = await readIcon(candidate);
2380
- if (icon) return icon;
2082
+ async closeRuntime(id) {
2083
+ const runtime = this.runtimes.get(id);
2084
+ if (runtime) {
2085
+ const authorization = await authorizeDesktopRuntimeStop(this.trustBoundary, runtime);
2086
+ if (!authorization.allowed) {
2087
+ throw new Error(`Desktop runtime stop denied: ${authorization.reason}`);
2088
+ }
2089
+ }
2090
+ await this.closeRuntimeInternal(id, { persistWorkspace: true });
2381
2091
  }
2382
- return void 0;
2383
- }
2384
-
2385
- // src/main/window-state-controller.ts
2386
- var DesktopWindowStateController = class {
2387
- constructor(ctx) {
2388
- this.ctx = ctx;
2092
+ async closeAll(options = {}) {
2093
+ const persistWorkspace = options.persistWorkspace ?? true;
2094
+ await Promise.all(
2095
+ Array.from(this.runtimes.keys()).map(
2096
+ (id) => this.closeRuntimeInternal(id, { persistWorkspace })
2097
+ )
2098
+ );
2389
2099
  }
2390
- ctx;
2391
- saveTimer = null;
2392
- scheduleSave() {
2393
- if (this.saveTimer) clearTimeout(this.saveTimer);
2394
- this.saveTimer = setTimeout(() => {
2395
- this.saveTimer = null;
2396
- void this.save();
2397
- }, 350);
2100
+ async registerProject(projectRoot) {
2101
+ const resolved = path5.resolve(projectRoot);
2102
+ const stat3 = await fs3.stat(resolved).catch(() => null);
2103
+ if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
2104
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2105
+ const entry = {
2106
+ name: path5.basename(resolved) || resolved,
2107
+ root: resolved,
2108
+ slug: projectSlug(resolved),
2109
+ lastSeen: now,
2110
+ lastWorkingDir: resolved
2111
+ };
2112
+ this.registeredProjects = await touchGlobalProjectManifest(entry);
2113
+ this.emitChanged();
2398
2114
  }
2399
- async save() {
2400
- const window = this.ctx.getWindow();
2401
- if (!window || window.isDestroyed?.()) return;
2402
- const bounds = window.getNormalBounds();
2403
- await this.ctx.save({ ...bounds, maximized: window.isMaximized() });
2115
+ async unregisterProject(projectRoot) {
2116
+ const resolved = path5.resolve(projectRoot);
2117
+ this.registeredProjects = await removeGlobalProjectManifest(resolved);
2118
+ await this.saveDesktopState();
2119
+ this.emitChanged();
2404
2120
  }
2405
- validated(state) {
2406
- if (!state || !Number.isFinite(state.width) || !Number.isFinite(state.height) || state.width < MIN_WINDOW_WIDTH2 || state.height < MIN_WINDOW_HEIGHT2)
2407
- return null;
2408
- if (state.x === void 0 || state.y === void 0) {
2409
- return { width: state.width, height: state.height, maximized: state.maximized };
2121
+ async closeRuntimeInternal(id, options) {
2122
+ const runtime = this.runtimes.get(id);
2123
+ if (!runtime) return;
2124
+ runtime.status = "stopped";
2125
+ const child = runtime.child;
2126
+ runtime.child = null;
2127
+ await terminateProcessTree(child);
2128
+ if (runtime.logNotifyTimer) {
2129
+ clearTimeout(runtime.logNotifyTimer);
2130
+ runtime.logNotifyTimer = null;
2410
2131
  }
2411
- const candidate = { x: state.x, y: state.y, width: state.width, height: state.height };
2412
- return this.ctx.getDisplays().some(({ workArea }) => intersects(candidate, workArea)) ? {
2413
- x: state.x,
2414
- y: state.y,
2415
- width: state.width,
2416
- height: state.height,
2417
- maximized: state.maximized
2418
- } : null;
2132
+ this.runtimes.delete(id);
2133
+ if (this.activeRuntimeId === id) this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
2134
+ if (this.lastActiveProjectRoot && samePath(this.lastActiveProjectRoot, runtime.root)) {
2135
+ this.lastActiveProjectRoot = firstProjectRuntimeRoot(this.runtimes);
2136
+ }
2137
+ if (options.persistWorkspace) {
2138
+ await this.persistWorkspaceState();
2139
+ }
2140
+ this.emitChanged();
2419
2141
  }
2420
- };
2421
- function intersects(left, right) {
2422
- return left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
2423
- }
2424
-
2425
- // src/main/runtime/operations.ts
2426
- import * as fs4 from "node:fs/promises";
2427
- async function openProject(ctx, requestedRoot) {
2428
- let projectRoot = requestedRoot;
2429
- if (!projectRoot) {
2430
- projectRoot = await ctx.chooseProjectRoot("open");
2142
+ async touchProject(projectRoot) {
2143
+ const resolved = path5.resolve(projectRoot);
2144
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2145
+ const entry = {
2146
+ name: path5.basename(resolved) || resolved,
2147
+ root: resolved,
2148
+ slug: projectSlug(resolved),
2149
+ lastSeen: now,
2150
+ lastWorkingDir: resolved
2151
+ };
2152
+ this.recentProjects = [
2153
+ entry,
2154
+ ...this.recentProjects.filter((p) => !samePath(p.root, resolved))
2155
+ ].slice(0, 24);
2156
+ const [, registeredProjects] = await Promise.all([
2157
+ this.saveDesktopState(),
2158
+ touchGlobalProjectManifest(entry)
2159
+ ]);
2160
+ this.registeredProjects = registeredProjects;
2431
2161
  }
2432
- if (!projectRoot) return ctx.getRuntimeManager().snapshot();
2433
- await ctx.getRuntimeManager().openProject(projectRoot);
2434
- ctx.syncActiveWebuiView();
2435
- ctx.broadcastState();
2436
- return ctx.getRuntimeManager().snapshot();
2437
- }
2438
- async function registerProject(ctx, requestedRoot) {
2439
- let projectRoot = requestedRoot;
2440
- if (!projectRoot) {
2441
- projectRoot = await ctx.chooseProjectRoot("register");
2162
+ async persistWorkspaceState() {
2163
+ await this.saveDesktopState();
2442
2164
  }
2443
- if (!projectRoot) return ctx.getRuntimeManager().snapshot();
2444
- await ctx.getRuntimeManager().registerProject(projectRoot);
2445
- ctx.broadcastState();
2446
- return ctx.getRuntimeManager().snapshot();
2447
- }
2448
- async function unregisterProject(ctx, root) {
2449
- if (!root || typeof root !== "string") return ctx.getRuntimeManager().snapshot();
2450
- await ctx.getRuntimeManager().unregisterProject(root);
2451
- ctx.broadcastState();
2452
- return ctx.getRuntimeManager().snapshot();
2453
- }
2454
- async function openProjectSession(ctx, runtimeId) {
2455
- const snapshot = ctx.getRuntimeManager().snapshot();
2456
- const runtime = (runtimeId ? snapshot.runtimes.find((candidate) => candidate.id === runtimeId) : void 0) ?? snapshot.runtimes.find((candidate) => candidate.id === snapshot.activeRuntimeId);
2457
- if (runtime?.kind !== "project") {
2458
- return openProject(ctx);
2165
+ async loadDesktopState() {
2166
+ try {
2167
+ const raw = await fs3.readFile(this.stateFile, "utf8");
2168
+ const parsed = JSON.parse(raw);
2169
+ const openProjects = normalizePathList(parsed.openProjects);
2170
+ const openProjectSessions = normalizeSessionStateList(
2171
+ parsed.openProjectSessions,
2172
+ openProjects
2173
+ );
2174
+ return {
2175
+ recentProjects: normalizeProjectEntries(parsed.recentProjects),
2176
+ openProjects,
2177
+ openProjectSessions,
2178
+ activeRuntimeId: normalizeRuntimeId(parsed.activeRuntimeId) ?? null,
2179
+ activeProjectRoot: typeof parsed.activeProjectRoot === "string" && parsed.activeProjectRoot.trim() ? path5.resolve(parsed.activeProjectRoot) : null,
2180
+ window: normalizeWindowState(parsed.window)
2181
+ };
2182
+ } catch {
2183
+ return {
2184
+ recentProjects: [],
2185
+ openProjects: [],
2186
+ openProjectSessions: [],
2187
+ activeRuntimeId: null,
2188
+ activeProjectRoot: null,
2189
+ window: null
2190
+ };
2191
+ }
2459
2192
  }
2460
- await ctx.getRuntimeManager().openProject(runtime.root, { forceNew: true });
2461
- ctx.syncActiveWebuiView();
2462
- ctx.broadcastState();
2463
- return ctx.getRuntimeManager().snapshot();
2464
- }
2465
- async function openSettings(ctx) {
2466
- const snapshot = ctx.getRuntimeManager().snapshot();
2467
- const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
2468
- if (!active || active.kind === "global-settings" || active.status !== "running") {
2469
- const root = desktopSettingsWorkspaceRoot();
2470
- await fs4.mkdir(root, { recursive: true });
2471
- await ctx.getRuntimeManager().openProject(root, {
2472
- name: "Global Settings",
2473
- kind: "global-settings",
2474
- touchRecent: false
2475
- });
2476
- ctx.syncActiveWebuiView();
2477
- ctx.broadcastState();
2193
+ async saveDesktopState() {
2194
+ await fs3.mkdir(path5.dirname(this.stateFile), { recursive: true });
2195
+ const liveProjectSessions = Array.from(this.runtimes.values()).filter((runtime) => runtime.status !== "stopped" && runtime.kind === "project").map((runtime) => runtimeToSessionState(runtime));
2196
+ const openProjectSessions = liveProjectSessions.length === 0 && !this.workspaceRestoreCompleted ? [...this.restoreProjectSessions] : liveProjectSessions;
2197
+ const openProjects = openProjectSessions.map((session) => session.root);
2198
+ const activeRuntime = this.activeRuntimeId ? this.runtimes.get(this.activeRuntimeId) : null;
2199
+ const lastActiveProjectRoot = this.lastActiveProjectRoot;
2200
+ const fallbackSession = lastActiveProjectRoot ? openProjectSessions.find((session) => samePath(session.root, lastActiveProjectRoot)) : void 0;
2201
+ const activeSession = activeRuntime?.kind === "project" ? runtimeToSessionState(activeRuntime) : fallbackSession ?? openProjectSessions[0];
2202
+ const activeRoot = activeSession?.root;
2203
+ const activeRuntimeId = activeSession?.runtimeId ?? null;
2204
+ await atomicWrite2(
2205
+ this.stateFile,
2206
+ `${JSON.stringify(
2207
+ {
2208
+ recentProjects: this.recentProjects,
2209
+ openProjects,
2210
+ openProjectSessions,
2211
+ activeRuntimeId,
2212
+ activeProjectRoot: activeRoot ?? null,
2213
+ window: this.windowState
2214
+ },
2215
+ null,
2216
+ 2
2217
+ )}
2218
+ `,
2219
+ { mode: 384 }
2220
+ );
2478
2221
  }
2479
- await ctx.dispatchWebuiCommand({ view: "settings" });
2480
- return ctx.getRuntimeManager().snapshot();
2481
- }
2482
- async function activateRuntime(ctx, id) {
2483
- await ctx.getRuntimeManager().activateRuntime(id);
2484
- ctx.syncActiveWebuiView();
2485
- ctx.broadcastState();
2486
- return ctx.getRuntimeManager().snapshot();
2487
- }
2488
- async function closeRuntime(ctx, id) {
2489
- ctx.getAgentBridge().close(id);
2490
- await ctx.getRuntimeManager().closeRuntime(id);
2491
- ctx.syncActiveWebuiView();
2492
- ctx.broadcastState();
2493
- return ctx.getRuntimeManager().snapshot();
2494
- }
2495
- async function restoreLastWorkspace(ctx) {
2496
- await ctx.getRuntimeManager().restoreLastWorkspace();
2497
- ctx.syncActiveWebuiView();
2498
- ctx.broadcastState();
2499
- }
2500
-
2501
- // src/main/layout/sidebar.ts
2502
- function getSidebarWidth(windowWidth, collapsed) {
2503
- if (collapsed) return SIDEBAR_WIDTH_COLLAPSED;
2504
- if (windowWidth < 900) return SIDEBAR_WIDTH_NARROW;
2505
- if (windowWidth < 1180) return SIDEBAR_WIDTH_MEDIUM;
2506
- return SIDEBAR_WIDTH_WIDE;
2507
- }
2508
-
2509
- // src/main/menu/index.ts
2510
- import { Menu as Menu2 } from "electron";
2511
-
2512
- // src/main/menu/projects-menu.ts
2513
- import path4 from "node:path";
2514
- function buildProjectsMenu(runtimes, actions, t) {
2515
- const projectGroups = groupProjectRuntimesForMenu(runtimes);
2516
- const menu = [
2517
- {
2518
- label: t("openProjectEllipsis"),
2519
- accelerator: "CmdOrCtrl+O",
2520
- click: () => actions.newSession("")
2521
- },
2522
- {
2523
- label: t("registerProjectEllipsis"),
2524
- click: () => actions.registerProject?.()
2525
- },
2526
- { type: "separator" }
2527
- ];
2528
- if (projectGroups.length === 0) {
2529
- menu.push({ label: t("noOpenProjectSessions"), enabled: false });
2530
- return menu;
2222
+ emitChanged() {
2223
+ this.emit("changed");
2531
2224
  }
2532
- for (const group of projectGroups) {
2533
- menu.push({
2534
- label: group.name,
2535
- submenu: [
2536
- {
2537
- label: t("newSession"),
2538
- click: () => actions.newSession(group.sessions[0]?.id ?? ""),
2539
- enabled: Boolean(group.sessions[0])
2540
- },
2541
- {
2542
- label: t("revealProjectFolder"),
2543
- click: () => actions.reveal(group.sessions[0]?.id ?? ""),
2544
- enabled: Boolean(group.sessions[0])
2545
- },
2546
- { type: "separator" },
2547
- ...group.sessions.map((runtime, index) => buildSessionMenu(runtime, index + 1, actions, t))
2548
- ]
2225
+ /**
2226
+ * Notify the shell that a runtime produced output.
2227
+ *
2228
+ * Only the ACTIVE runtime's logs reach the renderer (see `publicRuntime`), so
2229
+ * output from a background project has nothing to show and must not cost a
2230
+ * broadcast. Before this guard, every project writing to stdout scheduled its
2231
+ * own 250 ms timer, and each one fired a FULL snapshot: N chatty projects
2232
+ * produced 4N broadcasts per second carrying N x 40 log lines each, for a
2233
+ * panel that displays one runtime's output.
2234
+ *
2235
+ * The 250 ms debounce is per-runtime by construction (the timer lives on the
2236
+ * runtime record) but only one runtime can be active, so at most one such
2237
+ * timer is ever armed now.
2238
+ */
2239
+ /**
2240
+ * Begin reclaiming idle project servers.
2241
+ *
2242
+ * Idempotent, and a no-op when the timeout is disabled. The interval is
2243
+ * unref'd so a pending sweep never holds the process open during quit.
2244
+ */
2245
+ startIdleSweep(options = {}) {
2246
+ if (this.idleSweepTimer) return;
2247
+ this.idleTimeoutMs = options.idleTimeoutMs ?? resolveIdleTimeoutMs();
2248
+ if (this.idleTimeoutMs <= 0) return;
2249
+ this.idleSweepTimer = setInterval(() => {
2250
+ void this.sweepIdleRuntimes();
2251
+ }, IDLE_SWEEP_INTERVAL_MS);
2252
+ this.idleSweepTimer.unref?.();
2253
+ }
2254
+ stopIdleSweep() {
2255
+ if (!this.idleSweepTimer) return;
2256
+ clearInterval(this.idleSweepTimer);
2257
+ this.idleSweepTimer = null;
2258
+ }
2259
+ /** One pass. Exposed so a test can drive it without waiting on the interval. */
2260
+ async sweepIdleRuntimes(now = Date.now()) {
2261
+ const ids = reclaimableRuntimeIds(this.runtimes, {
2262
+ activeRuntimeId: this.activeRuntimeId,
2263
+ idleTimeoutMs: this.idleTimeoutMs,
2264
+ now
2549
2265
  });
2266
+ for (const id of ids) {
2267
+ await this.closeRuntimeInternal(id, { persistWorkspace: false });
2268
+ }
2269
+ return ids;
2550
2270
  }
2551
- return menu;
2552
- }
2553
- function buildSessionMenu(runtime, index, actions, t) {
2554
- const running = runtime.status === "running";
2555
- const label = `${t("session")} ${index} \xB7 ${runtime.status}`;
2556
- return {
2557
- label,
2558
- submenu: [
2559
- {
2560
- label: t("quickView"),
2561
- click: () => actions.activate(runtime.id)
2562
- },
2563
- {
2564
- label: "WebUI",
2565
- enabled: running,
2566
- submenu: [
2567
- {
2568
- label: t("chat"),
2569
- click: () => actions.activateAndNavigate(runtime.id, { activity: "chat", view: "chat" })
2570
- },
2571
- {
2572
- label: t("focusPrompt"),
2573
- click: () => actions.activateAndNavigate(runtime.id, { action: "focus-chat" })
2574
- },
2575
- {
2576
- label: t("terminal"),
2577
- click: () => actions.activateAndNavigate(runtime.id, { terminal: "toggle" })
2578
- },
2579
- {
2580
- label: t("newTerminal"),
2581
- click: () => actions.activateAndNavigate(runtime.id, { terminal: "new" })
2582
- },
2583
- { type: "separator" },
2584
- {
2585
- label: t("files"),
2586
- click: () => actions.activateAndNavigate(runtime.id, { activity: "files", view: "files" })
2587
- },
2588
- {
2589
- label: t("changes"),
2590
- click: () => actions.activateAndNavigate(runtime.id, { activity: "changes", view: "changes" })
2591
- },
2592
- {
2593
- label: t("sessions"),
2594
- click: () => actions.activateAndNavigate(runtime.id, { view: "sessions" })
2595
- },
2596
- {
2597
- label: t("fleetHQ"),
2598
- click: () => actions.activateAndNavigate(runtime.id, { view: "roster" })
2599
- },
2600
- {
2601
- label: t("settings"),
2602
- click: () => actions.activateAndNavigate(runtime.id, { view: "settings" })
2603
- },
2604
- { type: "separator" },
2605
- {
2606
- label: t("commandPalette"),
2607
- click: () => actions.activateAndNavigate(runtime.id, { action: "open-command-palette" })
2608
- },
2609
- {
2610
- label: t("modelSwitcher"),
2611
- click: () => actions.activateAndNavigate(runtime.id, { action: "open-model-switcher" })
2612
- }
2613
- ]
2614
- },
2615
- { type: "separator" },
2616
- {
2617
- label: t("openInBrowser"),
2618
- enabled: running,
2619
- click: () => actions.openBrowser(runtime.id)
2620
- },
2621
- {
2622
- label: t("reloadWebui"),
2623
- enabled: running,
2624
- click: () => actions.reload(runtime.id)
2625
- },
2626
- {
2627
- label: t("closeSession"),
2628
- click: () => actions.close(runtime.id)
2271
+ scheduleLogChanged(runtime) {
2272
+ if (runtime.id !== this.activeRuntimeId) return;
2273
+ if (runtime.logNotifyTimer) return;
2274
+ runtime.logNotifyTimer = setTimeout(() => {
2275
+ runtime.logNotifyTimer = null;
2276
+ if (this.runtimes.get(runtime.id) === runtime && runtime.id === this.activeRuntimeId) {
2277
+ this.emitChanged();
2629
2278
  }
2630
- ]
2631
- };
2279
+ }, 250);
2280
+ }
2281
+ };
2282
+ function hasChildExited(child) {
2283
+ return child.exitCode !== null || child.signalCode !== null;
2632
2284
  }
2633
- function groupProjectRuntimesForMenu(runtimes) {
2634
- const groups = /* @__PURE__ */ new Map();
2635
- for (const runtime of runtimes) {
2636
- if (runtime.kind !== "project") continue;
2637
- const key = normalizeMenuRoot(runtime.root);
2638
- const existing = groups.get(key);
2639
- if (existing) {
2640
- existing.sessions.push(runtime);
2641
- continue;
2285
+ function waitForChildExit(child, timeoutMs) {
2286
+ if (hasChildExited(child)) return Promise.resolve(true);
2287
+ return new Promise((resolve4) => {
2288
+ let settled = false;
2289
+ const finish = (exited) => {
2290
+ if (settled) return;
2291
+ settled = true;
2292
+ clearTimeout(timer);
2293
+ child.off("exit", onExit);
2294
+ resolve4(exited);
2295
+ };
2296
+ const onExit = () => finish(true);
2297
+ const timer = setTimeout(() => finish(hasChildExited(child)), timeoutMs);
2298
+ timer.unref?.();
2299
+ child.once("exit", onExit);
2300
+ if (hasChildExited(child)) finish(true);
2301
+ });
2302
+ }
2303
+ async function terminateProcessTree(child) {
2304
+ if (!child?.pid || hasChildExited(child)) return;
2305
+ if (process.platform !== "win32") {
2306
+ const pid = child.pid;
2307
+ const exited = waitForChildExit(child, 5e3);
2308
+ child.kill("SIGTERM");
2309
+ if (await exited) return;
2310
+ if (!hasChildExited(child)) {
2311
+ try {
2312
+ process.kill(-pid, "SIGKILL");
2313
+ } catch {
2314
+ child.kill("SIGKILL");
2315
+ }
2316
+ await waitForChildExit(child, 1e3);
2642
2317
  }
2643
- groups.set(key, {
2644
- key,
2645
- name: path4.basename(runtime.root) || runtime.name,
2646
- root: runtime.root,
2647
- sessions: [runtime]
2648
- });
2318
+ return;
2649
2319
  }
2650
- return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
2651
- }
2652
- function normalizeMenuRoot(root) {
2653
- return root.replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
2320
+ await new Promise((resolve4) => {
2321
+ let settled = false;
2322
+ const finish = () => {
2323
+ if (settled) return;
2324
+ settled = true;
2325
+ resolve4();
2326
+ };
2327
+ const timer = setTimeout(finish, 3e3);
2328
+ timer.unref?.();
2329
+ const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
2330
+ stdio: "ignore",
2331
+ windowsHide: true
2332
+ });
2333
+ killer.once("exit", () => {
2334
+ clearTimeout(timer);
2335
+ finish();
2336
+ });
2337
+ killer.once("error", () => {
2338
+ clearTimeout(timer);
2339
+ child.kill();
2340
+ finish();
2341
+ });
2342
+ });
2654
2343
  }
2655
-
2656
- // src/main/menu/sections.ts
2657
- function buildFileMenu(ctx, _actions, hasActiveRuntime, hasActiveProjectWebui, active, navigate, getActiveRuntimeId) {
2658
- return {
2659
- label: ctx.t("file"),
2660
- submenu: [
2661
- {
2662
- label: ctx.t("openProjectEllipsis"),
2663
- accelerator: "CmdOrCtrl+O",
2664
- click: () => void ctx.openProject()
2665
- },
2666
- { label: ctx.t("registerProjectEllipsis"), click: () => void ctx.registerProject() },
2667
- {
2668
- label: ctx.t("removeActiveFromRegistry"),
2669
- enabled: hasActiveProjectWebui,
2670
- click: () => {
2671
- if (active?.kind === "project") void ctx.unregisterProject(active.root);
2672
- }
2673
- },
2674
- { type: "separator" },
2675
- {
2676
- label: ctx.t("newSessionForActive"),
2677
- accelerator: "CmdOrCtrl+N",
2678
- enabled: hasActiveProjectWebui,
2679
- click: () => void ctx.openProjectSession(active?.id)
2680
- },
2681
- {
2682
- label: ctx.t("settings"),
2683
- accelerator: "CmdOrCtrl+,",
2684
- click: () => {
2685
- if (hasActiveRuntime) navigate({ view: "settings" });
2686
- else void ctx.openSettings();
2687
- }
2688
- },
2689
- { type: "separator" },
2690
- {
2691
- label: ctx.t("closeActiveRuntime"),
2692
- accelerator: "CmdOrCtrl+W",
2693
- enabled: hasActiveRuntime,
2694
- click: () => {
2695
- const id = getActiveRuntimeId();
2696
- if (id) void ctx.closeRuntime(id);
2697
- }
2698
- },
2699
- { type: "separator" },
2700
- {
2701
- role: process.platform === "darwin" ? "close" : "quit"
2702
- }
2703
- ]
2704
- };
2705
- }
2706
- function buildWorkspaceMenu(ctx, _actions, hasActiveWebui, prefs, navigate) {
2707
- const yoloChecked = prefs?.yolo === true;
2708
- const nextPredictionChecked = prefs?.nextPrediction === true;
2709
- const contextAutoCompactChecked = prefs?.contextAutoCompact === true;
2710
- const webuiItem = (item) => ({
2711
- ...item,
2712
- enabled: item.enabled ?? hasActiveWebui
2713
- });
2344
+ var SNAPSHOT_LOG_LINES = 40;
2345
+ function publicRuntime(runtime, includeLogs) {
2346
+ const {
2347
+ child: _child,
2348
+ token: _token,
2349
+ logs,
2350
+ logNotifyTimer: _logNotifyTimer,
2351
+ ...record
2352
+ } = runtime;
2353
+ void _child;
2354
+ void _token;
2355
+ void _logNotifyTimer;
2356
+ if (!includeLogs) return record;
2714
2357
  return {
2715
- label: ctx.t("workspace"),
2716
- submenu: [
2717
- webuiItem({
2718
- label: ctx.t("openChat"),
2719
- accelerator: "CmdOrCtrl+1",
2720
- click: () => navigate({ activity: "chat", view: "chat" })
2721
- }),
2722
- webuiItem({
2723
- label: ctx.t("focusPrompt"),
2724
- accelerator: "CmdOrCtrl+/",
2725
- click: () => navigate({ action: "focus-chat" })
2726
- }),
2727
- webuiItem({
2728
- label: ctx.t("toggleTerminal"),
2729
- accelerator: "CmdOrCtrl+`",
2730
- click: () => navigate({ terminal: "toggle" })
2731
- }),
2732
- webuiItem({ label: ctx.t("newTerminal"), click: () => navigate({ terminal: "new" }) }),
2733
- { type: "separator" },
2734
- webuiItem({
2735
- label: ctx.t("commandPalette"),
2736
- accelerator: "CmdOrCtrl+K",
2737
- click: () => navigate({ action: "open-command-palette" })
2738
- }),
2739
- webuiItem({
2740
- label: ctx.t("quickModelSwitcher"),
2741
- accelerator: "CmdOrCtrl+M",
2742
- click: () => navigate({ action: "open-model-switcher" })
2743
- }),
2744
- webuiItem({
2745
- type: "checkbox",
2746
- label: ctx.t("yoloMode"),
2747
- checked: yoloChecked,
2748
- accelerator: "CmdOrCtrl+Shift+Y",
2749
- click: () => navigate({ pref: { key: "yolo", toggle: true } })
2750
- }),
2751
- webuiItem({
2752
- type: "checkbox",
2753
- label: ctx.t("nextPrediction"),
2754
- checked: nextPredictionChecked,
2755
- click: () => navigate({ pref: { key: "nextPrediction", toggle: true } })
2756
- }),
2757
- webuiItem({
2758
- type: "checkbox",
2759
- label: ctx.t("contextAutoCompact"),
2760
- checked: contextAutoCompactChecked,
2761
- click: () => navigate({ pref: { key: "contextAutoCompact", toggle: true } })
2762
- }),
2763
- { type: "separator" },
2764
- webuiItem({
2765
- label: ctx.t("reloadActiveWebui"),
2766
- accelerator: "CmdOrCtrl+Shift+R",
2767
- click: () => void ctx.reloadActiveWebuiView()
2768
- })
2769
- ]
2358
+ ...record,
2359
+ recentLogs: logs.slice(-SNAPSHOT_LOG_LINES)
2770
2360
  };
2771
2361
  }
2772
- function buildViewMenu(ctx) {
2362
+ function runtimeToSessionState(runtime) {
2773
2363
  return {
2774
- label: ctx.t("view"),
2775
- submenu: [
2776
- {
2777
- type: "checkbox",
2778
- label: ctx.t("compactDesktopSidebar"),
2779
- accelerator: "CmdOrCtrl+B",
2780
- checked: ctx.getShellSidebarCollapsed(),
2781
- click: () => ctx.setShellSidebarCollapsed(!ctx.getShellSidebarCollapsed())
2782
- },
2783
- { type: "separator" },
2784
- { role: "reload" },
2785
- { role: "toggleDevTools" },
2786
- { type: "separator" },
2787
- { role: "resetZoom" },
2788
- { role: "zoomIn" },
2789
- { role: "zoomOut" },
2790
- { type: "separator" },
2791
- { role: "togglefullscreen" }
2792
- ]
2364
+ runtimeId: runtime.id,
2365
+ name: runtime.name,
2366
+ root: runtime.root,
2367
+ startedAt: runtime.startedAt
2793
2368
  };
2794
2369
  }
2795
-
2796
- // src/main/menu/index.ts
2797
- function configureApplicationMenu(ctx) {
2798
- const snapshot = ctx.getSnapshot();
2799
- const active = ctx.getActiveRuntime();
2800
- const hasActiveRuntime = Boolean(active);
2801
- const hasActiveWebui = active?.status === "running";
2802
- const hasActiveProjectWebui = hasActiveWebui && active?.kind === "project";
2803
- const activeWebuiPrefs = ctx.getActiveWebuiPrefs();
2804
- const navigate = (command) => {
2805
- void ctx.dispatchWebuiCommand(command);
2806
- };
2807
- const activateAndNavigate = (runtimeId, command) => {
2808
- void ctx.activateRuntime(runtimeId).then(() => ctx.dispatchWebuiCommand(command));
2809
- };
2810
- const reloadRuntimeWebui = (runtimeId) => {
2811
- void ctx.activateRuntime(runtimeId).then(() => ctx.reloadActiveWebuiView());
2812
- };
2813
- const actions = {
2814
- activate: (runtimeId) => void ctx.activateRuntime(runtimeId),
2815
- activateAndNavigate,
2816
- registerProject: () => void ctx.registerProject(),
2817
- newSession: (runtimeId) => {
2818
- if (runtimeId) void ctx.openProjectSession(runtimeId);
2819
- else void ctx.openProject();
2820
- },
2821
- openBrowser: (runtimeId) => {
2822
- const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(runtimeId);
2823
- if (url) ctx.openExternal(url);
2824
- },
2825
- reload: reloadRuntimeWebui,
2826
- close: (runtimeId) => void ctx.closeRuntime(runtimeId),
2827
- reveal: (runtimeId) => {
2828
- const runtime = ctx.getRuntimeManager().getRuntime(runtimeId);
2829
- if (runtime) ctx.revealInExplorer(runtime.root);
2830
- }
2831
- };
2832
- const template = [
2833
- buildFileMenu(
2834
- ctx,
2835
- actions,
2836
- hasActiveRuntime,
2837
- hasActiveProjectWebui,
2838
- active,
2839
- navigate,
2840
- ctx.getActiveRuntimeId
2841
- ),
2842
- {
2843
- label: ctx.t("projects"),
2844
- submenu: buildProjectsMenu(snapshot.runtimes, actions, ctx.t)
2845
- },
2846
- buildWorkspaceMenu(ctx, actions, hasActiveWebui, activeWebuiPrefs, navigate),
2847
- buildViewMenu(ctx)
2848
- ];
2849
- Menu2.setApplicationMenu(Menu2.buildFromTemplate(template));
2370
+ function appendRuntimeLog(runtime, stream, text) {
2371
+ for (const rawLine of text.split(/\r?\n/)) {
2372
+ const line = rawLine.trimEnd();
2373
+ if (!line) continue;
2374
+ runtime.logs.push(`[${stream}] ${line}`);
2375
+ }
2376
+ if (runtime.logs.length > 120) {
2377
+ runtime.logs.splice(0, runtime.logs.length - 120);
2378
+ }
2850
2379
  }
2851
-
2852
- // src/main/ipc-handlers/index.ts
2853
- import { ipcMain } from "electron";
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
- };
2380
+ function normalizePathList(value) {
2381
+ if (!Array.isArray(value)) return [];
2382
+ const roots = [];
2383
+ for (const item of value) {
2384
+ if (typeof item !== "string" || !item.trim()) continue;
2385
+ const resolved = path5.resolve(item);
2386
+ roots.push(resolved);
2387
+ }
2388
+ return roots.slice(0, 12);
2886
2389
  }
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 [];
2390
+ function normalizeSessionStateList(value, fallbackRoots) {
2391
+ if (!Array.isArray(value)) {
2392
+ return fallbackRoots.map((root) => ({ root })).slice(0, 12);
2903
2393
  }
2904
2394
  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;
2395
+ for (const item of value) {
2396
+ if (!item || typeof item !== "object") continue;
2397
+ const candidate = item;
2398
+ if (typeof candidate.root !== "string" || !candidate.root.trim()) continue;
2399
+ const session = {
2400
+ root: path5.resolve(candidate.root)
2401
+ };
2402
+ const runtimeId = normalizeRuntimeId(candidate.runtimeId);
2403
+ if (runtimeId) session.runtimeId = runtimeId;
2404
+ if (typeof candidate.name === "string" && candidate.name.trim()) {
2405
+ session.name = candidate.name.trim().slice(0, 120);
2913
2406
  }
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);
2407
+ if (typeof candidate.startedAt === "string" && candidate.startedAt.trim()) {
2408
+ session.startedAt = candidate.startedAt.trim();
2936
2409
  }
2410
+ sessions.push(session);
2937
2411
  }
2938
- sessions.sort((a, b) => (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? ""));
2939
- cache.set(sessionsDir, { readAt: Date.now(), limit, sessions });
2940
- return sessions;
2412
+ return sessions.slice(0, 12);
2941
2413
  }
2942
-
2943
- // src/main/validation/index.ts
2944
- function validate(schema, data) {
2945
- try {
2946
- const result = schema.safeParse(data);
2947
- if (result.success) {
2948
- return { success: true, data: result.data };
2949
- }
2950
- return { success: false, error: formatZodError(result.error) };
2951
- } catch (err) {
2952
- return { success: false, error: String(err) };
2414
+ function normalizeRuntimeId(value) {
2415
+ if (typeof value !== "string") return void 0;
2416
+ const trimmed = value.trim();
2417
+ if (!/^[a-zA-Z0-9._:-]{3,120}$/.test(trimmed)) return void 0;
2418
+ return trimmed;
2419
+ }
2420
+ function normalizeWindowState(value) {
2421
+ if (!value || typeof value !== "object") return null;
2422
+ const candidate = value;
2423
+ const width = Number(candidate.width);
2424
+ const height = Number(candidate.height);
2425
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
2426
+ if (width < MIN_WINDOW_WIDTH2 || height < MIN_WINDOW_HEIGHT2) return null;
2427
+ const state = {
2428
+ width: Math.round(width),
2429
+ height: Math.round(height),
2430
+ maximized: Boolean(candidate.maximized)
2431
+ };
2432
+ if (Number.isFinite(Number(candidate.x))) state.x = Math.round(Number(candidate.x));
2433
+ if (Number.isFinite(Number(candidate.y))) state.y = Math.round(Number(candidate.y));
2434
+ return state;
2435
+ }
2436
+ function firstRunningRuntimeId(runtimes) {
2437
+ return Array.from(runtimes.values()).find((runtime) => runtime.status === "running")?.id ?? null;
2438
+ }
2439
+ function firstProjectRuntimeRoot(runtimes) {
2440
+ return Array.from(runtimes.values()).find(
2441
+ (runtime) => runtime.status === "running" && runtime.kind === "project"
2442
+ )?.root ?? null;
2443
+ }
2444
+ function usedPorts(runtimes) {
2445
+ const ports = /* @__PURE__ */ new Set();
2446
+ for (const runtime of runtimes.values()) {
2447
+ ports.add(runtime.httpPort);
2448
+ ports.add(runtime.wsPort);
2953
2449
  }
2450
+ return ports;
2954
2451
  }
2955
- function validateOrDefault(schema, data, defaultValue) {
2956
- const result = validate(schema, data);
2957
- return result.success ? result.data : defaultValue;
2452
+ function nextRuntimeName(runtimes, root, kind) {
2453
+ const baseName = path5.basename(root) || root;
2454
+ if (kind !== "project") return baseName;
2455
+ const liveSameRoot = Array.from(runtimes.values()).filter(
2456
+ (runtime) => runtime.kind === "project" && samePath(runtime.root, root) && runtime.status !== "stopped"
2457
+ ).length;
2458
+ return liveSameRoot === 0 ? baseName : `${baseName} #${liveSameRoot + 1}`;
2958
2459
  }
2959
- function formatZodError(error) {
2960
- return error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
2460
+ function pathKey(value) {
2461
+ const resolved = path5.resolve(value);
2462
+ return os.platform() === "win32" ? resolved.toLowerCase() : resolved;
2961
2463
  }
2962
- function createValidationLogger(prefix) {
2963
- const loggedErrors = /* @__PURE__ */ new Set();
2964
- const maxErrors = 100;
2965
- return {
2966
- log(error) {
2967
- if (loggedErrors.size >= maxErrors) return;
2968
- const key = `${prefix}:${error}`;
2969
- if (!loggedErrors.has(key)) {
2970
- loggedErrors.add(key);
2971
- console.warn(
2972
- JSON.stringify({
2973
- level: "warn",
2974
- event: "desktop.validation_error",
2975
- prefix,
2976
- message: error,
2977
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2978
- })
2979
- );
2464
+ async function findFreePort(startPort, exclude) {
2465
+ for (let port = startPort; port < startPort + 200; port++) {
2466
+ if (exclude.has(port)) continue;
2467
+ if (await isPortFree(port)) return port;
2468
+ }
2469
+ throw new Error(`No free local port found near ${startPort}`);
2470
+ }
2471
+ function isPortFree(port) {
2472
+ return new Promise((resolve4) => {
2473
+ const server = net.createServer();
2474
+ server.once("error", () => resolve4(false));
2475
+ server.once("listening", () => {
2476
+ server.close(() => resolve4(true));
2477
+ });
2478
+ server.listen(port, "127.0.0.1");
2479
+ });
2480
+ }
2481
+ function waitForHttpReady(baseUrl, token, timeoutMs) {
2482
+ const deadline = Date.now() + timeoutMs;
2483
+ const url = new URL(baseUrl);
2484
+ url.searchParams.set("token", token);
2485
+ url.searchParams.set("shell", "desktop");
2486
+ return new Promise((resolve4, reject) => {
2487
+ let probeTimer;
2488
+ const cleanup = () => {
2489
+ if (probeTimer) {
2490
+ clearTimeout(probeTimer);
2491
+ probeTimer = void 0;
2980
2492
  }
2981
- },
2982
- reset() {
2983
- loggedErrors.clear();
2984
- }
2493
+ };
2494
+ const probe = () => {
2495
+ let done = false;
2496
+ const triggerRetry = () => {
2497
+ if (done) return;
2498
+ done = true;
2499
+ if (Date.now() >= deadline) {
2500
+ reject(new Error(`WebUI did not become ready at ${baseUrl}`));
2501
+ return;
2502
+ }
2503
+ probeTimer = setTimeout(probe, 250);
2504
+ };
2505
+ const req = http.get(url, (res) => {
2506
+ res.resume();
2507
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
2508
+ if (!done) {
2509
+ done = true;
2510
+ cleanup();
2511
+ resolve4();
2512
+ }
2513
+ return;
2514
+ }
2515
+ triggerRetry();
2516
+ });
2517
+ req.once("error", () => {
2518
+ triggerRetry();
2519
+ });
2520
+ req.setTimeout(1e3, () => {
2521
+ req.destroy();
2522
+ triggerRetry();
2523
+ });
2524
+ };
2525
+ probe();
2526
+ });
2527
+ }
2528
+ async function readGlobalProjectManifest() {
2529
+ const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
2530
+ try {
2531
+ const raw = await fs3.readFile(manifestFile, "utf8");
2532
+ return normalizeProjectManifest(JSON.parse(raw));
2533
+ } catch {
2534
+ return [];
2535
+ }
2536
+ }
2537
+ function normalizeProjectManifest(value) {
2538
+ if (Array.isArray(value)) return normalizeProjectEntries(value).slice(0, 80);
2539
+ if (!value || typeof value !== "object") return [];
2540
+ const manifest = value;
2541
+ const source = Array.isArray(manifest.projects) ? manifest.projects : Array.isArray(manifest.recentProjects) ? manifest.recentProjects : Array.isArray(manifest.recents) ? manifest.recents : [];
2542
+ return normalizeProjectEntries(source).slice(0, 80);
2543
+ }
2544
+ function normalizeProjectEntries(value) {
2545
+ if (!Array.isArray(value)) return [];
2546
+ const seen = /* @__PURE__ */ new Set();
2547
+ const projects = [];
2548
+ for (const item of value) {
2549
+ const project = normalizeProjectEntry(item);
2550
+ if (!project) continue;
2551
+ const key = pathKey(project.root);
2552
+ if (seen.has(key)) continue;
2553
+ seen.add(key);
2554
+ projects.push(project);
2555
+ }
2556
+ return projects.sort(
2557
+ (a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
2558
+ );
2559
+ }
2560
+ function normalizeProjectEntry(value) {
2561
+ if (!value || typeof value !== "object") return null;
2562
+ const candidate = value;
2563
+ if (typeof candidate.root !== "string" || !candidate.root.trim()) return null;
2564
+ const root = path5.resolve(candidate.root);
2565
+ const name = typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim() : path5.basename(root) || root;
2566
+ const entry = {
2567
+ name,
2568
+ root,
2569
+ slug: typeof candidate.slug === "string" && candidate.slug.trim() ? candidate.slug.trim() : projectSlug(root)
2985
2570
  };
2571
+ if (typeof candidate.lastSeen === "string" && candidate.lastSeen.trim()) {
2572
+ entry.lastSeen = candidate.lastSeen.trim();
2573
+ }
2574
+ if (typeof candidate.createdAt === "string" && candidate.createdAt.trim()) {
2575
+ entry.createdAt = candidate.createdAt.trim();
2576
+ }
2577
+ if (typeof candidate.lastWorkingDir === "string" && candidate.lastWorkingDir.trim()) {
2578
+ entry.lastWorkingDir = path5.resolve(candidate.lastWorkingDir);
2579
+ }
2580
+ return entry;
2581
+ }
2582
+ async function touchGlobalProjectManifest(entry) {
2583
+ const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
2584
+ const projects = await readGlobalProjectManifest();
2585
+ const existing = projects.find((p) => samePath(p.root, entry.root));
2586
+ if (existing) {
2587
+ existing.name = entry.name;
2588
+ existing.slug = entry.slug;
2589
+ existing.lastSeen = entry.lastSeen;
2590
+ existing.lastWorkingDir = entry.lastWorkingDir;
2591
+ } else {
2592
+ projects.push({ ...entry, createdAt: entry.lastSeen });
2593
+ }
2594
+ const sorted = projects.sort(
2595
+ (a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
2596
+ ).slice(0, 80);
2597
+ await fs3.mkdir(path5.dirname(manifestFile), { recursive: true });
2598
+ await atomicWrite2(manifestFile, `${JSON.stringify({ projects: sorted }, null, 2)}
2599
+ `, {
2600
+ mode: 384
2601
+ });
2602
+ return sorted;
2603
+ }
2604
+ async function removeGlobalProjectManifest(projectRoot) {
2605
+ const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
2606
+ const resolved = path5.resolve(projectRoot);
2607
+ const projects = (await readGlobalProjectManifest()).filter(
2608
+ (project) => !samePath(project.root, resolved)
2609
+ );
2610
+ await fs3.mkdir(path5.dirname(manifestFile), { recursive: true });
2611
+ await atomicWrite2(manifestFile, `${JSON.stringify({ projects }, null, 2)}
2612
+ `, { mode: 384 });
2613
+ return projects;
2614
+ }
2615
+ function samePath(left, right) {
2616
+ const a = path5.resolve(left);
2617
+ const b = path5.resolve(right);
2618
+ return os.platform() === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
2986
2619
  }
2987
2620
 
2988
- // src/main/validation/schemas.ts
2989
- import { statSync } from "node:fs";
2990
- import * as path6 from "node:path";
2991
- import { z } from "zod";
2992
- var RUNTIME_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,120}$/;
2993
- var runtimeIdSchema = z.string().regex(RUNTIME_ID_PATTERN, "Invalid runtime ID format");
2994
- var pathSchema = z.string().min(1).max(1e4);
2995
- var projectRootSchema = pathSchema.refine((value) => !value.includes("\0"), {
2996
- message: "path must not contain a NUL byte"
2997
- }).refine(
2998
- (value) => {
2999
- try {
3000
- return statSync(path6.resolve(value)).isDirectory();
3001
- } catch {
3002
- return false;
3003
- }
3004
- },
3005
- { message: "path must be an existing directory" }
3006
- );
3007
- var booleanSchema = z.boolean();
3008
- var numberSchema = z.number().int().finite();
3009
- var openProjectSchema = z.object({
3010
- requestedRoot: pathSchema.optional()
3011
- });
3012
- var registerProjectSchema = z.object({
3013
- requestedRoot: pathSchema.optional()
3014
- });
3015
- var unregisterProjectSchema = z.object({
3016
- root: pathSchema
3017
- });
3018
- var openProjectSessionSchema = z.object({
3019
- runtimeId: runtimeIdSchema.optional()
3020
- });
3021
- var activateRuntimeSchema = z.object({
3022
- id: runtimeIdSchema
3023
- });
3024
- var closeRuntimeSchema = z.object({
3025
- id: runtimeIdSchema
3026
- });
3027
- var sendMessageSchema = z.object({
3028
- id: runtimeIdSchema,
3029
- content: z.string()
3030
- });
3031
- var abortRuntimeSchema = z.object({
3032
- id: runtimeIdSchema
3033
- });
3034
- var openRuntimeInBrowserSchema = z.object({
3035
- id: runtimeIdSchema
3036
- });
3037
- var revealRuntimeRootSchema = z.object({
3038
- id: runtimeIdSchema
3039
- });
3040
- var setShellSidebarCollapsedSchema = z.object({
3041
- collapsed: booleanSchema
3042
- });
3043
- var navigateWebuiSchema = z.object({
3044
- command: z.unknown()
3045
- });
3046
- var getConversationSchema = z.object({
3047
- runtimeId: runtimeIdSchema
3048
- });
3049
- var webuiReadyChangedSchema = z.object({
3050
- ready: booleanSchema
3051
- });
3052
- var webuiPrefsChangedSchema = z.object({
3053
- prefs: z.record(z.string(), z.unknown())
3054
- });
3055
- var webuiCommandAckSchema = z.object({
3056
- requestId: z.string(),
3057
- handled: z.boolean(),
3058
- message: z.string().optional()
3059
- });
3060
- var setLocaleSchema = z.object({
3061
- locale: z.string().min(2).max(10)
3062
- });
2621
+ // src/main/runtime/operations.ts
2622
+ async function openProject(ctx, requestedRoot) {
2623
+ let projectRoot = requestedRoot;
2624
+ if (!projectRoot) {
2625
+ projectRoot = await ctx.chooseProjectRoot("open");
2626
+ }
2627
+ if (!projectRoot) return ctx.getRuntimeManager().snapshot();
2628
+ await ctx.getRuntimeManager().openProject(projectRoot);
2629
+ ctx.syncActiveWebuiView();
2630
+ ctx.broadcastState();
2631
+ return ctx.getRuntimeManager().snapshot();
2632
+ }
2633
+ async function registerProject(ctx, requestedRoot) {
2634
+ let projectRoot = requestedRoot;
2635
+ if (!projectRoot) {
2636
+ projectRoot = await ctx.chooseProjectRoot("register");
2637
+ }
2638
+ if (!projectRoot) return ctx.getRuntimeManager().snapshot();
2639
+ await ctx.getRuntimeManager().registerProject(projectRoot);
2640
+ ctx.broadcastState();
2641
+ return ctx.getRuntimeManager().snapshot();
2642
+ }
2643
+ async function unregisterProject(ctx, root) {
2644
+ if (!root || typeof root !== "string") return ctx.getRuntimeManager().snapshot();
2645
+ await ctx.getRuntimeManager().unregisterProject(root);
2646
+ ctx.broadcastState();
2647
+ return ctx.getRuntimeManager().snapshot();
2648
+ }
2649
+ async function openProjectSession(ctx, runtimeId) {
2650
+ const snapshot = ctx.getRuntimeManager().snapshot();
2651
+ const runtime = (runtimeId ? snapshot.runtimes.find((candidate) => candidate.id === runtimeId) : void 0) ?? snapshot.runtimes.find((candidate) => candidate.id === snapshot.activeRuntimeId);
2652
+ if (runtime?.kind !== "project") {
2653
+ return openProject(ctx);
2654
+ }
2655
+ await ctx.getRuntimeManager().openProject(runtime.root, { forceNew: true });
2656
+ ctx.syncActiveWebuiView();
2657
+ ctx.broadcastState();
2658
+ return ctx.getRuntimeManager().snapshot();
2659
+ }
2660
+ async function openSettings(ctx) {
2661
+ const snapshot = ctx.getRuntimeManager().snapshot();
2662
+ const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
2663
+ if (!active || active.kind === "global-settings" || active.status !== "running") {
2664
+ const root = desktopSettingsWorkspaceRoot();
2665
+ await fs4.mkdir(root, { recursive: true });
2666
+ await ctx.getRuntimeManager().openProject(root, {
2667
+ name: "Global Settings",
2668
+ kind: "global-settings",
2669
+ touchRecent: false
2670
+ });
2671
+ ctx.syncActiveWebuiView();
2672
+ ctx.broadcastState();
2673
+ }
2674
+ await ctx.dispatchWebuiCommand({ view: "settings" });
2675
+ return ctx.getRuntimeManager().snapshot();
2676
+ }
2677
+ async function activateRuntime(ctx, id) {
2678
+ await ctx.getRuntimeManager().activateRuntime(id);
2679
+ ctx.syncActiveWebuiView();
2680
+ ctx.broadcastState();
2681
+ return ctx.getRuntimeManager().snapshot();
2682
+ }
2683
+ async function closeRuntime(ctx, id) {
2684
+ ctx.getAgentBridge().close(id);
2685
+ await ctx.getRuntimeManager().closeRuntime(id);
2686
+ ctx.syncActiveWebuiView();
2687
+ ctx.broadcastState();
2688
+ return ctx.getRuntimeManager().snapshot();
2689
+ }
2690
+ async function restoreLastWorkspace(ctx) {
2691
+ await ctx.getRuntimeManager().restoreLastWorkspace();
2692
+ ctx.syncActiveWebuiView();
2693
+ ctx.broadcastState();
2694
+ }
3063
2695
 
3064
- // src/main/ipc-handlers/index.ts
3065
- var validationLogger = createValidationLogger("IPC");
3066
- function registerIpcHandlers(ctx) {
3067
- ipcMain.handle(IPC.getState, () => ctx.getRuntimeManager().snapshot());
3068
- ipcMain.handle(IPC.getConversation, (_event, runtimeId) => {
3069
- const result = validate(runtimeIdSchema, runtimeId);
3070
- if (!result.success) {
3071
- validationLogger.log(`getConversation: ${result.error}`);
3072
- return ctx.getAgentBridge().snapshot("");
2696
+ // src/main/webui/controller.ts
2697
+ import { shell, WebContentsView } from "electron";
2698
+
2699
+ // src/main/webui-command-bridge.ts
2700
+ var DESKTOP_WEBUI_ACTIONS = /* @__PURE__ */ new Set([
2701
+ "new-session",
2702
+ "clear-context",
2703
+ "compact-context",
2704
+ "repair-context",
2705
+ "download-chat",
2706
+ "focus-chat",
2707
+ "open-command-palette",
2708
+ "open-shortcuts",
2709
+ "search-chat",
2710
+ "open-model-switcher",
2711
+ "open-prompt-library"
2712
+ ]);
2713
+ var DESKTOP_WEBUI_VIEWS = /* @__PURE__ */ new Set([
2714
+ "chat",
2715
+ "settings",
2716
+ "goal",
2717
+ "sddhub",
2718
+ "files",
2719
+ "changes",
2720
+ "sessions",
2721
+ "setup",
2722
+ "skill",
2723
+ "roster",
2724
+ "mailbox",
2725
+ "debug",
2726
+ "design-gallery",
2727
+ "refresh-debug",
2728
+ "analytics"
2729
+ ]);
2730
+ var DESKTOP_WEBUI_ACTIVITIES = /* @__PURE__ */ new Set([
2731
+ "chat",
2732
+ "agents",
2733
+ "history",
2734
+ "files",
2735
+ "changes",
2736
+ "mailbox",
2737
+ "skills",
2738
+ "design"
2739
+ ]);
2740
+ var DESKTOP_WEBUI_OVERLAYS = /* @__PURE__ */ new Set([
2741
+ "fleet",
2742
+ "agents-monitor",
2743
+ "processes",
2744
+ "queue"
2745
+ ]);
2746
+ var DESKTOP_WEBUI_DOCKS = /* @__PURE__ */ new Set([
2747
+ "goal",
2748
+ "fleet",
2749
+ "work",
2750
+ "worktrees",
2751
+ "collab"
2752
+ ]);
2753
+ var DESKTOP_WEBUI_WORK_TABS = /* @__PURE__ */ new Set(["todos", "tasks", "plan"]);
2754
+ var DESKTOP_WEBUI_PREF_KEYS = /* @__PURE__ */ new Set([
2755
+ "yolo",
2756
+ "nextPrediction",
2757
+ "contextAutoCompact"
2758
+ ]);
2759
+ function normalizeDesktopWebuiCommand(value) {
2760
+ if (!isRecord3(value)) return null;
2761
+ const command = {};
2762
+ let hasCommand = false;
2763
+ const sessionId = value["sessionId"];
2764
+ if (sessionId !== void 0) {
2765
+ if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > 512) {
2766
+ return null;
3073
2767
  }
3074
- return ctx.getAgentBridge().snapshot(result.data);
3075
- });
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 [];
2768
+ command.sessionId = sessionId;
2769
+ hasCommand = true;
2770
+ }
2771
+ const action = value["action"];
2772
+ if (action !== void 0) {
2773
+ if (typeof action !== "string" || !DESKTOP_WEBUI_ACTIONS.has(action)) {
2774
+ return null;
3082
2775
  }
3083
- return listProjectSessions(result.data);
3084
- });
3085
- ipcMain.handle(IPC.navigateWebui, async (_event, command) => {
3086
- return ctx.dispatchWebuiCommand(command);
3087
- });
3088
- ipcMain.handle(IPC.reloadWebui, async () => ctx.reloadActiveWebuiView());
3089
- ipcMain.handle(IPC.setShellSidebarCollapsed, (_event, collapsed) => {
3090
- const result = validateOrDefault(booleanSchema, collapsed, true);
3091
- ctx.setShellSidebarCollapsed(result);
3092
- return true;
3093
- });
3094
- ipcMain.handle(IPC.openSettings, async () => ctx.openSettings());
3095
- ipcMain.handle(IPC.openProjectSession, async (_event, runtimeId) => {
3096
- const validated = validateOptional(runtimeIdSchema, runtimeId);
3097
- return ctx.openProjectSession(validated);
3098
- });
3099
- ipcMain.handle(IPC.openProject, async (_event, requestedRoot) => {
3100
- const validated = validateOptional(projectRootSchema, requestedRoot);
3101
- return ctx.openProject(validated);
3102
- });
3103
- ipcMain.handle(IPC.registerProject, async (_event, requestedRoot) => {
3104
- const validated = validateOptional(projectRootSchema, requestedRoot);
3105
- return ctx.registerProject(validated);
3106
- });
3107
- ipcMain.handle(IPC.unregisterProject, async (_event, root) => {
3108
- const result = validate(pathSchema, root);
3109
- if (!result.success) {
3110
- validationLogger.log(`unregisterProject: ${result.error}`);
3111
- return ctx.getRuntimeManager().snapshot();
2776
+ command.action = action;
2777
+ hasCommand = true;
2778
+ }
2779
+ const view = value["view"];
2780
+ if (view !== void 0) {
2781
+ if (typeof view !== "string" || !DESKTOP_WEBUI_VIEWS.has(view)) {
2782
+ return null;
3112
2783
  }
3113
- return ctx.unregisterProject(result.data);
3114
- });
3115
- ipcMain.handle(IPC.activateRuntime, async (_event, id) => {
3116
- const result = validate(runtimeIdSchema, id);
3117
- if (!result.success) {
3118
- validationLogger.log(`activateRuntime: ${result.error}`);
3119
- return ctx.getRuntimeManager().snapshot();
2784
+ command.view = view;
2785
+ hasCommand = true;
2786
+ }
2787
+ const activity = value["activity"];
2788
+ if (activity !== void 0) {
2789
+ if (typeof activity !== "string" || !DESKTOP_WEBUI_ACTIVITIES.has(activity)) {
2790
+ return null;
3120
2791
  }
3121
- return ctx.activateRuntime(result.data);
3122
- });
3123
- ipcMain.handle(IPC.closeRuntime, async (_event, id) => {
3124
- const result = validate(runtimeIdSchema, id);
3125
- if (!result.success) {
3126
- validationLogger.log(`closeRuntime: ${result.error}`);
3127
- return ctx.getRuntimeManager().snapshot();
2792
+ command.activity = activity;
2793
+ hasCommand = true;
2794
+ }
2795
+ const overlay = value["overlay"];
2796
+ if (overlay !== void 0) {
2797
+ if (typeof overlay !== "string" || !DESKTOP_WEBUI_OVERLAYS.has(overlay)) {
2798
+ return null;
3128
2799
  }
3129
- return ctx.closeRuntime(result.data);
3130
- });
3131
- ipcMain.handle(IPC.sendMessage, async (_event, id, content) => {
3132
- const idResult = validate(runtimeIdSchema, id);
3133
- if (!idResult.success) {
3134
- validationLogger.log(`sendMessage (id): ${idResult.error}`);
3135
- return ctx.sendMessage("", "", "");
2800
+ command.overlay = overlay;
2801
+ hasCommand = true;
2802
+ }
2803
+ const dockSection = value["dockSection"];
2804
+ if (dockSection !== void 0) {
2805
+ if (typeof dockSection !== "string" || !DESKTOP_WEBUI_DOCKS.has(dockSection)) {
2806
+ return null;
3136
2807
  }
3137
- const contentResult = validate(
3138
- pathSchema.transform(() => String(content ?? "")),
3139
- content
3140
- );
3141
- const safeContent = contentResult.success ? contentResult.data : String(content ?? "");
3142
- return ctx.sendMessage(
3143
- idResult.data,
3144
- ctx.getRuntimeManager().getRuntimeWsUrlWithToken(idResult.data) ?? "",
3145
- safeContent
3146
- );
3147
- });
3148
- ipcMain.handle(IPC.abortRuntime, async (_event, id) => {
3149
- const result = validate(runtimeIdSchema, id);
3150
- if (!result.success) {
3151
- validationLogger.log(`abortRuntime: ${result.error}`);
3152
- return ctx.abortRuntime("", "");
2808
+ command.dockSection = dockSection;
2809
+ hasCommand = true;
2810
+ }
2811
+ const workTab = value["workTab"];
2812
+ if (workTab !== void 0) {
2813
+ if (typeof workTab !== "string" || !DESKTOP_WEBUI_WORK_TABS.has(workTab)) {
2814
+ return null;
3153
2815
  }
3154
- return ctx.abortRuntime(
3155
- result.data,
3156
- ctx.getRuntimeManager().getRuntimeWsUrlWithToken(result.data) ?? ""
3157
- );
3158
- });
3159
- ipcMain.handle(IPC.openRuntimeInBrowser, async (_event, id) => {
3160
- const result = validate(runtimeIdSchema, id);
3161
- if (!result.success) {
3162
- validationLogger.log(`openRuntimeInBrowser: ${result.error}`);
2816
+ command.workTab = workTab;
2817
+ hasCommand = true;
2818
+ }
2819
+ const terminal = value["terminal"];
2820
+ if (terminal !== void 0) {
2821
+ if (terminal !== true && terminal !== false && terminal !== "toggle" && terminal !== "new") {
2822
+ return null;
2823
+ }
2824
+ command.terminal = terminal;
2825
+ hasCommand = true;
2826
+ }
2827
+ const pref = value["pref"];
2828
+ if (pref !== void 0) {
2829
+ if (!isRecord3(pref)) return null;
2830
+ const key = pref["key"];
2831
+ if (typeof key !== "string" || !DESKTOP_WEBUI_PREF_KEYS.has(key)) {
2832
+ return null;
2833
+ }
2834
+ const toggle = pref["toggle"];
2835
+ const prefValue = pref["value"];
2836
+ if (toggle !== void 0 && typeof toggle !== "boolean") return null;
2837
+ if (prefValue !== void 0 && typeof prefValue !== "boolean") return null;
2838
+ if (toggle === void 0 && prefValue === void 0) return null;
2839
+ command.pref = {
2840
+ key,
2841
+ ...typeof prefValue === "boolean" ? { value: prefValue } : {},
2842
+ ...typeof toggle === "boolean" ? { toggle } : {}
2843
+ };
2844
+ hasCommand = true;
2845
+ }
2846
+ return hasCommand ? command : null;
2847
+ }
2848
+ function buildWebuiCommandFallbackScript(command) {
2849
+ const payload = JSON.stringify(command).replace(/</g, "\\u003c");
2850
+ return `window.dispatchEvent(new CustomEvent('wrongstack:desktop-command', { detail: ${payload} })); true;`;
2851
+ }
2852
+ function isRecord3(value) {
2853
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2854
+ }
2855
+
2856
+ // src/main/webui/navigation.ts
2857
+ function allowedExternalProtocol(target) {
2858
+ try {
2859
+ const protocol = new URL(target).protocol;
2860
+ return OPEN_EXTERNAL_ALLOWED_PROTOCOLS.has(protocol) ? protocol : void 0;
2861
+ } catch {
2862
+ return void 0;
2863
+ }
2864
+ }
2865
+ function sameOrigin(candidate, base) {
2866
+ if (!base) return false;
2867
+ try {
2868
+ return new URL(candidate).origin === new URL(base).origin;
2869
+ } catch {
2870
+ return false;
2871
+ }
2872
+ }
2873
+
2874
+ // src/main/webui/controller.ts
2875
+ var DesktopWebuiController = class {
2876
+ constructor(ctx) {
2877
+ this.ctx = ctx;
2878
+ }
2879
+ ctx;
2880
+ views = /* @__PURE__ */ new Map();
2881
+ /** Last live four-tab declaration from each running project WebUI. */
2882
+ openSessions = /* @__PURE__ */ new Map();
2883
+ pendingAcks = /* @__PURE__ */ new Map();
2884
+ activeRuntimeId = null;
2885
+ status = { runtimeId: null, status: "idle" };
2886
+ commandSequence = 0;
2887
+ openExternal(target) {
2888
+ const protocol = allowedExternalProtocol(target);
2889
+ if (!protocol) return;
2890
+ void authorizeDesktopAction(this.ctx.trustBoundary, {
2891
+ capability: "url.open-external",
2892
+ subject: { kind: "url", id: target, attributes: { protocol } },
2893
+ risk: "elevated",
2894
+ // Reached from the WebUI view, whose content is remote and reflects agent
2895
+ // and tool output (WS-SEC-03).
2896
+ origin: "remote-client",
2897
+ metadata: { operation: "webui-navigation" }
2898
+ }).then((decision) => decision.allowed ? void shell.openExternal(target) : void 0).catch(() => void 0);
2899
+ }
2900
+ publishStatus(next) {
2901
+ this.status = next;
2902
+ const shellView2 = this.ctx.getShellView();
2903
+ if (!shellView2 || shellView2.webContents.isDestroyed()) return;
2904
+ shellView2.webContents.send(IPC.webuiStatusChanged, next);
2905
+ }
2906
+ setEntryStatus(entry, next) {
2907
+ const previousPrefs = entry.status.prefs;
2908
+ entry.status = {
2909
+ ...next,
2910
+ ...next.prefs === void 0 && previousPrefs !== void 0 ? { prefs: previousPrefs } : {},
2911
+ // `pendingCommands` is always derived from the entry's own array (source of truth).
2912
+ // Any `next.pendingCommands` value is intentionally overwritten.
2913
+ pendingCommands: entry.pendingCommands.length
2914
+ };
2915
+ if (this.activeRuntimeId === entry.runtimeId) {
2916
+ this.publishStatus(entry.status);
2917
+ this.ctx.onPrefsChanged?.(previousPrefs, entry.status.prefs);
2918
+ }
2919
+ }
2920
+ openSessionSnapshots() {
2921
+ return [...this.openSessions].map(([runtimeId, sessions]) => ({ runtimeId, sessions }));
2922
+ }
2923
+ setOpenSessions(runtimeId, sessions) {
2924
+ if (sessions.length === 0) this.openSessions.delete(runtimeId);
2925
+ else this.openSessions.set(runtimeId, sessions);
2926
+ this.ctx.onOpenSessionsChanged?.({ runtimeId, sessions });
2927
+ }
2928
+ pruneOpenSessions(liveRuntimeIds) {
2929
+ for (const runtimeId of [...this.openSessions.keys()]) {
2930
+ if (liveRuntimeIds.has(runtimeId)) continue;
2931
+ this.openSessions.delete(runtimeId);
2932
+ this.ctx.onOpenSessionsChanged?.({ runtimeId, sessions: [] });
2933
+ }
2934
+ }
2935
+ ensure(runtimeId) {
2936
+ const mainWindow2 = this.ctx.getMainWindow();
2937
+ if (!mainWindow2) return null;
2938
+ const existing = this.views.get(runtimeId);
2939
+ if (existing) return existing;
2940
+ const view = new WebContentsView({
2941
+ webPreferences: {
2942
+ preload: webuiPreloadPath(),
2943
+ contextIsolation: true,
2944
+ nodeIntegration: false,
2945
+ // This is the view that renders agent output, tool results, file
2946
+ // contents, and fetched pages — i.e. the most attacker-influenceable
2947
+ // surface in the app, and the one that most needs process-level
2948
+ // containment rather than only bridge-level. webui-preload.ts imports
2949
+ // just electron's contextBridge/ipcRenderer and a constants map, so it
2950
+ // is already within the sandboxed preload subset (WS-093).
2951
+ sandbox: true
2952
+ }
2953
+ });
2954
+ const entry = {
2955
+ runtimeId,
2956
+ view,
2957
+ url: null,
2958
+ status: { runtimeId, status: "idle" },
2959
+ bridgeReady: false,
2960
+ attached: false,
2961
+ pendingCommands: [],
2962
+ pendingFlushTimer: null,
2963
+ pendingFlushAttempts: 0
2964
+ };
2965
+ view.webContents.setWindowOpenHandler(({ url }) => {
2966
+ this.openExternal(url);
2967
+ return { action: "deny" };
2968
+ });
2969
+ view.webContents.on("will-navigate", (event, url) => {
2970
+ if (sameOrigin(url, entry.url)) return;
2971
+ event.preventDefault();
2972
+ this.openExternal(url);
2973
+ });
2974
+ view.webContents.on("did-start-loading", () => {
2975
+ if (this.views.get(runtimeId) !== entry) return;
2976
+ entry.bridgeReady = false;
2977
+ this.setEntryStatus(entry, { runtimeId, status: "loading" });
2978
+ });
2979
+ view.webContents.on("did-finish-load", () => {
2980
+ if (this.views.get(runtimeId) !== entry) return;
2981
+ this.scheduleFlush(entry);
2982
+ try {
2983
+ entry.view.webContents.send(IPC.webuiLocaleChanged, this.ctx.getLocale());
2984
+ } catch {
2985
+ }
2986
+ });
2987
+ view.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
2988
+ if (this.views.get(runtimeId) !== entry || errorCode === -3) return;
2989
+ this.setEntryStatus(entry, { runtimeId, status: "error", error: errorDescription });
2990
+ });
2991
+ view.webContents.on("render-process-gone", (_event, details) => {
2992
+ if (this.views.get(runtimeId) !== entry) return;
2993
+ this.setEntryStatus(entry, {
2994
+ runtimeId,
2995
+ status: "error",
2996
+ error: `WebUI renderer exited: ${details.reason}`
2997
+ });
2998
+ });
2999
+ this.views.set(runtimeId, entry);
3000
+ return entry;
3001
+ }
3002
+ attach(entry) {
3003
+ const mainWindow2 = this.ctx.getMainWindow();
3004
+ if (!mainWindow2 || entry.attached) return;
3005
+ mainWindow2.contentView.addChildView(entry.view);
3006
+ entry.attached = true;
3007
+ }
3008
+ dispose(entry) {
3009
+ this.views.delete(entry.runtimeId);
3010
+ entry.pendingCommands.length = 0;
3011
+ this.settleRuntimeAcks(entry.runtimeId, false);
3012
+ if (entry.pendingFlushTimer) clearTimeout(entry.pendingFlushTimer);
3013
+ entry.pendingFlushTimer = null;
3014
+ const mainWindow2 = this.ctx.getMainWindow();
3015
+ if (mainWindow2 && entry.attached) mainWindow2.contentView.removeChildView(entry.view);
3016
+ entry.attached = false;
3017
+ if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
3018
+ if (this.activeRuntimeId === entry.runtimeId) this.activeRuntimeId = null;
3019
+ }
3020
+ disposeAll() {
3021
+ for (const entry of [...this.views.values()]) this.dispose(entry);
3022
+ this.openSessions.clear();
3023
+ }
3024
+ findBySenderId(senderId) {
3025
+ return [...this.views.values()].find((entry) => entry.view.webContents.id === senderId);
3026
+ }
3027
+ /**
3028
+ * Point the window at the active runtime's WebUI, and keep no other view.
3029
+ *
3030
+ * Only views whose runtime had STOPPED were released here. A view for a
3031
+ * running-but-background project stayed alive for the life of the app — a
3032
+ * full Chromium renderer process each, hidden purely by setting its width to
3033
+ * zero in `layoutViews`. Ten open projects meant ten renderer processes to
3034
+ * show one, and memory grew with every project ever visited.
3035
+ *
3036
+ * Now exactly one view exists: the active one. Switching projects releases
3037
+ * the previous view and loads the next, which costs a page load on the way
3038
+ * back — the deliberate trade for a footprint that does not grow with how
3039
+ * many projects are open. The shell covers that load with its own
3040
+ * `loading` state, so the gap is visible as progress rather than as a blank
3041
+ * window.
3042
+ *
3043
+ * Queued WebUI commands are not at risk: `dispatch` only ever targets
3044
+ * `activeEntry()`, so a disposed background view cannot have had any.
3045
+ */
3046
+ syncActive() {
3047
+ if (!this.ctx.getMainWindow()) return;
3048
+ const snapshot = this.ctx.manager.snapshot();
3049
+ this.pruneOpenSessions(
3050
+ new Set(
3051
+ snapshot.runtimes.filter((runtime) => runtime.status === "running" || runtime.status === "starting").map((runtime) => runtime.id)
3052
+ )
3053
+ );
3054
+ const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
3055
+ const keep = active?.status === "running" ? active.id : null;
3056
+ for (const [id, entry2] of this.views) if (id !== keep) this.dispose(entry2);
3057
+ if (active?.status !== "running") {
3058
+ this.activeRuntimeId = active?.id ?? null;
3059
+ this.publishStatus({ runtimeId: active?.id ?? null, status: "idle" });
3060
+ this.ctx.layoutViews();
3163
3061
  return;
3164
3062
  }
3165
- const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(result.data);
3166
- if (url) ctx.openExternal(url);
3167
- });
3168
- ipcMain.handle(IPC.revealRuntimeRoot, async (_event, id) => {
3169
- const result = validate(runtimeIdSchema, id);
3170
- if (!result.success) {
3171
- validationLogger.log(`revealRuntimeRoot: ${result.error}`);
3063
+ const url = this.ctx.manager.getRuntimeUrlWithToken(active.id);
3064
+ if (!url) {
3065
+ this.activeRuntimeId = active.id;
3066
+ this.publishStatus({ runtimeId: active.id, status: "idle" });
3067
+ this.ctx.layoutViews();
3172
3068
  return;
3173
3069
  }
3174
- const runtime = ctx.getRuntimeManager().getRuntime(result.data);
3175
- if (runtime) ctx.revealInExplorer(runtime.root);
3176
- });
3177
- ipcMain.on(IPC.webuiReadyChanged, (event, ready) => {
3178
- const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
3070
+ const entry = this.ensure(active.id);
3179
3071
  if (!entry) return;
3180
- const isReady = ready === true;
3181
- entry.bridgeReady = isReady;
3182
- if (entry.bridgeReady) {
3183
- ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "ready" });
3184
- ctx.schedulePendingWebuiFlush(entry);
3185
- } else if (entry.status.status === "ready") {
3186
- ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "loading" });
3072
+ this.activeRuntimeId = active.id;
3073
+ this.attach(entry);
3074
+ this.ctx.layoutViews();
3075
+ this.publishStatus(entry.status);
3076
+ if (entry.url === url) return;
3077
+ entry.url = url;
3078
+ entry.bridgeReady = false;
3079
+ this.setEntryStatus(entry, { runtimeId: active.id, status: "loading" });
3080
+ void entry.view.webContents.loadURL(url).catch((error) => {
3081
+ this.setEntryStatus(entry, {
3082
+ runtimeId: active.id,
3083
+ status: "error",
3084
+ error: error instanceof Error ? error.message : String(error)
3085
+ });
3086
+ });
3087
+ }
3088
+ broadcastLocale(locale) {
3089
+ for (const entry of this.views.values()) {
3090
+ if (!entry.view.webContents.isDestroyed())
3091
+ entry.view.webContents.send(IPC.webuiLocaleChanged, locale);
3187
3092
  }
3188
- });
3189
- ipcMain.on(IPC.webuiPrefsChanged, (event, prefs) => {
3190
- const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
3191
- if (!entry) return;
3192
- const sanitized = sanitizeWebuiPrefs(prefs);
3193
- if (Object.keys(sanitized).length === 0) return;
3194
- ctx.setEntryWebuiStatus(entry, {
3195
- ...entry.status,
3196
- prefs: { ...entry.status.prefs ?? {}, ...sanitized }
3093
+ }
3094
+ activeEntry() {
3095
+ const id = this.ctx.manager.snapshot().activeRuntimeId;
3096
+ return id ? this.views.get(id) : void 0;
3097
+ }
3098
+ async dispatch(commandInput) {
3099
+ const command = normalizeDesktopWebuiCommand(commandInput);
3100
+ if (!command) return false;
3101
+ const entry = this.activeEntry();
3102
+ if (!entry?.url) return false;
3103
+ if (entry.status.status !== "ready" || !entry.bridgeReady) {
3104
+ if (!entry.view.webContents.isLoading() && entry.status.status !== "error")
3105
+ return this.dispatchNow(entry, command);
3106
+ this.queue(entry, command);
3107
+ this.scheduleFlush(entry);
3108
+ return true;
3109
+ }
3110
+ return this.dispatchNow(entry, command);
3111
+ }
3112
+ async reload() {
3113
+ const entry = this.activeEntry();
3114
+ if (!entry?.url) return false;
3115
+ entry.bridgeReady = false;
3116
+ this.setEntryStatus(entry, { runtimeId: entry.runtimeId, status: "loading" });
3117
+ return entry.view.webContents.loadURL(entry.url).then(() => true).catch((error) => {
3118
+ this.setEntryStatus(entry, {
3119
+ runtimeId: entry.runtimeId,
3120
+ status: "error",
3121
+ error: error instanceof Error ? error.message : String(error)
3122
+ });
3123
+ return false;
3197
3124
  });
3198
- });
3199
- ipcMain.on(
3200
- IPC.webuiCommandAck,
3201
- (event, requestId, handled, _message) => {
3202
- const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
3203
- if (!entry) return;
3204
- const result = validate(webuiCommandAckSchema, {
3205
- requestId,
3206
- handled,
3207
- message: _message
3125
+ }
3126
+ queue(entry, command) {
3127
+ entry.pendingCommands.push(command);
3128
+ if (entry.pendingCommands.length > MAX_PENDING_WEBUI_COMMANDS)
3129
+ entry.pendingCommands.splice(0, entry.pendingCommands.length - MAX_PENDING_WEBUI_COMMANDS);
3130
+ entry.pendingFlushAttempts = 0;
3131
+ this.setEntryStatus(entry, entry.status);
3132
+ }
3133
+ dispatchNow(entry, command) {
3134
+ if (this.views.get(entry.runtimeId) !== entry || !entry.url) return Promise.resolve(false);
3135
+ const requestId = `${entry.runtimeId}:${Date.now()}:${++this.commandSequence}`;
3136
+ const outbound = { ...command, requestId };
3137
+ return new Promise((resolve4) => {
3138
+ const fallbackTimer = setTimeout(() => {
3139
+ if (!this.pendingAcks.has(requestId)) return;
3140
+ if (this.views.get(entry.runtimeId) !== entry || entry.view.webContents.isDestroyed())
3141
+ return;
3142
+ void entry.view.webContents.executeJavaScript(buildWebuiCommandFallbackScript(outbound), true).catch(() => void 0);
3143
+ }, WEBUI_COMMAND_FALLBACK_MS);
3144
+ const timer = setTimeout(
3145
+ () => this.settleAck(requestId, false),
3146
+ WEBUI_COMMAND_ACK_TIMEOUT_MS
3147
+ );
3148
+ this.pendingAcks.set(requestId, {
3149
+ runtimeId: entry.runtimeId,
3150
+ timer,
3151
+ fallbackTimer,
3152
+ resolve: resolve4
3208
3153
  });
3209
- if (!result.success) {
3210
- validationLogger.log(`webuiCommandAck: ${result.error}`);
3154
+ try {
3155
+ entry.view.webContents.send(IPC.webuiCommand, outbound);
3156
+ if (this.activeRuntimeId === entry.runtimeId) entry.view.webContents.focus();
3157
+ } catch {
3158
+ this.settleAck(requestId, false);
3159
+ }
3160
+ });
3161
+ }
3162
+ settleAck(requestId, handled) {
3163
+ const pending = this.pendingAcks.get(requestId);
3164
+ if (!pending) return;
3165
+ this.pendingAcks.delete(requestId);
3166
+ clearTimeout(pending.timer);
3167
+ if (pending.fallbackTimer) clearTimeout(pending.fallbackTimer);
3168
+ if (handled) {
3169
+ const entry = this.views.get(pending.runtimeId);
3170
+ if (entry) {
3171
+ entry.bridgeReady = true;
3172
+ this.setEntryStatus(entry, { ...entry.status, status: "ready" });
3173
+ }
3174
+ }
3175
+ pending.resolve(handled);
3176
+ }
3177
+ settleRuntimeAcks(runtimeId, handled) {
3178
+ for (const [id, pending] of [...this.pendingAcks])
3179
+ if (pending.runtimeId === runtimeId) this.settleAck(id, handled);
3180
+ }
3181
+ scheduleFlush(entry) {
3182
+ if (entry.pendingFlushTimer) return;
3183
+ entry.pendingFlushTimer = setTimeout(() => {
3184
+ entry.pendingFlushTimer = null;
3185
+ void this.flush(entry);
3186
+ }, 250);
3187
+ }
3188
+ async flush(entry) {
3189
+ if (this.views.get(entry.runtimeId) !== entry || entry.pendingCommands.length === 0) return;
3190
+ if (!entry.bridgeReady) {
3191
+ entry.pendingFlushAttempts += 1;
3192
+ const shouldExecuteFallback = !entry.view.webContents.isLoading() && entry.pendingFlushAttempts >= 4;
3193
+ if (!shouldExecuteFallback && entry.pendingFlushAttempts <= MAX_PENDING_FLUSH_ATTEMPTS) {
3194
+ this.scheduleFlush(entry);
3195
+ this.setEntryStatus(entry, entry.status);
3196
+ return;
3197
+ }
3198
+ if (!shouldExecuteFallback) {
3199
+ entry.pendingCommands.length = 0;
3200
+ this.setEntryStatus(entry, {
3201
+ runtimeId: entry.runtimeId,
3202
+ status: "error",
3203
+ error: "WebUI command bridge did not become ready."
3204
+ });
3211
3205
  return;
3212
3206
  }
3213
- const pending = ctx.getPendingWebuiCommandAcks().get(result.data.requestId);
3214
- if (!pending || pending.runtimeId !== entry.runtimeId) return;
3215
- ctx.settlePendingWebuiCommandAck(result.data.requestId, result.data.handled);
3216
3207
  }
3217
- );
3218
- ipcMain.on(IPC.setLocale, (_event, locale) => {
3219
- const result = validate(setLocaleSchema, { locale });
3220
- if (!result.success) {
3221
- validationLogger.log(`setLocale: ${result.error}`);
3222
- return;
3208
+ entry.pendingFlushAttempts = 0;
3209
+ const commands = entry.pendingCommands.splice(0);
3210
+ this.setEntryStatus(entry, entry.status);
3211
+ for (const command of commands) await this.dispatchNow(entry, command).catch(() => void 0);
3212
+ }
3213
+ };
3214
+
3215
+ // src/main/window-state-controller.ts
3216
+ var DesktopWindowStateController = class {
3217
+ constructor(ctx) {
3218
+ this.ctx = ctx;
3219
+ }
3220
+ ctx;
3221
+ saveTimer = null;
3222
+ scheduleSave() {
3223
+ if (this.saveTimer) clearTimeout(this.saveTimer);
3224
+ this.saveTimer = setTimeout(() => {
3225
+ this.saveTimer = null;
3226
+ void this.save();
3227
+ }, 350);
3228
+ }
3229
+ async save() {
3230
+ const window = this.ctx.getWindow();
3231
+ if (!window || window.isDestroyed?.()) return;
3232
+ const bounds = window.getNormalBounds();
3233
+ await this.ctx.save({ ...bounds, maximized: window.isMaximized() });
3234
+ }
3235
+ validated(state) {
3236
+ if (!state || !Number.isFinite(state.width) || !Number.isFinite(state.height) || state.width < MIN_WINDOW_WIDTH || state.height < MIN_WINDOW_HEIGHT)
3237
+ return null;
3238
+ if (state.x === void 0 || state.y === void 0) {
3239
+ return { width: state.width, height: state.height, maximized: state.maximized };
3223
3240
  }
3224
- ctx.getI18n().setMainLocale(result.data.locale);
3225
- ctx.configureApplicationMenu();
3226
- ctx.broadcastLocaleToEmbeddedWebuis(result.data.locale);
3227
- void ctx.getConfigIo().writeUiLocale(result.data.locale);
3228
- });
3229
- }
3230
- function validateOptional(schema, value) {
3231
- if (value === void 0 || value === null) return void 0;
3232
- const result = schema.safeParse(value);
3233
- return result.success ? result.data : void 0;
3234
- }
3235
- function sanitizeWebuiPrefs(prefs) {
3236
- const next = {};
3237
- if (!isRecord2(prefs)) return next;
3238
- if (typeof prefs["yolo"] === "boolean") next.yolo = prefs["yolo"];
3239
- if (typeof prefs["nextPrediction"] === "boolean") next.nextPrediction = prefs["nextPrediction"];
3240
- if (typeof prefs["contextAutoCompact"] === "boolean") {
3241
- next.contextAutoCompact = prefs["contextAutoCompact"];
3241
+ const candidate = { x: state.x, y: state.y, width: state.width, height: state.height };
3242
+ return this.ctx.getDisplays().some(({ workArea }) => intersects(candidate, workArea)) ? {
3243
+ x: state.x,
3244
+ y: state.y,
3245
+ width: state.width,
3246
+ height: state.height,
3247
+ maximized: state.maximized
3248
+ } : null;
3242
3249
  }
3243
- return next;
3244
- }
3245
- function isRecord2(value) {
3246
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3250
+ };
3251
+ function intersects(left, right) {
3252
+ return left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
3247
3253
  }
3248
3254
 
3249
3255
  // src/main/main.ts
@@ -3253,7 +3259,7 @@ if (process.platform === "win32") {
3253
3259
  }
3254
3260
  app2.setPath(
3255
3261
  "userData",
3256
- path7.join(
3262
+ path6.join(
3257
3263
  resolveWstackPaths3({ projectRoot: process.cwd() }).configDir,
3258
3264
  "desktop",
3259
3265
  "electron-profile"
@@ -3275,6 +3281,10 @@ var webuiController = new DesktopWebuiController({
3275
3281
  layoutViews: () => layoutWebuiViews(),
3276
3282
  onPrefsChanged: (previous, next) => {
3277
3283
  if (menuRelevantPrefsChanged(previous, next)) configureApplicationMenu2();
3284
+ },
3285
+ onOpenSessionsChanged: (snapshot) => {
3286
+ if (!shellView || shellView.webContents.isDestroyed()) return;
3287
+ shellView.webContents.send(IPC.openSessionsChanged, snapshot);
3278
3288
  }
3279
3289
  });
3280
3290
  var windowStateController = new DesktopWindowStateController({
@@ -3289,6 +3299,7 @@ function safeOpenExternal(target) {
3289
3299
  capability: "url.open-external",
3290
3300
  subject: { kind: "url", id: target, attributes: { protocol } },
3291
3301
  risk: "elevated",
3302
+ origin: "user",
3292
3303
  metadata: { operation: "open-external" }
3293
3304
  }).then((authorization) => {
3294
3305
  if (authorization.allowed) return shell2.openExternal(target);
@@ -3301,13 +3312,14 @@ function revealInExplorer(root) {
3301
3312
  capability: "filesystem.open-native",
3302
3313
  subject: { kind: "path", id: root, attributes: { target: "file-manager" } },
3303
3314
  risk: "elevated",
3315
+ origin: "user",
3304
3316
  cwd: root,
3305
3317
  metadata: { operation: "reveal-in-explorer" }
3306
3318
  }).then((authorization) => {
3307
3319
  if (!authorization.allowed) return;
3308
3320
  return shell2.openPath(root).catch((err) => {
3309
3321
  if (process.platform === "darwin") {
3310
- void shell2.openPath(path7.dirname(root)).catch(() => void 0);
3322
+ void shell2.openPath(path6.dirname(root)).catch(() => void 0);
3311
3323
  }
3312
3324
  console.error(
3313
3325
  JSON.stringify({
@@ -3446,6 +3458,7 @@ function buildIpcHandlerContext() {
3446
3458
  getShellView: () => shellView,
3447
3459
  getWebuiViews: () => webuiController.views,
3448
3460
  getWebuiStatus: () => webuiController.status,
3461
+ getOpenSessions: () => webuiController.openSessionSnapshots(),
3449
3462
  getRuntimeManager: () => manager,
3450
3463
  getAgentBridge: () => bridge,
3451
3464
  getI18n: () => ({ getMainLocale, setMainLocale, tMain }),
@@ -3479,7 +3492,8 @@ function buildIpcHandlerContext() {
3479
3492
  getPendingWebuiCommandAcks: () => webuiController.pendingAcks,
3480
3493
  settlePendingWebuiCommandAck: (requestId, handled) => webuiController.settleAck(requestId, handled),
3481
3494
  setEntryWebuiStatus: (entry, next) => webuiController.setEntryStatus(entry, next),
3482
- schedulePendingWebuiFlush: (entry) => webuiController.scheduleFlush(entry)
3495
+ schedulePendingWebuiFlush: (entry) => webuiController.scheduleFlush(entry),
3496
+ setOpenSessions: (runtimeId, sessions) => webuiController.setOpenSessions(runtimeId, sessions)
3483
3497
  };
3484
3498
  }
3485
3499
  async function boot() {
@@ -3519,8 +3533,8 @@ async function boot() {
3519
3533
  width: prevState?.width ?? defaultWidth,
3520
3534
  height: prevState?.height ?? defaultHeight,
3521
3535
  show: false,
3522
- minWidth: MIN_WINDOW_WIDTH2,
3523
- minHeight: MIN_WINDOW_HEIGHT2,
3536
+ minWidth: MIN_WINDOW_WIDTH,
3537
+ minHeight: MIN_WINDOW_HEIGHT,
3524
3538
  title: tMain("windowTitle"),
3525
3539
  ...appIcon ? { icon: appIcon } : {}
3526
3540
  };