@wrongstack/desktop 1.0.3 → 1.0.6

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