Compare commits

...

10 Commits

10 changed files with 1556 additions and 86 deletions
+2
View File
@@ -1,3 +1,5 @@
node_modules/ node_modules/
dist/ dist/
.env .env
.claude/
*.log
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
# Telegram Bot для управления сессиями Claude Code
## Обзор
Персональный Telegram-бот для управления сессиями Claude Code, запущенными на локальном сервере. Позволяет создавать, останавливать, переключать сессии для разных проектов и вести полноценный диалог с Claude через Telegram.
## Решения
- **Подход:** Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) — программный контроль сессий из одного процесса
- **Пользователь:** один (персональный бот)
- **Взаимодействие:** полноценный чат — сообщения в Telegram отправляются как промпты в активную сессию
- **Управление проектами:** быстрый старт по пути + именованные закладки
- **Мультисессия:** одна активная сессия получает сообщения, переключение через `/switch` или inline-кнопки
- **Вывод:** только финальный результат (без стриминга промежуточных шагов)
## Архитектура
```
┌─────────────┐ Telegram Bot API ┌──────────────────────┐
│ Telegram │◄───────────────────────►│ Bot Server │
│ (владелец) │ │ (Bun + TypeScript) │
└─────────────┘ │ │
│ ┌────────────────┐ │
│ │ Session Manager │ │
│ │ - activeSession│ │
│ │ - sessions map │ │
│ │ - projects map │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ Claude Agent SDK│ │
│ │ query({ │ │
│ │ prompt, │ │
│ │ cwd, │ │
│ │ resume, │ │
│ │ allowedTools │ │
│ │ }) │ │
│ └────────────────┘ │
└──────────────────────┘
┌──────────▼──────────┐
│ Файловая система │
│ сервера (проекты) │
└─────────────────────┘
```
**Три компонента:**
1. **Telegram Bot** (grammy) — принимает сообщения, отправляет ответы, inline-кнопки
2. **Session Manager** — CRUD сессий, переключение, закладки проектов, персистентность
3. **Claude Agent SDK** — выполнение запросов, управление session ID, resume
**Стек:** TypeScript, Bun, grammy, @anthropic-ai/claude-agent-sdk
## Команды бота
### Управление сессиями
| Команда | Описание |
|---------|----------|
| `/start <path или name>` | Создать новую сессию для проекта |
| `/stop` | Остановить текущую активную сессию |
| `/stop <name>` | Остановить конкретную сессию |
| `/stopall` | Остановить все сессии |
| `/switch <name>` | Переключиться на другую активную сессию |
| `/sessions` | Список активных сессий с inline-кнопками для переключения |
| `/status` | Статус текущей сессии (idle/busy, проект, cwd) |
### Управление проектами (закладки)
| Команда | Описание |
|---------|----------|
| `/save <name> <path>` | Сохранить закладку проекта |
| `/remove <name>` | Удалить закладку |
| `/projects` | Список закладок с inline-кнопками для запуска |
### Прочее
| Команда | Описание |
|---------|----------|
| `/help` | Справка по командам |
### Обычные сообщения
Текст без `/` отправляется как промпт в текущую активную сессию.
### Inline-кнопки
- `/sessions` — кнопки с именами сессий для быстрого переключения
- `/projects` — кнопки "Запустить <name>" для быстрого старта сессии
### Обработка ошибок
- Сообщение без активной сессии → "Нет активной сессии. Используй /start"
- Сообщение пока сессия busy → "Сессия занята, дождись ответа"
- `/start` с уже существующим именем → предложить `/switch`
## Модель данных
Состояние хранится в `~/.claude-telegram-bot/state.json`:
```typescript
interface AppState {
// Закладки проектов
projects: Record<string, string>; // name → absolute path
// Активные сессии
sessions: Record<string, { // name → session info
sessionId: string; // ID из Claude Agent SDK
cwd: string; // рабочая директория
status: "idle" | "busy";
}>;
// Какая сессия сейчас принимает сообщения
activeSession: string | null; // name из sessions
}
```
**Персистентность:**
- Записывается при каждом изменении
- При старте бота загружается из файла
- `sessions` при рестарте: sessionId сохраняются, при следующем сообщении пробуем resume — если не удаётся, сообщаем пользователю
- `projects` переживают перезапуск полностью
**Именование сессий:**
- `/start /d/my-project` → имя из последнего сегмента пути: `my-project`
- `/start mybot` (закладка) → имя = имя закладки
- Конфликт имён → суффикс: `my-project-2`
## Обработка сообщений
```
Telegram сообщение
├─ начинается с "/" → парсинг команды → выполнение
└─ обычный текст → отправка в активную сессию:
1. Проверить activeSession != null
2. Проверить session.status === "idle"
3. Поставить status = "busy"
4. Отправить typing индикатор
5. query({ prompt: text, resume: sessionId })
6. Собрать финальный ResultMessage
7. Отправить ответ (разбить на чанки если > 4096 символов)
8. Поставить status = "idle"
При ошибке → сообщение об ошибке, вернуть status = "idle"
```
**Разбивка длинных ответов:**
- Лимит Telegram — 4096 символов
- Разбиваем по границам `\n\n`, затем `\n`
- Markdown-форматирование в каждом чанке
## Безопасность
- **Allowlist по chat_id** — бот отвечает только владельцу (TELEGRAM_OWNER_ID), остальных игнорирует
- **Permissions для Claude Agent SDK** — `permissionMode: "bypassPermissions"` (персональный бот, интерактивный approval нецелесообразен)
- **Без валидации путей** — единственный пользователь, ограничение файловой системы не нужно
## Конфигурация
Файл `.env`:
```
TELEGRAM_BOT_TOKEN=<токен от BotFather>
TELEGRAM_OWNER_ID=<твой Telegram user ID>
ANTHROPIC_API_KEY=<API ключ Anthropic>
```
## Структура проекта
```
claude-telegram-bot/
├── src/
│ ├── index.ts # Точка входа
│ ├── bot.ts # grammy Bot, middleware owner check, хендлеры
│ ├── handlers/
│ │ ├── commands.ts # Хендлеры команд
│ │ └── message.ts # Текстовые сообщения → Claude
│ ├── services/
│ │ ├── session-manager.ts # CRUD сессий, переключение
│ │ └── claude.ts # Обёртка Claude Agent SDK
│ ├── state/
│ │ └── store.ts # Загрузка/сохранение state.json
│ └── utils/
│ └── telegram.ts # Разбивка сообщений, форматирование
├── .env
├── package.json
├── tsconfig.json
└── README.md
```
## Git
- Репозиторий: `http://localhost:3000/admin/claude-telegram-bot`
- Инициализация git при создании проекта, push в remote по завершении
+130
View File
@@ -0,0 +1,130 @@
# Master Test Plan — Claude Telegram Bot
Compiled from per-block test plans produced during implementation.
---
## Block 1: Project Scaffolding
- [ ] 1.1: `git log --oneline` shows initial commit
- [ ] 1.2: `bun run src/index.ts` starts without errors (with valid .env)
- [ ] 1.3: `.env` is NOT tracked by git
- [ ] 1.4: `git show --stat HEAD~8` shows exactly: .gitignore, bun.lock, package.json, src/index.ts, tsconfig.json
- [ ] 1.5: `node_modules/` exists with dependencies installed
## Block 2: State Store (`src/state/store.ts`)
- [ ] 2.1: `new Store()` returns default state `{ projects: {}, sessions: {}, activeSession: null }` when no state file exists
- [ ] 2.2: `store.save()` creates `~/.claude-telegram-bot/state.json` with valid JSON
- [ ] 2.3: Save then load round-trips correctly — write state, create new Store, verify data matches
- [ ] 2.4: `save()` auto-creates `~/.claude-telegram-bot/` directory if missing
- [ ] 2.5: TypeScript rejects invalid `SessionInfo.status` values (only "idle" | "busy")
## Block 3: Session Manager (`src/services/session-manager.ts`)
- [ ] 3.1: `activeSession` returns `null` when no session started
- [ ] 3.2: `createSession` sets session data, marks as active, and persists
- [ ] 3.3: `stopSession` removes session, reassigns active to remaining (or null)
- [ ] 3.4: `stopSession` returns `false` for unknown session name
- [ ] 3.5: `stopAll` clears all sessions, sets activeSession to null, returns count
- [ ] 3.6: `switchSession` updates activeSession and returns true for valid name
- [ ] 3.7: `switchSession` returns false for unknown name
- [ ] 3.8: `setStatus` updates only status field, no-ops for unknown session
- [ ] 3.9: `resolveProjectPath` returns bookmarked path when name matches a project
- [ ] 3.10: `resolveProjectPath` extracts basename from path for non-bookmarked input
- [ ] 3.11: `generateName` appends `-2`, `-3` on name conflicts
- [ ] 3.12: `saveProject` / `removeProject` CRUD works correctly
- [ ] 3.13: Every mutating method calls `store.save()` exactly once
## Block 4: Claude Service (`src/services/claude.ts`)
- [ ] 4.1: `createSession` returns non-empty `sessionId` with valid prompt and cwd
- [ ] 4.2: `createSession` returns non-empty `result` string
- [ ] 4.3: `sendMessage` with valid sessionId resumes context and returns result
- [ ] 4.4: Session context is preserved — sendMessage result reflects prior conversation
- [ ] 4.5: Invalid sessionId in `sendMessage` throws or returns empty string gracefully
- [ ] 4.6: `cwd` is respected — Claude tools resolve paths relative to it
- [ ] 4.7: Multi-message stream handled — only final result captured
## Block 5: Telegram Utilities (`src/utils/telegram.ts`)
- [ ] 5.1: Short message (<=4096 chars) returns single-element array
- [ ] 5.2: Exactly 4096 chars returns single-element array (boundary)
- [ ] 5.3: Long message with `\n\n` splits at paragraph boundary
- [ ] 5.4: Long message with only `\n` splits at line boundary
- [ ] 5.5: Long message with no whitespace hard-splits at 4096
- [ ] 5.6: All chunks are <= 4096 characters
- [ ] 5.7: No data loss — concatenated chunks reconstruct original content
- [ ] 5.8: `escapeMarkdown` returns input unchanged
## Block 6: Command Handlers (`src/handlers/commands.ts`)
- [ ] 6.1: `/start` without argument shows usage
- [ ] 6.2: `/start <path>` with existing session name suggests `/switch`
- [ ] 6.3: `/start <path>` creates Claude session, registers in manager, replies with result
- [ ] 6.4: `/stop` with no active session replies "Nothing to stop"
- [ ] 6.5: `/stop <name>` removes session, shows next active if any
- [ ] 6.6: `/stop <name>` for unknown session replies "not found"
- [ ] 6.7: `/stopall` stops all, reports count
- [ ] 6.8: `/switch` without argument shows usage
- [ ] 6.9: `/switch <name>` switches and confirms
- [ ] 6.10: `/switch <name>` for unknown session shows error + hint
- [ ] 6.11: `/sessions` with no sessions replies "No active sessions"
- [ ] 6.12: `/sessions` lists sessions with `►` marker and inline keyboard
- [ ] 6.13: `/status` with no session replies "No active session"
- [ ] 6.14: `/status` shows name, path, status
- [ ] 6.15: `/save` with <2 args shows usage
- [ ] 6.16: `/save <name> <path>` saves and confirms
- [ ] 6.17: `/remove <name>` removes or says "not found"
- [ ] 6.18: `/projects` shows list with inline "Start" buttons
- [ ] 6.19: `/help` shows full command reference
## Block 7: Message Handler (`src/handlers/message.ts`)
- [ ] 7.1: Non-text message ignored silently
- [ ] 7.2: Text with no active session shows "No active session" message
- [ ] 7.3: Text when session busy shows "Session is busy" message
- [ ] 7.4: Status set to "busy" before Claude call, "idle" after (including on error)
- [ ] 7.5: Typing indicator sent before Claude call
- [ ] 7.6: Response split into chunks and sent sequentially
- [ ] 7.7: Empty Claude response sends "(empty response)"
- [ ] 7.8: Claude error caught, error message sent, status reset to idle
## Block 8: Bot Setup (`src/bot.ts`)
- [ ] 8.1: `createBot` returns Bot instance without errors
- [ ] 8.2: Non-owner messages silently dropped by middleware
- [ ] 8.3: Owner messages reach handlers
- [ ] 8.4: `switch:` callback switches session and confirms
- [ ] 8.5: `start:` callback for unknown project answers "not found"
- [ ] 8.6: `start:` callback for existing session switches instead of creating
- [ ] 8.7: `start:` callback for new project creates session via Claude
- [ ] 8.8: Claude error in `start:` callback sends error message
- [ ] 8.9: All imports are static, `createBot` is synchronous
## Block 9: Entry Point (`src/index.ts`)
- [ ] 9.1: Missing TELEGRAM_BOT_TOKEN throws with clear message
- [ ] 9.2: Missing TELEGRAM_OWNER_ID throws with clear message
- [ ] 9.3: Missing ANTHROPIC_API_KEY throws with clear message
- [ ] 9.4: All vars present — bot starts, logs "Bot started successfully"
- [ ] 9.5: TELEGRAM_OWNER_ID converted to number
- [ ] 9.6: SIGINT triggers graceful shutdown with "Shutting down..." log
- [ ] 9.7: `.env` file loaded by dotenv/config
---
## Integration / Smoke Test
- [ ] E2E-1: `/help` — shows command list
- [ ] E2E-2: `/start /d/claude_telegram_bot` — creates session, Claude responds
- [ ] E2E-3: `What files are in this directory?` — Claude lists project files
- [ ] E2E-4: `/status` — shows session name, path, idle status
- [ ] E2E-5: `/sessions` — shows list with inline button
- [ ] E2E-6: Click inline button — switches session
- [ ] E2E-7: `/save mybot /d/claude_telegram_bot` — saves bookmark
- [ ] E2E-8: `/projects` — shows bookmark with "Start" button
- [ ] E2E-9: `/stop` — stops session
- [ ] E2E-10: `/sessions` — shows "No active sessions"
- [ ] E2E-11: Send text without session — shows "No active session" error
- [ ] E2E-12: Message from non-owner — silently ignored
+55 -11
View File
@@ -1,10 +1,27 @@
import { Bot, type Context } from "grammy"; import { Bot, type Context, Keyboard } from "grammy";
import { Store } from "./state/store.js"; import { Store } from "./state/store.js";
import { SessionManager } from "./services/session-manager.js"; import { SessionManager } from "./services/session-manager.js";
import { ClaudeService } from "./services/claude.js"; import { ClaudeService } from "./services/claude.js";
import { registerCommands } from "./handlers/commands.js"; import { registerCommands } from "./handlers/commands.js";
import { createMessageHandler } from "./handlers/message.js"; import { createMessageHandler } from "./handlers/message.js";
// Persistent reply keyboard
const mainKeyboard = new Keyboard()
.text("Sessions").text("Projects").text("Status").row()
.text("Stop").text("Stop All").text("Help")
.resized()
.persistent();
// Button text → command name mapping
const BUTTON_COMMANDS: Record<string, string> = {
"Sessions": "sessions",
"Projects": "projects",
"Status": "status",
"Stop": "stop",
"Stop All": "stopall",
"Help": "help",
};
export function createBot(token: string, ownerId: number): Bot { export function createBot(token: string, ownerId: number): Bot {
const bot = new Bot(token); const bot = new Bot(token);
@@ -20,12 +37,25 @@ export function createBot(token: string, ownerId: number): Bot {
// Register command handlers // Register command handlers
const commands: Record<string, (ctx: Context) => Promise<void>> = {}; const commands: Record<string, (ctx: Context) => Promise<void>> = {};
registerCommands(manager, claude, commands); registerCommands(manager, claude, commands, mainKeyboard);
for (const [name, handler] of Object.entries(commands)) { for (const [name, handler] of Object.entries(commands)) {
bot.command(name, handler); bot.command(name, handler);
} }
// Handle reply keyboard button presses
bot.on("message:text", async (ctx, next) => {
const text = ctx.message.text;
const commandName = BUTTON_COMMANDS[text];
if (commandName && commands[commandName]) {
// Simulate empty match for commands that don't need args
(ctx as any).match = "";
await commands[commandName](ctx);
return;
}
await next();
});
// Callback queries for inline buttons // Callback queries for inline buttons
bot.on("callback_query:data", async (ctx) => { bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery.data; const data = ctx.callbackQuery.data;
@@ -54,21 +84,35 @@ export function createBot(token: string, ownerId: number): Bot {
await ctx.answerCallbackQuery({ text: `Starting "${resolved.name}"...` }); await ctx.answerCallbackQuery({ text: `Starting "${resolved.name}"...` });
await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`); await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`);
try { claude.createSession(
const { sessionId, result } = await claude.createSession( "You are now connected via Telegram. Briefly confirm you're ready and state the project directory.",
"You are now connected via Telegram. Briefly confirm you're ready and state the project directory.", resolved.path,
resolved.path, ).then(({ sessionId, result }) => {
);
manager.createSession(resolved.name, resolved.path, sessionId); manager.createSession(resolved.name, resolved.path, sessionId);
await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`); ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
} catch (err) { }).catch((err) => {
await ctx.reply(`Failed to create session: ${err}`); ctx.reply(`Failed to create session: ${err}`);
} });
} }
}); });
// Plain text messages // Plain text messages
bot.on("message:text", createMessageHandler(manager, claude)); bot.on("message:text", createMessageHandler(manager, claude));
// Send main keyboard on /start (Telegram's built-in start) with no args
bot.api.setMyCommands([
{ command: "start", description: "Start a session: /start <path|name>" },
{ command: "save", description: "Save bookmark: /save <name> <path>" },
{ command: "switch", description: "Switch session: /switch <name>" },
{ command: "remove", description: "Remove bookmark: /remove <name>" },
]);
// Show keyboard on first interaction
bot.use(async (ctx, next) => {
await next();
});
return bot; return bot;
} }
export { mainKeyboard };
+34 -30
View File
@@ -1,4 +1,5 @@
import { type Context, InlineKeyboard } from "grammy"; import { type Context, InlineKeyboard, type Keyboard } from "grammy";
import { existsSync } from "fs";
import { SessionManager } from "../services/session-manager.js"; import { SessionManager } from "../services/session-manager.js";
import { ClaudeService } from "../services/claude.js"; import { ClaudeService } from "../services/claude.js";
@@ -6,11 +7,12 @@ export function registerCommands(
manager: SessionManager, manager: SessionManager,
claude: ClaudeService, claude: ClaudeService,
onCommand: Record<string, (ctx: Context) => Promise<void>>, onCommand: Record<string, (ctx: Context) => Promise<void>>,
keyboard: Keyboard,
): void { ): void {
onCommand["start"] = async (ctx) => { onCommand["start"] = async (ctx) => {
const arg = ctx.match as string; const arg = ctx.match as string;
if (!arg) { if (!arg) {
await ctx.reply("Usage: /start <path or project name>"); await ctx.reply("Usage: /start <path or project name>", { reply_markup: keyboard });
return; return;
} }
@@ -20,6 +22,12 @@ export function registerCommands(
return; return;
} }
// Validate that directory exists (skip for bookmarks — already validated on save)
if (!existsSync(resolved.path)) {
await ctx.reply(`Directory not found: ${resolved.path}`);
return;
}
if (manager.getAllSessions()[resolved.name]) { if (manager.getAllSessions()[resolved.name]) {
await ctx.reply( await ctx.reply(
`Session "${resolved.name}" already exists. Use /switch ${resolved.name}`, `Session "${resolved.name}" already exists. Use /switch ${resolved.name}`,
@@ -29,16 +37,16 @@ export function registerCommands(
await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`); await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`);
try { // Run Claude session creation without blocking the bot
const { sessionId, result } = await claude.createSession( claude.createSession(
"You are now connected via Telegram. Briefly confirm you're ready and state the project directory.", "Ты подключён через Telegram. Коротко подтверди готовность и укажи рабочую директорию проекта.",
resolved.path, resolved.path,
); ).then(({ sessionId, result }) => {
manager.createSession(resolved.name, resolved.path, sessionId); manager.createSession(resolved.name, resolved.path, sessionId);
await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`); ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
} catch (err) { }).catch((err) => {
await ctx.reply(`Failed to create session: ${err}`); ctx.reply(`Failed to create session: ${err}`);
} });
}; };
onCommand["stop"] = async (ctx) => { onCommand["stop"] = async (ctx) => {
@@ -80,7 +88,7 @@ export function registerCommands(
const sessions = manager.getAllSessions(); const sessions = manager.getAllSessions();
const names = Object.keys(sessions); const names = Object.keys(sessions);
if (names.length === 0) { if (names.length === 0) {
await ctx.reply("No active sessions."); await ctx.reply("No active sessions.", { reply_markup: keyboard });
return; return;
} }
@@ -91,12 +99,12 @@ export function registerCommands(
return `${marker} ${name}${s.cwd} (${s.status})`; return `${marker} ${name}${s.cwd} (${s.status})`;
}); });
const keyboard = new InlineKeyboard(); const inlineKb = new InlineKeyboard();
for (const name of names) { for (const name of names) {
keyboard.text(name, `switch:${name}`); inlineKb.text(name, `switch:${name}`);
} }
await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); await ctx.reply(lines.join("\n"), { reply_markup: inlineKb });
}; };
onCommand["status"] = async (ctx) => { onCommand["status"] = async (ctx) => {
@@ -139,37 +147,33 @@ export function registerCommands(
const projects = manager.getProjects(); const projects = manager.getProjects();
const names = Object.keys(projects); const names = Object.keys(projects);
if (names.length === 0) { if (names.length === 0) {
await ctx.reply("No saved projects."); await ctx.reply("No saved projects. Use /save <name> <path>", { reply_markup: keyboard });
return; return;
} }
const lines = names.map((n) => `${n}${projects[n]}`); const lines = names.map((n) => `${n}${projects[n]}`);
const keyboard = new InlineKeyboard(); const inlineKb = new InlineKeyboard();
for (const name of names) { for (const name of names) {
keyboard.text(`Start ${name}`, `start:${name}`); inlineKb.text(`Start ${name}`, `start:${name}`);
} }
await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); await ctx.reply(lines.join("\n"), { reply_markup: inlineKb });
}; };
onCommand["help"] = async (ctx) => { onCommand["help"] = async (ctx) => {
await ctx.reply( await ctx.reply(
[ [
"Session commands:", "Use buttons below or commands:",
" /start <path|name> — start new session",
" /stop [name] — stop session",
" /stopall — stop all sessions",
" /switch <name> — switch active session",
" /sessions — list sessions",
" /status — current session info",
"", "",
"Project bookmarks:", "/start <path|name> — start new session",
" /save <name> <path> — save bookmark", "/stop [name] — stop session",
" /remove <name> — remove bookmark", "/switch <name> — switch active session",
" /projects — list bookmarks", "/save <name> <path> — save bookmark",
"/remove <name> — remove bookmark",
"", "",
"Any other text is sent to the active session.", "Any other text is sent to the active session.",
].join("\n"), ].join("\n"),
{ reply_markup: keyboard },
); );
}; };
} }
+6 -7
View File
@@ -28,18 +28,17 @@ export function createMessageHandler(
manager.setStatus(activeName, "busy"); manager.setStatus(activeName, "busy");
await ctx.replyWithChatAction("typing"); await ctx.replyWithChatAction("typing");
try { // Run Claude call without blocking the bot's event loop
const result = await claude.sendMessage(text, session.sessionId); claude.sendMessage(text, session.sessionId, session.cwd).then(async (result) => {
const response = result || "(empty response)"; const response = result || "(empty response)";
const chunks = splitMessage(response); const chunks = splitMessage(response);
for (const chunk of chunks) { for (const chunk of chunks) {
await ctx.reply(chunk); await ctx.reply(chunk);
} }
} catch (err) { }).catch((err) => {
await ctx.reply(`Error: ${err}`); ctx.reply(`Error: ${err}`);
} finally { }).finally(() => {
manager.setStatus(activeName, "idle"); manager.setStatus(activeName, "idle");
} });
}; };
} }
+76 -36
View File
@@ -1,52 +1,92 @@
import { query } from "@anthropic-ai/claude-agent-sdk"; import { spawn } from "child_process";
export interface ClaudeResult { export interface ClaudeResult {
sessionId: string; sessionId: string;
result: string; result: string;
} }
interface CliJsonResult {
result?: string;
session_id?: string;
is_error?: boolean;
}
function runClaude(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
console.log(`[claude] Running: npx claude-code ${args.join(" ")}`);
console.log(`[claude] CWD: ${cwd}`);
const proc = spawn("npx", ["--yes", "@anthropic-ai/claude-code", ...args], {
cwd,
shell: true,
env: { ...process.env },
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("close", (code: number | null) => {
console.log(`[claude] Exit code: ${code}`);
console.log(`[claude] Stdout length: ${stdout.length}`);
if (stderr) console.log(`[claude] Stderr: ${stderr.slice(0, 500)}`);
if (code !== 0 && !stdout) {
reject(new Error(stderr || `claude exited with code ${code}`));
} else {
resolve(stdout);
}
});
proc.on("error", (err) => {
console.error(`[claude] Spawn error:`, err);
reject(err);
});
});
}
const APPEND_PROMPT = "Always respond in Russian. You are a helpful coding assistant connected via Telegram. Engage fully with user requests - analyze, suggest, implement. Never ask the user to repeat or clarify when the request is clear enough to act on.";
export class ClaudeService { export class ClaudeService {
async createSession(prompt: string, cwd: string): Promise<ClaudeResult> { async createSession(prompt: string, cwd: string): Promise<ClaudeResult> {
let sessionId = ""; const raw = await runClaude(
let result = ""; ["-p", prompt, "--output-format", "json", "--dangerously-skip-permissions", "--append-system-prompt", APPEND_PROMPT],
cwd,
);
for await (const message of query({ const lastLine = raw.trim().split("\n").pop()!;
prompt, console.log(`[claude] createSession raw last line (first 300 chars): ${lastLine.slice(0, 300)}`);
options: { const parsed = JSON.parse(lastLine) as CliJsonResult;
cwd, console.log(`[claude] createSession result: "${parsed.result?.slice(0, 200)}"`);
permissionMode: "bypassPermissions", console.log(`[claude] createSession session_id: ${parsed.session_id}`);
allowedTools: [
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
"WebSearch", "WebFetch", "Agent",
],
},
})) {
if (message.type === "system" && message.subtype === "init") {
sessionId = message.session_id;
}
if ("result" in message && typeof message.result === "string") {
result = message.result;
}
}
return { sessionId, result }; return {
sessionId: parsed.session_id ?? "",
result: parsed.result ?? "",
};
} }
async sendMessage(prompt: string, sessionId: string): Promise<string> { async sendMessage(prompt: string, sessionId: string, cwd: string): Promise<string> {
let result = ""; console.log(`[claude] sendMessage prompt: "${prompt.slice(0, 100)}"`);
console.log(`[claude] sendMessage sessionId: ${sessionId}`);
for await (const message of query({ const raw = await runClaude(
prompt, ["-p", prompt, "--output-format", "json", "--resume", sessionId, "--dangerously-skip-permissions", "--append-system-prompt", APPEND_PROMPT],
options: { cwd,
resume: sessionId, );
permissionMode: "bypassPermissions",
},
})) {
if ("result" in message && typeof message.result === "string") {
result = message.result;
}
}
return result; const lastLine = raw.trim().split("\n").pop()!;
console.log(`[claude] sendMessage raw last line (first 300 chars): ${lastLine.slice(0, 300)}`);
const parsed = JSON.parse(lastLine) as CliJsonResult;
console.log(`[claude] sendMessage result: "${parsed.result?.slice(0, 200)}"`);
return parsed.result ?? "";
} }
} }
+6 -2
View File
@@ -1,5 +1,6 @@
import { Store, type SessionInfo } from "../state/store.js"; import { Store, type SessionInfo } from "../state/store.js";
import { basename } from "path"; import { basename } from "path";
import { normalizePath } from "../utils/telegram.js";
export class SessionManager { export class SessionManager {
constructor(private store: Store) {} constructor(private store: Store) {}
@@ -19,12 +20,15 @@ export class SessionManager {
} }
resolveProjectPath(nameOrPath: string): { name: string; path: string } | null { resolveProjectPath(nameOrPath: string): { name: string; path: string } | null {
// Check bookmarks first
const bookmarked = this.store.data.projects[nameOrPath]; const bookmarked = this.store.data.projects[nameOrPath];
if (bookmarked) { if (bookmarked) {
return { name: nameOrPath, path: bookmarked }; return { name: nameOrPath, path: bookmarked };
} }
const name = this.generateName(basename(nameOrPath)); // Treat as path — normalize for Windows
return { name, path: nameOrPath }; const normalizedPath = normalizePath(nameOrPath);
const name = this.generateName(basename(normalizedPath));
return { name, path: normalizedPath };
} }
private generateName(base: string): string { private generateName(base: string): string {
+15
View File
@@ -1,5 +1,20 @@
import { resolve } from "path";
const MAX_LENGTH = 4096; const MAX_LENGTH = 4096;
/**
* Convert Unix-style /d/path to Windows D:/path if on Windows.
* Passes through normal paths unchanged.
*/
export function normalizePath(inputPath: string): string {
// Convert /d/... → D:/...
const match = inputPath.match(/^\/([a-zA-Z])\/(.*)/);
if (match) {
return resolve(`${match[1].toUpperCase()}:/${match[2]}`);
}
return resolve(inputPath);
}
export function splitMessage(text: string): string[] { export function splitMessage(text: string): string[] {
if (text.length <= MAX_LENGTH) return [text]; if (text.length <= MAX_LENGTH) return [text];