mesh: download fresh binary on every start, kill mesh when goose exits (#8228)
Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
+13
-1
@@ -12,7 +12,7 @@ import { openSharedSessionFromDeepLink } from './sessionLinks';
|
|||||||
import { type SharedSessionDetails } from './sharedSessions';
|
import { type SharedSessionDetails } from './sharedSessions';
|
||||||
import { ErrorUI } from './components/ErrorBoundary';
|
import { ErrorUI } from './components/ErrorBoundary';
|
||||||
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
import AnnouncementModal from './components/AnnouncementModal';
|
import AnnouncementModal from './components/AnnouncementModal';
|
||||||
import TelemetryConsentPrompt from './components/TelemetryConsentPrompt';
|
import TelemetryConsentPrompt from './components/TelemetryConsentPrompt';
|
||||||
import OnboardingGuard from './components/onboarding/OnboardingGuard';
|
import OnboardingGuard from './components/onboarding/OnboardingGuard';
|
||||||
@@ -455,6 +455,18 @@ export function AppInner() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Show a toast if mesh is the configured provider but isn't running.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
toast.warn('Inference Mesh is set as your provider but isn\'t running. Open Settings → Mesh to start it. Keep goose running to stay connected.', {
|
||||||
|
autoClose: false,
|
||||||
|
toastId: 'mesh-not-running',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.electron.on('mesh-not-running', handler);
|
||||||
|
return () => { window.electron.off('mesh-not-running', handler); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Prevent default drag and drop behavior globally to avoid opening files in new windows
|
// Prevent default drag and drop behavior globally to avoid opening files in new windows
|
||||||
// but allow our React components to handle drops in designated areas
|
// but allow our React components to handle drops in designated areas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
import { Input } from '../../ui/input';
|
import { Input } from '../../ui/input';
|
||||||
@@ -47,7 +46,7 @@ interface MeshStatusInfo {
|
|||||||
|
|
||||||
export const MeshSettings = () => {
|
export const MeshSettings = () => {
|
||||||
const { refreshCurrentModelAndProvider } = useModelAndProvider();
|
const { refreshCurrentModelAndProvider } = useModelAndProvider();
|
||||||
const canAutoDownload = window.electron.platform === 'darwin' && window.electron.arch === 'arm64';
|
const isMacOS = window.electron.platform === 'darwin' && window.electron.arch === 'arm64';
|
||||||
const [status, setStatus] = useState<MeshStatus>('unknown');
|
const [status, setStatus] = useState<MeshStatus>('unknown');
|
||||||
const [statusInfo, setStatusInfo] = useState<MeshStatusInfo>({
|
const [statusInfo, setStatusInfo] = useState<MeshStatusInfo>({
|
||||||
running: false,
|
running: false,
|
||||||
@@ -57,7 +56,7 @@ export const MeshSettings = () => {
|
|||||||
const [mode, setMode] = useState<MeshMode>('auto');
|
const [mode, setMode] = useState<MeshMode>('auto');
|
||||||
const [selectedModel, setSelectedModel] = useState(MESH_DEFAULT_MODEL);
|
const [selectedModel, setSelectedModel] = useState(MESH_DEFAULT_MODEL);
|
||||||
const [joinToken, setJoinToken] = useState('');
|
const [joinToken, setJoinToken] = useState('');
|
||||||
const [contributeGpu, setContributeGpu] = useState(true);
|
const [contributeGpu, setContributeGpu] = useState(false);
|
||||||
const [copiedToken, setCopiedToken] = useState(false);
|
const [copiedToken, setCopiedToken] = useState(false);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -80,10 +79,12 @@ export const MeshSettings = () => {
|
|||||||
if (result.running) {
|
if (result.running) {
|
||||||
setStatus('running');
|
setStatus('running');
|
||||||
setStatusInfo(result);
|
setStatusInfo(result);
|
||||||
} else if (!result.installed) {
|
} else if (!result.installed && !isMacOS) {
|
||||||
|
// On non-macOS, binary must be manually installed.
|
||||||
setStatus((prev) => (prev === 'downloading' ? prev : 'not-installed'));
|
setStatus((prev) => (prev === 'downloading' ? prev : 'not-installed'));
|
||||||
setStatusInfo({ running: false, installed: false, models: [] });
|
setStatusInfo({ running: false, installed: false, models: [] });
|
||||||
} else {
|
} else {
|
||||||
|
// On macOS, start-mesh handles downloading, so treat not-installed as stopped.
|
||||||
setStatus((prev) => (prev === 'starting' || prev === 'downloading' ? prev : 'stopped'));
|
setStatus((prev) => (prev === 'starting' || prev === 'downloading' ? prev : 'stopped'));
|
||||||
setStatusInfo({ ...result, models: [] });
|
setStatusInfo({ ...result, models: [] });
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,7 @@ export const MeshSettings = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isMacOS]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
@@ -165,7 +166,8 @@ export const MeshSettings = () => {
|
|||||||
|
|
||||||
const startMesh = async () => {
|
const startMesh = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus('starting');
|
// On macOS, start-mesh downloads the latest binary first.
|
||||||
|
setStatus(isMacOS ? 'downloading' : 'starting');
|
||||||
try {
|
try {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ export const MeshSettings = () => {
|
|||||||
setStatus('stopped');
|
setStatus('stopped');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setStatus('starting');
|
||||||
// Polling will pick up when it's ready. Timeout after 5 min so
|
// Polling will pick up when it's ready. Timeout after 5 min so
|
||||||
// the UI doesn't get stuck in "starting" if the daemon crashes.
|
// the UI doesn't get stuck in "starting" if the daemon crashes.
|
||||||
if (startTimeoutRef.current) {
|
if (startTimeoutRef.current) {
|
||||||
@@ -230,28 +233,6 @@ export const MeshSettings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadMesh = async () => {
|
|
||||||
setError(null);
|
|
||||||
setStatus('downloading');
|
|
||||||
try {
|
|
||||||
const result = await window.electron.downloadMesh();
|
|
||||||
if (result.downloaded) {
|
|
||||||
setStatusInfo((prev) => ({
|
|
||||||
...prev,
|
|
||||||
installed: true,
|
|
||||||
binaryPath: result.binaryPath,
|
|
||||||
}));
|
|
||||||
setStatus('stopped');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Download failed');
|
|
||||||
setStatus('not-installed');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Download failed: ${err}`);
|
|
||||||
setStatus('not-installed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToken = () => {
|
const copyToken = () => {
|
||||||
if (statusInfo.token) {
|
if (statusInfo.token) {
|
||||||
navigator.clipboard.writeText(statusInfo.token);
|
navigator.clipboard.writeText(statusInfo.token);
|
||||||
@@ -286,7 +267,7 @@ export const MeshSettings = () => {
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-yellow-500">
|
<span className="flex items-center gap-1.5 text-xs text-yellow-500">
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
Downloading mesh-llm (~19 MB)...
|
Downloading latest mesh-llm (~19 MB)...
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'not-installed':
|
case 'not-installed':
|
||||||
@@ -352,21 +333,15 @@ export const MeshSettings = () => {
|
|||||||
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
|
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Not installed — offer download or install link */}
|
{/* Not installed — non-macOS only; on macOS start-mesh handles the download */}
|
||||||
{status === 'not-installed' && (
|
{status === 'not-installed' && (
|
||||||
<div className="border border-border-subtle rounded-xl p-4 bg-background-default">
|
<div className="border border-border-subtle rounded-xl p-4 bg-background-default">
|
||||||
<p className="text-sm font-medium text-text-default">Get started</p>
|
<p className="text-sm font-medium text-text-default">Get started</p>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
mesh-llm is a small download (~19 MB) that manages local inference and mesh networking.
|
mesh-llm is not installed. Follow the install guide to set it up, or connect to
|
||||||
Models are downloaded separately when you start a mesh.
|
a mesh already running on this machine.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
{canAutoDownload && (
|
|
||||||
<Button size="sm" onClick={downloadMesh}>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
|
||||||
Download mesh-llm
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<a href="https://docs.anarchai.org/" target="_blank" rel="noopener noreferrer">
|
<a href="https://docs.anarchai.org/" target="_blank" rel="noopener noreferrer">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="w-3 h-3 mr-1" />
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
@@ -384,9 +359,9 @@ export const MeshSettings = () => {
|
|||||||
{/* Downloading */}
|
{/* Downloading */}
|
||||||
{status === 'downloading' && (
|
{status === 'downloading' && (
|
||||||
<div className="border border-yellow-500/30 rounded-xl p-4 bg-yellow-500/5">
|
<div className="border border-yellow-500/30 rounded-xl p-4 bg-yellow-500/5">
|
||||||
<p className="text-sm font-medium text-text-default">Downloading mesh-llm...</p>
|
<p className="text-sm font-medium text-text-default">Downloading latest mesh-llm...</p>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
Downloading and installing to ~/.mesh-llm/. This should only take a moment.
|
Fetching the latest version to ~/.mesh-llm/. This should only take a moment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -504,6 +479,10 @@ export const MeshSettings = () => {
|
|||||||
<Play className="w-3 h-3 mr-1" />
|
<Play className="w-3 h-3 mr-1" />
|
||||||
Start Mesh
|
Start Mesh
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
When you start the mesh, keep goose running to stay connected.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -605,6 +584,10 @@ export const MeshSettings = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Keep goose running to stay connected to the mesh.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Actions row */}
|
{/* Actions row */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={stopMesh}>
|
<Button variant="outline" size="sm" onClick={stopMesh}>
|
||||||
|
|||||||
+19
-242
@@ -23,8 +23,7 @@ import fsSync from 'node:fs';
|
|||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { execFile, execFileSync, execSync, spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import http from 'node:http';
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { checkServerStatus } from './goosed';
|
import { checkServerStatus } from './goosed';
|
||||||
import { startGoosed } from './goosed';
|
import { startGoosed } from './goosed';
|
||||||
@@ -50,6 +49,7 @@ import { UPDATES_ENABLED } from './updates';
|
|||||||
import './utils/recipeHash';
|
import './utils/recipeHash';
|
||||||
import { Client } from './api/client';
|
import { Client } from './api/client';
|
||||||
import { GooseApp } from './api';
|
import { GooseApp } from './api';
|
||||||
|
import * as mesh from './mesh';
|
||||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||||
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
|
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
|
||||||
import { buildCSP } from './utils/csp';
|
import { buildCSP } from './utils/csp';
|
||||||
@@ -712,6 +712,16 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
|||||||
stopErrorLogCollection();
|
stopErrorLogCollection();
|
||||||
errorLog.length = 0;
|
errorLog.length = 0;
|
||||||
|
|
||||||
|
// Nudge the user if mesh is their provider but isn't running.
|
||||||
|
// Delay to let the renderer mount before sending the IPC event.
|
||||||
|
setTimeout(() => {
|
||||||
|
mesh.checkProviderRunning(goosedClient).then((ok) => {
|
||||||
|
if (!ok && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('mesh-not-running');
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
// Let windowStateKeeper manage the window
|
// Let windowStateKeeper manage the window
|
||||||
mainWindowState.manage(mainWindow);
|
mainWindowState.manage(mainWindow);
|
||||||
|
|
||||||
@@ -1565,247 +1575,11 @@ ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string)
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Mesh-LLM lifecycle ──────────────────────────────────────────────
|
// ── Mesh-LLM lifecycle (see mesh.ts) ────────────────────────────────
|
||||||
|
|
||||||
const MESH_API_PORT = 9337;
|
ipcMain.handle('check-mesh', () => mesh.check());
|
||||||
const MESH_CONSOLE_PORT = 3131;
|
ipcMain.handle('start-mesh', (_event, args: string[]) => mesh.start(args));
|
||||||
const MESH_DOWNLOAD_URL =
|
ipcMain.handle('stop-mesh', () => mesh.stop());
|
||||||
'https://github.com/michaelneale/mesh-llm/releases/latest/download/mesh-bundle.tar.gz';
|
|
||||||
|
|
||||||
async function findMeshBinary(): Promise<string | null> {
|
|
||||||
// 1. PATH
|
|
||||||
try {
|
|
||||||
const binPath = execSync('which mesh-llm 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
|
|
||||||
if (binPath) return binPath;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ~/.mesh-llm/ (our download location)
|
|
||||||
const meshDir = path.join(os.homedir(), '.mesh-llm', 'mesh-llm');
|
|
||||||
if (fsSync.existsSync(meshDir)) return meshDir;
|
|
||||||
|
|
||||||
// 3. ~/.local/bin/
|
|
||||||
const localBin = path.join(os.homedir(), '.local', 'bin', 'mesh-llm');
|
|
||||||
if (fsSync.existsSync(localBin)) return localBin;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('check-mesh', async () => {
|
|
||||||
const result: {
|
|
||||||
running: boolean;
|
|
||||||
installed: boolean;
|
|
||||||
models: string[];
|
|
||||||
token?: string;
|
|
||||||
peerCount?: number;
|
|
||||||
nodeStatus?: string;
|
|
||||||
binaryPath?: string;
|
|
||||||
} = { running: false, installed: true, models: [] };
|
|
||||||
|
|
||||||
// Check if mesh-llm binary exists
|
|
||||||
const binary = await findMeshBinary();
|
|
||||||
if (binary) {
|
|
||||||
result.binaryPath = binary;
|
|
||||||
} else {
|
|
||||||
result.installed = false;
|
|
||||||
// Still probe the API — maybe it's running from somewhere unexpected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe the API
|
|
||||||
try {
|
|
||||||
const modelsData: { running: boolean; models: string[] } = await new Promise((resolve) => {
|
|
||||||
const req = http.get(`http://localhost:${MESH_API_PORT}/v1/models`, { timeout: 3000 }, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (chunk: Buffer) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
resolve({ running: false, models: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
if (!Array.isArray(data.data)) {
|
|
||||||
resolve({ running: false, models: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const models = data.data
|
|
||||||
.filter((m: { id?: unknown }) => typeof m.id === 'string')
|
|
||||||
.map((m: { id: string }) => m.id);
|
|
||||||
resolve({ running: true, models });
|
|
||||||
} catch {
|
|
||||||
resolve({ running: false, models: [] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', () => resolve({ running: false, models: [] }));
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
resolve({ running: false, models: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
result.running = modelsData.running;
|
|
||||||
result.models = modelsData.models;
|
|
||||||
} catch {
|
|
||||||
// API not reachable
|
|
||||||
}
|
|
||||||
|
|
||||||
// If running, also grab console status for invite token + peer info
|
|
||||||
if (result.running) {
|
|
||||||
try {
|
|
||||||
const statusData: { token?: string; peerCount?: number; nodeStatus?: string } =
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
const req = http.get(
|
|
||||||
`http://localhost:${MESH_CONSOLE_PORT}/api/status`,
|
|
||||||
{ timeout: 3000 },
|
|
||||||
(res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (chunk: Buffer) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
resolve({
|
|
||||||
token: data.token,
|
|
||||||
peerCount: Array.isArray(data.peers) ? data.peers.length : undefined,
|
|
||||||
nodeStatus: data.node_status,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
resolve({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on('error', () => resolve({}));
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
resolve({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
result.token = statusData.token;
|
|
||||||
result.peerCount = statusData.peerCount;
|
|
||||||
result.nodeStatus = statusData.nodeStatus;
|
|
||||||
} catch {
|
|
||||||
// console not available — that's fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('start-mesh', async (_event, args: string[]) => {
|
|
||||||
const binary = await findMeshBinary();
|
|
||||||
if (!binary) {
|
|
||||||
return {
|
|
||||||
started: false,
|
|
||||||
error: 'mesh-llm not found. Download it first from the Mesh settings tab.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to ~/.mesh-llm/mesh-llm.log
|
|
||||||
const logDir = path.join(os.homedir(), '.mesh-llm');
|
|
||||||
if (!fsSync.existsSync(logDir)) {
|
|
||||||
fsSync.mkdirSync(logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
const logPath = path.join(logDir, 'mesh-llm.log');
|
|
||||||
const out = fsSync.openSync(logPath, 'a');
|
|
||||||
|
|
||||||
// Spawn detached — mesh-llm outlives Goose.
|
|
||||||
// Wait briefly for early spawn errors (bad permissions, missing binary, etc.)
|
|
||||||
const child = spawn(binary, args, {
|
|
||||||
detached: true,
|
|
||||||
stdio: ['ignore', out, out],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await new Promise<{ started: boolean; error?: string; pid?: number }>(
|
|
||||||
(resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
child.removeAllListeners('error');
|
|
||||||
child.unref();
|
|
||||||
resolve({ started: true, pid: child.pid });
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
child.once('error', (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve({ started: false, error: `Failed to spawn mesh-llm: ${err.message}` });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
fsSync.closeSync(out);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('stop-mesh', async () => {
|
|
||||||
try {
|
|
||||||
const binary = await findMeshBinary();
|
|
||||||
if (!binary) {
|
|
||||||
return { stopped: false };
|
|
||||||
}
|
|
||||||
execFileSync(binary, ['stop'], { timeout: 5000, encoding: 'utf8' });
|
|
||||||
return { stopped: true };
|
|
||||||
} catch {
|
|
||||||
return { stopped: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function execFileP(cmd: string, args: string[], opts: { timeout: number }): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
execFile(cmd, args, opts, (err) => (err ? reject(err) : resolve()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('download-mesh', async () => {
|
|
||||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
||||||
return { downloaded: false, error: 'Auto-download is only available on macOS (Apple Silicon)' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const installDir = path.join(os.homedir(), '.mesh-llm');
|
|
||||||
if (!fsSync.existsSync(installDir)) {
|
|
||||||
fsSync.mkdirSync(installDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tarball = path.join(installDir, 'mesh-bundle.tar.gz');
|
|
||||||
try {
|
|
||||||
// Download and extract — mesh-bundle.tar.gz contains mesh-bundle/{mesh-llm,rpc-server,llama-server}
|
|
||||||
await execFileP('curl', ['-fsSL', '-o', tarball, MESH_DOWNLOAD_URL], { timeout: 120000 });
|
|
||||||
await execFileP('tar', ['xz', '--strip-components=1', '-C', installDir, '-f', tarball], { timeout: 30000 });
|
|
||||||
|
|
||||||
const binary = path.join(installDir, 'mesh-llm');
|
|
||||||
if (!fsSync.existsSync(binary)) {
|
|
||||||
return { downloaded: false, error: 'Download succeeded but mesh-llm binary not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS: ad-hoc codesign + clear quarantine to avoid Gatekeeper prompts
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
for (const name of ['mesh-llm', 'rpc-server', 'llama-server']) {
|
|
||||||
const bin = path.join(installDir, name);
|
|
||||||
if (fsSync.existsSync(bin)) {
|
|
||||||
try {
|
|
||||||
await execFileP('codesign', ['-s', '-', bin], { timeout: 10000 });
|
|
||||||
} catch {
|
|
||||||
// codesign may fail if already signed
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await execFileP('xattr', ['-cr', bin], { timeout: 10000 });
|
|
||||||
} catch {
|
|
||||||
// xattr may fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { downloaded: true, binaryPath: binary };
|
|
||||||
} catch (err) {
|
|
||||||
return { downloaded: false, error: `Download failed: ${err}` };
|
|
||||||
} finally {
|
|
||||||
try { fsSync.unlinkSync(tarball); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('check-ollama', async () => {
|
ipcMain.handle('check-ollama', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -2716,6 +2490,9 @@ async function getAllowList(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('will-quit', async () => {
|
app.on('will-quit', async () => {
|
||||||
|
// Stop the mesh child process if we spawned one.
|
||||||
|
mesh.cleanup();
|
||||||
|
|
||||||
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
|
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
|
||||||
try {
|
try {
|
||||||
powerSaveBlocker.stop(blockerId);
|
powerSaveBlocker.stop(blockerId);
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* mesh-llm process lifecycle — download, start, stop, auto-start.
|
||||||
|
*
|
||||||
|
* macOS (Apple Silicon) only for download/spawn; other platforms can still
|
||||||
|
* probe the API port to detect an externally-running mesh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile, execFileSync, spawn } from 'child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import fsSync from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import log from './utils/logger';
|
||||||
|
import { Client } from './api/client';
|
||||||
|
import { readConfig } from './api/sdk.gen';
|
||||||
|
|
||||||
|
const API_PORT = 9337;
|
||||||
|
const CONSOLE_PORT = 3131;
|
||||||
|
const DOWNLOAD_URL =
|
||||||
|
'https://github.com/michaelneale/mesh-llm/releases/latest/download/mesh-bundle.tar.gz';
|
||||||
|
|
||||||
|
let childProcess: ReturnType<typeof spawn> | null = null;
|
||||||
|
|
||||||
|
function execFileP(cmd: string, args: string[], opts: { timeout: number }): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(cmd, args, opts, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Binary discovery ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function findBinary(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const binPath = execFileSync('which', ['mesh-llm'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||||
|
if (binPath) return binPath;
|
||||||
|
} catch {
|
||||||
|
// ignore — which returns non-zero if not found
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshDir = path.join(os.homedir(), '.mesh-llm', 'mesh-llm');
|
||||||
|
if (fsSync.existsSync(meshDir)) return meshDir;
|
||||||
|
|
||||||
|
const localBin = path.join(os.homedir(), '.local', 'bin', 'mesh-llm');
|
||||||
|
if (fsSync.existsSync(localBin)) return localBin;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBinary(): Promise<{ binary: string } | { error: string }> {
|
||||||
|
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
||||||
|
return { error: 'Auto-download is only available on macOS (Apple Silicon)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const installDir = path.join(os.homedir(), '.mesh-llm');
|
||||||
|
if (!fsSync.existsSync(installDir)) {
|
||||||
|
fsSync.mkdirSync(installDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tarball = path.join(installDir, 'mesh-bundle.tar.gz');
|
||||||
|
try {
|
||||||
|
await execFileP('curl', ['-fsSL', '-o', tarball, DOWNLOAD_URL], { timeout: 120000 });
|
||||||
|
await execFileP('tar', ['xz', '--strip-components=1', '-C', installDir, '-f', tarball], {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const binary = path.join(installDir, 'mesh-llm');
|
||||||
|
if (!fsSync.existsSync(binary)) {
|
||||||
|
return { error: 'Download succeeded but mesh-llm binary not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of ['mesh-llm', 'rpc-server', 'llama-server']) {
|
||||||
|
const bin = path.join(installDir, name);
|
||||||
|
if (fsSync.existsSync(bin)) {
|
||||||
|
try {
|
||||||
|
await execFileP('codesign', ['-s', '-', bin], { timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
/* codesign may fail if already signed */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execFileP('xattr', ['-cr', bin], { timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
/* xattr may fail */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { binary };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `Download failed: ${err}` };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fsSync.unlinkSync(tarball);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port probing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function isRunning(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.get(`http://localhost:${API_PORT}/v1/models`, { timeout: 2000 }, (res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode === 200);
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(false));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status check (used by check-mesh IPC) ───────────────────────────
|
||||||
|
|
||||||
|
export interface MeshStatus {
|
||||||
|
running: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
models: string[];
|
||||||
|
token?: string;
|
||||||
|
peerCount?: number;
|
||||||
|
nodeStatus?: string;
|
||||||
|
binaryPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function check(): Promise<MeshStatus> {
|
||||||
|
const result: MeshStatus = { running: false, installed: true, models: [] };
|
||||||
|
|
||||||
|
const binary = await findBinary();
|
||||||
|
if (binary) {
|
||||||
|
result.binaryPath = binary;
|
||||||
|
} else {
|
||||||
|
result.installed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe the API
|
||||||
|
try {
|
||||||
|
const modelsData = await new Promise<{ running: boolean; models: string[] }>((resolve) => {
|
||||||
|
const req = http.get(`http://localhost:${API_PORT}/v1/models`, { timeout: 3000 }, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
resolve({ running: false, models: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
if (!Array.isArray(data.data)) {
|
||||||
|
resolve({ running: false, models: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const models = data.data
|
||||||
|
.filter((m: { id?: unknown }) => typeof m.id === 'string')
|
||||||
|
.map((m: { id: string }) => m.id);
|
||||||
|
resolve({ running: true, models });
|
||||||
|
} catch {
|
||||||
|
resolve({ running: false, models: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve({ running: false, models: [] }));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({ running: false, models: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result.running = modelsData.running;
|
||||||
|
result.models = modelsData.models;
|
||||||
|
} catch {
|
||||||
|
// API not reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.running) {
|
||||||
|
try {
|
||||||
|
const statusData = await new Promise<{
|
||||||
|
token?: string;
|
||||||
|
peerCount?: number;
|
||||||
|
nodeStatus?: string;
|
||||||
|
}>((resolve) => {
|
||||||
|
const req = http.get(
|
||||||
|
`http://localhost:${CONSOLE_PORT}/api/status`,
|
||||||
|
{ timeout: 3000 },
|
||||||
|
(res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
resolve({
|
||||||
|
token: data.token,
|
||||||
|
peerCount: Array.isArray(data.peers) ? data.peers.length : undefined,
|
||||||
|
nodeStatus: data.node_status,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', () => resolve({}));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
result.token = statusData.token;
|
||||||
|
result.peerCount = statusData.peerCount;
|
||||||
|
result.nodeStatus = statusData.nodeStatus;
|
||||||
|
} catch {
|
||||||
|
// console not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start / stop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function spawnAttached(
|
||||||
|
binary: string,
|
||||||
|
args: string[]
|
||||||
|
): Promise<{ started: boolean; error?: string; pid?: number }> {
|
||||||
|
const logDir = path.join(os.homedir(), '.mesh-llm');
|
||||||
|
if (!fsSync.existsSync(logDir)) fsSync.mkdirSync(logDir, { recursive: true });
|
||||||
|
const logPath = path.join(logDir, 'mesh-llm.log');
|
||||||
|
const out = fsSync.openSync(logPath, 'a');
|
||||||
|
|
||||||
|
const child = spawn(binary, args, { stdio: ['ignore', out, out] });
|
||||||
|
childProcess = child;
|
||||||
|
child.on('exit', () => {
|
||||||
|
if (childProcess === child) childProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.removeAllListeners('error');
|
||||||
|
resolve({ started: true, pid: child.pid });
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
child.once('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
childProcess = null;
|
||||||
|
resolve({ started: false, error: `Failed to spawn mesh-llm: ${err.message}` });
|
||||||
|
});
|
||||||
|
}).then((result) => {
|
||||||
|
fsSync.closeSync(out);
|
||||||
|
return result as { started: boolean; error?: string; pid?: number };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start(
|
||||||
|
args: string[]
|
||||||
|
): Promise<{ started: boolean; error?: string; pid?: number; alreadyRunning?: boolean }> {
|
||||||
|
if (await isRunning()) {
|
||||||
|
return { started: true, alreadyRunning: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dlResult = await downloadBinary();
|
||||||
|
let binary: string;
|
||||||
|
if ('error' in dlResult) {
|
||||||
|
const existing = await findBinary();
|
||||||
|
if (!existing) {
|
||||||
|
return { started: false, error: dlResult.error };
|
||||||
|
}
|
||||||
|
binary = existing;
|
||||||
|
} else {
|
||||||
|
binary = dlResult.binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return spawnAttached(binary, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stop(): Promise<{ stopped: boolean }> {
|
||||||
|
if (childProcess) {
|
||||||
|
cleanup();
|
||||||
|
return { stopped: true };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const binary = await findBinary();
|
||||||
|
if (!binary) return { stopped: false };
|
||||||
|
execFileSync(binary, ['stop'], { timeout: 5000, encoding: 'utf8' });
|
||||||
|
return { stopped: true };
|
||||||
|
} catch {
|
||||||
|
return { stopped: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup(): void {
|
||||||
|
if (!childProcess) return;
|
||||||
|
try {
|
||||||
|
childProcess.kill('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
childProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Startup check ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function checkProviderRunning(goosedClient: Client): Promise<boolean> {
|
||||||
|
const res = await readConfig({
|
||||||
|
body: { key: 'GOOSE_PROVIDER', is_secret: false },
|
||||||
|
client: goosedClient,
|
||||||
|
});
|
||||||
|
const provider = typeof res.data === 'string' ? res.data : String(res.data ?? '');
|
||||||
|
if (!provider.includes('mesh')) return true;
|
||||||
|
if (await isRunning()) return true;
|
||||||
|
|
||||||
|
log.info('Mesh provider configured but not running');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -126,7 +126,6 @@ type ElectronAPI = {
|
|||||||
}>;
|
}>;
|
||||||
startMesh: (args: string[]) => Promise<{ started: boolean; error?: string; pid?: number }>;
|
startMesh: (args: string[]) => Promise<{ started: boolean; error?: string; pid?: number }>;
|
||||||
stopMesh: () => Promise<{ stopped: boolean }>;
|
stopMesh: () => Promise<{ stopped: boolean }>;
|
||||||
downloadMesh: () => Promise<{ downloaded: boolean; error?: string; binaryPath?: string }>;
|
|
||||||
selectFileOrDirectory: (defaultPath?: string) => Promise<string | null>;
|
selectFileOrDirectory: (defaultPath?: string) => Promise<string | null>;
|
||||||
getBinaryPath: (binaryName: string) => Promise<string>;
|
getBinaryPath: (binaryName: string) => Promise<string>;
|
||||||
readFile: (directory: string) => Promise<FileResponse>;
|
readFile: (directory: string) => Promise<FileResponse>;
|
||||||
@@ -218,7 +217,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
checkMesh: () => ipcRenderer.invoke('check-mesh'),
|
checkMesh: () => ipcRenderer.invoke('check-mesh'),
|
||||||
startMesh: (args: string[]) => ipcRenderer.invoke('start-mesh', args),
|
startMesh: (args: string[]) => ipcRenderer.invoke('start-mesh', args),
|
||||||
stopMesh: () => ipcRenderer.invoke('stop-mesh'),
|
stopMesh: () => ipcRenderer.invoke('stop-mesh'),
|
||||||
downloadMesh: () => ipcRenderer.invoke('download-mesh'),
|
|
||||||
selectFileOrDirectory: (defaultPath?: string) =>
|
selectFileOrDirectory: (defaultPath?: string) =>
|
||||||
ipcRenderer.invoke('select-file-or-directory', defaultPath),
|
ipcRenderer.invoke('select-file-or-directory', defaultPath),
|
||||||
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),
|
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),
|
||||||
|
|||||||
Reference in New Issue
Block a user