This commit is contained in:
Eugene 2026-02-21 02:41:39 +03:00
commit 0525cea647
28 changed files with 8350 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
ANTHROPIC_API_KEY=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
LOG_LEVEL=debug

32
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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": []
}

View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}