local-operator-ui 0.23.0 → 0.23.1

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.
Files changed (61) hide show
  1. package/out/main/index.js +627 -741
  2. package/out/renderer/assets/{_basePickBy-_D80de5G.js → _basePickBy-mmFlh3ho.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-2CVM3wP6.js → _baseUniq-DAWnt4WG.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-DDzWQkTU.js → agent-details-page-CO72YkSX.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-BJZLQyGG.js → agent-hub-page-DhYc5_gP.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DemONOpM.js → agents-page-BopOzax_.js} +2 -2
  7. package/out/renderer/assets/{arc-5WLfklOA.js → arc-BgHsUFIt.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-2esPWMfK.js → architectureDiagram-IEHRJDOE-BWxIiACo.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CxEr3yHk.js → blockDiagram-JOT3LUYC-CoJDUfWc.js} +1 -1
  10. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CW3c7Z5k.js → c4Diagram-VJAJSXHY-B8vXmmLx.js} +1 -1
  11. package/out/renderer/assets/channel-DAvIgUhZ.js +1 -0
  12. package/out/renderer/assets/{chunk-4BMEZGHF-D_Bp0wT-.js → chunk-4BMEZGHF-rO5ve7XY.js} +1 -1
  13. package/out/renderer/assets/{chunk-A2AXSNBT-DmJd08BZ.js → chunk-A2AXSNBT-CTPb1xgY.js} +1 -1
  14. package/out/renderer/assets/{chunk-AEK57VVT-ujtyZ3tC.js → chunk-AEK57VVT-5VpTdLBm.js} +1 -1
  15. package/out/renderer/assets/{chunk-D6G4REZN-Dsw-P_RE.js → chunk-D6G4REZN-C3CEf0kC.js} +1 -1
  16. package/out/renderer/assets/{chunk-RZ5BOZE2-DehW_14d.js → chunk-RZ5BOZE2-B7nn7Euk.js} +1 -1
  17. package/out/renderer/assets/{chunk-XZIHB7SX-DMF0PowC.js → chunk-XZIHB7SX-CnPH3vBT.js} +1 -1
  18. package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +1 -0
  19. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +1 -0
  20. package/out/renderer/assets/clone-CeahOD_w.js +1 -0
  21. package/out/renderer/assets/{compact-pagination-uDiiJTfu.js → compact-pagination-Bu8vshU-.js} +1 -1
  22. package/out/renderer/assets/{dagre-OKDRZEBW-Cis1R8z2.js → dagre-OKDRZEBW-DUX1RPbJ.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-ccEDsRNu.js → diagram-SSKATNLV-Cpv42kzr.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-CvU-J7lk.js → diagram-VNBRO52H-CGkUnKEL.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-CzjCMPxk.js → erDiagram-Q7BY3M3F-iBbJbyJZ.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-B54QUlmV.js → flowDiagram-4HSFHLVR-B569Tv1u.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BGrZ0i8Q.js → ganttDiagram-APWFNJXF-nGbcgsgD.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-rcGPhQBW.js → gitGraphDiagram-7IBYFJ6S-vv_MhteL.js} +1 -1
  29. package/out/renderer/assets/{graph-J0pSfXIT.js → graph-krOMwYSa.js} +1 -1
  30. package/out/renderer/assets/{index-DdfOG3_H.js → index-BgGl2-0g.js} +1 -1
  31. package/out/renderer/assets/{index-DqOACDLL.js → index-BqWgqUKZ.js} +350 -350
  32. package/out/renderer/assets/{index-lFyj7RMx.js → index-CT6w0yXj.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-Bu4VJqUO.js → infoDiagram-PH2N3AL5-DHDA5253.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-CqkMOWse.js → journeyDiagram-U35MCT3I-CIrQ218m.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-6YsTdTHR.js → kanban-definition-NDS4AKOZ-DG_YsZbL.js} +1 -1
  36. package/out/renderer/assets/{layout-DxRkOtZA.js → layout-CB5zJSjt.js} +1 -1
  37. package/out/renderer/assets/{legacy-agents-page-B_TTtHbw.js → legacy-agents-page-B8pvrWy_.js} +1 -1
  38. package/out/renderer/assets/{mermaid.core-BGQGjyBM.js → mermaid.core-CYc-R2hQ.js} +5 -5
  39. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-COAAKLot.js → mindmap-definition-ALO5MXBD-C22fFThl.js} +1 -1
  40. package/out/renderer/assets/{parseISO-CJIN700N.js → parseISO-vr2fT-wq.js} +1 -1
  41. package/out/renderer/assets/{pieDiagram-IB7DONF6-bDFajT15.js → pieDiagram-IB7DONF6-DKRytw7K.js} +1 -1
  42. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BGbdCwMH.js → quadrantDiagram-7GDLP6J5-CWdnqGKr.js} +1 -1
  43. package/out/renderer/assets/{radar-MK3ICKWK-D61WJ-zJ.js → radar-MK3ICKWK-B0XgopGa.js} +1 -1
  44. package/out/renderer/assets/{radient-auth-buttons-XAgZ5KHN.js → radient-auth-buttons-RrBw8fkd.js} +1 -1
  45. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DGNaT_h7.js → requirementDiagram-KVF5MWMF-CXL8GuIo.js} +1 -1
  46. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BDNzMQ43.js → sankeyDiagram-QLVOVGJD-BQKCHfmN.js} +1 -1
  47. package/out/renderer/assets/{schedules-page-Cr0fc2A0.js → schedules-page-DtNhlycC.js} +1 -1
  48. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-ByLl22wr.js → sequenceDiagram-X6HHIX6F-awR0fjHu.js} +1 -1
  49. package/out/renderer/assets/{settings-page-DBLyEih6.js → settings-page-DCLaspsn.js} +1 -1
  50. package/out/renderer/assets/{stateDiagram-DGXRK772-Cs28xqpn.js → stateDiagram-DGXRK772-BjhA_mJe.js} +1 -1
  51. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-nAxLNCrm.js +1 -0
  52. package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cc2py6Fo.js → timeline-definition-BDJGKUSR-BFEDYBl8.js} +1 -1
  53. package/out/renderer/assets/{use-agent-like-mutation-CIJBNaU9.js → use-agent-like-mutation-Bxx_WHvo.js} +1 -1
  54. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-Buajaj4y.js → xychartDiagram-VJFVF3MP-CwLeRa1a.js} +1 -1
  55. package/out/renderer/index.html +1 -1
  56. package/package.json +2 -2
  57. package/out/renderer/assets/channel-BihnEq5U.js +0 -1
  58. package/out/renderer/assets/classDiagram-GIVACNV2-B-F2uCmh.js +0 -1
  59. package/out/renderer/assets/classDiagram-v2-COTLJTTW-B-F2uCmh.js +0 -1
  60. package/out/renderer/assets/clone-Cxm1r7GC.js +0 -1
  61. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CyaNxwAy.js +0 -1
package/out/main/index.js CHANGED
@@ -55,6 +55,11 @@ const profileName = zod.z.string().min(1).max(128).refine(
55
55
  )
56
56
  );
57
57
  const target = zod.z.object({ kind: zod.z.enum(["agent", "team"]), name: profileName }).strict();
58
+ const modelSelection = zod.z.object({
59
+ provider: zod.z.string().min(1).max(128),
60
+ model_id: zod.z.string().min(1).max(512),
61
+ reasoning_effort: zod.z.string().min(1).max(64).nullable()
62
+ }).strict();
58
63
  const profileFields = zod.z.object({
59
64
  kind: zod.z.enum(["role", "specialist"]).optional(),
60
65
  description: zod.z.string().max(8e3).optional(),
@@ -151,7 +156,14 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
151
156
  op: zod.z.literal("sessions.create"),
152
157
  requestId,
153
158
  cwd: zod.z.string().min(1).max(4096),
154
- target: target.optional()
159
+ target: target.optional(),
160
+ /*
161
+ * OMITTED when the user never picked anything, so the body is the one
162
+ * this op sent before the draft's chips could open: making them
163
+ * actionable is strictly additive, and a `null` here would be a
164
+ * different request for every caller that never asked.
165
+ */
166
+ model: modelSelection.optional()
155
167
  }).strict(),
156
168
  /*
157
169
  * What a NEW conversation's readings would be, without creating anything.
@@ -168,7 +180,9 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
168
180
  * disagree with the session the first send creates.
169
181
  *
170
182
  * Body and response mirror `sessions.create` deliberately: same `cwd`, same
171
- * optional `target`, same 422 for an unresolvable profile. The response is a
183
+ * optional `target`, same `model` and same 422 for an unresolvable profile —
184
+ * the pane is asking the question it will ask for real on the first send, so
185
+ * the two bodies are derived from one selection. The response is a
172
186
  * `CanonicalFrontendSync` — the wire shape `sessions.watch` streams — whose
173
187
  * `snapshot.session_id` is EMPTY, because there is no session. The renderer
174
188
  * passes it to the strip and never into the canonical sessions store.
@@ -177,7 +191,11 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
177
191
  op: zod.z.literal("sessions.preview"),
178
192
  requestId,
179
193
  cwd: zod.z.string().min(1).max(4096),
180
- target: target.optional()
194
+ target: target.optional(),
195
+ /* Present only when the pane's chips were used: the preview then answers
196
+ the reading the CHOSEN model gives, which is the ladder and window the
197
+ first turn will actually get. */
198
+ model: modelSelection.optional()
181
199
  }).strict(),
