init
This commit is contained in:
commit
0525cea647
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
ANTHROPIC_API_KEY=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
|
||||
LOG_LEVEL=debug
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Local data & auth
|
||||
store/
|
||||
data/
|
||||
logs/
|
||||
|
||||
# Groups - only track base structure and specific CLAUDE.md files
|
||||
groups/*
|
||||
!groups/main/
|
||||
!groups/global/
|
||||
groups/main/*
|
||||
groups/global/*
|
||||
!groups/main/CLAUDE.md
|
||||
!groups/global/CLAUDE.md
|
||||
|
||||
# Secrets
|
||||
*.keys.json
|
||||
#.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
agents-sdk-docs
|
||||
14
11agent.json
Normal file
14
11agent.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Кодер",
|
||||
"slug": "coder",
|
||||
"prompt": "Ты опытный Go-разработчик. Пишешь чистый, идиоматичный Go-код. Следуешь best practices: обработка ошибок, тесты, понятные имена.",
|
||||
"tracker_url": "http://localhost:8100",
|
||||
"token": "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",
|
||||
"listen_port": 3200,
|
||||
"work_dir": "/projects/team-board",
|
||||
"capabilities": ["coding", "review"],
|
||||
"max_concurrent_tasks": 2,
|
||||
"model": "sonnet",
|
||||
"provider": "anthropic",
|
||||
"heartbeat_interval_sec": 30
|
||||
}
|
||||
329
CLAUDE.md
Normal file
329
CLAUDE.md
Normal file
@ -0,0 +1,329 @@
|
||||
# CLAUDE.md — picogent
|
||||
|
||||
## Что это
|
||||
|
||||
Минимальный AI-агент с in-process agentic loop на базе Pi Agent Core. Два режима работы:
|
||||
|
||||
1. **WebSocket-режим (чат)** — сервер WebSocket, браузерный клиент (`client.html`) отправляет промпты, получает стриминг-ответы
|
||||
2. **Agent-режим (Team Board Tracker)** — агент подключается к трекеру задач, получает задания (`task.assigned`), выполняет, отправляет результат
|
||||
|
||||
Режим определяется автоматически: если есть `agent.json` / директория с конфигом / env `TRACKER_URL` → agent, иначе → WebSocket.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
npm install # Установка
|
||||
npm run build # tsc -> dist/
|
||||
npm run dev # Dev (tsx + .env)
|
||||
npm start # Production (node + .env)
|
||||
npm test # Тесты (vitest)
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
npm run dev # WebSocket-режим на :3100
|
||||
npm run dev -- ./agents/coder/ # Agent-режим (директория)
|
||||
npm run dev -- ./agent.json # Agent-режим (файл)
|
||||
AGENT_TRANSPORT=ws npm run dev -- ./agents/coder/ # Agent-режим через WebSocket
|
||||
```
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # Точка входа, dual-mode wiring (WS-чат / Agent HTTP / Agent WS)
|
||||
config.ts # Config + AgentConfig, loadConfig(), loadAgentConfig()
|
||||
agent.ts # Ядро: Pi Agent Core session, runAgent() async generator
|
||||
sandbox.ts # createSandboxedTools() — tools с проверкой allowed_paths
|
||||
router.ts # TaskTracker interface + EventRouter: tracker events -> runAgent() -> tracker API
|
||||
logger.ts # Pino logger
|
||||
session.ts # (не используется, legacy)
|
||||
transport/
|
||||
websocket.ts # WebSocket-сервер (чат-режим, принимает подключения от клиентов)
|
||||
http.ts # HTTP-сервер (agent-режим, transport=http, POST /events)
|
||||
ws-client.ts # WS-клиент к трекеру (agent-режим, transport=ws)
|
||||
types.ts # Transport interface (IncomingMessage, OutgoingMessage, MessageHandler)
|
||||
tracker/
|
||||
client.ts # HTTP-клиент к Tracker REST API (updateTask, addComment и т.д.)
|
||||
registration.ts # Register + heartbeat с retry (используется только transport=http)
|
||||
types.ts # TrackerTask, TrackerEvent, RegistrationPayload, HeartbeatPayload
|
||||
```
|
||||
|
||||
## Архитектура Agent-режима — два транспорта
|
||||
|
||||
### Проблема
|
||||
|
||||
Изначально agent-режим работал только через HTTP: агент поднимает HTTP-сервер на `listen_port`, регистрируется в трекере с `callback_url`, трекер шлёт события POST-запросами. Это требует открытого порта на стороне агента и доступности агента по сети для трекера.
|
||||
|
||||
### Решение: WS-транспорт
|
||||
|
||||
Добавлен альтернативный WebSocket-транспорт (`transport: "ws"`). Агент сам подключается к трекеру — двунаправленный канал, не нужен ни открытый порт, ни callback_url. Удобно для NAT, firewall, облачных окружений.
|
||||
|
||||
### Как устроено
|
||||
|
||||
Выбор транспорта — поле `transport` в конфиге (`"http"` | `"ws"`, default `"http"` для обратной совместимости).
|
||||
|
||||
#### Общие компоненты (не зависят от транспорта)
|
||||
|
||||
| Компонент | Файл | Роль |
|
||||
|-----------|------|------|
|
||||
| `EventRouter` | `router.ts` | Получает `TrackerEvent`, вызывает `runAgent()`, отправляет результат через `TrackerClient` |
|
||||
| `TrackerClient` | `tracker/client.ts` | HTTP-клиент к REST API трекера (PATCH task, POST comment). Используется в обоих транспортах |
|
||||
| `TaskTracker` | `router.ts` (interface) | `addTask(id)` / `removeTask(id)` — нужен EventRouter для heartbeat-учёта |
|
||||
|
||||
#### transport=http
|
||||
|
||||
```
|
||||
index.ts → startAgentHttp()
|
||||
├── TrackerRegistration (implements TaskTracker) — register + heartbeat через REST
|
||||
├── HttpTransport — HTTP-сервер POST /events
|
||||
└── EventRouter(config, client, registration)
|
||||
```
|
||||
|
||||
Поток: Tracker → HTTP POST → HttpTransport → EventRouter → runAgent() → TrackerClient → Tracker
|
||||
|
||||
#### transport=ws
|
||||
|
||||
```
|
||||
index.ts → startAgentWs()
|
||||
├── WsClientTransport (implements TaskTracker) — connect + auth + heartbeat + events через WS
|
||||
└── EventRouter(config, client, wsTransport)
|
||||
```
|
||||
|
||||
Поток: Agent → WS connect → Tracker ← events → WsClientTransport → EventRouter → runAgent() → TrackerClient (REST) → Tracker
|
||||
|
||||
**Ключевое отличие:** В WS-режиме нет `TrackerRegistration` и нет `HttpTransport`. Регистрация и heartbeat встроены в сам WebSocket-канал. А вот `TrackerClient` по-прежнему используется для REST-вызовов (updateTask, addComment) — эти операции идут через HTTP, не через WS.
|
||||
|
||||
### Интерфейс TaskTracker
|
||||
|
||||
```typescript
|
||||
// router.ts
|
||||
export interface TaskTracker {
|
||||
addTask(taskId: string): void;
|
||||
removeTask(taskId: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Зачем:** EventRouter при обработке задачи вызывает `addTask()`/`removeTask()` чтобы heartbeat (неважно HTTP или WS) отправлял актуальный список задач и статус `busy`/`idle`. Раньше EventRouter зависел напрямую от `TrackerRegistration` — теперь зависит от интерфейса, что позволяет подставить WsClientTransport.
|
||||
|
||||
**Реализуют:**
|
||||
- `TrackerRegistration` (для transport=http) — хранит Set, heartbeat через REST
|
||||
- `WsClientTransport` (для transport=ws) — хранит Set, heartbeat через WS
|
||||
|
||||
### WS-клиент: детали реализации (`ws-client.ts`)
|
||||
|
||||
**URL:** Выводится из `tracker_url` автоматически — `http://` → `ws://`, `https://` → `wss://`, path `/ws?client_type=agent&client_id={slug}`. Совместим с существующим WS-эндпоинтом трекера (FastAPI, `ws/handler.py`).
|
||||
|
||||
**Протокол (JSON через WebSocket):**
|
||||
|
||||
```
|
||||
Agent → Tracker: { type: "auth", token: "...", agent: { name, slug, capabilities, max_concurrent_tasks } }
|
||||
Tracker → Agent: { type: "auth_ok" }
|
||||
Tracker → Agent: { type: "auth_error", message: "..." }
|
||||
Tracker → Agent: { type: "event", event: "task.assigned", data: {...}, ts: ..., id: "evt-xxx" }
|
||||
Agent → Tracker: { type: "ack", id: "evt-xxx" }
|
||||
Agent → Tracker: { type: "heartbeat", status: "idle"|"busy", current_tasks: ["task-1"] }
|
||||
```
|
||||
|
||||
**Жизненный цикл:**
|
||||
1. `start()` → `connect()` → WebSocket open → send `auth`
|
||||
2. Получаем `auth_ok` → start heartbeat → resolve start() promise
|
||||
3. Получаем `auth_error` → reject start() promise, close WS
|
||||
4. При закрытии WS (если не `stop()`) → scheduleReconnect()
|
||||
5. `stop()` → close WS, clear timers
|
||||
|
||||
**Reconnect:** Exponential backoff 1s → 2s → 4s → ... → 30s cap. Сбрасывается при успешной auth. При первом `start()` если auth_error — не реконнектится (promise reject). При последующих обрывах — реконнект автоматически.
|
||||
|
||||
**Dedup:** Set<string> с обрезкой при >10000 (оставляем последние 5000). Идентично HttpTransport.
|
||||
|
||||
**Heartbeat:** setInterval с `heartbeatIntervalSec` из конфига. Отправляет текущий статус (`idle`/`busy`) и список задач. Останавливается при закрытии WS, перезапускается при реконнекте.
|
||||
|
||||
### Что НЕ реализовано на стороне трекера
|
||||
|
||||
Серверная часть (tracker, Python/FastAPI) пока **не имеет** полной поддержки agent WS-протокола. Текущий `ws/handler.py`:
|
||||
- Принимает WebSocket-подключения с `client_type=agent`
|
||||
- Обрабатывает `agent.heartbeat` (отвечает `agent.heartbeat.ack`)
|
||||
- **Не** обрабатывает: `auth`, `ack`, отправку `event` агенту
|
||||
|
||||
**Для полной работы transport=ws нужно доработать трекер:** добавить обработку `type: "auth"` (валидация токена, регистрация агента), отправку `type: "event"` при назначении задачи агенту, и обработку `type: "ack"`.
|
||||
|
||||
## Ключевые модули
|
||||
|
||||
### index.ts — Точка входа
|
||||
|
||||
- `main()` → `hasAgentConfig()` ? `startAgentMode()` : `startWebSocketMode()`
|
||||
- `startAgentMode()` → `loadAgentConfig()` → `initAgent()` → `TrackerClient` → if ws: `startAgentWs()`, else: `startAgentHttp()`
|
||||
- `startAgentHttp(config, client)` — TrackerRegistration + HttpTransport + EventRouter
|
||||
- `startAgentWs(config, client)` — WsClientTransport + EventRouter (без HTTP-сервера)
|
||||
- `startWebSocketMode()` — WebSocketTransport (чат-режим, не связан с трекером)
|
||||
|
||||
### agent.ts — Ядро
|
||||
|
||||
- `initAgent(provider, apiKey)` — вызвать при старте для injection API key (через `AuthStorage.setRuntimeApiKey()`)
|
||||
- `runAgent(prompt, options)` — async generator, yield-ит AgentMessage (`type: 'text'|'tool_use'|'tool_result'|'error'|'done'|'session'`)
|
||||
- MODEL_ALIASES: sonnet → claude-sonnet-4-6, opus → claude-opus-4-6, haiku → claude-haiku-4-5 и т.д.
|
||||
- DEFAULT_MODELS: автоматически создаёт `~/.picogent/models.json`
|
||||
- Skills: `loadSkills()` + `formatSkillsForPrompt()` из pi-coding-agent (progressive loading)
|
||||
- Sandbox: `createSandboxedTools(workDir, allowedPaths)` из sandbox.ts
|
||||
|
||||
### config.ts — Конфигурация
|
||||
|
||||
- `Config` — для WebSocket-режима, из env vars
|
||||
- `AgentConfig` — для Agent-режима, из agent.json + env
|
||||
- `loadConfig()` / `loadAgentConfig()` — загрузчики
|
||||
- `resolveConfig()` — CLI arg (dir/file) → env → default
|
||||
- `hasAgentConfig()` — определяет режим (есть конфиг/dir/TRACKER_URL?)
|
||||
|
||||
Приоритет: env > JSON file > defaults.
|
||||
|
||||
### sandbox.ts
|
||||
|
||||
- `createSandboxedTools(cwd, allowedPaths?)` — создаёт tools с проверкой путей
|
||||
- allowedPaths пустой/undefined = без ограничений
|
||||
- Оборачивает Read/Write/Edit operations с `guard()`, Bash с `spawnHook`
|
||||
|
||||
### router.ts — Роутер событий
|
||||
|
||||
- `TaskTracker` interface — абстракция для учёта активных задач
|
||||
- `EventRouter` — принимает `TrackerEvent`, диспетчеризует:
|
||||
- `task.assigned` → updateTask(in_progress) → runAgent() → addComment → updateTask(review)
|
||||
- `chat.message` → runAgent() → addComment (если есть task_id)
|
||||
- `buildPromptFromTask(task)` — формирует промпт из данных задачи (key, title, description, priority, files)
|
||||
- Контроль конкурентности: `activeTasks` counter + `maxConcurrentTasks` check
|
||||
|
||||
### tracker/client.ts — REST-клиент
|
||||
|
||||
HTTP-клиент к API трекера. Используется **в обоих** транспортах (http и ws) для мутаций:
|
||||
- `register(payload)` → POST `/api/v1/agents/register`
|
||||
- `heartbeat(payload)` → POST `/api/v1/agents/heartbeat`
|
||||
- `updateTask(id, fields)` → PATCH `/api/v1/tasks/{id}`
|
||||
- `addComment(id, content)` → POST `/api/v1/tasks/{id}/comments`
|
||||
- `uploadFile()`, `getTask()`, `listTaskFiles()` — дополнительные
|
||||
|
||||
Авторизация: Bearer token в заголовке.
|
||||
|
||||
### tracker/registration.ts — HTTP-регистрация
|
||||
|
||||
Используется **только** в transport=http:
|
||||
- `register(callbackUrl)` — отправляет POST register, retry каждые 5с при ошибке
|
||||
- `startHeartbeat()` / `stopHeartbeat()` — интервальные POST heartbeat
|
||||
- Implements `TaskTracker` — addTask/removeTask для учёта в heartbeat
|
||||
|
||||
### tracker/types.ts — Типы
|
||||
|
||||
```typescript
|
||||
TrackerEvent { event: string, data: Record<string,unknown>, ts: number, id: string }
|
||||
TrackerTask { id, key, title, description, status, priority, project_id, parent_id?, files? }
|
||||
TrackerFile { id, name, url, size }
|
||||
RegistrationPayload { name, slug, capabilities, max_concurrent_tasks, callback_url }
|
||||
HeartbeatPayload { status: 'idle'|'busy', current_tasks: string[] }
|
||||
```
|
||||
|
||||
### transport/http.ts — HTTP-транспорт
|
||||
|
||||
HTTP-сервер (agent-режим, transport=http):
|
||||
- POST `/events` — принимает TrackerEvent, dedup по id, вызывает handler async
|
||||
- GET `/health` — `{ status: 'ok' }`
|
||||
- Dedup: Set<string>, обрезка при >10000 (последние 5000)
|
||||
|
||||
### transport/ws-client.ts — WS-транспорт
|
||||
|
||||
WebSocket-клиент к трекеру (agent-режим, transport=ws). Описание выше в разделе архитектуры.
|
||||
|
||||
### transport/websocket.ts — WS-сервер (чат)
|
||||
|
||||
WebSocket-сервер для чат-режима (не agent). Принимает `IncomingMessage`, стримит `OutgoingMessage`.
|
||||
|
||||
### transport/types.ts — Общие типы
|
||||
|
||||
```typescript
|
||||
IncomingMessage { id, content, sessionId?, workDir?, model?, systemPrompt? }
|
||||
OutgoingMessage { id, type: 'text'|'tool_use'|'tool_result'|'error'|'done', content, sessionId? }
|
||||
MessageHandler (msg: IncomingMessage) => Promise<void>
|
||||
Transport { start(), stop(), onMessage(handler), send(clientId, msg) }
|
||||
```
|
||||
|
||||
## Зависимости (Pi SDK)
|
||||
|
||||
```
|
||||
@mariozechner/pi-agent-core — AgentTool, createAgentSession, SessionManager
|
||||
@mariozechner/pi-coding-agent — createReadTool, createWriteTool и т.д., loadSkills
|
||||
@mariozechner/pi-ai — streamSimple (LLM streaming)
|
||||
```
|
||||
|
||||
Важно:
|
||||
- `codingTools` — дефолтные tools привязанные к process.cwd(), **НЕ** используем напрямую
|
||||
- `createCodingTools(cwd)` — tools привязанные к cwd, но без path restriction
|
||||
- Вместо них используем свой `createSandboxedTools()` из sandbox.ts
|
||||
- `ModelRegistry.find(provider, modelId)` требует ПОЛНЫЙ model ID, не алиас
|
||||
- `AuthStorage.setRuntimeApiKey()` — runtime injection, вызывать до первого prompt
|
||||
|
||||
## Конфигурация агента
|
||||
|
||||
Файл `agent.json` (или через env). Полный пример с описаниями: `agent.example.json`.
|
||||
|
||||
Обязательные поля: `tracker_url`, `token`.
|
||||
Env-переменные имеют приоритет над JSON.
|
||||
|
||||
| Поле | Env | Default | Описание |
|
||||
|---|---|---|---|
|
||||
| `name` | `AGENT_NAME` | `Agent` | Имя агента в трекере |
|
||||
| `slug` | `AGENT_SLUG` | `agent` | Уникальный ID (латиница) |
|
||||
| `prompt` | `AGENT_PROMPT` | — | Системный промпт (роль агента) |
|
||||
| `tracker_url` | `TRACKER_URL` | — | URL трекера (**обязательно**) |
|
||||
| `token` | `AGENT_TOKEN` | — | Bearer-токен (**обязательно**) |
|
||||
| `transport` | `AGENT_TRANSPORT` | `http` | `http` или `ws` |
|
||||
| `listen_port` | `AGENT_PORT` | `3200` | Порт HTTP (только transport=http) |
|
||||
| `work_dir` | `PICOGENT_WORK_DIR` | agentHome/cwd | Рабочая директория |
|
||||
| `model` | `PICOGENT_MODEL` | `sonnet` | Модель LLM |
|
||||
| `provider` | `PICOGENT_PROVIDER` | `anthropic` | Провайдер LLM |
|
||||
| `api_key` | `PICOGENT_API_KEY` | — | API ключ |
|
||||
| `capabilities` | — | `["coding"]` | Возможности для трекера |
|
||||
| `max_concurrent_tasks` | — | `2` | Макс. параллельных задач |
|
||||
| `heartbeat_interval_sec` | — | `30` | Интервал heartbeat (сек.) |
|
||||
| `allowed_paths` | — | `[]` (без ограничений) | Ограничение доступа к FS |
|
||||
|
||||
## Режим директории
|
||||
|
||||
```
|
||||
agents/coder/
|
||||
agent.json # конфиг
|
||||
skills/ # скилы (SKILL.md)
|
||||
sessions/ # сессии (auto)
|
||||
```
|
||||
|
||||
`agentHome` = путь к директории. Skills из `agentHome/skills/`, сессии в `agentHome/sessions/`.
|
||||
|
||||
## Связь с трекером (Team Board Tracker)
|
||||
|
||||
Трекер — отдельный проект (`tracker/`), Python/FastAPI + SQLAlchemy. API:
|
||||
|
||||
- REST: `/api/v1/agents/`, `/api/v1/tasks/`, `/api/v1/projects/`, `/api/v1/labels/`, `/api/v1/chats/`
|
||||
- WebSocket: `/ws?client_type=...&client_id=...`
|
||||
|
||||
Текущий WS-handler трекера (`tracker/src/tracker/ws/handler.py`) поддерживает:
|
||||
- `agent.heartbeat` → `agent.heartbeat.ack`
|
||||
- `chat.send` / `chat.subscribe` / `chat.unsubscribe` — чат-функционал
|
||||
|
||||
**Не поддерживает пока:** protocol `type: "auth"/"auth_ok"/"event"/"ack"` — нужно для transport=ws.
|
||||
|
||||
Агенты в трекере: CRUD `/api/v1/agents/`, модель Agent (id, name, slug, token, capabilities, status, max_concurrent, timeout_seconds).
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- Bash sandbox проверяет только cwd, не содержимое команд
|
||||
- Grep/Find/Ls не ограничиваются allowed_paths (read-only)
|
||||
- session.ts не используется (legacy от первой версии)
|
||||
- **transport=ws** требует доработки серверной части трекера (auth, event dispatch, ack handling)
|
||||
- TrackerClient (REST) endpoints — могут потребовать адаптации под конкретный трекер
|
||||
- В WS-режиме REST-вызовы (updateTask, addComment) идут отдельным HTTP-каналом, не через WS
|
||||
|
||||
## Происхождение
|
||||
|
||||
Проект извлечён из двух соседних проектов:
|
||||
|
||||
- **OpenClaw** (`openclaw/`) — паттерн in-process agent loop через Pi Agent Core
|
||||
- **NanoClaw** (`nanoclaw/`) — паттерн pino-логгера, структура "сообщение → агент → результат"
|
||||
|
||||
Сознательно выброшено: каналы, контейнеры, плагины, auth profiles, MCP, subagents и прочая сложность. Picogent — это голый agent loop + транспорт.
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
# Mount workspace as volume: -v /path/to/repos:/workspace
|
||||
ENV PICOGENT_WORK_DIR=/workspace
|
||||
CMD ["node", "dist/index.js"]
|
||||
353
README.md
Normal file
353
README.md
Normal file
@ -0,0 +1,353 @@
|
||||
# picogent
|
||||
|
||||
Минимальный AI-агент с in-process agentic loop на базе Pi Agent Core. Два режима работы: WebSocket-чат и интеграция с Team Board Tracker.
|
||||
|
||||
## Требования
|
||||
|
||||
- Node.js 22+
|
||||
- npm
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Режимы работы
|
||||
|
||||
Picogent автоматически определяет режим по наличию конфигурации:
|
||||
|
||||
### 1. WebSocket-режим (чат)
|
||||
|
||||
Без конфига запускается как WebSocket-сервер для прямого общения через браузерный клиент.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# или
|
||||
npm start
|
||||
```
|
||||
|
||||
Откройте `client.html` в браузере, подключение к `ws://localhost:3100`.
|
||||
|
||||
Настройка через переменные окружения (или `.env` файл):
|
||||
|
||||
| Переменная | Описание | Default |
|
||||
|---|---|---|
|
||||
| `PICOGENT_PORT` | Порт WebSocket-сервера | `3100` |
|
||||
| `PICOGENT_WORK_DIR` | Рабочая директория агента | `cwd` |
|
||||
| `PICOGENT_MODEL` | Модель LLM | `sonnet` |
|
||||
| `PICOGENT_PROVIDER` | Провайдер LLM | `anthropic` |
|
||||
| `PICOGENT_API_KEY` | API ключ | — |
|
||||
| `ANTHROPIC_API_KEY` | API ключ (альтернатива) | — |
|
||||
|
||||
### 2. Agent-режим (Team Board Tracker)
|
||||
|
||||
Агент регистрируется в трекере, получает задачи и выполняет их. Поддерживает два транспорта:
|
||||
|
||||
- **`http`** (по умолчанию) — агент поднимает HTTP-сервер, трекер шлёт события через POST. Требует открытый порт (`listen_port`).
|
||||
- **`ws`** — агент сам подключается к трекеру по WebSocket. Не требует открытого порта и callback_url. Автоматический reconnect с exponential backoff.
|
||||
|
||||
Активируется при наличии `agent.json`, директории с конфигом, или переменной `TRACKER_URL`.
|
||||
|
||||
## Параметры запуска
|
||||
|
||||
```bash
|
||||
# Режим директории — папка с agent.json, skills/, sessions/
|
||||
npm run dev -- ./agents/coder/
|
||||
|
||||
# Режим файла — путь к JSON-конфигу
|
||||
npm run dev -- ./my-agent.json
|
||||
|
||||
# Через переменную окружения
|
||||
PICOGENT_CONFIG=./agent.json npm run dev
|
||||
|
||||
# Только через env (без файла конфига)
|
||||
TRACKER_URL=http://localhost:8100 AGENT_TOKEN=tb-xxx npm run dev
|
||||
|
||||
# Production
|
||||
npm start -- ./agents/coder/
|
||||
```
|
||||
|
||||
### Приоритет конфигурации
|
||||
|
||||
1. CLI аргумент (директория или файл)
|
||||
2. Переменная `PICOGENT_CONFIG`
|
||||
3. `./agent.json` в текущей директории (если существует)
|
||||
4. Env-переменные (`TRACKER_URL` + `AGENT_TOKEN`)
|
||||
|
||||
Env-переменные всегда имеют приоритет над значениями из JSON-файла.
|
||||
|
||||
## Конфигурация агента (`agent.json`)
|
||||
|
||||
Все поля опциональны (кроме `tracker_url` и `token` для agent-режима). Полный пример с описаниями — `agent.example.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Кодер",
|
||||
"slug": "coder",
|
||||
"prompt": "Ты опытный Go-разработчик...",
|
||||
"tracker_url": "http://localhost:8100",
|
||||
"token": "tb-agent-abc123",
|
||||
"transport": "http",
|
||||
"listen_port": 3200,
|
||||
"work_dir": "/projects/my-app",
|
||||
"model": "sonnet",
|
||||
"provider": "anthropic",
|
||||
"capabilities": ["coding", "review"],
|
||||
"max_concurrent_tasks": 2,
|
||||
"heartbeat_interval_sec": 30,
|
||||
"allowed_paths": []
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Env | Default | Описание |
|
||||
|---|---|---|---|
|
||||
| `name` | `AGENT_NAME` | `Agent` | Имя агента в трекере |
|
||||
| `slug` | `AGENT_SLUG` | `agent` | Уникальный ID (латиница) |
|
||||
| `prompt` | `AGENT_PROMPT` | — | Системный промпт — роль агента |
|
||||
| `tracker_url` | `TRACKER_URL` | — | URL Team Board Tracker (**обязательно**) |
|
||||
| `token` | `AGENT_TOKEN` | — | Bearer-токен для Tracker API (**обязательно**) |
|
||||
| `transport` | `AGENT_TRANSPORT` | `http` | Транспорт: `http` или `ws` |
|
||||
| `listen_port` | `AGENT_PORT` | `3200` | Порт HTTP (только transport=http) |
|
||||
| `work_dir` | `PICOGENT_WORK_DIR` | `agentHome` / `cwd` | Рабочая директория |
|
||||
| `model` | `PICOGENT_MODEL` | `sonnet` | Модель LLM |
|
||||
| `provider` | `PICOGENT_PROVIDER` | `anthropic` | Провайдер LLM |
|
||||
| `api_key` | `PICOGENT_API_KEY` / `ANTHROPIC_API_KEY` | — | API ключ провайдера |
|
||||
| `capabilities` | — | `["coding"]` | Возможности для трекера |
|
||||
| `max_concurrent_tasks` | — | `2` | Макс. параллельных задач |
|
||||
| `heartbeat_interval_sec` | — | `30` | Интервал heartbeat (сек.) |
|
||||
| `allowed_paths` | — | `[]` (без ограничений) | Ограничение доступа к FS |
|
||||
|
||||
### Алиасы моделей
|
||||
|
||||
| Алиас | Полный ID |
|
||||
|---|---|
|
||||
| `sonnet` | `claude-sonnet-4-6` |
|
||||
| `opus` | `claude-opus-4-6` |
|
||||
| `haiku` | `claude-haiku-4-5` |
|
||||
| `sonnet-4` | `claude-sonnet-4-6` |
|
||||
| `opus-4` | `claude-opus-4-6` |
|
||||
| `sonnet-3.5` | `claude-3-5-sonnet-20241022` |
|
||||
|
||||
## Режим директории (рекомендуется)
|
||||
|
||||
Самый удобный способ — создать папку со всем необходимым:
|
||||
|
||||
```
|
||||
agents/coder/
|
||||
agent.json # конфиг
|
||||
skills/ # скилы агента
|
||||
review/
|
||||
SKILL.md
|
||||
sessions/ # сессии (создаётся автоматически)
|
||||
```
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
npm run dev -- ./agents/coder/
|
||||
```
|
||||
|
||||
В этом режиме:
|
||||
- `agentHome` = путь к директории
|
||||
- `workDir` = то же (если не переопределён через `work_dir` или env)
|
||||
- Скилы загружаются из `agentHome/skills/`
|
||||
- Сессии сохраняются в `agentHome/sessions/`
|
||||
|
||||
## Скилы (Skills)
|
||||
|
||||
Агент поддерживает скилы — специализированные инструкции в формате `SKILL.md`.
|
||||
|
||||
### Структура скила
|
||||
|
||||
```
|
||||
skills/
|
||||
review/
|
||||
SKILL.md
|
||||
refactor/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
### Формат `SKILL.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: review
|
||||
description: "Code review — анализ кода на ошибки, стиль, производительность"
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
Инструкции для агента...
|
||||
```
|
||||
|
||||
### Как работает
|
||||
|
||||
Используется **progressive loading** (как в Claude Code):
|
||||
1. В системный промпт попадают только XML-сводки скилов (имя + описание + путь)
|
||||
2. Агент читает полный `SKILL.md` через инструмент `Read` только когда скил нужен
|
||||
3. Контекст не засоряется лишним текстом
|
||||
|
||||
Скилы загружаются из двух мест:
|
||||
- `agentHome/skills/` (или `workDir/skills/`) — скилы конкретного агента
|
||||
- `~/.picogent/skills/` — глобальные скилы
|
||||
|
||||
## Ограничение доступа к файлам (`allowed_paths`)
|
||||
|
||||
По умолчанию агент может читать и писать файлы без ограничений. Для ограничения укажите список разрешённых директорий:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowed_paths": [
|
||||
"/projects/my-app/src",
|
||||
"/projects/my-app/tests"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Что защищено:
|
||||
- **Read** — проверка пути перед чтением
|
||||
- **Write** — проверка перед записью и созданием директорий
|
||||
- **Edit** — проверка перед чтением и записью
|
||||
- **Bash** — проверка, что рабочая директория команды внутри allowed_paths
|
||||
|
||||
Что **не** защищено:
|
||||
- **Grep/Find/Ls** — read-only инструменты, не ограничиваются
|
||||
- **Bash-команды** — содержимое команд не парсится (агент может выполнить `cat /etc/passwd`). Для полной изоляции bash используйте контейнеры
|
||||
|
||||
## `.env` файл
|
||||
|
||||
API ключи и секреты удобно хранить в `.env` файле в корне проекта:
|
||||
|
||||
```env
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
TRACKER_URL=http://localhost:8100
|
||||
AGENT_TOKEN=tb-agent-abc123
|
||||
```
|
||||
|
||||
Node 22 загружает `.env` автоматически через флаг `--env-file=.env` (прописан в `package.json`).
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # Точка входа, dual-mode wiring
|
||||
config.ts # Загрузка конфигурации (JSON + env)
|
||||
agent.ts # Pi Agent Core — agentic loop, skills, sessions
|
||||
sandbox.ts # Sandboxed tools с проверкой путей
|
||||
router.ts # Роутер событий трекера -> agent
|
||||
logger.ts # Pino logger
|
||||
transport/
|
||||
websocket.ts # WebSocket-сервер (чат-режим)
|
||||
http.ts # HTTP-сервер (agent-режим, transport=http)
|
||||
ws-client.ts # WebSocket-клиент к трекеру (agent-режим, transport=ws)
|
||||
types.ts # Общие типы транспорта
|
||||
tracker/
|
||||
client.ts # HTTP-клиент к Tracker API
|
||||
registration.ts # Регистрация + heartbeat
|
||||
types.ts # Типы Tracker API
|
||||
```
|
||||
|
||||
### Поток данных
|
||||
|
||||
**WebSocket-режим:**
|
||||
```
|
||||
Browser (client.html) -> WebSocket -> index.ts -> runAgent() -> Pi Agent Core -> WebSocket -> Browser
|
||||
```
|
||||
|
||||
**Agent-режим (transport=http):**
|
||||
```
|
||||
Tracker -> HTTP POST /events -> HttpTransport -> EventRouter -> runAgent() -> Pi Agent Core -> TrackerClient -> Tracker
|
||||
```
|
||||
|
||||
**Agent-режим (transport=ws):**
|
||||
```
|
||||
Agent -> WebSocket -> Tracker (auth + heartbeat + events) -> WsClientTransport -> EventRouter -> runAgent() -> TrackerClient -> Tracker
|
||||
```
|
||||
|
||||
### Ядро (`agent.ts`)
|
||||
|
||||
Использует Pi Agent Core (`@mariozechner/pi-coding-agent`) для in-process agentic loop:
|
||||
- Модели через `ModelRegistry` с `models.json` в `~/.picogent/`
|
||||
- API ключи через `AuthStorage` с runtime injection (`initAgent()`)
|
||||
- Инструменты: Read, Write, Edit, Bash, Grep, Find, Ls (через `createSandboxedTools`)
|
||||
- Сессии: JSONL-файлы через `SessionManager`
|
||||
- Скилы: `loadSkills()` + `formatSkillsForPrompt()` для progressive loading
|
||||
|
||||
### Tracker интеграция
|
||||
|
||||
- **HTTP-транспорт** (по умолчанию): POST `/api/v1/agents/register` с callback_url, POST `/api/v1/agents/heartbeat`, события через HTTP POST
|
||||
- **WS-транспорт**: агент подключается к `/ws?client_type=agent`, регистрация и heartbeat через WebSocket, события приходят через тот же канал
|
||||
- **Задачи**: трекер отправляет `task.assigned` event -> агент выполняет -> результат как комментарий + статус `review`
|
||||
- **Чат**: трекер отправляет `chat.message` -> агент отвечает -> комментарий к задаче
|
||||
- **Устойчивость**: HTTP — retry регистрации каждые 5с. WS — reconnect с exponential backoff (1с → 30с)
|
||||
|
||||
## WebSocket-протокол
|
||||
|
||||
### Клиент -> Сервер
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg-1",
|
||||
"content": "Какие файлы есть в текущей директории?",
|
||||
"sessionId": "my-session",
|
||||
"workDir": "/projects/my-app",
|
||||
"systemPrompt": "Ты помощник-разработчик"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Обязательно | Описание |
|
||||
|---|---|---|
|
||||
| `id` | да | Уникальный ID сообщения |
|
||||
| `content` | да | Текст промпта |
|
||||
| `sessionId` | нет | Имя сессии (для сохранения контекста между сообщениями) |
|
||||
| `workDir` | нет | Рабочая директория для этого запроса |
|
||||
| `systemPrompt` | нет | Дополнительный системный промпт |
|
||||
|
||||
### Сервер -> Клиент (стрим)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg-1",
|
||||
"type": "text",
|
||||
"content": "В директории находятся...",
|
||||
"sessionId": "abc-123"
|
||||
}
|
||||
```
|
||||
|
||||
Типы сообщений:
|
||||
- **`text`** — фрагмент текстового ответа (стримится по мере генерации)
|
||||
- **`tool_use`** — агент вызвал инструмент (формат: `ToolName: описание`)
|
||||
- **`tool_result`** — результат инструмента с ошибкой
|
||||
- **`error`** — ошибка агента или API
|
||||
- **`done`** — запрос завершён
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
npm install # Установка зависимостей
|
||||
npm run build # Компиляция TypeScript
|
||||
npm run dev # Dev-режим (tsx, .env)
|
||||
npm start # Запуск из dist/ (.env)
|
||||
npm test # Тесты (vitest)
|
||||
```
|
||||
|
||||
## Файлы данных
|
||||
|
||||
| Путь | Описание |
|
||||
|---|---|
|
||||
| `~/.picogent/` | Домашняя директория picogent |
|
||||
| `~/.picogent/models.json` | Конфигурация моделей (создаётся автоматически) |
|
||||
| `~/.picogent/auth.json` | Хранилище API ключей |
|
||||
| `~/.picogent/sessions/` | Сессии по умолчанию |
|
||||
| `~/.picogent/skills/` | Глобальные скилы |
|
||||
|
||||
## Происхождение
|
||||
|
||||
Проект извлечён из двух соседних проектов:
|
||||
|
||||
- **OpenClaw** (`openclaw/`) — паттерн in-process agent loop через Pi Agent Core (`createAgentSession`, `AuthStorage`, `ModelRegistry`, `codingTools`, event subscription)
|
||||
- **NanoClaw** (`nanoclaw/`) — паттерн pino-логгера, структура "получи сообщение -> запусти агента -> отправь результат"
|
||||
|
||||
Сознательно выброшено: каналы, контейнеры, плагины, auth profiles, MCP, subagents и прочая сложность. Picogent — это голый agent loop + транспорт.
|
||||
48
agent.example.json
Normal file
48
agent.example.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"_comment": "Пример конфигурации агента picogent. Скопируйте в agent.json и настройте.",
|
||||
|
||||
"name": "Кодер",
|
||||
"_name_comment": "Имя агента (отображается в трекере). Env: AGENT_NAME",
|
||||
|
||||
"slug": "coder",
|
||||
"_slug_comment": "Уникальный идентификатор агента (латиница, без пробелов). Env: AGENT_SLUG",
|
||||
|
||||
"prompt": "Ты опытный Go-разработчик. Пишешь чистый, идиоматичный Go-код.",
|
||||
"_prompt_comment": "Системный промпт — описание роли и компетенций агента. Env: AGENT_PROMPT",
|
||||
|
||||
"tracker_url": "http://localhost:8100",
|
||||
"_tracker_url_comment": "URL Team Board Tracker для регистрации и получения задач. Env: TRACKER_URL",
|
||||
|
||||
"token": "tb-agent-abc123",
|
||||
"_token_comment": "Токен авторизации для Tracker API (Bearer token). Env: AGENT_TOKEN",
|
||||
|
||||
"transport": "http",
|
||||
"_transport_comment": "Транспорт для связи с трекером: 'http' (agent слушает HTTP, трекер шлёт POST) или 'ws' (agent подключается к трекеру по WebSocket). Env: AGENT_TRANSPORT. Default: http",
|
||||
|
||||
"listen_port": 3200,
|
||||
"_listen_port_comment": "Порт HTTP-сервера для приёма событий от трекера (только для transport=http). Env: AGENT_PORT. Default: 3200",
|
||||
|
||||
"work_dir": "/projects/my-app",
|
||||
"_work_dir_comment": "Рабочая директория агента (где он выполняет задачи). Env: PICOGENT_WORK_DIR. Default: agentHome или cwd",
|
||||
|
||||
"model": "sonnet",
|
||||
"_model_comment": "Модель LLM. Алиасы: sonnet, opus, haiku, sonnet-4, opus-4. Полные ID тоже работают. Env: PICOGENT_MODEL. Default: sonnet",
|
||||
|
||||
"provider": "anthropic",
|
||||
"_provider_comment": "Провайдер LLM. Default: anthropic. Env: PICOGENT_PROVIDER",
|
||||
|
||||
"api_key": "",
|
||||
"_api_key_comment": "API ключ провайдера. Лучше через .env файл. Env: PICOGENT_API_KEY или ANTHROPIC_API_KEY",
|
||||
|
||||
"capabilities": ["coding", "review"],
|
||||
"_capabilities_comment": "Список возможностей агента — передаётся трекеру при регистрации. Default: [\"coding\"]",
|
||||
|
||||
"max_concurrent_tasks": 2,
|
||||
"_max_concurrent_tasks_comment": "Сколько задач агент может выполнять параллельно. Default: 2",
|
||||
|
||||
"heartbeat_interval_sec": 30,
|
||||
"_heartbeat_interval_sec_comment": "Интервал heartbeat к трекеру (секунды). Default: 30",
|
||||
|
||||
"allowed_paths": [],
|
||||
"_allowed_paths_comment": "Ограничение доступа к файлам. Пустой массив = без ограничений. Пример: [\"/projects/my-app/src\", \"/projects/my-app/tests\"]"
|
||||
}
|
||||
14
agents/coder/agent.json
Normal file
14
agents/coder/agent.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Кодер",
|
||||
"slug": "coder",
|
||||
"prompt": "Ты опытный Go-разработчик. Пишешь чистый, идиоматичный Go-код. Следуешь best practices: обработка ошибок, тесты, понятные имена.",
|
||||
"tracker_url": "http://localhost:8100",
|
||||
"token": "tb-agent-abc123",
|
||||
"listen_port": 3200,
|
||||
"capabilities": ["coding", "review"],
|
||||
"max_concurrent_tasks": 2,
|
||||
"model": "sonnet",
|
||||
"provider": "anthropic",
|
||||
"heartbeat_interval_sec": 30,
|
||||
"allowed_paths": []
|
||||
}
|
||||
22
agents/coder/skills/review/SKILL.md
Normal file
22
agents/coder/skills/review/SKILL.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
name: review
|
||||
description: "Code review — анализ кода на ошибки, стиль, производительность и безопасность"
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
When asked to review code, follow this process:
|
||||
|
||||
1. **Read the files** mentioned by the user
|
||||
2. **Analyze** for:
|
||||
- Bugs and logic errors
|
||||
- Security vulnerabilities (injection, auth issues, data leaks)
|
||||
- Performance problems (N+1 queries, unnecessary allocations, blocking calls)
|
||||
- Code style and readability
|
||||
- Missing error handling
|
||||
3. **Output** a structured review:
|
||||
- List issues by severity: critical > warning > suggestion
|
||||
- For each issue: file, line, description, fix suggestion
|
||||
- End with a summary: "X critical, Y warnings, Z suggestions"
|
||||
|
||||
Keep feedback actionable and concise. Don't nitpick formatting if there's a linter.
|
||||
192
client.html
Normal file
192
client.html
Normal file
@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>picogent client</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Consolas', 'Monaco', monospace; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 12px 16px; background: #16213e; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 16px; color: #a8b2d1; }
|
||||
.status { font-size: 12px; padding: 2px 8px; border-radius: 10px; }
|
||||
.status.connected { background: #1b4332; color: #52b788; }
|
||||
.status.disconnected { background: #3d0000; color: #e74c3c; }
|
||||
.settings { display: flex; gap: 8px; margin-left: auto; align-items: center; }
|
||||
.settings label { font-size: 11px; color: #777; }
|
||||
.settings input { background: #0f3460; border: 1px solid #444; color: #e0e0e0; padding: 3px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; width: 140px; }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.msg { padding: 8px 12px; border-radius: 6px; max-width: 85%; word-wrap: break-word; white-space: pre-wrap; font-size: 13px; line-height: 1.5; }
|
||||
.msg.user { background: #0f3460; align-self: flex-end; color: #a8d8ea; }
|
||||
.msg.assistant { background: #1f2833; align-self: flex-start; border: 1px solid #333; }
|
||||
.msg.tool { background: #1a1a2e; align-self: flex-start; color: #f0a500; font-size: 12px; border-left: 3px solid #f0a500; }
|
||||
.msg.error { background: #2d0000; align-self: flex-start; color: #e74c3c; border-left: 3px solid #e74c3c; }
|
||||
.msg.system { background: transparent; align-self: center; color: #555; font-size: 11px; }
|
||||
.input-area { padding: 12px 16px; background: #16213e; border-top: 1px solid #333; display: flex; gap: 8px; }
|
||||
#prompt { flex: 1; background: #0f3460; border: 1px solid #444; color: #e0e0e0; padding: 10px 14px; border-radius: 6px; font-family: inherit; font-size: 14px; resize: none; outline: none; min-height: 44px; max-height: 120px; }
|
||||
#prompt:focus { border-color: #a8d8ea; }
|
||||
#prompt::placeholder { color: #555; }
|
||||
button { background: #e94560; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: bold; }
|
||||
button:hover { background: #c73e54; }
|
||||
button:disabled { background: #444; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>picogent</h1>
|
||||
<span id="status" class="status disconnected">disconnected</span>
|
||||
<div class="settings">
|
||||
<label>session:</label>
|
||||
<input id="sessionId" placeholder="(optional)" value="">
|
||||
<label>workDir:</label>
|
||||
<input id="workDir" placeholder="(default)" value="">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="prompt" rows="1" placeholder="Type a message... (Shift+Enter for newline)"></textarea>
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const WS_URL = 'ws://localhost:3100';
|
||||
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const promptEl = document.getElementById('prompt');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const statusEl = document.getElementById('status');
|
||||
const sessionIdEl = document.getElementById('sessionId');
|
||||
const workDirEl = document.getElementById('workDir');
|
||||
|
||||
let ws = null;
|
||||
let msgCounter = 0;
|
||||
// Track the current assistant response being streamed
|
||||
let currentAssistantEl = null;
|
||||
let currentAssistantId = null;
|
||||
|
||||
// Auto-generate session ID so conversation persists across messages
|
||||
if (!sessionIdEl.value.trim()) {
|
||||
sessionIdEl.value = 'chat-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
statusEl.textContent = 'connected';
|
||||
statusEl.className = 'status connected';
|
||||
addMsg('system', 'Connected to ' + WS_URL);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
statusEl.textContent = 'disconnected';
|
||||
statusEl.className = 'status disconnected';
|
||||
addMsg('system', 'Disconnected. Reconnecting in 3s...');
|
||||
currentAssistantEl = null;
|
||||
currentAssistantId = null;
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
addMsg('error', 'Bad JSON from server: ' + event.data);
|
||||
return;
|
||||
}
|
||||
handleResponse(data);
|
||||
};
|
||||
}
|
||||
|
||||
function handleResponse(data) {
|
||||
const { id, type, content, sessionId } = data;
|
||||
|
||||
if (type === 'text') {
|
||||
// Accumulate text into a single assistant bubble per message id
|
||||
if (currentAssistantId === id && currentAssistantEl) {
|
||||
currentAssistantEl.textContent += content;
|
||||
} else {
|
||||
currentAssistantEl = addMsg('assistant', content);
|
||||
currentAssistantId = id;
|
||||
}
|
||||
} else if (type === 'tool_use') {
|
||||
addMsg('tool', '⚙ ' + content);
|
||||
// Reset accumulator so next text chunk starts a new bubble after tool
|
||||
currentAssistantEl = null;
|
||||
currentAssistantId = null;
|
||||
} else if (type === 'error') {
|
||||
addMsg('error', content);
|
||||
currentAssistantEl = null;
|
||||
currentAssistantId = null;
|
||||
} else if (type === 'done') {
|
||||
if (sessionId) {
|
||||
addMsg('system', 'done (session: ' + sessionId.slice(0, 8) + '...)');
|
||||
}
|
||||
sendBtn.disabled = false;
|
||||
currentAssistantEl = null;
|
||||
currentAssistantId = null;
|
||||
}
|
||||
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function addMsg(cls, text) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg ' + cls;
|
||||
el.textContent = text;
|
||||
messagesEl.appendChild(el);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const content = promptEl.value.trim();
|
||||
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const msg = {
|
||||
id: 'msg-' + (++msgCounter),
|
||||
content,
|
||||
};
|
||||
|
||||
const sid = sessionIdEl.value.trim();
|
||||
if (sid) msg.sessionId = sid;
|
||||
|
||||
const wd = workDirEl.value.trim();
|
||||
if (wd) msg.workDir = wd;
|
||||
|
||||
addMsg('user', content);
|
||||
ws.send(JSON.stringify(msg));
|
||||
promptEl.value = '';
|
||||
promptEl.style.height = 'auto';
|
||||
sendBtn.disabled = true;
|
||||
currentAssistantEl = null;
|
||||
currentAssistantId = null;
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
promptEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
promptEl.addEventListener('input', () => {
|
||||
promptEl.style.height = 'auto';
|
||||
promptEl.style.height = Math.min(promptEl.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5513
package-lock.json
generated
Normal file
5513
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "picogent",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Minimal chat service with in-process Pi Agent Core agentic loop and pluggable I/O",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx --env-file=.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "0.52.12",
|
||||
"@mariozechner/pi-ai": "0.52.12",
|
||||
"@mariozechner/pi-coding-agent": "0.52.12",
|
||||
"ws": "^8.18.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.7.0",
|
||||
"tsx": "^4.19.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
22
skills/review/SKILL.md
Normal file
22
skills/review/SKILL.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
name: review
|
||||
description: "Code review — анализ кода на ошибки, стиль, производительность и безопасность"
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
When asked to review code, follow this process:
|
||||
|
||||
1. **Read the files** mentioned by the user
|
||||
2. **Analyze** for:
|
||||
- Bugs and logic errors
|
||||
- Security vulnerabilities (injection, auth issues, data leaks)
|
||||
- Performance problems (N+1 queries, unnecessary allocations, blocking calls)
|
||||
- Code style and readability
|
||||
- Missing error handling
|
||||
3. **Output** a structured review:
|
||||
- List issues by severity: critical > warning > suggestion
|
||||
- For each issue: file, line, description, fix suggestion
|
||||
- End with a summary: "X critical, Y warnings, Z suggestions"
|
||||
|
||||
Keep feedback actionable and concise. Don't nitpick formatting if there's a linter.
|
||||
378
src/agent.ts
Normal file
378
src/agent.ts
Normal file
@ -0,0 +1,378 @@
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
AuthStorage,
|
||||
ModelRegistry,
|
||||
createAgentSession,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
loadSkills,
|
||||
formatSkillsForPrompt,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import { createSandboxedTools } from './sandbox.js';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { streamSimple } from '@mariozechner/pi-ai';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface AgentOptions {
|
||||
workDir: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
systemPrompt?: string;
|
||||
/** Directory to load skills from (default: workDir) */
|
||||
skillsDir?: string;
|
||||
/** Directory to store session files (default: ~/.picogent/sessions/) */
|
||||
sessionDir?: string;
|
||||
/** Restrict file access to these directories. Empty/undefined = unrestricted. */
|
||||
allowedPaths?: string[];
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'error' | 'session' | 'done';
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
// --- Model alias map: short name → full model ID ---
|
||||
const MODEL_ALIASES: Record<string, string> = {
|
||||
'sonnet': 'claude-sonnet-4-6',
|
||||
'opus': 'claude-opus-4-6',
|
||||
'haiku': 'claude-haiku-4-5',
|
||||
'sonnet-4': 'claude-sonnet-4-6',
|
||||
'opus-4': 'claude-opus-4-6',
|
||||
'sonnet-3.5': 'claude-3-5-sonnet-20241022',
|
||||
};
|
||||
|
||||
function resolveModelId(input: string): string {
|
||||
return MODEL_ALIASES[input] || input;
|
||||
}
|
||||
|
||||
// --- Default models.json for Pi SDK ModelRegistry ---
|
||||
const DEFAULT_MODELS = {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKey: 'ANTHROPIC_API_KEY',
|
||||
api: 'anthropic-messages',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
reasoning: true,
|
||||
input: ['text', 'image'],
|
||||
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
reasoning: false,
|
||||
input: ['text', 'image'],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5',
|
||||
name: 'Claude Haiku 4.5',
|
||||
reasoning: false,
|
||||
input: ['text', 'image'],
|
||||
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// --- Singleton init (once per process) ---
|
||||
const agentDir = path.join(os.homedir(), '.picogent');
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
// Ensure models.json exists
|
||||
const modelsPath = path.join(agentDir, 'models.json');
|
||||
if (!fs.existsSync(modelsPath)) {
|
||||
fs.writeFileSync(modelsPath, JSON.stringify(DEFAULT_MODELS, null, 2));
|
||||
}
|
||||
|
||||
const authStorage = new AuthStorage(path.join(agentDir, 'auth.json'));
|
||||
const modelRegistry = new ModelRegistry(authStorage, modelsPath);
|
||||
|
||||
/** Call once at startup to inject API key into AuthStorage. */
|
||||
export function initAgent(provider: string, apiKey: string): void {
|
||||
if (apiKey) {
|
||||
authStorage.setRuntimeApiKey(provider, apiKey);
|
||||
logger.info({ provider, keyPrefix: apiKey.slice(0, 10) + '...' }, 'API key injected');
|
||||
} else {
|
||||
logger.warn({ provider }, 'No API key provided — agent will fail on API calls');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an agent session using Pi Agent Core (in-process agentic loop).
|
||||
* Same public interface as the old CLI spawn version — async generator of AgentMessage.
|
||||
*/
|
||||
export async function* runAgent(
|
||||
prompt: string,
|
||||
options: AgentOptions,
|
||||
): AsyncGenerator<AgentMessage> {
|
||||
const log = logger.child({ workDir: options.workDir, session: options.sessionId || 'new' });
|
||||
|
||||
const provider = options.provider || 'anthropic';
|
||||
const rawModelId = options.model || 'sonnet';
|
||||
const modelId = resolveModelId(rawModelId);
|
||||
const model = modelRegistry.find(provider, modelId);
|
||||
if (!model) {
|
||||
yield { type: 'error', content: `Model not found: ${provider}/${modelId} (from "${rawModelId}")` };
|
||||
return;
|
||||
}
|
||||
|
||||
// Session file for persistence
|
||||
const sessionsBase = options.sessionDir || path.join(agentDir, 'sessions');
|
||||
const sessionFile = options.sessionId
|
||||
? path.join(sessionsBase, `${sanitizeFilename(options.sessionId)}.jsonl`)
|
||||
: path.join(sessionsBase, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jsonl`);
|
||||
|
||||
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const settingsManager = SettingsManager.create(options.workDir, agentDir);
|
||||
|
||||
// Create tools — sandboxed if allowedPaths is configured
|
||||
const tools = createSandboxedTools(options.workDir, options.allowedPaths);
|
||||
if (options.allowedPaths?.length) {
|
||||
log.info({ allowedPaths: options.allowedPaths }, 'File access restricted to allowed paths');
|
||||
}
|
||||
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>['session'];
|
||||
try {
|
||||
({ session } = await createAgentSession({
|
||||
cwd: options.workDir,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel: 'off',
|
||||
tools,
|
||||
customTools: [],
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
}));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ err }, 'Failed to create agent session');
|
||||
yield { type: 'error', content: `Failed to create agent session: ${msg}` };
|
||||
return;
|
||||
}
|
||||
|
||||
session.agent.streamFn = streamSimple;
|
||||
|
||||
// Load skills from skillsDir (or workDir) + global ~/.picogent/skills/
|
||||
const skillsCwd = options.skillsDir || options.workDir;
|
||||
const { skills } = loadSkills({ cwd: skillsCwd, agentDir });
|
||||
const skillsXml = skills.length > 0 ? formatSkillsForPrompt(skills) : '';
|
||||
|
||||
if (skills.length > 0) {
|
||||
log.info({ count: skills.length, names: skills.map(s => s.name) }, 'Skills loaded');
|
||||
}
|
||||
|
||||
// Build system prompt additions
|
||||
const promptParts: string[] = [];
|
||||
|
||||
if (skillsXml) {
|
||||
promptParts.push(
|
||||
'## Skills (mandatory)',
|
||||
'Before replying: scan <available_skills> <description> entries.',
|
||||
'- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.',
|
||||
'- If multiple could apply: choose the most specific one, then read/follow it.',
|
||||
'- If none clearly apply: do not read any SKILL.md.',
|
||||
'Constraints: never read more than one skill up front; only read after selecting.',
|
||||
skillsXml,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.systemPrompt) {
|
||||
promptParts.push(options.systemPrompt);
|
||||
}
|
||||
|
||||
if (promptParts.length > 0) {
|
||||
const existing = session.systemPrompt || '';
|
||||
const addition = promptParts.join('\n');
|
||||
session.agent.setSystemPrompt(
|
||||
existing ? `${existing}\n\n${addition}` : addition,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = session.sessionId || sessionFile;
|
||||
yield { type: 'session', content: '', sessionId };
|
||||
|
||||
// --- Bridge Pi Agent Core events → async generator via a queue ---
|
||||
const queue: AgentMessage[] = [];
|
||||
let resolveWait: (() => void) | null = null;
|
||||
let done = false;
|
||||
|
||||
function enqueue(msg: AgentMessage) {
|
||||
queue.push(msg);
|
||||
if (resolveWait) {
|
||||
const r = resolveWait;
|
||||
resolveWait = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
|
||||
function signalDone() {
|
||||
done = true;
|
||||
if (resolveWait) {
|
||||
const r = resolveWait;
|
||||
resolveWait = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
|
||||
// Track accumulated text from text_delta events for dedup
|
||||
let accumulatedText = '';
|
||||
let emittedAnyText = false;
|
||||
|
||||
const unsubscribe = session.subscribe((evt: AgentSessionEvent) => {
|
||||
const evtType = evt.type;
|
||||
log.info({ evtType, evt: JSON.stringify(evt).slice(0, 500) }, 'Pi Agent event');
|
||||
|
||||
if (evtType === 'message_update') {
|
||||
const update = evt as { type: 'message_update'; message: { role?: string }; assistantMessageEvent: { type: string; delta?: string; content?: string } };
|
||||
if (update.message?.role !== 'assistant') return;
|
||||
|
||||
const subType = update.assistantMessageEvent?.type;
|
||||
|
||||
if (subType === 'text_delta') {
|
||||
const delta = (update.assistantMessageEvent as { delta?: string }).delta || '';
|
||||
if (delta) {
|
||||
accumulatedText += delta;
|
||||
emittedAnyText = true;
|
||||
enqueue({ type: 'text', content: delta, sessionId });
|
||||
}
|
||||
} else if (subType === 'text_end') {
|
||||
// Final text — emit any remaining delta not yet sent
|
||||
const content = (update.assistantMessageEvent as { content?: string }).content || '';
|
||||
if (content && content.length > accumulatedText.length && content.startsWith(accumulatedText)) {
|
||||
const remaining = content.slice(accumulatedText.length);
|
||||
if (remaining) {
|
||||
emittedAnyText = true;
|
||||
enqueue({ type: 'text', content: remaining, sessionId });
|
||||
}
|
||||
}
|
||||
accumulatedText = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (evtType === 'message_end') {
|
||||
const end = evt as { type: 'message_end'; message: { role?: string; stopReason?: string; errorMessage?: string; content?: Array<{ type: string; text?: string }> } };
|
||||
if (end.message?.role !== 'assistant') return;
|
||||
|
||||
// Surface API/model errors to the client
|
||||
if (end.message.stopReason === 'error' && end.message.errorMessage) {
|
||||
enqueue({ type: 'error', content: end.message.errorMessage, sessionId });
|
||||
emittedAnyText = false;
|
||||
accumulatedText = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// If no text was streamed via deltas, extract from final message
|
||||
if (!emittedAnyText && end.message.content) {
|
||||
const text = end.message.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text?: string }) => c.text || '')
|
||||
.join('');
|
||||
if (text) {
|
||||
enqueue({ type: 'text', content: text, sessionId });
|
||||
}
|
||||
}
|
||||
emittedAnyText = false;
|
||||
accumulatedText = '';
|
||||
}
|
||||
|
||||
if (evtType === 'tool_execution_start') {
|
||||
const start = evt as { type: 'tool_execution_start'; toolName: string; args: unknown };
|
||||
const meta = summarizeToolArgs(start.toolName, start.args);
|
||||
enqueue({ type: 'tool_use', content: meta ? `${start.toolName}: ${meta}` : start.toolName, sessionId });
|
||||
}
|
||||
|
||||
if (evtType === 'tool_execution_end') {
|
||||
const end = evt as { type: 'tool_execution_end'; toolName: string; result: unknown; isError: boolean };
|
||||
if (end.isError) {
|
||||
const errorText = typeof end.result === 'string' ? end.result : JSON.stringify(end.result);
|
||||
enqueue({ type: 'tool_result', content: `${end.toolName} error: ${errorText?.slice(0, 200)}`, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (evtType === 'agent_end') {
|
||||
signalDone();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Start the prompt (runs the agentic loop)
|
||||
log.info({ promptLength: prompt.length, modelId, provider }, 'Starting agent prompt');
|
||||
const promptPromise = session.prompt(prompt).then((result: unknown) => {
|
||||
log.info({ result: JSON.stringify(result)?.slice(0, 500) }, 'Agent prompt resolved');
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ err }, 'Agent prompt failed');
|
||||
enqueue({ type: 'error', content: msg, sessionId });
|
||||
signalDone();
|
||||
});
|
||||
|
||||
// Yield events from the queue as they arrive
|
||||
while (true) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (done) break;
|
||||
await new Promise<void>((r) => { resolveWait = r; });
|
||||
}
|
||||
|
||||
// Drain remaining items
|
||||
while (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
}
|
||||
|
||||
// Ensure prompt is fully resolved
|
||||
await promptPromise;
|
||||
|
||||
yield { type: 'done', content: '', sessionId };
|
||||
log.info({ sessionId }, 'Agent query completed');
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ err }, 'Agent query failed');
|
||||
yield { type: 'error', content: errorMsg, sessionId };
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function summarizeToolArgs(toolName: string, args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return '';
|
||||
const record = args as Record<string, unknown>;
|
||||
switch (toolName.toLowerCase()) {
|
||||
case 'read':
|
||||
case 'write':
|
||||
case 'edit':
|
||||
return String(record.file_path || record.filePath || '');
|
||||
case 'bash':
|
||||
case 'exec':
|
||||
return String(record.command || '').slice(0, 100);
|
||||
case 'glob':
|
||||
case 'grep':
|
||||
return String(record.pattern || '');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
157
src/config.ts
Normal file
157
src/config.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
defaultWorkDir: string;
|
||||
sessionDir: string;
|
||||
model: string;
|
||||
logLevel: string;
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
prompt: string;
|
||||
trackerUrl: string;
|
||||
token: string;
|
||||
transport: 'http' | 'ws';
|
||||
listenPort: number;
|
||||
workDir: string;
|
||||
agentHome: string;
|
||||
capabilities: string[];
|
||||
maxConcurrentTasks: number;
|
||||
model: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
heartbeatIntervalSec: number;
|
||||
/** Restrict file access to these directories. Empty = unrestricted. */
|
||||
allowedPaths: string[];
|
||||
}
|
||||
|
||||
const homeDir = os.homedir();
|
||||
|
||||
export function loadConfig(): Config {
|
||||
return {
|
||||
port: parseInt(process.env.PICOGENT_PORT || '3100', 10),
|
||||
defaultWorkDir: process.env.PICOGENT_WORK_DIR || process.cwd(),
|
||||
sessionDir: process.env.PICOGENT_SESSION_DIR || path.join(homeDir, '.picogent', 'sessions'),
|
||||
model: process.env.PICOGENT_MODEL || 'sonnet',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
apiKey: process.env.PICOGENT_API_KEY || process.env.ANTHROPIC_API_KEY || '',
|
||||
provider: process.env.PICOGENT_PROVIDER || 'anthropic',
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedConfig {
|
||||
configPath: string | null;
|
||||
agentHome: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve config: CLI arg can be a .json file OR a directory.
|
||||
*
|
||||
* Directory mode: picogent ./agents/coder/
|
||||
* → configPath = ./agents/coder/agent.json
|
||||
* → agentHome = ./agents/coder/
|
||||
*
|
||||
* File mode: picogent ./agent.json
|
||||
* → configPath = ./agent.json
|
||||
* → agentHome = null
|
||||
*
|
||||
* Env/default fallback: same as before
|
||||
*/
|
||||
export function resolveConfig(): ResolvedConfig {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (arg) {
|
||||
const resolved = path.resolve(arg);
|
||||
|
||||
// Directory mode: picogent ./agents/coder/
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
const configInDir = path.join(resolved, 'agent.json');
|
||||
return {
|
||||
configPath: fs.existsSync(configInDir) ? configInDir : null,
|
||||
agentHome: resolved,
|
||||
};
|
||||
}
|
||||
|
||||
// File mode: picogent ./agent.json
|
||||
if (arg.endsWith('.json')) {
|
||||
return { configPath: resolved, agentHome: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Env fallback
|
||||
if (process.env.PICOGENT_CONFIG) {
|
||||
return { configPath: path.resolve(process.env.PICOGENT_CONFIG), agentHome: null };
|
||||
}
|
||||
|
||||
// Default: ./agent.json if exists
|
||||
const defaultPath = path.resolve('./agent.json');
|
||||
if (fs.existsSync(defaultPath)) {
|
||||
return { configPath: defaultPath, agentHome: null };
|
||||
}
|
||||
|
||||
return { configPath: null, agentHome: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent mode if: config found, directory passed, or TRACKER_URL env set.
|
||||
*/
|
||||
export function hasAgentConfig(): boolean {
|
||||
const { configPath, agentHome } = resolveConfig();
|
||||
return configPath !== null || agentHome !== null || !!process.env.TRACKER_URL;
|
||||
}
|
||||
|
||||
export function loadAgentConfig(): AgentConfig {
|
||||
const { configPath, agentHome } = resolveConfig();
|
||||
|
||||
// Load file if available, otherwise empty object
|
||||
let file: Record<string, unknown> = {};
|
||||
if (configPath) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(`Agent config not found: ${configPath}`);
|
||||
}
|
||||
file = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
|
||||
const trackerUrl = process.env.TRACKER_URL || (file.tracker_url as string) || '';
|
||||
const token = process.env.AGENT_TOKEN || (file.token as string) || '';
|
||||
|
||||
if (!trackerUrl) {
|
||||
throw new Error('Tracker URL required. Set TRACKER_URL env or tracker_url in config.');
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('Agent token required. Set AGENT_TOKEN env or token in config.');
|
||||
}
|
||||
|
||||
// In directory mode, agentHome IS the work dir (unless overridden)
|
||||
const resolvedHome = agentHome || '';
|
||||
const workDir = process.env.PICOGENT_WORK_DIR
|
||||
|| (file.work_dir as string)
|
||||
|| resolvedHome
|
||||
|| process.cwd();
|
||||
|
||||
return {
|
||||
name: (file.name as string) || process.env.AGENT_NAME || 'Agent',
|
||||
slug: (file.slug as string) || process.env.AGENT_SLUG || 'agent',
|
||||
prompt: (file.prompt as string) || process.env.AGENT_PROMPT || '',
|
||||
trackerUrl,
|
||||
token,
|
||||
transport: (process.env.AGENT_TRANSPORT || (file.transport as string) || 'http') as 'http' | 'ws',
|
||||
listenPort: parseInt(process.env.AGENT_PORT || String(file.listen_port || '3200'), 10),
|
||||
workDir,
|
||||
agentHome: resolvedHome || workDir,
|
||||
capabilities: (file.capabilities as string[]) || ['coding'],
|
||||
maxConcurrentTasks: (file.max_concurrent_tasks as number) || 2,
|
||||
model: process.env.PICOGENT_MODEL || (file.model as string) || 'sonnet',
|
||||
provider: process.env.PICOGENT_PROVIDER || (file.provider as string) || 'anthropic',
|
||||
apiKey: process.env.PICOGENT_API_KEY || process.env.ANTHROPIC_API_KEY || (file.api_key as string) || '',
|
||||
heartbeatIntervalSec: (file.heartbeat_interval_sec as number) || 30,
|
||||
allowedPaths: (file.allowed_paths as string[]) || [],
|
||||
};
|
||||
}
|
||||
146
src/index.ts
Normal file
146
src/index.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { loadConfig, hasAgentConfig, loadAgentConfig } from './config.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { runAgent, initAgent } from './agent.js';
|
||||
import { WebSocketTransport } from './transport/websocket.js';
|
||||
import { HttpTransport } from './transport/http.js';
|
||||
import { WsClientTransport } from './transport/ws-client.js';
|
||||
import { TrackerClient } from './tracker/client.js';
|
||||
import { TrackerRegistration } from './tracker/registration.js';
|
||||
import { EventRouter } from './router.js';
|
||||
import type { IncomingMessage } from './transport/types.js';
|
||||
|
||||
async function startAgentHttp(config: AgentConfig, client: TrackerClient): Promise<void> {
|
||||
const registration = new TrackerRegistration(client, config);
|
||||
const router = new EventRouter(config, client, registration);
|
||||
const http = new HttpTransport(config.listenPort);
|
||||
|
||||
http.onEvent((event) => router.handleEvent(event));
|
||||
|
||||
await http.start();
|
||||
|
||||
const callbackUrl = `http://localhost:${config.listenPort}/events`;
|
||||
registration.register(callbackUrl);
|
||||
registration.startHeartbeat();
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down agent (http)...');
|
||||
registration.stopHeartbeat();
|
||||
http.stop().then(() => {
|
||||
logger.info('Agent stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
async function startAgentWs(config: AgentConfig, client: TrackerClient): Promise<void> {
|
||||
const wsTransport = new WsClientTransport(config);
|
||||
const router = new EventRouter(config, client, wsTransport);
|
||||
|
||||
wsTransport.onEvent((event) => router.handleEvent(event));
|
||||
|
||||
await wsTransport.start();
|
||||
logger.info('Connected to tracker via WebSocket');
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down agent (ws)...');
|
||||
wsTransport.stop().then(() => {
|
||||
logger.info('Agent stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
async function startAgentMode(): Promise<void> {
|
||||
const config = loadAgentConfig();
|
||||
initAgent(config.provider, config.apiKey);
|
||||
|
||||
logger.info({
|
||||
config: {
|
||||
name: config.name,
|
||||
slug: config.slug,
|
||||
trackerUrl: config.trackerUrl,
|
||||
transport: config.transport,
|
||||
listenPort: config.transport === 'http' ? config.listenPort : undefined,
|
||||
workDir: config.workDir,
|
||||
agentHome: config.agentHome,
|
||||
model: config.model,
|
||||
},
|
||||
}, 'Starting picogent in agent mode');
|
||||
|
||||
const client = new TrackerClient(config.trackerUrl, config.token);
|
||||
|
||||
if (config.transport === 'ws') {
|
||||
await startAgentWs(config, client);
|
||||
} else {
|
||||
await startAgentHttp(config, client);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWebSocketMode(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
initAgent(config.provider, config.apiKey);
|
||||
const transport = new WebSocketTransport(config.port);
|
||||
|
||||
transport.onMessage(async (msg: IncomingMessage & { _clientId?: string }) => {
|
||||
const clientId = msg._clientId || 'unknown';
|
||||
const workDir = msg.workDir || config.defaultWorkDir;
|
||||
|
||||
logger.info({
|
||||
messageId: msg.id,
|
||||
clientId,
|
||||
workDir,
|
||||
sessionId: msg.sessionId || '(none)',
|
||||
}, 'Processing message');
|
||||
|
||||
for await (const agentMsg of runAgent(msg.content, {
|
||||
workDir,
|
||||
sessionId: msg.sessionId,
|
||||
model: config.model,
|
||||
provider: config.provider,
|
||||
systemPrompt: msg.systemPrompt,
|
||||
})) {
|
||||
transport.send(clientId, {
|
||||
id: msg.id,
|
||||
type: agentMsg.type === 'session' ? 'text' : agentMsg.type === 'done' ? 'done' : agentMsg.type,
|
||||
content: agentMsg.content,
|
||||
sessionId: agentMsg.sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.info({
|
||||
config: { port: config.port, defaultWorkDir: config.defaultWorkDir, sessionDir: config.sessionDir, model: config.model },
|
||||
}, 'Starting picogent');
|
||||
await transport.start();
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
transport.stop().then(() => {
|
||||
logger.info('Stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (hasAgentConfig()) {
|
||||
await startAgentMode();
|
||||
} else {
|
||||
await startWebSocketMode();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.fatal({ err }, 'Failed to start');
|
||||
process.exit(1);
|
||||
});
|
||||
15
src/logger.ts
Normal file
15
src/logger.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.fatal({ err }, 'Uncaught exception');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error({ err: reason }, 'Unhandled rejection');
|
||||
});
|
||||
160
src/router.ts
Normal file
160
src/router.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
import { runAgent } from './agent.js';
|
||||
import { TrackerClient } from './tracker/client.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TrackerEvent, TrackerTask } from './tracker/types.js';
|
||||
|
||||
export interface TaskTracker {
|
||||
addTask(taskId: string): void;
|
||||
removeTask(taskId: string): void;
|
||||
}
|
||||
|
||||
export class EventRouter {
|
||||
private log = logger.child({ component: 'event-router' });
|
||||
private activeTasks = 0;
|
||||
|
||||
constructor(
|
||||
private config: AgentConfig,
|
||||
private client: TrackerClient,
|
||||
private taskTracker: TaskTracker,
|
||||
) {}
|
||||
|
||||
async handleEvent(event: TrackerEvent): Promise<void> {
|
||||
this.log.info({ event: event.event, id: event.id }, 'Handling event');
|
||||
|
||||
switch (event.event) {
|
||||
case 'task.assigned':
|
||||
await this.handleTaskAssigned(event.data);
|
||||
break;
|
||||
case 'chat.message':
|
||||
await this.handleChatMessage(event.data);
|
||||
break;
|
||||
default:
|
||||
this.log.warn({ event: event.event }, 'Unknown event type, ignoring');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaskAssigned(data: Record<string, unknown>): Promise<void> {
|
||||
const task = data.task as TrackerTask;
|
||||
if (!task?.id) {
|
||||
this.log.error({ data }, 'task.assigned event missing task data');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTasks >= this.config.maxConcurrentTasks) {
|
||||
this.log.warn({ taskId: task.id, activeTasks: this.activeTasks }, 'Max concurrent tasks reached, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTasks++;
|
||||
this.taskTracker.addTask(task.id);
|
||||
this.log.info({ taskId: task.id, key: task.key, title: task.title }, 'Processing task');
|
||||
|
||||
try {
|
||||
// Update status → in_progress
|
||||
await this.client.updateTask(task.id, { status: 'in_progress' }).catch((err) => {
|
||||
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to in_progress');
|
||||
});
|
||||
|
||||
// Build prompt from task
|
||||
const prompt = buildPromptFromTask(task);
|
||||
|
||||
// Run agent and collect output
|
||||
let collectedText = '';
|
||||
for await (const msg of runAgent(prompt, {
|
||||
workDir: this.config.workDir,
|
||||
model: this.config.model,
|
||||
provider: this.config.provider,
|
||||
systemPrompt: this.config.prompt || undefined,
|
||||
skillsDir: this.config.agentHome,
|
||||
sessionDir: path.join(this.config.agentHome, 'sessions'),
|
||||
allowedPaths: this.config.allowedPaths,
|
||||
})) {
|
||||
if (msg.type === 'text') {
|
||||
collectedText += msg.content;
|
||||
} else if (msg.type === 'error') {
|
||||
this.log.error({ taskId: task.id, error: msg.content }, 'Agent error during task');
|
||||
}
|
||||
}
|
||||
|
||||
// Post result as comment
|
||||
if (collectedText.trim()) {
|
||||
await this.client.addComment(task.id, collectedText.trim()).catch((err) => {
|
||||
this.log.error({ err, taskId: task.id }, 'Failed to add comment');
|
||||
});
|
||||
}
|
||||
|
||||
// Update status → review
|
||||
await this.client.updateTask(task.id, { status: 'review' }).catch((err) => {
|
||||
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to review');
|
||||
});
|
||||
|
||||
this.log.info({ taskId: task.id, resultLength: collectedText.length }, 'Task completed');
|
||||
} catch (err) {
|
||||
this.log.error({ err, taskId: task.id }, 'Task processing failed');
|
||||
|
||||
await this.client.addComment(task.id, `Agent error: ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
|
||||
await this.client.updateTask(task.id, { status: 'error' }).catch(() => {});
|
||||
} finally {
|
||||
this.activeTasks--;
|
||||
this.taskTracker.removeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChatMessage(data: Record<string, unknown>): Promise<void> {
|
||||
const content = (data.content as string) || (data.message as string) || '';
|
||||
const taskId = data.task_id as string | undefined;
|
||||
|
||||
if (!content) {
|
||||
this.log.warn({ data }, 'chat.message event missing content');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info({ taskId, contentLength: content.length }, 'Processing chat message');
|
||||
|
||||
let collectedText = '';
|
||||
for await (const msg of runAgent(content, {
|
||||
workDir: this.config.workDir,
|
||||
model: this.config.model,
|
||||
provider: this.config.provider,
|
||||
systemPrompt: this.config.prompt || undefined,
|
||||
skillsDir: this.config.agentHome,
|
||||
sessionDir: path.join(this.config.agentHome, 'sessions'),
|
||||
allowedPaths: this.config.allowedPaths,
|
||||
})) {
|
||||
if (msg.type === 'text') {
|
||||
collectedText += msg.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId && collectedText.trim()) {
|
||||
await this.client.addComment(taskId, collectedText.trim()).catch((err) => {
|
||||
this.log.error({ err, taskId }, 'Failed to add chat reply comment');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPromptFromTask(task: TrackerTask): string {
|
||||
const parts = [`# Задача: ${task.key} — ${task.title}`, ''];
|
||||
|
||||
if (task.description) {
|
||||
parts.push(task.description, '');
|
||||
}
|
||||
|
||||
if (task.priority) {
|
||||
parts.push(`Приоритет: ${task.priority}`);
|
||||
}
|
||||
|
||||
if (task.files?.length) {
|
||||
parts.push('', 'Прикреплённые файлы:');
|
||||
for (const f of task.files) {
|
||||
parts.push(`- ${f.name} (${f.url})`);
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('', 'Выполни задачу. После завершения опиши что было сделано.');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
148
src/sandbox.ts
Normal file
148
src/sandbox.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
createEditTool,
|
||||
createBashTool,
|
||||
createGrepTool,
|
||||
createFindTool,
|
||||
createLsTool,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type {
|
||||
ReadOperations,
|
||||
WriteOperations,
|
||||
EditOperations,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { AgentTool } from '@mariozechner/pi-agent-core';
|
||||
|
||||
/**
|
||||
* Resolve and normalize a path for comparison.
|
||||
* Handles ~, relative paths, and normalizes separators.
|
||||
*/
|
||||
function resolvePath(filePath: string, cwd: string): string {
|
||||
let expanded = filePath;
|
||||
if (expanded.startsWith('~')) {
|
||||
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '', expanded.slice(1));
|
||||
}
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
expanded = path.resolve(cwd, expanded);
|
||||
}
|
||||
return path.normalize(expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resolved absolute path is within any of the allowed directories.
|
||||
*/
|
||||
function isPathAllowed(absolutePath: string, allowedPaths: string[]): boolean {
|
||||
const normalized = path.normalize(absolutePath) + path.sep;
|
||||
for (const allowed of allowedPaths) {
|
||||
const allowedNorm = path.normalize(allowed) + path.sep;
|
||||
if (normalized.startsWith(allowedNorm) || path.normalize(absolutePath) === path.normalize(allowed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pathError(absolutePath: string, allowedPaths: string[]): Error {
|
||||
return new Error(
|
||||
`Access denied: "${absolutePath}" is outside allowed paths.\n` +
|
||||
`Allowed: ${allowedPaths.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a set of coding tools with path restrictions.
|
||||
*
|
||||
* - If `allowedPaths` is provided and non-empty, all file operations (read, write, edit)
|
||||
* are validated to ensure they only access files within those directories.
|
||||
* Bash commands are restricted to run with cwd inside allowed paths.
|
||||
*
|
||||
* - If `allowedPaths` is empty or undefined, tools work without restrictions
|
||||
* (same as default codingTools, but bound to the given cwd).
|
||||
*/
|
||||
export function createSandboxedTools(cwd: string, allowedPaths?: string[]): AgentTool<any>[] {
|
||||
// Resolve allowed paths to absolute
|
||||
const resolved = (allowedPaths || [])
|
||||
.map(p => path.resolve(cwd, p))
|
||||
.filter(p => p.length > 0);
|
||||
|
||||
// No restrictions — just create tools bound to cwd
|
||||
if (resolved.length === 0) {
|
||||
return [
|
||||
createReadTool(cwd),
|
||||
createBashTool(cwd),
|
||||
createEditTool(cwd),
|
||||
createWriteTool(cwd),
|
||||
createGrepTool(cwd),
|
||||
createFindTool(cwd),
|
||||
createLsTool(cwd),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Sandboxed operations ---
|
||||
|
||||
function guard(absolutePath: string): void {
|
||||
const full = resolvePath(absolutePath, cwd);
|
||||
if (!isPathAllowed(full, resolved)) {
|
||||
throw pathError(full, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const readOps: ReadOperations = {
|
||||
readFile: async (absolutePath: string) => {
|
||||
guard(absolutePath);
|
||||
return fs.readFile(absolutePath);
|
||||
},
|
||||
access: async (absolutePath: string) => {
|
||||
guard(absolutePath);
|
||||
await fs.access(absolutePath);
|
||||
},
|
||||
};
|
||||
|
||||
const writeOps: WriteOperations = {
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
guard(absolutePath);
|
||||
await fs.writeFile(absolutePath, content, 'utf-8');
|
||||
},
|
||||
mkdir: async (dir: string) => {
|
||||
guard(dir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
},
|
||||
};
|
||||
|
||||
const editOps: EditOperations = {
|
||||
readFile: async (absolutePath: string) => {
|
||||
guard(absolutePath);
|
||||
return fs.readFile(absolutePath);
|
||||
},
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
guard(absolutePath);
|
||||
await fs.writeFile(absolutePath, content, 'utf-8');
|
||||
},
|
||||
access: async (absolutePath: string) => {
|
||||
guard(absolutePath);
|
||||
await fs.access(absolutePath);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
createReadTool(cwd, { operations: readOps }),
|
||||
createBashTool(cwd, {
|
||||
spawnHook: (ctx) => {
|
||||
// Ensure bash cwd is within allowed paths
|
||||
const bashCwd = resolvePath(ctx.cwd, cwd);
|
||||
if (!isPathAllowed(bashCwd, resolved)) {
|
||||
throw pathError(bashCwd, resolved);
|
||||
}
|
||||
return ctx;
|
||||
},
|
||||
}),
|
||||
createEditTool(cwd, { operations: editOps }),
|
||||
createWriteTool(cwd, { operations: writeOps }),
|
||||
createGrepTool(cwd),
|
||||
createFindTool(cwd),
|
||||
createLsTool(cwd),
|
||||
];
|
||||
}
|
||||
56
src/session.ts
Normal file
56
src/session.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
interface SessionData {
|
||||
claudeSessionId: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
constructor(private sessionDir: string) {
|
||||
fs.mkdirSync(this.sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
private filePath(id: string): string {
|
||||
const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return path.join(this.sessionDir, `${safe}.json`);
|
||||
}
|
||||
|
||||
get(id: string): string | null {
|
||||
const fp = this.filePath(id);
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
try {
|
||||
const data: SessionData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
||||
return data.claudeSessionId;
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, 'Failed to read session');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
save(id: string, claudeSessionId: string): void {
|
||||
const fp = this.filePath(id);
|
||||
const existing = fs.existsSync(fp);
|
||||
const data: SessionData = {
|
||||
claudeSessionId,
|
||||
createdAt: existing
|
||||
? JSON.parse(fs.readFileSync(fp, 'utf-8')).createdAt
|
||||
: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(fp, JSON.stringify(data, null, 2));
|
||||
logger.debug({ id, claudeSessionId }, 'Session saved');
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
try {
|
||||
return fs.readdirSync(this.sessionDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => f.replace(/\.json$/, ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/tracker/client.ts
Normal file
63
src/tracker/client.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { logger } from '../logger.js';
|
||||
import type { RegistrationPayload, HeartbeatPayload } from './types.js';
|
||||
|
||||
export class TrackerClient {
|
||||
private log = logger.child({ component: 'tracker-client' });
|
||||
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private token: string,
|
||||
) {}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Tracker API ${method} ${path} failed: ${res.status} ${text}`);
|
||||
}
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
async register(payload: RegistrationPayload): Promise<void> {
|
||||
this.log.info({ slug: payload.slug }, 'Registering agent');
|
||||
await this.request('POST', '/api/v1/agents/register', payload);
|
||||
}
|
||||
|
||||
async heartbeat(payload: HeartbeatPayload): Promise<void> {
|
||||
await this.request('POST', '/api/v1/agents/heartbeat', payload);
|
||||
}
|
||||
|
||||
async updateTask(taskId: string, fields: Record<string, unknown>): Promise<void> {
|
||||
this.log.info({ taskId, fields }, 'Updating task');
|
||||
await this.request('PATCH', `/api/v1/tasks/${taskId}`, fields);
|
||||
}
|
||||
|
||||
async addComment(taskId: string, content: string): Promise<void> {
|
||||
this.log.info({ taskId, contentLength: content.length }, 'Adding comment');
|
||||
await this.request('POST', `/api/v1/tasks/${taskId}/comments`, { content });
|
||||
}
|
||||
|
||||
async uploadFile(taskId: string, filename: string, content: string): Promise<void> {
|
||||
await this.request('POST', `/api/v1/tasks/${taskId}/files`, { filename, content });
|
||||
}
|
||||
|
||||
async getTask(taskId: string): Promise<Record<string, unknown>> {
|
||||
return this.request('GET', `/api/v1/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async listTaskFiles(taskId: string): Promise<Record<string, unknown>[]> {
|
||||
return this.request('GET', `/api/v1/tasks/${taskId}/files`);
|
||||
}
|
||||
}
|
||||
82
src/tracker/registration.ts
Normal file
82
src/tracker/registration.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { TrackerClient } from './client.js';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import type { TaskTracker } from '../router.js';
|
||||
|
||||
export class TrackerRegistration implements TaskTracker {
|
||||
private log = logger.child({ component: 'tracker-registration' });
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentTasks = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private client: TrackerClient,
|
||||
private config: AgentConfig,
|
||||
) {}
|
||||
|
||||
private callbackUrl = '';
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private registered = false;
|
||||
|
||||
async register(callbackUrl: string): Promise<void> {
|
||||
this.callbackUrl = callbackUrl;
|
||||
await this.tryRegister();
|
||||
}
|
||||
|
||||
private async tryRegister(): Promise<void> {
|
||||
try {
|
||||
this.log.info({ callbackUrl: this.callbackUrl }, 'Registering with tracker');
|
||||
await this.client.register({
|
||||
name: this.config.name,
|
||||
slug: this.config.slug,
|
||||
capabilities: this.config.capabilities,
|
||||
max_concurrent_tasks: this.config.maxConcurrentTasks,
|
||||
callback_url: this.callbackUrl,
|
||||
});
|
||||
this.registered = true;
|
||||
this.log.info('Registration successful');
|
||||
} catch (err) {
|
||||
this.registered = false;
|
||||
this.log.warn({ err }, 'Registration failed, retrying in 5s');
|
||||
this.retryTimer = setTimeout(() => this.tryRegister(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
startHeartbeat(): void {
|
||||
const intervalMs = this.config.heartbeatIntervalSec * 1000;
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.client.heartbeat({
|
||||
status: this.currentTasks.size > 0 ? 'busy' : 'idle',
|
||||
current_tasks: [...this.currentTasks],
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn({ err }, 'Heartbeat failed');
|
||||
}
|
||||
}, intervalMs);
|
||||
this.log.info({ intervalSec: this.config.heartbeatIntervalSec }, 'Heartbeat started');
|
||||
}
|
||||
|
||||
stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.log.info('Heartbeat stopped');
|
||||
}
|
||||
|
||||
addTask(taskId: string): void {
|
||||
this.currentTasks.add(taskId);
|
||||
}
|
||||
|
||||
removeTask(taskId: string): void {
|
||||
this.currentTasks.delete(taskId);
|
||||
}
|
||||
|
||||
get activeTasks(): string[] {
|
||||
return [...this.currentTasks];
|
||||
}
|
||||
}
|
||||
49
src/tracker/types.ts
Normal file
49
src/tracker/types.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/** Task from the tracker */
|
||||
export interface TrackerTask {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
project_id: string;
|
||||
parent_id?: string;
|
||||
files?: TrackerFile[];
|
||||
}
|
||||
|
||||
export interface TrackerFile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Incoming event (tracker → agent via HTTP POST) */
|
||||
export interface TrackerEvent {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
ts: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Agent response to tracker */
|
||||
export interface TrackerResponse {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
req_id: string;
|
||||
}
|
||||
|
||||
/** Registration payload */
|
||||
export interface RegistrationPayload {
|
||||
name: string;
|
||||
slug: string;
|
||||
capabilities: string[];
|
||||
max_concurrent_tasks: number;
|
||||
callback_url: string;
|
||||
}
|
||||
|
||||
/** Heartbeat payload */
|
||||
export interface HeartbeatPayload {
|
||||
status: 'idle' | 'busy';
|
||||
current_tasks: string[];
|
||||
}
|
||||
109
src/transport/http.ts
Normal file
109
src/transport/http.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import http from 'node:http';
|
||||
import { logger } from '../logger.js';
|
||||
import type { TrackerEvent } from '../tracker/types.js';
|
||||
|
||||
export type EventHandler = (event: TrackerEvent) => Promise<void>;
|
||||
|
||||
export class HttpTransport {
|
||||
private server: http.Server | null = null;
|
||||
private handler: EventHandler | null = null;
|
||||
private log = logger.child({ component: 'http-transport' });
|
||||
private processedIds = new Set<string>();
|
||||
|
||||
constructor(private port: number) {}
|
||||
|
||||
onEvent(handler: EventHandler): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/events') {
|
||||
await this.handleEventRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
this.log.error({ err }, 'HTTP server error');
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
this.log.info({ port: this.port }, 'HTTP transport listening');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close(() => {
|
||||
this.log.info('HTTP transport stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleEventRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
}
|
||||
|
||||
let event: TrackerEvent;
|
||||
try {
|
||||
event = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.event || !event.id) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'missing required fields: event, id' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication
|
||||
if (this.processedIds.has(event.id)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'duplicate' }));
|
||||
return;
|
||||
}
|
||||
this.processedIds.add(event.id);
|
||||
|
||||
// Cap dedup set size
|
||||
if (this.processedIds.size > 10000) {
|
||||
const entries = [...this.processedIds];
|
||||
this.processedIds = new Set(entries.slice(entries.length - 5000));
|
||||
}
|
||||
|
||||
this.log.info({ eventType: event.event, eventId: event.id }, 'Received event');
|
||||
|
||||
// Respond immediately, process async
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'accepted' }));
|
||||
|
||||
if (this.handler) {
|
||||
this.handler(event).catch((err) => {
|
||||
this.log.error({ err, eventId: event.id }, 'Event handler failed');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/transport/types.ts
Normal file
24
src/transport/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export interface IncomingMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
workDir?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
id: string;
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'error' | 'done';
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export type MessageHandler = (msg: IncomingMessage) => Promise<void>;
|
||||
|
||||
export interface Transport {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
onMessage(handler: MessageHandler): void;
|
||||
send(clientId: string, msg: OutgoingMessage): void;
|
||||
}
|
||||
128
src/transport/websocket.ts
Normal file
128
src/transport/websocket.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { logger } from '../logger.js';
|
||||
import type { Transport, IncomingMessage, OutgoingMessage, MessageHandler } from './types.js';
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
export class WebSocketTransport implements Transport {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private handler: MessageHandler | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private clients = new Map<string, WebSocket>();
|
||||
private clientCounter = 0;
|
||||
|
||||
constructor(private port: number) {}
|
||||
|
||||
onMessage(handler: MessageHandler): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
send(clientId: string, msg: OutgoingMessage): void {
|
||||
const ws = this.clients.get(clientId);
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.wss = new WebSocketServer({ port: this.port }, () => {
|
||||
logger.info({ port: this.port }, 'WebSocket server listening');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
const clientId = `client-${++this.clientCounter}`;
|
||||
this.clients.set(clientId, ws);
|
||||
(ws as WebSocket & { isAlive: boolean }).isAlive = true;
|
||||
logger.info({ clientId }, 'Client connected');
|
||||
|
||||
ws.on('pong', () => {
|
||||
(ws as WebSocket & { isAlive: boolean }).isAlive = true;
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
this.handleRawMessage(clientId, data.toString());
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(clientId);
|
||||
logger.info({ clientId }, 'Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logger.error({ err, clientId }, 'WebSocket error');
|
||||
this.clients.delete(clientId);
|
||||
});
|
||||
});
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
for (const [clientId, ws] of this.clients) {
|
||||
const alive = ws as WebSocket & { isAlive: boolean };
|
||||
if (!alive.isAlive) {
|
||||
logger.warn({ clientId }, 'Client heartbeat timeout, terminating');
|
||||
ws.terminate();
|
||||
this.clients.delete(clientId);
|
||||
continue;
|
||||
}
|
||||
alive.isAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
for (const ws of this.clients.values()) {
|
||||
ws.close(1001, 'Server shutting down');
|
||||
}
|
||||
this.clients.clear();
|
||||
return new Promise((resolve) => {
|
||||
if (this.wss) {
|
||||
this.wss.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleRawMessage(clientId: string, raw: string): void {
|
||||
let parsed: IncomingMessage & { _clientId?: string };
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
this.send(clientId, {
|
||||
id: 'error',
|
||||
type: 'error',
|
||||
content: 'Invalid JSON',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed.id || !parsed.content) {
|
||||
this.send(clientId, {
|
||||
id: parsed.id || 'error',
|
||||
type: 'error',
|
||||
content: 'Missing required fields: id, content',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach clientId so the handler knows where to send responses
|
||||
parsed._clientId = clientId;
|
||||
|
||||
if (this.handler) {
|
||||
this.handler(parsed).catch((err) => {
|
||||
logger.error({ err, clientId, messageId: parsed.id }, 'Handler error');
|
||||
this.send(clientId, {
|
||||
id: parsed.id,
|
||||
type: 'error',
|
||||
content: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/transport/ws-client.ts
Normal file
234
src/transport/ws-client.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import WebSocket from 'ws';
|
||||
import { logger } from '../logger.js';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import type { TaskTracker } from '../router.js';
|
||||
import type { TrackerEvent } from '../tracker/types.js';
|
||||
|
||||
export type EventHandler = (event: TrackerEvent) => Promise<void>;
|
||||
|
||||
/**
|
||||
* WebSocket client transport for connecting to the tracker.
|
||||
*
|
||||
* Instead of running an HTTP server and receiving events via POST,
|
||||
* the agent connects to the tracker over WebSocket. The bidirectional
|
||||
* channel handles auth, events, heartbeat, and ack — no open port needed.
|
||||
*/
|
||||
export class WsClientTransport implements TaskTracker {
|
||||
private log = logger.child({ component: 'ws-client-transport' });
|
||||
private ws: WebSocket | null = null;
|
||||
private handler: EventHandler | null = null;
|
||||
private currentTasks = new Set<string>();
|
||||
private processedIds = new Set<string>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = 1000;
|
||||
private stopped = false;
|
||||
private resolveStart: (() => void) | null = null;
|
||||
private rejectStart: ((err: Error) => void) | null = null;
|
||||
private authenticated = false;
|
||||
|
||||
constructor(private config: AgentConfig) {}
|
||||
|
||||
onEvent(handler: EventHandler): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
addTask(taskId: string): void {
|
||||
this.currentTasks.add(taskId);
|
||||
}
|
||||
|
||||
removeTask(taskId: string): void {
|
||||
this.currentTasks.delete(taskId);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.resolveStart = resolve;
|
||||
this.rejectStart = reject;
|
||||
this.connect();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Agent shutting down');
|
||||
this.ws = null;
|
||||
}
|
||||
this.log.info('WS client transport stopped');
|
||||
}
|
||||
|
||||
private buildWsUrl(): string {
|
||||
const base = this.config.trackerUrl
|
||||
.replace(/^http:\/\//, 'ws://')
|
||||
.replace(/^https:\/\//, 'wss://');
|
||||
// Use the existing /ws endpoint with client_type=agent
|
||||
return `${base.replace(/\/$/, '')}/ws?client_type=agent&client_id=${encodeURIComponent(this.config.slug)}`;
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
const url = this.buildWsUrl();
|
||||
this.log.info({ url }, 'Connecting to tracker via WebSocket');
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on('open', () => {
|
||||
this.log.info('WebSocket connected, sending auth');
|
||||
this.sendJson({
|
||||
type: 'auth',
|
||||
token: this.config.token,
|
||||
agent: {
|
||||
name: this.config.name,
|
||||
slug: this.config.slug,
|
||||
capabilities: this.config.capabilities,
|
||||
max_concurrent_tasks: this.config.maxConcurrentTasks,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(data.toString());
|
||||
} catch {
|
||||
this.log.warn('Received non-JSON message, ignoring');
|
||||
return;
|
||||
}
|
||||
this.handleMessage(msg);
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
this.log.info({ code, reason: reason.toString() }, 'WebSocket closed');
|
||||
this.authenticated = false;
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (!this.stopped) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
this.log.warn({ err: err.message }, 'WebSocket error');
|
||||
// 'close' event will follow, triggering reconnect
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(msg: Record<string, unknown>): void {
|
||||
const type = msg.type as string;
|
||||
|
||||
switch (type) {
|
||||
case 'auth_ok':
|
||||
this.log.info('Authenticated with tracker');
|
||||
this.authenticated = true;
|
||||
this.reconnectDelay = 1000; // Reset backoff on successful auth
|
||||
this.startHeartbeat();
|
||||
// Resolve the start() promise on first successful auth
|
||||
if (this.resolveStart) {
|
||||
this.resolveStart();
|
||||
this.resolveStart = null;
|
||||
this.rejectStart = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
this.log.error({ message: msg.message }, 'Authentication failed');
|
||||
if (this.rejectStart) {
|
||||
this.rejectStart(new Error(`Auth failed: ${msg.message}`));
|
||||
this.resolveStart = null;
|
||||
this.rejectStart = null;
|
||||
}
|
||||
// Close — don't reconnect on auth error from initial start
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Auth failed');
|
||||
this.ws = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'event':
|
||||
this.handleTrackerEvent(msg);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.log.debug({ type }, 'Unknown message type');
|
||||
}
|
||||
}
|
||||
|
||||
private handleTrackerEvent(msg: Record<string, unknown>): void {
|
||||
const event: TrackerEvent = {
|
||||
event: msg.event as string,
|
||||
data: msg.data as Record<string, unknown>,
|
||||
ts: msg.ts as number,
|
||||
id: msg.id as string,
|
||||
};
|
||||
|
||||
if (!event.event || !event.id) {
|
||||
this.log.warn({ msg }, 'Invalid event: missing event or id');
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication
|
||||
if (this.processedIds.has(event.id)) {
|
||||
this.log.debug({ eventId: event.id }, 'Duplicate event, skipping');
|
||||
return;
|
||||
}
|
||||
this.processedIds.add(event.id);
|
||||
|
||||
// Cap dedup set size
|
||||
if (this.processedIds.size > 10000) {
|
||||
const entries = [...this.processedIds];
|
||||
this.processedIds = new Set(entries.slice(entries.length - 5000));
|
||||
}
|
||||
|
||||
// Send ack
|
||||
this.sendJson({ type: 'ack', id: event.id });
|
||||
|
||||
this.log.info({ eventType: event.event, eventId: event.id }, 'Received event');
|
||||
|
||||
if (this.handler) {
|
||||
this.handler(event).catch((err) => {
|
||||
this.log.error({ err, eventId: event.id }, 'Event handler failed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||
const intervalMs = this.config.heartbeatIntervalSec * 1000;
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.sendJson({
|
||||
type: 'heartbeat',
|
||||
status: this.currentTasks.size > 0 ? 'busy' : 'idle',
|
||||
current_tasks: [...this.currentTasks],
|
||||
});
|
||||
}, intervalMs);
|
||||
this.log.info({ intervalSec: this.config.heartbeatIntervalSec }, 'Heartbeat started');
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.log.info({ delayMs: this.reconnectDelay }, 'Scheduling reconnect');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
// Exponential backoff: 1s → 2s → 4s → ... → 30s cap
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
}
|
||||
|
||||
private sendJson(data: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user