Или история о том, как два ИИ вместе создали инструмент разработчика, пока я просто передавал файлы между ними
Несколько месяцев назад я поймал себя на мысли: я плачу за Cursor, плачу за Claude Pro, использую квоты, завишу от серверов в другой стране — и при этом сам инструмент закрытый, его нельзя ни изменить под себя, ни развернуть локально. А что, если сделать свой?
Идея казалась амбициозной. Но у меня уже были под рукой Google AI Studio с его бесплатным тарифом, Claude Chat как умный ревьюер кода, и желание разобраться — насколько далеко можно зайти, не написав самому ни строчки кода.
Спойлер: очень далеко.
Прежде чем рассказывать о процессе, покажу, что получилось на выходе. Это браузерная IDE с подключением к удалённому серверу через SSH, агентом на базе LLM и набором возможностей, который удивил даже меня.
Интерфейс и редактор
Полноценная IDE в браузере — тёмная тема, интерфейс в стиле VS Code, адаптированный под длительную работу с кодом
Встроенный редактор Monaco (то самое ядро, на котором работает VS Code) с подсветкой синтаксиса для десятков языков
Разделённый интерфейс: файловый менеджер слева, редактор по центру, чат с ИИ справа
Адаптивные панели с возможностью изменения ширины перетаскиванием
Визуальные индикаторы статуса файлов: несохранённые изменения, файлы в процессе загрузки, синхронизированные версии
Подключение и работа с сервером
Безопасное stateless-подключение к любому SSH-серверу по логину и паролю — без установки агентов на сервер
Интерактивный файловый менеджер с поддержкой ленивой загрузки вложенных папок
Создание, переименование, перемещение, копирование и удаление файлов и папок прямо из интерфейса
Синхронизация папок: выбор корневой директории и рекурсивная загрузка всего дерева файлов
Встроенный терминал на базе Xterm.js с поддержкой ресайза для выполнения shell-команд прямо на сервере
Автоматическое определение изменений с возможностью сохранения всех файлов одним кликом
ИИ-агент
Два режима работы: "Чат" для консультаций по коду и "Агент" для автономного выполнения задач
Поддержка нескольких топовых LLM: Google Gemini, GPT, Grok, Claude — переключение без перезагрузки
Агент самостоятельно читает структуру проекта, планирует шаги и вносит изменения в файлы
Умный пат��инг файлов: алгоритм с двухуровневым поиском (точное совпадение + fuzzy regex) для точечного изменения кода без полной перезаписи
Система подтверждений для критических действий: удаление, перемещение, выполнение плана — всё требует явного ОК от пользователя
Визуализация пошагового плана (Chain of Thought) перед выполнением: агент показывает, что собирается делать, вы решаете — запускать или нет
Автоматическое добавление файлов в контекст при открытии или чтении агентом
Работа с изменениями кода
Visual Diff Viewer: встроенное сравнение версий до применения изменений
Массовое ревью: модальное окно для просмотра, принятия или отклонения изменений сразу во всех файлах
История изменений сессии с откатом (Undo) для любого файла, который трогал агент
Pending changes — буфер изменений между агентом и реальным сохранением на сервер
Автосохранение рабочей копии в редакторе с явным шагом публикации на сервер
Управление контекстом ИИ
Панель "В контексте ИИ": список файлов, которые модель видит при каждом запросе
Ручное управление: добавление и удаление файлов из контекста одним кликом прямо в дереве файлов
Индикатор иконкой Brain рядом с каждым файлом — сразу видно, что в памяти у агента
Оценка расхода токенов на контекст в реальном времени: сколько токенов занимают загруженные файлы
Кнопка "Очистить всё" для сброса контекста перед новой задачей
Дополнительно
Голосовое управление: запись и автоматическая транскрипция (Speech-to-Text) — общайтесь с агентом без клавиатуры
Облачное сохранение проектов: настройки подключений, история чатов, метаданные — всё в базе данных
Мониторинг токенов и символов в реальном времени
Системный отладчик: просмотр "сырого" системного промпта, который уходит в LLM
Логирование всех действий SSH-сервиса и ответов API-роутера во встроенной консоли
Полная изоляция: агент физически не может выйти за пределы выбранной папки проекта
Браузер не умеет подключаться к SSH напрямую. Нужен посредник — небольшой сервер, который принимает REST-запросы от фронтенда и транслирует их в SSH-команды.
Я описал задачу в Google AI Studio: нужен Express-сервер, который через заголовок x-ssh-auth получает credentials в base64, подключается к серверу через библиотеку ssh2 и предоставляет REST API для базовых файловых операций.
Ключевое архитектурное решение здесь — stateless-подход. Каждый запрос открывает новое SSH-соединение, выполняет задачу и закрывает его. Никакого постоянного соединения, никакого состояния на сервере. Это упрощает масштабирование и делает сервер устойчивым к обрывам.
AI Studio сгенерировал рабочий сервер примерно за два промпта. Вот что получилось в итоге — семь эндпоинтов:
GET /api/files — рекурсивное дерево файлов директории
GET /api/file — чтение содержимого файла
POST /api/file — запись / создание файла
POST /api/folder — создание директории (через mkdir -p)
DELETE /api/file — удаление файла или папки (rm -rf для директорий)
PUT /api/file/rename — переименование / перемещение
POST /api/file/copy — копирование
[СКРИНШОТ: код функции executeSSH — как выглядит stateless-подход с открытием/закрытием соединения]
Один момент, который стоит выделить: заголовок авторизации кодируется на клиенте в base64 с учётом Unicode (кириллица в паролях — реальная жизнь), а на сервере декодируется обратно. Это позволяет не хранить credentials нигде — ни в куках, ни в localStorage.
Полный код сервера SSH:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { Client } = require('ssh2');
const path = require('path');
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.text({ type: '*/*' }));
const PORT = process.env.PORT || 3016;
// ==================== SSH HELPER ====================
function executeSSH(config, callback) {
const conn = new Client();
const sshConfig = {
host: config.host,
port: config.port || 22,
username: config.username,
password: config.password,
readyTimeout: 20000,
algorithms: {
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1',
],
serverHostKey: [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa'
],
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'aes128-cbc', 'aes256-cbc'
]
}
};
return new Promise((resolve, reject) => {
conn.on('ready', async () => {
try {
const result = await callback(conn);
resolve(result);
} catch (err) {
reject(err);
} finally {
conn.end();
}
});
conn.on('error', (err) => reject(err));
conn.connect(sshConfig);
});
}
// ==================== AUTH MIDDLEWARE ====================
const authMiddleware = (req, res, next) => {
const authHeader = req.headers['x-ssh-auth'];
if (!authHeader) return res.status(401).json({ error: 'Missing x-ssh-auth header' });
try {
const decoded = JSON.parse(Buffer.from(authHeader, 'base64').toString('utf-8'));
if (!decoded.host || !decoded.username || !decoded.password) throw new Error('Invalid auth payload');
req.sshConfig = decoded;
next();
} catch (e) {
res.status(400).json({ error: 'Invalid auth header format' });
}
};
app.use(authMiddleware);
// ==================== ROUTES ====================
// 1. Получить структуру файлов (Stateless)
app.get('/api/files', async (req, res) => {
const targetPath = req.query.path || '.';
try {
const files = await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
const allFiles = [];
const readDirRecursive = (dirPath) => {
return new Promise((res, rej) => {
sftp.readdir(dirPath, (err, list) => {
if (err) return res([]); // Игнорируем ошибки доступа
const promises = list.map(item => {
const fullPath = path.posix.join(dirPath, item.filename);
const type = item.longname.startsWith('d') ? 'directory' : 'file';
allFiles.push({ name: item.filename, path: fullPath, type: type });
if (type === 'directory') return readDirRecursive(fullPath);
return Promise.resolve();
});
Promise.all(promises).then(() => res()).catch(rej);
});
});
};
readDirRecursive(targetPath).then(() => resolve(allFiles)).catch(reject);
});
});
});
res.json({ files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. Прочитать файл
app.get('/api/file', async (req, res) => {
const filePath = req.query.path;
if (!filePath) return res.status(400).json({ error: 'Path required' });
try {
const content = await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
let data = '';
const stream = sftp.createReadStream(filePath);
stream.on('data', chunk => data += chunk);
stream.on('end', () => resolve(data));
stream.on('error', reject);
});
});
});
res.send(content);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3. Создать папку (Оптимизировано)
app.post('/api/folder', async (req, res) => {
const dirPath = req.query.path;
if (!dirPath) return res.status(400).json({ error: 'Path required' });
try {
await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.exec(`mkdir -p "${dirPath}"`, (err, stream) => {
if (err) return reject(err);
stream.on('data', () => {}); // Важно: потребляем stdout
stream.stderr.on('data', () => {}); // Важно: потребляем stderr
stream.on('close', (code) => {
if (code !== 0) return reject(new Error(`mkdir failed with code ${code}`));
resolve();
});
});
});
});
res.json({ success: true, message: 'Folder created' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. Записать/Создать файл
app.post('/api/file', async (req, res) => {
const filePath = req.query.path;
const content = req.body; // express.text обрабатывает тело как строку
if (!filePath) return res.status(400).json({ error: 'Path required' });
try {
await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
const stream = sftp.createWriteStream(filePath);
stream.write(content);
stream.end();
stream.on('close', resolve);
stream.on('error', reject);
});
});
});
res.json({ success: true, message: 'File saved' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 5. Удаление файла или папки
app.delete('/api/file', async (req, res) => {
const itemPath = req.query.path;
if (!itemPath) return res.status(400).json({ error: 'Path required' });
try {
await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
sftp.stat(itemPath, (err, stats) => {
if (err) return reject(err);
if (stats.isDirectory()) {
conn.exec(`rm -rf "${itemPath}"`, (err, stream) => {
if (err) return reject(err);
stream.on('close', resolve);
});
} else {
sftp.unlink(itemPath, (err) => err ? reject(err) : resolve());
}
});
});
});
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 6. Переименование / Перемещение
app.put('/api/file/rename', async (req, res) => {
const { oldPath, newPath } = req.body;
if (!oldPath || !newPath) return res.status(400).json({ error: 'Paths required' });
try {
await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
sftp.rename(oldPath, newPath, (err) => err ? reject(err) : resolve());
});
});
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7. Дублирование (Копирование)
app.post('/api/file/copy', async (req, res) => {
const { source, destination } = req.body;
if (!source || !destination) return res.status(400).json({ error: 'Source and Destination required' });
try {
await executeSSH(req.sshConfig, (conn) => {
return new Promise((resolve, reject) => {
conn.exec(`cp -r "${source}" "${destination}"`, (err, stream) => {
if (err) return reject(err);
let stderr = '';
stream.stderr.on('data', (data) => stderr += data);
stream.on('close', (code) => {
if (code !== 0) return reject(new Error(`Copy failed: ${stderr}`));
resolve();
});
});
});
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ==================== START ====================
app.listen(PORT, '0.0.0.0', () => {
console.log(`Stateless SSH REST API running on port ${PORT}`);
});
Проект хранит пользовательские данные в MySQL. Вся структура уместилась в одной таблице user_projects:
CREATE TABLE `user_projects` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_email` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`ssh_host` varchar(255) NOT NULL,
`ssh_user` varchar(255) NOT NULL,
`root_dir` varchar(255) DEFAULT NULL,
`ssh_password` varchar(255) NOT NULL,
`chat_history` longtext,
`token_usage` int(11) DEFAULT '0',
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Здесь хранится всё: параметры SSH-подключения, выбранная корневая папка, история переписки с ИИ и счётчик потраченных токенов. Никаких ORM, никаких миграций — просто запросы через прокси к MySQL.
Для работы с базой использовался готовый MySQL-прокси: HTTP-запрос с SQL внутри, ответ в JSON. Google AI Studio сгенерировал весь клиентский код для работы с этим API за один сеанс.
Дальше начинается самая интересная часть.
Весь фронтенд — React + TypeScript + Tailwind — писал Google AI Studio. Я описывал задачу, вставлял сгенерированный код в проект, смотрел на результат, описывал что не так — и так по кругу.
Но параллельно я делал кое-что ещё. После каждого значимого изменения я открывал Claude Chat и скидывал ему изменённые файлы. Не для того, чтобы он что-то переписывал — а чтобы он делал концептуальное ревью: правильно ли архитектурное решение, нет ли логических ошибок, что стоит улучшить дальше.
Claude не писал код — он думал вместе со мной. Это оказалось невероятно продуктивной связкой:
Google AI Studio — быстро генерирует, хорошо держит большой контекст файлов, отлично справляется с "напиши вот это"
Claude Chat — видит архитектурные проблемы, замечает потенциальные баги, предлагает концептуальные улучшения
Я был мостиком между ними: брал файлы из AI Studio, показывал Claude, брал комментарии Claude, формулировал следующий промпт для AI Studio.
Первая версия инструмента была просто чатом с ИИ, который видел структуру файлов и мог читать их содержимое. Уже это было полезно.
Но настоящий прорыв случился, когда появился агентный режим — способность ИИ самостоятельно выполнять цепочки действий: прочитать файл, понять структуру, составить план, изменить код, проверить результат.
Архитектура агента строится на function calling: модели даётся набор инструментов с описаниями, она сама решает какой вызвать и в каком порядке. Вот полный список инструментов, которые видит агент:
list_dir — посмотреть содержимое папки
read_file — прочитать файл
patch_file — точечно изменить фрагмент кода (поиск + замена)
write_file — создать новый файл или полностью перезаписать
create_folder — создать директорию
delete_item — удалить (с подтверждением пользователя)
move_item — переместить или переименовать (с подтверждением)
copy_item — скопировать (с подтверждением)
create_plan — сформировать план действий (обязательный шаг перед изменениями)
undo_edit — откатить изменения в файле к предыдущей версии
task_completed — завершить задачу и показать отчёт
[СКРИНШОТ: чат с агентом — видны блоки инструментов: list_dir, read_file, create_plan]
Важный момент: create_plan — обязательный инструмент. Агент не может изменить ни один файл, не показав сначала, что именно собирается сделать. Пользователь видит пошаговый план и либо утверждает его, либо отклоняет. Это снимает главный страх перед автономными агентами — "а вдруг он что-то сломает".
Это одна из самых важных технических деталей, которую подсветил Claude в процессе ревью.
Изначально агент изменял файлы через write_file — просто генерировал полное содержимое файла и записывал его. Проблема в том, что языковые модели имеют "рабочую память" на большие объёмы кода: они компрессируют, пропускают, сокращают. Даже если файл был прочитан несколько шагов назад, к моменту записи часть кода могла "испариться".
Решение — инструмент patch_file с параметрами search_str и replace_str. Агент передаёт только тот фрагмент, который нужно найти, и то, чем его заменить. Остальной файл остаётся нетронутым — агент его даже не видит.
На стороне клиента реализован двухуровневый поиск:
Точное совпадение строки
Fuzzy regex с нормализацией пробелов — на случай если модель вернула search_str с чуть другими отступами
Если оба варианта не нашли совпадение, агент получает ошибку с подсказкой и пробует снова.
Ещё одна проблема, которую мы решили в процессе: агент должен понимать кодовую базу до того, как начнёт планировать. Если он видит только список путей — он строит план вслепую.
Решение: система явного управления контекстом. В сайдбаре появилась панель "В контексте ИИ" — список файлов, содержимое которых передаётся модели при каждом запросе. Рядом с каждым файлом в дереве — иконка Brain. Нажал — файл добавлен в контекст. Нажал ещё раз — убрал.
Файлы добавляются в контекст автоматически при открытии в редакторе или при чтении агентом. Можно очистить весь контекст перед новой задачей одной кнопкой. Панель показывает приблизительное количество токенов, которое занимают загруженные файлы — это помогает не выйти за пределы разумного.
Агент может изменить несколько файлов за один сеанс. Просто принять все изменения на слово — не лучшая идея. Поэтому в инструменте есть два механизма проверки.
Visual Diff Viewer — встроенное сравнение версий прямо в редакторе. Зелёное — добавлено, красное — удалено. Можно посмотреть на каждый файл до того, как нажать "Сохранить".
Модальное окно массового ревью — открывается после завершения задачи агентом. Показывает все изменённые файлы списком, каждое изменение можно принять или отклонить по отдельности. Только после явного подтверждения файлы уходят на сервер.
Отдельного разговора заслуживает то, как реализована поддержка нескольких моделей.
Все запросы к LLM идут не напрямую к OpenAI или Anthropic, а через роутер ProTalk — российский сервис, который проксирует запросы к топовым моделям: Gemini, GPT, Claude, Grok. Роутер работает через российские серверы, что решает вопрос доступности без VPN.
Тариф за 490 рублей в месяц даёт 2 миллиона токенов. Для сравнения: Cursor или Claude Code тратят токены на порядок быстрее, потому что при каждом обращении передают в контекст весь проект целиком. Здесь же пользователь сам контролирует, что попадает в контекст — и расход получается на порядки меньше.
С технической стороны всё просто: один эндпоинт роутера, авторизация через токен, стандартный формат OpenAI Chat Completions. Переключение между моделями — это просто смена строки с названием модели в запросе.
Когда я смотрю на то, что получилось, меня поражает не столько функциональность — сколько то, как это было создано.
Инструмент уровня Cursor, с агентом, Diff viewer, управлением контекстом и SSH-интеграцией — написан преимущественно двумя ИИ-системами, пока я выступал в роли архитектора и ревьюера. Бесплатными инструментами: Google AI Studio на бесплатном тарифе и Claude Chat.
Важная мысль, которую хочу донести: то, что вы создаёте таким способом — полностью ваше. Весь код открыт, вся инфраструктура под вашим контролем. Хотите развернуть на своём сервере — разворачивайте. Хотите добавить функцию — описываете ИИ, получаете код, проверяете. Хотите монетизировать — это ваш продукт, ваши правила.
Курсор за подписку даёт вам инструмент, который вы не контролируете. То, что описано в этой статье, даёт вам инструмент, который вы понимаете — потому что участвовали в каждом архитектурном решении.
Мы построили:
SSH-прокси на Node.js — stateless REST API для работы с файловой системой удалённого сервера
React-фронтенд с редактором Monaco, файловым менеджером и чатом с ИИ-агентом
Агента с инструментами, системой планирования, умным патчингом и управлением контекстом
Полную систему ревью изменений: Visual Diff, массовое принятие/отклонение, история откатов
Интеграцию с ProTalk для мультимодельности через российский роутер
Весь стек: Node.js, React, TypeScript, Monaco Editor, MySQL, ssh2, Xterm.js. Ничего экзотического.
Если вам интересно повторить этот опыт — я готов поделиться деталями: архитектурными решениями, которые не вошли в статью, промптами для AI Studio, которые давали наилучшие результаты, настройками роутера ProTalk, и SSH-прокси, который можно развернуть на своём VPS за 15 минут.
P.S. В комментариях напишите еще кому интересно попробовать сам инструмент в своих проектах и я вышлю ссылку с доступом. Если удобнее не в комментарии, то пишите мне в мой телеграм: https://t.me/chatgptdom_telegram_bot>.
Пишите в комментариях или напрямую — расскажу всё, что знаю.