182
200
  zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
183
201
  zod.z.object({
@@ -681,7 +699,8 @@ function desktopEndpoint(request) {
681
699
  body: {
682
700
  request_id: request.requestId,
683
701
  cwd: request.cwd,
684
- ...request.target ? { target: request.target } : {}
702
+ ...request.target ? { target: request.target } : {},
703
+ ...request.model ? { model: request.model } : {}
685
704
  }
686
705
  };
687
706
  case "sessions.preview":
@@ -691,7 +710,8 @@ function desktopEndpoint(request) {
691
710
  body: {
692
711
  request_id: request.requestId,
693
712
  cwd: request.cwd,
694
- ...request.target ? { target: request.target } : {}
713
+ ...request.target ? { target: request.target } : {},
714
+ ...request.model ? { model: request.model } : {}
695
715
  }
696
716
  };
697
717
  case "sessions.get":
@@ -1828,6 +1848,268 @@ const backendConfig = loadConfig();
1828
1848
  const apiConfig = {
1829
1849
  baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
1830
1850
  };
1851
+ const SHEBANG = /^#!([^\r\n]+)\r?\n/;
1852
+ const PYTHON_NAME = /[/\\]python(?:\d+(?:\.\d+)*)?$/;
1853
+ const SERVE_ENTRYPOINT = "from local_operator.cli import main; main()";
1854
+ const PROBE_SCRIPT = "import json, sys; from local_operator.cli import main; print(json.dumps([sys.executable, getattr(sys, '_base_executable', None) or sys.executable, callable(main), [p for p in sys.path if p and p.startswith(sys.prefix)]]))";
1855
+ const DEFAULT_BUDGET = {
1856
+ totalMs: 45e3,
1857
+ attemptMs: 3e4,
1858
+ graceMs: 2e3,
1859
+ slackMs: 2e3
1860
+ };
1861
+ const DISCOVERY_TIMEOUT_MS = 5e3;
1862
+ const INTERPRETER_RESOLUTION_WORST_MS = DEFAULT_BUDGET.totalMs + 2 * (DISCOVERY_TIMEOUT_MS + DEFAULT_BUDGET.graceMs + DEFAULT_BUDGET.slackMs);
1863
+ class ProbeTimeout extends Error {
1864
+ constructor(interpreter, timeoutMs) {
1865
+ super(
1866
+ `${interpreter} did not answer an identity probe within ${timeoutMs} ms`
1867
+ );
1868
+ this.name = "ProbeTimeout";
1869
+ }
1870
+ }
1871
+ const describe = (error) => error instanceof Error ? error.message : String(error);
1872
+ function runBounded(command, args, env, bounds) {
1873
+ return new Promise((resolve, reject) => {
1874
+ const child = node_child_process.spawn(command, args, {
1875
+ env,
1876
+ // Piped, so the answer is read rather than inherited by the app.
1877
+ stdio: ["ignore", "pipe", "pipe"],
1878
+ windowsHide: true
1879
+ });
1880
+ let stdout = "";
1881
+ let stderr = "";
1882
+ child.stdout?.on("data", (chunk) => {
1883
+ stdout += chunk.toString();
1884
+ });
1885
+ child.stderr?.on("data", (chunk) => {
1886
+ stderr += chunk.toString();
1887
+ });
1888
+ let settled = false;
1889
+ let timedOut = false;
1890
+ const timers = [];
1891
+ const settle2 = (finish) => {
1892
+ if (settled) return;
1893
+ settled = true;
1894
+ for (const timer of timers) clearTimeout(timer);
1895
+ timers.length = 0;
1896
+ finish();
1897
+ };
1898
+ timers.push(
1899
+ setTimeout(() => {
1900
+ timedOut = true;
1901
+ child.kill("SIGTERM");
1902
+ timers.push(setTimeout(() => child.kill("SIGKILL"), bounds.graceMs));
1903
+ timers.push(
1904
+ setTimeout(
1905
+ () => settle2(() => reject(new ProbeTimeout(command, bounds.timeoutMs))),
1906
+ bounds.graceMs + bounds.slackMs
1907
+ )
1908
+ );
1909
+ }, bounds.timeoutMs)
1910
+ );
1911
+ child.on("error", (error) => settle2(() => reject(error)));
1912
+ child.on(
1913
+ "close",
1914
+ (code, signal) => settle2(() => {
1915
+ if (timedOut) {
1916
+ reject(new ProbeTimeout(command, bounds.timeoutMs));
1917
+ return;
1918
+ }
1919
+ if (code === 0) {
1920
+ resolve(stdout);
1921
+ return;
1922
+ }
1923
+ const detail = errorTail(stderr);
1924
+ reject(
1925
+ new Error(
1926
+ `exited with code ${code ?? "null"}${signal ? ` (${signal})` : ""}${detail ? `: ${detail}` : ""}`
1927
+ )
1928
+ );
1929
+ })
1930
+ );
1931
+ });
1932
+ }
1933
+ const errorTail = (text, lines = 3) => {
1934
+ const kept = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1935
+ if (kept.length === 0) return "";
1936
+ const tail = kept.slice(-lines).join(" | ");
1937
+ return kept.length > lines ? `… ${tail}` : tail;
1938
+ };
1939
+ function parseIdentity(stdout, interpreter) {
1940
+ const parsed = JSON.parse(stdout.trim());
1941
+ const [executable, baseExecutable, callable, prefixPaths] = parsed;
1942
+ if (typeof executable !== "string" || typeof callable !== "boolean")
1943
+ throw new Error(`${interpreter} reported no usable identity`);
1944
+ return {
1945
+ executable,
1946
+ baseExecutable: baseExecutable || executable,
1947
+ callable,
1948
+ prefixPaths: Array.isArray(prefixPaths) ? prefixPaths : []
1949
+ };
1950
+ }
1951
+ async function probeOnce(interpreter, env, timeoutMs, bounds) {
1952
+ return parseIdentity(
1953
+ await runBounded(interpreter, ["-c", PROBE_SCRIPT], env, {
1954
+ ...bounds,
1955
+ timeoutMs
1956
+ }),
1957
+ interpreter
1958
+ );
1959
+ }
1960
+ async function probeWithin(interpreter, env, deadline2, bounds) {
1961
+ let lastTimeout = null;
1962
+ for (let attempt = 1; ; attempt++) {
1963
+ const remaining = deadline2 - Date.now() - bounds.graceMs - bounds.slackMs;
1964
+ if (remaining <= 0) {
1965
+ if (lastTimeout) throw lastTimeout;
1966
+ throw new Error(
1967
+ `the ${bounds.totalMs} ms interpreter-probe budget ran out before ${interpreter} answered`
1968
+ );
1969
+ }
1970
+ try {
1971
+ return await probeOnce(
1972
+ interpreter,
1973
+ env,
1974
+ Math.min(bounds.attemptMs, remaining),
1975
+ bounds
1976
+ );
1977
+ } catch (error) {
1978
+ if (error instanceof ProbeTimeout) {
1979
+ lastTimeout = error;
1980
+ if (attempt < PROBE_ATTEMPTS && Date.now() < deadline2) continue;
1981
+ }
1982
+ throw error;
1983
+ }
1984
+ }
1985
+ }
1986
+ const PROBE_ATTEMPTS = 2;
1987
+ async function planFor(identity, interpreter, port, env, platform, deadline2, bounds) {
1988
+ if (!identity.callable || !path$1.isAbsolute(identity.executable))
1989
+ throw new Error(
1990
+ `the CLI entrypoint does not import in ${identity.executable || interpreter}`
1991
+ );
1992
+ const args = ["-c", SERVE_ENTRYPOINT, "serve", "--port", String(port)];
1993
+ if (platform !== "win32")
1994
+ return {
1995
+ command: "bash",
1996
+ args: ["-c", 'exec "$@"', "owned-serve", identity.executable, ...args],
1997
+ env
1998
+ };
1999
+ if (identity.executable.toLowerCase() === identity.baseExecutable.toLowerCase())
2000
+ return { command: identity.executable, args, env };
2001
+ if (!path$1.isAbsolute(identity.baseExecutable))
2002
+ throw new Error(
2003
+ `it is a venv redirector whose base interpreter is not an absolute path (${identity.baseExecutable || "nothing"})`
2004
+ );
2005
+ const baseEnv = {
2006
+ ...env,
2007
+ // Windows' own separator, deliberately independent of the host evaluating
2008
+ // the plan: the plan describes the Windows launch, not the machine that
2009
+ // built it, and the test host is not Windows.
2010
+ PYTHONPATH: [...identity.prefixPaths, env.PYTHONPATH].filter(Boolean).join(";")
2011
+ };
2012
+ const base = await probeWithin(
2013
+ identity.baseExecutable,
2014
+ baseEnv,
2015
+ deadline2,
2016
+ bounds
2017
+ );
2018
+ if (!base.callable || base.executable.toLowerCase() !== identity.baseExecutable.toLowerCase())
2019
+ throw new Error(
2020
+ `its base interpreter ${identity.baseExecutable} is not the serving process (${base.callable ? `it reported itself as ${base.executable || "nothing"}` : "it cannot import local_operator.cli even with the venv's own import paths"})`
2021
+ );
2022
+ return { command: identity.baseExecutable, args, env: baseEnv };
2023
+ }
2024
+ async function ownedServeLaunch(interpreters, port, env, platform = process.platform, budget = {}, fallbackClaims) {
2025
+ const bounds = {
2026
+ ...DEFAULT_BUDGET,
2027
+ ...budget
2028
+ };
2029
+ const deadline2 = Date.now() + bounds.totalMs;
2030
+ const rejected = [];
2031
+ const attempt = async (claims) => {
2032
+ for (const interpreter of [...new Set(claims)]) {
2033
+ if (!path$1.isAbsolute(interpreter)) {
2034
+ rejected.push(`${interpreter || "(empty)"}: not an absolute path`);
2035
+ continue;
2036
+ }
2037
+ try {
2038
+ const identity = await probeWithin(interpreter, env, deadline2, bounds);
2039
+ return await planFor(
2040
+ identity,
2041
+ interpreter,
2042
+ port,
2043
+ env,
2044
+ platform,
2045
+ deadline2,
2046
+ bounds
2047
+ );
2048
+ } catch (error) {
2049
+ rejected.push(`${interpreter}: ${describe(error)}`);
2050
+ }
2051
+ }
2052
+ return null;
2053
+ };
2054
+ if (interpreters.length === 0 && !fallbackClaims)
2055
+ throw new Error(
2056
+ "No backend interpreter was resolved. Install the Local Operator backend (`uv tool install local-operator`) or pair an external backend."
2057
+ );
2058
+ const planned = await attempt(interpreters) ?? (fallbackClaims ? await attempt(await fallbackClaims()) : null);
2059
+ if (planned) return planned;
2060
+ throw new Error(
2061
+ `No backend interpreter could be proven. Tried ${rejected.length}: ${rejected.join("; ")}. Install the backend with a supported installer (\`uv tool install local-operator\`) or pair an external backend.`
2062
+ );
2063
+ }
2064
+ async function firstLine(command, args, env) {
2065
+ try {
2066
+ const stdout = await runBounded(command, args, env, {
2067
+ ...DEFAULT_BUDGET,
2068
+ timeoutMs: DISCOVERY_TIMEOUT_MS
2069
+ });
2070
+ const line = stdout.trim().split(/\r?\n/)[0]?.trim();
2071
+ return line ? line : null;
2072
+ } catch {
2073
+ return null;
2074
+ }
2075
+ }
2076
+ async function windowsInterpreterCandidates(consolePath, env) {
2077
+ const directory = path$1.dirname(consolePath);
2078
+ const tool = path$1.basename(consolePath).replace(/\.(exe|cmd|bat)$/i, "");
2079
+ const candidates = [path$1.join(directory, "python.exe")];
2080
+ const uvTools = env.UV_TOOL_DIR ?? (env.APPDATA ? path$1.join(env.APPDATA, "uv", "tools") : null);
2081
+ if (uvTools) candidates.push(path$1.join(uvTools, tool, "Scripts", "python.exe"));
2082
+ const pipxHome = env.PIPX_HOME ?? (env.LOCALAPPDATA ? path$1.join(env.LOCALAPPDATA, "pipx") : null) ?? (env.USERPROFILE ? path$1.join(env.USERPROFILE, "pipx") : null);
2083
+ if (pipxHome)
2084
+ candidates.push(path$1.join(pipxHome, "venvs", tool, "Scripts", "python.exe"));
2085
+ return [...new Set(candidates)].filter(
2086
+ (candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
2087
+ );
2088
+ }
2089
+ async function windowsPathInterpreterCandidates(env) {
2090
+ const found = [];
2091
+ const onPath = await firstLine("where", ["python.exe"], env);
2092
+ if (onPath) found.push(onPath);
2093
+ const viaLauncher = await firstLine(
2094
+ "py",
2095
+ ["-3", "-c", "import sys; print(sys.executable)"],
2096
+ env
2097
+ );
2098
+ if (viaLauncher) found.push(viaLauncher);
2099
+ return [...new Set(found)].filter(
2100
+ (candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
2101
+ );
2102
+ }
2103
+ function consoleInterpreter(consolePath) {
2104
+ const script = fs.readFileSync(consolePath, "utf8");
2105
+ const shebang = SHEBANG.exec(script)?.[1];
2106
+ if (!shebang || !path$1.isAbsolute(shebang) || !PYTHON_NAME.test(shebang) || !script.includes("from local_operator.cli import main")) {
2107
+ throw new Error(
2108
+ "Cannot safely own this backend launcher; use a directly paired external backend"
2109
+ );
2110
+ }
2111
+ return shebang;
2112
+ }
1831
2113
  var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
1832
2114
  LocalOperatorStartupMode2["EXISTING_SERVER"] = "EXISTING_SERVER";
1833
2115
  LocalOperatorStartupMode2["GLOBAL_INSTALL"] = "GLOBAL_INSTALL";
@@ -1835,8 +2117,21 @@ var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
1835
2117
  LocalOperatorStartupMode2["NOT_STARTED"] = "NOT_STARTED";
1836
2118
  return LocalOperatorStartupMode2;
1837
2119
  })(LocalOperatorStartupMode || {});
2120
+ const CONSOLE_DISCOVERY_TIMEOUT_MS = 5e3;
2121
+ const CONSOLE_RESOLUTION_WORST_MS = 2 * CONSOLE_DISCOVERY_TIMEOUT_MS;
2122
+ const SHUTDOWN_TIMEOUT_DEFAULTS = {
2123
+ restart: 1e4,
2124
+ // 10 seconds for restart operations
2125
+ normal: 5e3,
2126
+ // 5 seconds for normal shutdowns
2127
+ force: 3e3
2128
+ // 3 seconds before force killing after SIGKILL
2129
+ };
2130
+ const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
2131
+ const READINESS_POLL_INTERVAL_MS = 1e3;
1838
2132
  const execPromise = node_util.promisify(node_child_process.exec);
1839
2133
  const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
2134
+ const LINE_BREAK = /\r?\n/;
1840
2135
  class BackendServiceManager {
1841
2136
  process = null;
1842
2137
  isRunning = false;
@@ -1848,8 +2143,18 @@ class BackendServiceManager {
1848
2143
  appDataPath = electron.app.getPath("userData");
1849
2144
  venvPath;
1850
2145
  healthCheckInterval = null;
1851
- exitPromise = null;
1852
- exitResolve = null;
2146
+ /** The only process this manager may terminate. Null means it owns nothing,
2147
+ * which is a reason to report "nothing to stop" - never to go looking. */
2148
+ ownedServe = null;
2149
+ /** In-flight start, so a stop can await the resolver/readiness work that has
2150
+ * not yet produced a child. Without it, cleanup can return before a spawn
2151
+ * that was already committed, and the caller installs over a live serve. */
2152
+ startPromise = null;
2153
+ restartPromise = null;
2154
+ /** Bumped by every stop. A start that began under an older epoch cannot
2155
+ * report success or adopt its child: the cancellation happened after it
2156
+ * checked, and this is what it re-checks across each await. */
2157
+ startEpoch = 0;
1853
2158
  shellEnv = {};
1854
2159
  // External/dev backends may be explicitly paired through main's environment.
1855
2160
  // Managed starts always rotate this; it is never exposed by preload or logs.
@@ -1952,14 +2257,7 @@ class BackendServiceManager {
1952
2257
  // Flag to track when the app is being closed
1953
2258
  isAutoUpdating = false;
1954
2259
  // Flag to track when an autoupdate is in progress
1955
- shutdownTimeoutMs = {
1956
- restart: 1e4,
1957
- // 10 seconds for restart operations
1958
- normal: 5e3,
1959
- // 5 seconds for normal shutdowns
1960
- force: 3e3
1961
- // 3 seconds before force killing after SIGKILL
1962
- };
2260
+ shutdownTimeoutMs = { ...SHUTDOWN_TIMEOUT_DEFAULTS };
1963
2261
  // Configurable timeouts for different shutdown scenarios
1964
2262
  /**
1965
2263
  * Constructor
@@ -2246,7 +2544,12 @@ class BackendServiceManager {
2246
2544
  `Checking if local-operator command exists: ${command}`,
2247
2545
  LogFileType.BACKEND
2248
2546
  );
2249
- const { stdout } = await execPromise(command);
2547
+ const { stdout } = await execPromise(command, {
2548
+ // Bounded: a quit that arrives mid-start waits on this same path, and an
2549
+ // unbounded child here is a wait with nothing underneath it.
2550
+ timeout: CONSOLE_DISCOVERY_TIMEOUT_MS,
2551
+ windowsHide: true
2552
+ });
2250
2553
  if (stdout.trim()) {
2251
2554
  logger.info(
2252
2555
  `local-operator command found at: ${stdout.trim()}`,
@@ -2374,12 +2677,30 @@ class BackendServiceManager {
2374
2677
  }
2375
2678
  return false;
2376
2679
  }
2680
+ /** Keep PATH discovery authoritative; `ownedServeLaunch` proves the identity
2681
+ * of what it found rather than trusting the name it was resolved under. */
2682
+ async resolveGlobalConsole(env) {
2683
+ const { stdout } = await execPromise(
2684
+ process.platform === "win32" ? "where local-operator" : "which local-operator",
2685
+ { env, timeout: CONSOLE_DISCOVERY_TIMEOUT_MS }
2686
+ );
2687
+ return stdout.trim().split(LINE_BREAK)[0];
2688
+ }
2377
2689
  /**
2378
2690
  * Start the backend service
2379
2691
  * @returns Promise resolving to true if the backend was started successfully, false otherwise
2380
2692
  */
2381
- async start() {
2382
- this.isAppClosing = false;
2693
+ start() {
2694
+ if (this.isAppClosing) return Promise.resolve(false);
2695
+ if (this.startPromise) return this.startPromise;
2696
+ this.startPromise = this.startOwned(this.startEpoch).finally(() => {
2697
+ this.startPromise = null;
2698
+ });
2699
+ return this.startPromise;
2700
+ }
2701
+ async startOwned(epoch) {
2702
+ if (this.ownedServe?.stop) return false;
2703
+ if (this.ownedServe) return this.isRunning;
2383
2704
  if (this.isDisabled) {
2384
2705
  logger.info(
2385
2706
  "Backend Service Manager is disabled. Skipping backend start.",
@@ -2388,155 +2709,114 @@ class BackendServiceManager {
2388
2709
  this.notifyBackendReady();
2389
2710
  return true;
2390
2711
  }
2391
- if (await this.checkExistingBackend()) {
2712
+ const existing = await this.checkExistingBackend();
2713
+ if (epoch !== this.startEpoch || this.isAppClosing) return false;
2714
+ if (existing) {
2392
2715
  this.isRunning = true;
2393
2716
  this.startupMode = "EXISTING_SERVER";
2394
2717
  this.startHealthCheck();
2395
2718
  return true;
2396
2719
  }
2397
2720
  this.desktopToken = node_crypto.randomBytes(32).toString("hex");
2721
+ let captured = null;
2398
2722
  try {
2399
- if (await this.checkLocalOperatorExists()) {
2400
- logger.info(
2401
- "Using globally installed local-operator command",
2402
- LogFileType.BACKEND
2403
- );
2723
+ const globalInstall = await this.checkLocalOperatorExists();
2724
+ const env = this.backendSpawnEnv();
2725
+ let interpreters;
2726
+ if (globalInstall) {
2404
2727
  this.startupMode = "GLOBAL_INSTALL";
2405
- const cmd = process.platform === "win32" ? "cmd.exe" : "bash";
2406
- const args = process.platform === "win32" ? ["/c", `local-operator serve --port ${this.port}`] : ["-c", `local-operator serve --port ${this.port}`];
2407
- logger.info(
2408
- `Starting backend service with global command: ${cmd} ${args.join(" ")}`,
2409
- LogFileType.BACKEND
2410
- );
2411
- this.process = node_child_process.spawn(cmd, args, {
2412
- detached: false,
2413
- // Ensure process is not detached from parent
2414
- stdio: "pipe",
2415
- env: this.backendSpawnEnv(),
2416
- // On Windows, we need to create a new process group to ensure proper termination
2417
- ...process.platform === "win32" ? { windowsHide: true } : {}
2418
- });
2728
+ const executable = await this.resolveGlobalConsole(env);
2729
+ interpreters = process.platform === "win32" ? await windowsInterpreterCandidates(executable, env) : [consoleInterpreter(executable)];
2419
2730
  } else {
2420
- logger.info(
2421
- "Global local-operator not found, using virtual environment",
2422
- LogFileType.BACKEND
2423
- );
2424
2731
  this.startupMode = "APP_BUNDLED_VENV";
2425
- let cmd;
2426
- let args;
2427
- if (process.platform === "win32") {
2428
- const localOperatorExe = path$1.join(
2429
- this.venvPath,
2430
- "Scripts",
2431
- "local-operator.exe"
2432
- );
2433
- if (fs.existsSync(localOperatorExe)) {
2434
- cmd = localOperatorExe;
2435
- args = ["serve", "--port", this.port.toString()];
2436
- } else {
2437
- const pythonExe = path$1.join(this.venvPath, "Scripts", "python.exe");
2438
- if (fs.existsSync(pythonExe)) {
2439
- cmd = pythonExe;
2440
- args = [
2441
- "-m",
2442
- "local_operator",
2443
- "serve",
2444
- "--port",
2445
- this.port.toString()
2446
- ];
2447
- } else {
2448
- const activateScript = path$1.join(
2449
- this.venvPath,
2450
- "Scripts",
2451
- "Activate.ps1"
2452
- );
2453
- cmd = "powershell.exe";
2454
- args = [
2455
- "-ExecutionPolicy",
2456
- "Bypass",
2457
- "-Command",
2458
- `"& '${activateScript}'; local-operator serve --port ${this.port}"`
2459
- ];
2460
- }
2461
- }
2462
- } else {
2463
- const activateScript = path$1.join(this.venvPath, "bin", "activate");
2464
- cmd = "bash";
2465
- args = [
2466
- "-c",
2467
- `. "${activateScript}" && local-operator serve --port ${this.port}`
2468
- ];
2469
- }
2470
- logger.info(
2471
- `Starting backend service with venv: ${cmd} ${args.join(" ")}`,
2472
- LogFileType.BACKEND
2732
+ const bin = path$1.join(
2733
+ this.venvPath,
2734
+ process.platform === "win32" ? "Scripts" : "bin"
2473
2735
  );
2474
- this.process = node_child_process.spawn(cmd, args, {
2475
- detached: false,
2476
- // Ensure process is not detached from parent
2477
- stdio: "pipe",
2478
- env: this.backendSpawnEnv(),
2479
- // On Windows, we need to create a new process group to ensure proper termination
2480
- ...process.platform === "win32" ? { windowsHide: true } : {}
2481
- });
2482
- }
2483
- if (this.process.stdout) {
2484
- this.process.stdout.on("data", (data) => {
2736
+ interpreters = [
2737
+ path$1.join(bin, process.platform === "win32" ? "python.exe" : "python")
2738
+ ];
2739
+ env.VIRTUAL_ENV = this.venvPath;
2740
+ env.PATH = `${bin}${process.platform === "win32" ? ";" : ":"}${env.PATH ?? ""}`;
2741
+ env.PYTHONHOME = void 0;
2742
+ }
2743
+ const launch = await ownedServeLaunch(
2744
+ interpreters,
2745
+ this.port,
2746
+ env,
2747
+ process.platform,
2748
+ {},
2749
+ // The PATH-side claims spawn discovery children, so they are offered
2750
+ // only after the claims above have all failed (round 3, F14).
2751
+ process.platform === "win32" ? () => windowsPathInterpreterCandidates(env) : void 0
2752
+ );
2753
+ if (epoch !== this.startEpoch || this.isAppClosing) return false;
2754
+ const child = node_child_process.spawn(launch.command, launch.args, {
2755
+ detached: false,
2756
+ stdio: "pipe",
2757
+ // The plan owns the environment it proved: a Windows venv's base
2758
+ // interpreter needs the venv's import paths, added there and not here.
2759
+ env: launch.env,
2760
+ windowsHide: true
2761
+ });
2762
+ const generation = this.captureServe(child);
2763
+ captured = generation;
2764
+ if (child.stdout) {
2765
+ child.stdout.on("data", (data) => {
2485
2766
  logger.info(`Backend stdout: ${data}`, LogFileType.BACKEND);
2486
2767
  });
2487
2768
  }
2488
- if (this.process.stderr) {
2489
- this.process.stderr.on("data", (data) => {
2769
+ if (child.stderr) {
2770
+ child.stderr.on("data", (data) => {
2490
2771
  logger.error(`Backend stderr: ${data}`, LogFileType.BACKEND);
2491
2772
  });
2492
2773
  }
2493
- this.process.on("exit", (code) => {
2494
- logger.info(
2495
- `Backend process exited with code ${code}`,
2496
- LogFileType.BACKEND
2497
- );
2498
- this.isRunning = false;
2499
- this.process = null;
2500
- if (this.exitResolve) {
2501
- this.exitResolve();
2502
- this.exitResolve = null;
2503
- }
2504
- const isExpectedWindowsExit = process.platform === "win32" && (this.isAppClosing || this.isAutoUpdating) && code === 1;
2505
- if (code !== 0 && code !== null && !isExpectedWindowsExit) {
2506
- electron.dialog.showErrorBox(
2507
- "Backend Error",
2508
- `The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
2509
- );
2510
- }
2511
- });
2512
2774
  let attempts = 0;
2513
2775
  const maxAttempts = 30;
2514
2776
  while (attempts < maxAttempts) {
2515
- if (await this.checkHealth()) {
2777
+ const healthy = await this.checkHealth();
2778
+ if (epoch !== this.startEpoch || generation.exited || generation.stop)
2779
+ break;
2780
+ if (healthy) {
2516
2781
  this.isRunning = true;
2517
2782
  this.startHealthCheck();
2518
2783
  this.notifyBackendReady();
2519
2784
  return true;
2520
2785
  }
2521
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2786
+ await new Promise(
2787
+ (resolve) => setTimeout(resolve, READINESS_POLL_INTERVAL_MS)
2788
+ );
2522
2789
  attempts++;
2523
2790
  }
2791
+ await this.stopGeneration(generation, false);
2792
+ if (epoch !== this.startEpoch) return false;
2524
2793
  logger.error(
2525
2794
  "Failed to start backend service after multiple attempts",
2526
2795
  LogFileType.BACKEND
2527
2796
  );
2528
- electron.dialog.showErrorBox(
2797
+ this.reportStartFailure(
2529
2798
  "Backend Error",
2530
2799
  "Failed to start the Local Operator backend service. Please check the logs for more information."
2531
2800
  );
2532
2801
  return false;
2533
2802
  } catch (error) {
2803
+ if (captured) {
2804
+ try {
2805
+ await this.stopGeneration(captured, false);
2806
+ } catch (cleanupError) {
2807
+ logger.error(
2808
+ "Owned backend cleanup after a failed start did not confirm exit",
2809
+ LogFileType.BACKEND,
2810
+ cleanupError
2811
+ );
2812
+ }
2813
+ }
2534
2814
  logger.error(
2535
2815
  "Error starting backend service:",
2536
2816
  LogFileType.BACKEND,
2537
2817
  error
2538
2818
  );
2539
- electron.dialog.showErrorBox(
2819
+ this.reportStartFailure(
2540
2820
  "Backend Error",
2541
2821
  `Error starting the Local Operator backend service: ${error}`
2542
2822
  );
@@ -2544,232 +2824,156 @@ class BackendServiceManager {
2544
2824
  }
2545
2825
  }
2546
2826
  /**
2547
- * Stop the backend service
2548
- * @param isRestart - Whether this stop is part of a restart operation
2549
- * @returns Promise resolving when the backend has been stopped
2827
+ * Stop only the captured serve generation, including failed startups. A port
2828
+ * or process name is not ownership. Unconfirmed exit blocks replacement.
2550
2829
  */
2551
2830
  async stop(isRestart = false) {
2552
- if (!isRestart) {
2553
- this.isAppClosing = true;
2554
- }
2831
+ this.startEpoch++;
2832
+ if (!isRestart) this.isAppClosing = true;
2555
2833
  this.disposeStreamRelay();
2556
- if (this.healthCheckInterval) {
2557
- clearInterval(this.healthCheckInterval);
2558
- this.healthCheckInterval = null;
2559
- }
2560
- if (this.isDisabled || this.isExternalBackend) {
2561
- logger.info(
2562
- "Skipping backend stop (disabled or external backend)",
2563
- LogFileType.BACKEND
2564
- );
2565
- return;
2566
- }
2567
- if (this.process && this.isRunning) {
2568
- logger.info(
2569
- `Stopping backend service... (isRestart: ${isRestart})`,
2570
- LogFileType.BACKEND
2571
- );
2572
- this.exitPromise = new Promise((resolve) => {
2573
- this.exitResolve = resolve;
2574
- if (!this.process) {
2575
- if (this.exitResolve) {
2576
- this.exitResolve();
2577
- this.exitResolve = null;
2578
- }
2834
+ this.stopHealthCheck();
2835
+ const generation = this.ownedServe;
2836
+ if (generation) await this.stopGeneration(generation, isRestart);
2837
+ await this.startPromise;
2838
+ }
2839
+ captureServe(child) {
2840
+ let resolveExit = () => {
2841
+ };
2842
+ const generation = {
2843
+ child,
2844
+ exited: false,
2845
+ exit: new Promise((resolve) => {
2846
+ resolveExit = resolve;
2847
+ }),
2848
+ resolveExit: () => resolveExit(),
2849
+ stop: null,
2850
+ timers: /* @__PURE__ */ new Set()
2851
+ };
2852
+ this.ownedServe = generation;
2853
+ this.process = child;
2854
+ const exited = (code) => {
2855
+ if (generation.exited) return;
2856
+ generation.exited = true;
2857
+ for (const timer of generation.timers) clearTimeout(timer);
2858
+ generation.timers.clear();
2859
+ generation.resolveExit();
2860
+ if (this.ownedServe !== generation) return;
2861
+ this.ownedServe = null;
2862
+ this.process = null;
2863
+ this.isRunning = false;
2864
+ if (code != null && code !== 0 && !generation.stop && !this.isAppClosing && !this.isAutoUpdating) {
2865
+ electron.dialog.showErrorBox(
2866
+ "Backend Error",
2867
+ `The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
2868
+ );
2869
+ }
2870
+ };
2871
+ child.once("exit", exited);
2872
+ child.on("error", (error) => {
2873
+ logger.error("Owned backend process error", LogFileType.BACKEND, error);
2874
+ if (child.pid === void 0) exited();
2875
+ });
2876
+ return generation;
2877
+ }
2878
+ /** Signals may only be sent while the handle itself still reports a live
2879
+ * process. Node keeps `kill()` callable on a reaped child, and on a PID the
2880
+ * OS has since recycled that call reaches whatever now holds the number. */
2881
+ canSignal(generation) {
2882
+ return !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null;
2883
+ }
2884
+ /**
2885
+ * The one termination sequence: SIGTERM, wait out the grace, SIGKILL, wait
2886
+ * again, and throw if the process never reported exit.
2887
+ *
2888
+ * Failing rather than returning is the point. Callers use a resolved stop as
2889
+ * permission to replace the backend - install over it, spawn a successor - so
2890
+ * "we signalled it and moved on" would authorise exactly the double-serve
2891
+ * this class exists to prevent. An unconfirmed exit keeps ownership, and the
2892
+ * caller reports failure instead of proceeding.
2893
+ */
2894
+ stopGeneration(generation, isRestart) {
2895
+ if (generation.stop) return generation.stop;
2896
+ generation.stop = (async () => {
2897
+ const waitForExit = (ms) => new Promise((resolve) => {
2898
+ if (generation.exited) {
2899
+ resolve(true);
2579
2900
  return;
2580
2901
  }
2581
- this.process.once("exit", () => {
2582
- logger.info("Backend process exited", LogFileType.BACKEND);
2583
- this.process = null;
2584
- this.isRunning = false;
2585
- if (this.exitResolve) {
2586
- this.exitResolve();
2587
- this.exitResolve = null;
2588
- }
2902
+ const timer = setTimeout(() => {
2903
+ generation.timers.delete(timer);
2904
+ resolve(false);
2905
+ }, ms);
2906
+ generation.timers.add(timer);
2907
+ void generation.exit.then(() => {
2908
+ clearTimeout(timer);
2909
+ generation.timers.delete(timer);
2910
+ resolve(true);
2589
2911
  });
2590
2912
  });
2591
- try {
2592
- const pid = this.process.pid;
2593
- if (process.platform === "win32") {
2594
- if (pid) {
2595
- logger.info(
2596
- `Terminating Windows process with PID ${pid}`,
2597
- LogFileType.BACKEND
2598
- );
2599
- node_child_process.spawn("taskkill", ["/pid", pid.toString(), "/t"]);
2600
- }
2601
- } else {
2602
- logger.info(
2603
- "Sending SIGTERM to backend process",
2604
- LogFileType.BACKEND
2605
- );
2606
- this.process.kill("SIGTERM");
2607
- }
2608
- let forceKillTimeoutId = null;
2609
- const timeoutPromise = new Promise((resolve) => {
2610
- const timeoutMs = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
2611
- logger.info(
2612
- `Setting shutdown timeout to ${timeoutMs}ms for ${isRestart ? "restart" : "normal"} operation`,
2613
- LogFileType.BACKEND
2913
+ if (this.canSignal(generation)) generation.child.kill("SIGTERM");
2914
+ const grace = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
2915
+ if (!await waitForExit(grace)) {
2916
+ if (this.canSignal(generation)) generation.child.kill("SIGKILL");
2917
+ if (!await waitForExit(this.shutdownTimeoutMs.force)) {
2918
+ throw new Error(
2919
+ "Owned backend exit is unconfirmed; refusing replacement"
2614
2920
  );
2615
- forceKillTimeoutId = setTimeout(() => {
2616
- if (this.process) {
2617
- logger.info(
2618
- `Backend process did not exit within ${timeoutMs}ms, force killing`,
2619
- LogFileType.BACKEND
2620
- );
2621
- try {
2622
- if (process.platform === "win32" && this.process.pid) {
2623
- node_child_process.spawn("taskkill", [
2624
- "/pid",
2625
- this.process.pid.toString(),
2626
- "/f",
2627
- "/t"
2628
- ]);
2629
- try {
2630
- execPromise(
2631
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
2632
- ).catch((error) => {
2633
- logger.warn(
2634
- "Error terminating python processes:",
2635
- LogFileType.BACKEND,
2636
- error
2637
- );
2638
- });
2639
- execPromise("taskkill /f /im local-operator.exe /t").catch(
2640
- (error) => {
2641
- logger.warn(
2642
- "Error terminating local-operator processes:",
2643
- LogFileType.BACKEND,
2644
- error
2645
- );
2646
- }
2647
- );
2648
- } catch (taskkillError) {
2649
- logger.warn(
2650
- "Error executing taskkill command:",
2651
- LogFileType.BACKEND,
2652
- taskkillError
2653
- );
2654
- }
2655
- } else if (this.process) {
2656
- this.process.kill("SIGKILL");
2657
- if (!isRestart) {
2658
- try {
2659
- execPromise('pkill -f "local-operator serve"').catch(
2660
- (error) => {
2661
- logger.warn(
2662
- "Error killing processes by pattern:",
2663
- LogFileType.BACKEND,
2664
- error
2665
- );
2666
- }
2667
- );
2668
- } catch (pkillError) {
2669
- logger.warn(
2670
- "Error executing pkill command:",
2671
- LogFileType.BACKEND,
2672
- pkillError
2673
- );
2674
- }
2675
- }
2676
- setTimeout(() => {
2677
- if (this.process) {
2678
- logger.warn(
2679
- `Process still exists after SIGKILL, attempting more aggressive termination after ${this.shutdownTimeoutMs.force}ms`,
2680
- LogFileType.BACKEND
2681
- );
2682
- try {
2683
- if (!isRestart) {
2684
- execPromise(
2685
- 'pkill -9 -f "local-operator serve"'
2686
- ).catch(() => {
2687
- });
2688
- }
2689
- this.process = null;
2690
- this.isRunning = false;
2691
- if (this.exitResolve) {
2692
- this.exitResolve();
2693
- this.exitResolve = null;
2694
- }
2695
- } catch (finalError) {
2696
- logger.error(
2697
- "Error during final force kill attempt:",
2698
- LogFileType.BACKEND,
2699
- finalError
2700
- );
2701
- }
2702
- }
2703
- }, this.shutdownTimeoutMs.force);
2704
- }
2705
- } catch (error) {
2706
- logger.error(
2707
- "Error force killing process:",
2708
- LogFileType.BACKEND,
2709
- error
2710
- );
2711
- }
2712
- this.process = null;
2713
- this.isRunning = false;
2714
- if (this.exitResolve) {
2715
- this.exitResolve();
2716
- this.exitResolve = null;
2717
- }
2718
- }
2719
- resolve();
2720
- }, timeoutMs);
2721
- });
2722
- await Promise.race([this.exitPromise, timeoutPromise]);
2723
- if (forceKillTimeoutId) {
2724
- clearTimeout(forceKillTimeoutId);
2725
- forceKillTimeoutId = null;
2726
- }
2727
- if (!isRestart) {
2728
- logger.info(
2729
- "Performing additional cleanup for final shutdown",
2730
- LogFileType.BACKEND
2731
- );
2732
- try {
2733
- if (process.platform === "win32") {
2734
- await execPromise(
2735
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
2736
- ).catch(() => {
2737
- });
2738
- await execPromise("taskkill /f /im local-operator.exe /t").catch(
2739
- () => {
2740
- }
2741
- );
2742
- } else {
2743
- await execPromise('pkill -f "local-operator serve"').catch(() => {
2744
- });
2745
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2746
- await execPromise('pkill -9 -f "local-operator serve"').catch(
2747
- () => {
2748
- }
2749
- );
2750
- }
2751
- } catch (cleanupError) {
2752
- logger.warn(
2753
- "Error during additional cleanup (this may be normal if processes were already terminated):",
2754
- LogFileType.BACKEND,
2755
- cleanupError
2756
- );
2757
- }
2758
- }
2759
- } catch (error) {
2760
- logger.error(
2761
- "Error stopping backend process:",
2762
- LogFileType.BACKEND,
2763
- error
2764
- );
2765
- this.process = null;
2766
- this.isRunning = false;
2767
- if (this.exitResolve) {
2768
- this.exitResolve();
2769
- this.exitResolve = null;
2770
2921
  }
2771
2922
  }
2772
- logger.info("Backend service stopped", LogFileType.BACKEND);
2923
+ if (!isRestart && !this.ownedServe)
2924
+ this.startupMode = "NOT_STARTED";
2925
+ logger.info("Owned backend generation stopped", LogFileType.BACKEND);
2926
+ })();
2927
+ return generation.stop;
2928
+ }
2929
+ stopHealthCheck() {
2930
+ if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
2931
+ this.healthCheckInterval = null;
2932
+ }
2933
+ /** Whether a quit may proceed. `will-quit` cannot await a listener, so it
2934
+ * prevents the first quit, runs cleanup, and asks this on the retry. */
2935
+ /** Whether a shutdown is in flight.
2936
+ *
2937
+ * `stop(false)` is terminal and is what the quit path calls, so this is the
2938
+ * state a caller checks before raising blocking UI it would never be able to
2939
+ * dismiss - `index.ts`'s start-failure paths are the callers. */
2940
+ isShuttingDown() {
2941
+ return this.isAppClosing;
2942
+ }
2943
+ /**
2944
+ * Report a start failure to the user - unless the app is on its way out.
2945
+ *
2946
+ * `showErrorBox` is a native modal: it parks the main thread until somebody
2947
+ * dismisses it, and Electron cannot run the quit it was asked for past a
2948
+ * parked thread. On the quit path that is not a message, it is a deadlock -
2949
+ * the quit has already prevented itself, the owned cleanup it waits on can
2950
+ * never finish, and the quit's own failsafe cannot even fire, because that is
2951
+ * a timer on the thread the dialog holds (review round 4, Q-20: a SIGTERM
2952
+ * during the first start left the app alive 50 s later with the failsafe line
2953
+ * never logged). A shutdown in flight logs the failure instead, where the
2954
+ * post-mortem finds it without the process still being up.
2955
+ */
2956
+ reportStartFailure(title, message) {
2957
+ if (this.isAppClosing) {
2958
+ logger.error(
2959
+ `${title} (not shown; the app is shutting down): ${message}`,
2960
+ LogFileType.BACKEND
2961
+ );
2962
+ return;
2963
+ }
2964
+ electron.dialog.showErrorBox(title, message);
2965
+ }
2966
+ isOwnedCleanupComplete() {
2967
+ return this.isAppClosing && !this.ownedServe && !this.startPromise;
2968
+ }
2969
+ /** Synchronous exit cannot await cleanup. Canonical runtimes deliberately
2970
+ * outlive their HTTP server, so neither PID rediscovery nor descent is safe. */
2971
+ emergencyStopOwned() {
2972
+ this.startEpoch++;
2973
+ this.isAppClosing = true;
2974
+ const generation = this.ownedServe;
2975
+ if (generation && !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null) {
2976
+ generation.child.kill("SIGKILL");
2773
2977
  }
2774
2978
  }
2775
2979
  /**
@@ -2814,7 +3018,10 @@ class BackendServiceManager {
2814
3018
  * assertable without waiting out a 30s interval.
2815
3019
  */
2816
3020
  async checkUnhealthyBackend() {
3021
+ const epoch = this.startEpoch;
2817
3022
  const isHealthy = await this.checkHealth();
3023
+ if (epoch !== this.startEpoch || this.isAppClosing || this.isAutoUpdating || this.isDisabled)
3024
+ return;
2818
3025
  if (isHealthy) return;
2819
3026
  logger.info("Backend health check failed", LogFileType.BACKEND);
2820
3027
  if (this.isExternalBackend) {
@@ -2832,8 +3039,7 @@ class BackendServiceManager {
2832
3039
  await this.start();
2833
3040
  return;
2834
3041
  }
2835
- await this.stop(true);
2836
- await this.start();
3042
+ await this.restart();
2837
3043
  }
2838
3044
  /**
2839
3045
  * Get the port number used by the backend service
@@ -2883,52 +3089,21 @@ class BackendServiceManager {
2883
3089
  * timeouts from the stop operation don't affect the newly started process
2884
3090
  * @returns Promise resolving to true if the restart was successful, false otherwise
2885
3091
  */
2886
- async restart() {
2887
- logger.info("Restarting backend service...", LogFileType.BACKEND);
2888
- this.isAppClosing = false;
2889
- await this.stop(true);
2890
- await new Promise((resolve) => setTimeout(resolve, 2e3));
2891
- if (this.process) {
2892
- logger.warn(
2893
- "Process still exists after stop, attempting to force terminate",
2894
- LogFileType.BACKEND
2895
- );
3092
+ restart() {
3093
+ if (this.restartPromise) return this.restartPromise;
3094
+ this.restartPromise = (async () => {
2896
3095
  try {
2897
- if (process.platform === "win32" && this.process.pid) {
2898
- await node_util.promisify(node_child_process.exec)(`taskkill /pid ${this.process.pid} /f /t`);
2899
- } else if (this.process) {
2900
- this.process.kill("SIGKILL");
2901
- }
2902
- await new Promise((resolve) => {
2903
- if (!this.process) {
2904
- resolve(null);
2905
- return;
2906
- }
2907
- this.process.once("exit", () => {
2908
- resolve(null);
2909
- });
2910
- setTimeout(resolve, 1e3);
2911
- });
2912
- this.process = null;
2913
- this.isRunning = false;
3096
+ await this.stop(true);
3097
+ if (this.isAppClosing) return false;
3098
+ return await this.start();
2914
3099
  } catch (error) {
2915
- logger.error(
2916
- "Error force killing process during restart:",
2917
- LogFileType.BACKEND,
2918
- error
2919
- );
3100
+ logger.error("Backend restart refused", LogFileType.BACKEND, error);
3101
+ return false;
2920
3102
  }
2921
- }
2922
- const success = await this.start();
2923
- if (success) {
2924
- logger.info(
2925
- "Backend service restarted successfully",
2926
- LogFileType.BACKEND
2927
- );
2928
- } else {
2929
- logger.error("Failed to restart backend service", LogFileType.BACKEND);
2930
- }
2931
- return success;
3103
+ })().finally(() => {
3104
+ this.restartPromise = null;
3105
+ });
3106
+ return this.restartPromise;
2932
3107
  }
2933
3108
  }
2934
3109
  const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
@@ -9379,8 +9554,8 @@ function readInstallIdentity(shimPath) {
9379
9554
  }
9380
9555
  let shebang = null;
9381
9556
  try {
9382
- const firstLine = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
9383
- shebang = firstLine.startsWith("#!") ? firstLine : null;
9557
+ const firstLine2 = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
9558
+ shebang = firstLine2.startsWith("#!") ? firstLine2 : null;
9384
9559
  } catch {
9385
9560
  shebang = null;
9386
9561
  }
@@ -10491,86 +10666,6 @@ ${heal.removed.join("\n")}`,
10491
10666
  electron.ipcMain.removeHandler("quit-for-update-install");
10492
10667
  this.mainWindow = null;
10493
10668
  }
10494
- /**
10495
- * Register the backend service for proper shutdown when app quits
10496
- * This ensures that restarted backend services are properly shut down
10497
- */
10498
- registerBackendShutdown() {
10499
- if (!this.backendService || this.backendService.isUsingExternalBackend()) {
10500
- return;
10501
- }
10502
- const shutdownHandler = async () => {
10503
- logger.info(
10504
- "Shutting down backend service before app quit...",
10505
- LogFileType.UPDATE_SERVICE
10506
- );
10507
- try {
10508
- await this.backendService?.stop(false);
10509
- logger.info(
10510
- "Backend service successfully shut down before app quit",
10511
- LogFileType.UPDATE_SERVICE
10512
- );
10513
- } catch (error) {
10514
- logger.error(
10515
- "Error shutting down backend service before app quit:",
10516
- LogFileType.UPDATE_SERVICE,
10517
- error
10518
- );
10519
- try {
10520
- logger.info(
10521
- "Attempting forced shutdown of backend service...",
10522
- LogFileType.UPDATE_SERVICE
10523
- );
10524
- await this.forceTerminateBackendProcess();
10525
- logger.info(
10526
- "Forced shutdown of backend service completed",
10527
- LogFileType.UPDATE_SERVICE
10528
- );
10529
- } catch (forceError) {
10530
- logger.error(
10531
- "Error during forced shutdown of backend service:",
10532
- LogFileType.UPDATE_SERVICE,
10533
- forceError
10534
- );
10535
- }
10536
- }
10537
- };
10538
- electron.app.removeAllListeners("before-quit");
10539
- electron.app.once("before-quit", shutdownHandler);
10540
- electron.app.once("will-quit", shutdownHandler);
10541
- logger.info(
10542
- "Registered backend service for proper shutdown on app quit",
10543
- LogFileType.UPDATE_SERVICE
10544
- );
10545
- }
10546
- /**
10547
- * Force terminate the backend process using platform-specific commands
10548
- * This is a last resort method when normal termination fails
10549
- */
10550
- async forceTerminateBackendProcess() {
10551
- if (!this.backendService) {
10552
- return;
10553
- }
10554
- const execAsync = node_util.promisify(node_child_process.exec);
10555
- try {
10556
- if (process.platform === "win32") {
10557
- await execAsync('taskkill /f /im "local-operator serve" /t');
10558
- await execAsync(
10559
- `wmic process where "commandline like '%local-operator serve%'" call terminate`
10560
- );
10561
- } else {
10562
- await execAsync('pkill -f "local-operator serve"');
10563
- await new Promise((resolve) => setTimeout(resolve, 1e3));
10564
- await execAsync('pkill -9 -f "local-operator serve"');
10565
- }
10566
- } catch (error) {
10567
- logger.warn(
10568
- "Error during force termination (this may be normal if process was already terminated):",
10569
- LogFileType.UPDATE_SERVICE,
10570
- error
10571
- );
10572
- }
10573
- }
10574
10669
  /**
10575
10670
  * Set up event handlers for the autoUpdater
10576
10671
  */
@@ -10818,6 +10913,15 @@ ${heal.removed.join("\n")}`,
10818
10913
  let block = null;
10819
10914
  try {
10820
10915
  block = await this.runInstallPreflight(this.lastUpdateInfo);
10916
+ if (!block) await this.backendService?.stop(false);
10917
+ } catch (error) {
10918
+ this.updateStage = "idle";
10919
+ logger.error(
10920
+ "Update handoff refused: owned backend cleanup failed",
10921
+ LogFileType.UPDATE_SERVICE,
10922
+ error
10923
+ );
10924
+ return false;
10821
10925
  } finally {
10822
10926
  this.installPreflightInFlight = false;
10823
10927
  }
@@ -11649,16 +11753,6 @@ ${pipRun.stderr.trim()}`,
11649
11753
  "Restarting backend service after update...",
11650
11754
  LogFileType.UPDATE_SERVICE
11651
11755
  );
11652
- await new Promise((resolve) => setTimeout(resolve, 2e3));
11653
- const isHealthy = await this.checkBackendHealth();
11654
- if (isHealthy) {
11655
- logger.warn(
11656
- "Backend service is still running after stop command, attempting force termination",
11657
- LogFileType.UPDATE_SERVICE
11658
- );
11659
- await this.forceTerminateBackendProcess();
11660
- await new Promise((resolve) => setTimeout(resolve, 2e3));
11661
- }
11662
11756
  const restartSuccess = await this.backendService.restart();
11663
11757
  if (restartSuccess) {
11664
11758
  logger.info(
@@ -11692,7 +11786,6 @@ ${pipRun.stderr.trim()}`,
11692
11786
  return false;
11693
11787
  }
11694
11788
  }
11695
- this.registerBackendShutdown();
11696
11789
  } else {
11697
11790
  logger.error(
11698
11791
  "Failed to restart backend service after update",
@@ -12280,6 +12373,16 @@ function createWindow() {
12280
12373
  }
12281
12374
  const backendService = new BackendServiceManager();
12282
12375
  const backendInstaller = new BackendInstaller();
12376
+ const reportBackendFailure = (message, fileType) => {
12377
+ if (backendService.isShuttingDown()) {
12378
+ logger.error(
12379
+ `Backend Error (not shown; the app is shutting down): ${message}`,
12380
+ fileType
12381
+ );
12382
+ return;
12383
+ }
12384
+ electron.dialog.showErrorBox("Backend Error", message);
12385
+ };
12283
12386
  const windowLaunch = resolveWindowLaunchPlan({
12284
12387
  env: process.env,
12285
12388
  argv: process.argv
@@ -12644,9 +12747,9 @@ electron.app.whenReady().then(async () => {
12644
12747
  "Failed to start backend after installation, quitting app",
12645
12748
  LogFileType.INSTALLER
12646
12749
  );
12647
- electron.dialog.showErrorBox(
12648
- "Backend Error",
12649
- "Failed to start the Local Operator backend service after installation. Please restart the application."
12750
+ reportBackendFailure(
12751
+ "Failed to start the Local Operator backend service after installation. Please restart the application.",
12752
+ LogFileType.INSTALLER
12650
12753
  );
12651
12754
  electron.app.quit();
12652
12755
  return;
@@ -12658,9 +12761,9 @@ electron.app.whenReady().then(async () => {
12658
12761
  "Failed to start backend with existing installation, quitting app",
12659
12762
  LogFileType.BACKEND
12660
12763
  );
12661
- electron.dialog.showErrorBox(
12662
- "Backend Error",
12663
- "Failed to start the Local Operator backend service. Please restart the application."
12764
+ reportBackendFailure(
12765
+ "Failed to start the Local Operator backend service. Please restart the application.",
12766
+ LogFileType.BACKEND
12664
12767
  );
12665
12768
  electron.app.quit();
12666
12769
  return;
@@ -12784,161 +12887,34 @@ electron.app.on("window-all-closed", () => {
12784
12887
  );
12785
12888
  electron.app.quit();
12786
12889
  });
12787
- electron.app.on("will-quit", async (event) => {
12788
- const isBackendManagerDisabled = process.env.VITE_DISABLE_BACKEND_MANAGER === "true";
12789
- if (!isBackendManagerDisabled && !backendService.isUsingExternalBackend()) {
12790
- event.preventDefault();
12791
- try {
12792
- logger.info(
12793
- "App is quitting, stopping the Local Operator backend service...",
12794
- LogFileType.BACKEND
12795
- );
12796
- await backendService.stop(false);
12797
- logger.info(
12798
- "Local Operator backend service successfully stopped",
12799
- LogFileType.BACKEND
12800
- );
12801
- logger.info(
12802
- "Performing additional cleanup to ensure complete termination",
12803
- LogFileType.BACKEND
12804
- );
12805
- if (process.platform === "win32") {
12806
- try {
12807
- require("node:child_process").execSync(
12808
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
12809
- { stdio: "ignore" }
12810
- );
12811
- require("node:child_process").execSync(
12812
- "taskkill /f /im local-operator.exe /t",
12813
- { stdio: "ignore" }
12814
- );
12815
- } catch (err) {
12816
- logger.error(
12817
- "Error during Windows process cleanup (may be normal):",
12818
- LogFileType.BACKEND,
12819
- err
12820
- );
12821
- }
12822
- } else {
12823
- try {
12824
- require("node:child_process").execSync(
12825
- 'pkill -f "local-operator serve"',
12826
- { stdio: "ignore" }
12827
- );
12828
- await new Promise((resolve) => setTimeout(resolve, 1e3));
12829
- require("node:child_process").execSync(
12830
- 'pkill -9 -f "local-operator serve"',
12831
- { stdio: "ignore" }
12832
- );
12833
- } catch (err) {
12834
- logger.error(
12835
- "Error during Unix process cleanup (may be normal):",
12836
- LogFileType.BACKEND,
12837
- err
12838
- );
12839
- }
12840
- }
12841
- let allProcessesTerminated = true;
12842
- try {
12843
- if (process.platform === "win32") {
12844
- const { stdout: pythonOutput } = require("node:child_process").execSync(
12845
- `tasklist /fi "imagename eq python.exe" /fo csv`,
12846
- { encoding: "utf8" }
12847
- );
12848
- const hasPythonProcesses = pythonOutput?.trim().includes("python.exe");
12849
- const { stdout: localOperatorOutput } = require("node:child_process").execSync(
12850
- `tasklist /fi "imagename eq local-operator.exe" /fo csv`,
12851
- { encoding: "utf8" }
12852
- );
12853
- const hasLocalOperatorProcesses = localOperatorOutput?.trim().includes("local-operator.exe");
12854
- allProcessesTerminated = !hasPythonProcesses && !hasLocalOperatorProcesses;
12855
- } else {
12856
- const { stdout } = require("node:child_process").execSync(
12857
- `pgrep -f "local-operator serve" || echo ""`,
12858
- { encoding: "utf8" }
12859
- );
12860
- allProcessesTerminated = !stdout?.trim();
12861
- }
12862
- } catch (err) {
12863
- logger.error(
12864
- "Error checking for remaining processes:",
12865
- LogFileType.BACKEND,
12866
- err
12867
- );
12868
- allProcessesTerminated = true;
12869
- }
12870
- if (!allProcessesTerminated) {
12871
- logger.error(
12872
- "Some backend processes may still be running, attempting final cleanup",
12873
- LogFileType.BACKEND
12874
- );
12875
- try {
12876
- if (process.platform === "win32") {
12877
- require("node:child_process").execSync(
12878
- "taskkill /f /im python.exe /t",
12879
- { stdio: "ignore" }
12880
- );
12881
- } else {
12882
- require("node:child_process").execSync("pkill -9 -f python", {
12883
- stdio: "ignore"
12884
- });
12885
- }
12886
- } catch (finalErr) {
12887
- logger.error(
12888
- "Error during final cleanup (may be normal):",
12889
- LogFileType.BACKEND,
12890
- finalErr
12891
- );
12892
- }
12893
- }
12894
- } catch (error) {
12895
- logger.error(
12896
- "Error stopping the Local Operator backend service:",
12897
- LogFileType.BACKEND,
12898
- error
12899
- );
12900
- logger.info(
12901
- "Trying alternative termination approach",
12902
- LogFileType.BACKEND
12903
- );
12904
- if (process.platform === "win32") {
12905
- try {
12906
- require("node:child_process").execSync(
12907
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
12908
- { stdio: "ignore" }
12909
- );
12910
- require("node:child_process").execSync(
12911
- "taskkill /f /im local-operator.exe /t",
12912
- { stdio: "ignore" }
12913
- );
12914
- } catch (_err) {
12915
- }
12916
- } else {
12917
- try {
12918
- require("node:child_process").execSync(
12919
- 'pkill -f "local-operator serve"',
12920
- { stdio: "ignore" }
12921
- );
12922
- require("node:child_process").execSync(
12923
- 'sleep 1 && pkill -9 -f "local-operator serve"',
12924
- { stdio: "ignore" }
12925
- );
12926
- } catch (_err) {
12927
- }
12928
- }
12929
- } finally {
12930
- logger.info("Exiting application...", LogFileType.BACKEND);
12931
- setTimeout(() => {
12932
- logger.info("Forcing app exit", LogFileType.BACKEND);
12933
- electron.app.exit(0);
12934
- }, 2e3);
12935
- }
12936
- } else if (!isBackendManagerDisabled && backendService.isUsingExternalBackend()) {
12937
- logger.info(
12938
- "Using external backend, skipping termination on app quit",
12890
+ const QUIT_FAILSAFE_MARGIN_MS = 5e3;
12891
+ const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
12892
+ let backendQuitPending = false;
12893
+ electron.app.on("will-quit", (event) => {
12894
+ if (backendService.isOwnedCleanupComplete()) return;
12895
+ event.preventDefault();
12896
+ if (backendQuitPending) return;
12897
+ backendQuitPending = true;
12898
+ const failsafe = setTimeout(() => {
12899
+ logger.error(
12900
+ `Owned backend cleanup did not finish within ${QUIT_CLEANUP_FAILSAFE_MS} ms; exiting with failure`,
12939
12901
  LogFileType.BACKEND
12940
12902
  );
12941
- }
12903
+ electron.app.exit(1);
12904
+ }, QUIT_CLEANUP_FAILSAFE_MS);
12905
+ failsafe.unref();
12906
+ void backendService.stop(false).then(() => {
12907
+ clearTimeout(failsafe);
12908
+ electron.app.quit();
12909
+ }).catch((error) => {
12910
+ clearTimeout(failsafe);
12911
+ logger.error(
12912
+ "Owned backend cleanup failed; exiting with failure",
12913
+ LogFileType.BACKEND,
12914
+ error
12915
+ );
12916
+ electron.app.exit(1);
12917
+ });
12942
12918
  });
12943
12919
  electron.app.on("before-quit", () => {
12944
12920
  logger.info("App is about to quit", LogFileType.BACKEND);
@@ -12947,114 +12923,24 @@ electron.app.on("before-quit", () => {
12947
12923
  void stopBrowserHost();
12948
12924
  });
12949
12925
  process.on("exit", () => {
12950
- logger.info(
12951
- "Process exit event detected, ensuring the Local Operator backend service is terminated",
12952
- LogFileType.BACKEND
12953
- );
12954
12926
  try {
12955
- if (!process.env.VITE_DISABLE_BACKEND_MANAGER && !backendService.isUsingExternalBackend()) {
12956
- logger.info(
12957
- "Forcing termination of the Local Operator backend service",
12958
- LogFileType.BACKEND
12959
- );
12960
- logger.info(
12961
- "Performing final cleanup of any remaining backend processes",
12962
- LogFileType.BACKEND
12963
- );
12964
- if (process.platform === "win32") {
12965
- try {
12966
- require("node:child_process").spawnSync("cmd.exe", [
12967
- "/c",
12968
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
12969
- ]);
12970
- require("node:child_process").spawnSync("cmd.exe", [
12971
- "/c",
12972
- "taskkill /f /im local-operator.exe /t"
12973
- ]);
12974
- } catch (_err) {
12975
- }
12976
- } else {
12977
- try {
12978
- require("node:child_process").spawnSync("bash", [
12979
- "-c",
12980
- `ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
12981
- ]);
12982
- require("node:child_process").spawnSync("bash", [
12983
- "-c",
12984
- `sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
12985
- ]);
12986
- } catch (_err) {
12987
- }
12988
- }
12989
- } else if (!process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
12990
- logger.info(
12991
- "Using external backend, skipping force termination",
12992
- LogFileType.BACKEND
12993
- );
12994
- }
12927
+ backendService.emergencyStopOwned();
12995
12928
  } catch (error) {
12996
- logger.error("Error in exit handler", LogFileType.BACKEND, error);
12929
+ logger.error(
12930
+ "Owned backend emergency cleanup failed",
12931
+ LogFileType.BACKEND,
12932
+ error
12933
+ );
12997
12934
  }
12998
12935
  posthogClient.shutdown();
12999
12936
  });
13000
12937
  process.on("uncaughtException", (error) => {
13001
12938
  logger.error("Uncaught exception", LogFileType.BACKEND, error);
13002
- if (backendService && !process.env.VITE_DISABLE_BACKEND_MANAGER && !backendService.isUsingExternalBackend()) {
13003
- logger.info(
13004
- "Attempting to stop our backend service due to uncaught exception",
13005
- LogFileType.BACKEND
13006
- );
13007
- backendService.stop().catch((stopError) => {
13008
- logger.error(
13009
- "Error stopping the Local Operator backend service:",
13010
- LogFileType.BACKEND,
13011
- stopError
13012
- );
13013
- logger.info(
13014
- "Trying alternative termination approach",
13015
- LogFileType.BACKEND
13016
- );
13017
- if (process.platform === "win32") {
13018
- try {
13019
- require("node:child_process").spawnSync("cmd.exe", [
13020
- "/c",
13021
- 'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
13022
- ]);
13023
- require("node:child_process").spawnSync("cmd.exe", [
13024
- "/c",
13025
- "taskkill /f /im local-operator.exe /t"
13026
- ]);
13027
- } catch (_err) {
13028
- }
13029
- } else {
13030
- try {
13031
- require("node:child_process").spawnSync("bash", [
13032
- "-c",
13033
- `ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
13034
- ]);
13035
- require("node:child_process").spawnSync("bash", [
13036
- "-c",
13037
- `sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
13038
- ]);
13039
- } catch (_err) {
13040
- }
13041
- }
13042
- }).finally(() => {
13043
- setTimeout(() => {
13044
- logger.info(
13045
- "Forcing app exit after uncaught exception",
13046
- LogFileType.BACKEND
13047
- );
13048
- process.exit(1);
13049
- }, 1e3);
13050
- });
13051
- } else if (backendService && !process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
13052
- logger.info(
13053
- "Using external backend, skipping termination on uncaught exception",
13054
- LogFileType.BACKEND
12939
+ void backendService.stop(false).catch((stopError) => {
12940
+ logger.error(
12941
+ "Owned backend cleanup failed",
12942
+ LogFileType.BACKEND,
12943
+ stopError
13055
12944
  );
13056
- process.exit(1);
13057
- } else {
13058
- process.exit(1);
13059
- }
12945
+ }).finally(() => process.exit(1));
13060
12946
  });