felishub-gateway 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FelisHub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,406 @@
1
- # Temporary Holding Version
1
+ # felishub-gateway
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Шлюз агента FelisHub. Держит сессии Agent SDK по разговорам, очереди ходов, решения по рискованным действиям и вопросы, расписания, бюджеты, журнал событий, файлы и почту между агентами. Шлюз сам подключается к FelisHub исходящим соединением и ничего не слушает: сообщения, решения и настройки FelisHub присылает по этому соединению от имени людей. Структуру работы — название задачи, объяснение решения, предложения, замечания, файлы-результаты — агент передаёт встроенными инструментами `felishub`. Telegram шлюз больше не использует.
4
+
5
+ Код шлюза живёт только здесь. Агенты отличаются профилем и своим каталогом: CLAUDE.md, скиллы, `bin/`, хуки.
6
+
7
+ ## Подключение к FelisHub
8
+
9
+ Шлюз открывает одно соединение WebSocket к FelisHub (`wss://<адрес FelisHub>/agent/tunnel`) и держит его сам. Входящий порт, VPN и токены в окружении не нужны: хватает исходящего 443. Агент входит в FelisHub ключом Ed25519 из файла ключа, ключ выдаёт одноразовый код из окна «Подключить агента».
10
+
11
+ ### Новый агент
12
+
13
+ 1. В каталоге `gateway/` агента: `npm i felishub-gateway @anthropic-ai/claude-agent-sdk zod`. Нужен Node.js 22.4 или новее.
14
+ 2. `config.json` с `agentName` (остальное — по умолчанию, см. ниже).
15
+ 3. Логин Claude: `claude` в терминале этого пользователя или `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` в окружении службы.
16
+ 4. В FelisHub: «Команда» → «Подключить агента», скопировать команду. Человек выполняет её сам в терминале сервера, в каталоге `gateway/`:
17
+
18
+ ```sh
19
+ npx felishub-gateway connect https://hub.example.com K7QM-4XPD-R2TA
20
+ ```
21
+
22
+ Команда создаёт ключ, проходит проверку FelisHub и печатает, где лежит ключ. Код одноразовый и действует 15 минут; в коде не важны регистр и дефисы.
23
+ 5. Вернуться в мастер, закончить его — и запустить шлюз: `node src/index.js` (или `npx felishub-gateway` — шлюз без профиля). Запущенный раньше шлюз подхватит ключ сам в течение 5 секунд.
24
+
25
+ ### Файл ключа
26
+
27
+ - Путь — `FELISHUB_KEY_FILE`, иначе `~/.config/felishub/<agentName>.json`. Ключ — вне каталога агента: внутри него агент прочитал бы ключ своим `Read` или приложил бы его файлом, поэтому команда и шлюз такой путь не принимают.
28
+ - В файле — адрес FelisHub, пространство, имя агента и закрытый ключ. Файл пишется с правами `0600`, папка создаётся с `0700`. FelisHub хранит только открытый ключ.
29
+ - Ключ привязан к одному FelisHub и одному пространству. Повторный `connect` с новым кодом того же FelisHub перезаписывает ключ: это замена ключа или переезд в другое пространство. Ключ другого FelisHub команда без `--replace` не перезапишет, а запущенный шлюз на другой FelisHub не переходит до перезапуска.
30
+ - Шлюз перечитывает файл при каждом подключении и проверяет его при каждом пинге FelisHub. Новый ключ того же пространства вступает в силу, когда в FelisHub нажмут «Заменить ключ». Ключ другого пространства — сразу: шлюз переподключается туда, а разговоры и расписания прежнего пространства откладывает (см. «Безопасность»).
31
+
32
+ Строки для `.claude/settings.json` агента, чтобы агент не трогал ключ:
33
+
34
+ ```json
35
+ {
36
+ "permissions": {
37
+ "deny": ["Read(~/.config/felishub/**)", "Edit(~/.config/felishub/**)", "Write(~/.config/felishub/**)"]
38
+ }
39
+ }
40
+ ```
41
+
42
+ Если ключ лежит в другой папке (`FELISHUB_KEY_FILE`), запретите её так же.
43
+
44
+ `connect` не запускается из хода агента: внутри хода в окружении есть `OPS_AGENT_CONVERSATION`, и команда отказывает. Любой вызов инструмента, где упомянуты `felishub-gateway`, `felishub-key`, `.config/felishub` или `cli.js … connect`, шлюз всегда отдаёт людям как необратимое решение, что бы ни было в `irreversiblePatterns`.
45
+
46
+ ### Связь с FelisHub
47
+
48
+ Шлюз переподключается сам. Что он пишет в журнал и что делать:
49
+
50
+ | Строка журнала | Что значит |
51
+ |---|---|
52
+ | `tunnel: connected to hub.example.com as devops (workspace «Ромашка»), events after 1234` | на связи |
53
+ | `hub not connected` в строке запуска и подсказка «шлюз не подключён к FelisHub…» | файла ключа нет: выполните `connect`; шлюз проверяет файл каждые 5 секунд |
54
+ | `tunnel: waiting for FelisHub: «Подключить» or «Заменить ключ» (4000)` | ключ ждёт человека в мастере или в окне «Переподключить шлюз»; повтор каждые 5 секунд |
55
+ | `tunnel: refused (4003): …` | FelisHub отказал по причине из строки (агент отключён или выключен, шлюз устарел); повтор каждые 30 секунд |
56
+ | `tunnel: key not accepted (4004), waiting for a new key in …` | ключ не принят или заменён: шлюз не подключается, пока в файле не появится другой ключ |
57
+ | `tunnel: replaced by another process with the same key (4009), stopped until restart` | тот же ключ у другого процесса: остановите лишний шлюз и перезапустите этот |
58
+ | `tunnel: closed (1006), reconnecting in 7s` | обрыв связи: повтор от 1 до 30 секунд, после перезапуска FelisHub (1012) — через 1–5 секунд, при лимите попыток (4029) — через 30–60 секунд |
59
+ | `tunnel: hub silent for 55s, reconnecting` | соединение молча пропало, шлюз открывает новое |
60
+
61
+ Расписания работают и без связи с FelisHub: события копятся в журнале и доходят после подключения.
62
+
63
+ ### Служба systemd
64
+
65
+ ```ini
66
+ [Unit]
67
+ Description=SEO agent gateway for FelisHub (Agent SDK, outbound tunnel)
68
+ After=network-online.target
69
+ Wants=network-online.target
70
+
71
+ [Service]
72
+ User=seoagent
73
+ WorkingDirectory=/opt/seo-agent/gateway
74
+ EnvironmentFile=/home/seoagent/.config/seo-agent/gateway.env
75
+ ExecStart=/usr/bin/node /opt/seo-agent/gateway/node_modules/felishub-gateway/src/cli.js
76
+ Restart=on-failure
77
+
78
+ [Install]
79
+ WantedBy=multi-user.target
80
+ ```
81
+
82
+ В `gateway.env` — `FELISHUB_KEY_FILE=/home/seoagent/.config/seo-agent/felishub-key.json` и логин Claude. Агент с профилем запускается своим `src/index.js` вместо `cli.js`.
83
+
84
+ ### Профиль агента
85
+
86
+ В каталоге `gateway/` агента:
87
+
88
+ - `vendor/felishub-gateway-<версия>.tgz` — выпущенная версия этого пакета, её ставит `npm ci`. Серверам не нужен доступ к репозиторию шлюза.
89
+ - `package.json` — зависимости `felishub-gateway` (tgz из `vendor/`) и `@anthropic-ai/claude-agent-sdk`. Версию SDK агент закрепляет сам: вместе с SDK приезжает CLI Claude Code, и агенты обновляют его независимо.
90
+ - `src/index.js` — профиль агента, вызов `startGateway(...)`.
91
+ - `config.json`, `state.json`, `schedules.json`. Шлюз ищет `config.json` в текущем каталоге или по пути из `OPS_GATEWAY_CONFIG`. Запуск: `node src/index.js` из `gateway/` агента.
92
+
93
+ ```js
94
+ import { startGateway } from "felishub-gateway";
95
+
96
+ startGateway({
97
+ defaults: {
98
+ maxTurns: 240,
99
+ irreversiblePatterns: ["dns\\.py[\"']?\\s+(--json\\s+)?(renew|autorenew|ns|lock)\\b"],
100
+ },
101
+ instructions: ["Строка, которая добавится в системный промпт этого агента."],
102
+ turnOptions: ({ cfg, conversation, actor, prompt, model }) => ({
103
+ env: { MY_AGENT_VAR: "…" },
104
+ hooks: { PostToolUse: [{ matcher: "Read", hooks: [async () => ({})] }] },
105
+ }),
106
+ });
107
+ ```
108
+
109
+ - `defaults` — умолчания конфигурации этого агента поверх общих из `src/config.js`. Всё, что задано в `config.json`, их перекрывает.
110
+ - `instructions` — строки системного промпта после общих правил шлюза.
111
+ - `turnOptions` — вызывается перед каждым ходом. Получает `conversation` (`id`, `kind`, `title`, `model`, `budgetUsd`, `maxTurns`, у разговора коллеги ещё `peer`), `actor` (`id`, `name`, `role`), текст хода и модель. Возвращает переменные окружения сессии (`env`) и хуки Agent SDK (`hooks`).
112
+
113
+ В окружении сессии агента есть `OPS_AGENT_CONVERSATION` (id разговора), `OPS_AGENT_USER` (имя человека или агента, начавшего ход) и `OPS_AGENT_ROLE` (его роль). `FELISHUB_KEY_FILE` в окружение сессии не попадает: агент не узнаёт, где лежит ключ.
114
+
115
+ Проверка `.dev-unlock` общая: если файл лежит в корне агента, шлюз не стартует.
116
+
117
+ ## config.json
118
+
119
+ ```json
120
+ {
121
+ "agentName": "devops",
122
+ "agentDir": "..",
123
+ "timezone": "Europe/Moscow",
124
+ "model": null,
125
+ "maxConcurrentTurns": 1,
126
+ "maxTurns": 80,
127
+ "turnTimeoutSec": 1800,
128
+ "permissionTimeoutSec": 900,
129
+ "questionTimeoutSec": 1800,
130
+ "sessionIdleResetMin": 180,
131
+ "dailyBudgetUsd": null,
132
+ "irreversiblePatterns": [],
133
+ "mailMaxHops": 4,
134
+ "mailDailyLimit": 30
135
+ }
136
+ ```
137
+
138
+ | Ключ | По умолчанию | Что это |
139
+ |---|---|---|
140
+ | `agentName` | — | Обязателен. Имя агента для коллег и FelisHub: `a-z`, `0-9`, `_`, `-`, до 32 символов |
141
+ | `agentDir` | `..` | Каталог агента относительно `config.json`, там же `logs/events.jsonl` |
142
+ | `timezone` | `Europe/Moscow` | Часовой пояс расписаний, дневных бюджетов и времени в заголовке хода |
143
+ | `model` | `null` | Модель по умолчанию; `null` — как у `claude` на этой машине |
144
+ | `maxConcurrentTurns` | 1 | Сколько разговоров агент ведёт одновременно |
145
+ | `maxTurns` | 80 | Лимит шагов одного хода |
146
+ | `turnTimeoutSec` | 1800 | Ход дольше этого останавливается, исход `timeout` |
147
+ | `permissionTimeoutSec` | 900 | Сколько решение ждёт человека, потом отклоняется |
148
+ | `questionTimeoutSec` | 1800 | Сколько вопрос ждёт ответа |
149
+ | `sessionIdleResetMin` | 180 | Разговор без ходов дольше этого начинает новую сессию; 0 выключает |
150
+ | `dailyBudgetUsd` | `null` | Дневной бюджет разговора по умолчанию; `null` или 0 — без лимита |
151
+ | `irreversiblePatterns` | `[]` | Регулярные выражения. Для Bash проверяется команда, для остальных инструментов — `<инструмент> <JSON ввода>`. Совпало — решение уровня `irreversible`. Ключ шлюза и `connect` — `irreversible` всегда |
152
+ | `mailMaxHops` | 4 | Сколько раз подряд агенты пересылают друг другу запросы одной цепочки |
153
+ | `mailDailyLimit` | 30 | Запросов одному коллеге в сутки и столько же входящих от него |
154
+
155
+ Ключей `api` и `peers` нет: шлюз сам подключается к FelisHub, а коллег задают люди в FelisHub.
156
+
157
+ Окружение:
158
+
159
+ - `FELISHUB_KEY_FILE` — путь к файлу ключа вне каталога агента; по умолчанию `~/.config/felishub/<agentName>.json`.
160
+ - `OPS_GATEWAY_CONFIG` — путь к `config.json`, если он не в текущем каталоге.
161
+ - Логин Claude, как обычно: `ANTHROPIC_API_KEY` или `CLAUDE_CODE_OAUTH_TOKEN`, либо вход через `claude`.
162
+
163
+ ## Переход с 0.4
164
+
165
+ Шлюз 1.0 и новее не запускается с конфигурацией от Telegram-версии и печатает, какие ключи удалить:
166
+
167
+ - из `config.json`: `chatId`, `ownerId`, `topics`, `approvals`, `ownerCanApprove`, `ownerCanWriteAnywhere`, `showToolCalls`, `modelShortcuts`, `ownerOnlyPatterns`, `peers.<имя>.topic`;
168
+ - из `defaults` профиля — те же ключи. `ownerOnlyPatterns` переименуй в `irreversiblePatterns`.
169
+
170
+ `TELEGRAM_BOT_TOKEN` больше не нужен. Разговоры начинаются заново: привязки тем не переносятся, сессии тем и расписания тем Telegram из `schedules.json` отбрасываются, старые события без поля `conversation` не отдаются через API. Из `state.json` без реестра разговоров переносятся только расходы. Нумерация событий продолжается.
171
+
172
+ Что проверить в профиле агента: `turnOptions` получает `conversation` и `actor` вместо `topic` и `threadId`; вместо `OPS_AGENT_TOPIC` в окружении `OPS_AGENT_CONVERSATION`, это строка вида `dm-1a2b3c4d`, а не число; `OPS_AGENT_ROLE` — роль человека в FelisHub (например «Администратор»), а не `admin`/`operator`/`viewer`. Правила форматирования в CLAUDE.md агента (Telegram HTML) замени на Markdown.
173
+
174
+ ## Переход с 1.0
175
+
176
+ Шлюз 1.1 запускается с конфигурацией 1.0 как есть: новых ключей нет. Все изменения — дополнения: API FelisHub v4 новые поля и события пропускает, API v5 требует шлюз версии 1.1.0 или новее.
177
+
178
+ Что сделать при выкатке:
179
+
180
+ - `dailyBudgetUsd: null` в `config.json`: лимиты денег считает API FelisHub.
181
+ - `inbox/` в `.gitignore` репозитория агента: туда шлюз кладёт файлы от людей.
182
+ - Хуки агента не должны запрещать инструменты `mcp__felishub__*`: они ничего не меняют в системах.
183
+ - Поля `title`, `icon` и `example` во frontmatter умений (`.claude/skills/*/SKILL.md`) необязательны: без них приложение покажет `name` и `description`.
184
+
185
+ ## Переход с 1.1.0 и 1.1.1
186
+
187
+ Шлюз 1.1.2 ставится вместо 1.1.0 или 1.1.1 без изменений в конфигурации. Протокол между шлюзом и агентом использует имя FelisHub: MCP-сервер `felishub`, инструменты `mcp__felishub__*`, метка `[felishub]` в первой строке сообщения. Старые имена шлюз не понимает и не выдаёт, а API FelisHub не подключает шлюз ниже 1.1.2.
188
+
189
+ Что сделать при выкатке:
190
+
191
+ - В CLAUDE.md, умениях и скриптах агента проверь метку `[felishub]` и имена инструментов `mcp__felishub__*`.
192
+ - В хуках и правилах разрешений используй имена инструментов `mcp__felishub__*`.
193
+
194
+ ## Переход с 1.1
195
+
196
+ Шлюз 1.2 не запускается с конфигурацией и окружением 1.1 и пишет, что убрать:
197
+
198
+ - из `config.json` — `api` и `peers`;
199
+ - из `gateway.env` — `GATEWAY_API_TOKEN` и все `PEER_TOKEN_*`.
200
+
201
+ Что сделать при выкатке:
202
+
203
+ 1. Остановить шлюз 1.1, поставить 1.2 (`npm ci` после `bin/release.sh`).
204
+ 2. Убрать `api`, `peers` и токены, добавить `FELISHUB_KEY_FILE` в `gateway.env`, если ключ должен лежать не в `~/.config/felishub/`.
205
+ 3. Выполнить `connect` с кодом из окна «Подключить агента» и пройти мастер.
206
+ 4. Запустить шлюз. Коллег теперь задают в FelisHub: шаг «Агенты-коллеги» мастера или «Работает с агентами» → «Изменить» в профиле агента.
207
+
208
+ FelisHub не подключает шлюз ниже 1.2.0. Журнал событий, разговоры и расписания переходят как есть; события до подключения FelisHub не загружает.
209
+
210
+ ## Разговоры и акторы
211
+
212
+ Разговор — отдельная сессия Agent SDK со своей очередью, бюджетом и журналом. Id: `^[a-z0-9][a-z0-9_-]{0,63}$`.
213
+
214
+ - `dm` — разговор, созданный API FelisHub через `PUT /api/conversations/:id`: личный чат с агентом (`dm-…`) или ветка задачи (`th-…`). Шлюз хранит его в `state.json`, удаляет — `DELETE /api/conversations/:id`.
215
+ - `peer` — запросы от коллеги, id `peer-<имя>`. Шлюз ведёт его сам для каждого коллеги из списка, который присылает FelisHub (`PUT /api/peers`), `PUT` разговора для него запрещён.
216
+ - `system` — служебный: в нём события паузы. Тоже зарезервирован.
217
+
218
+ Каждая запись в API называет актора — человека, от имени которого действует FelisHub: `actor: { id, name, role }`, все три поля — строки (`role` может быть пустой). Шлюз доверяет API: права проверяет FelisHub, шлюз записывает, кто что сделал. Роль попадает в заголовок хода как контекст для модели, а не как разрешение.
219
+
220
+ ## Команды FelisHub
221
+
222
+ FelisHub шлёт команды по соединению шлюза кадрами `request` с методом и путём из таблиц ниже, шлюз отвечает кадром `response` с кодом и телом — теми же, что у HTTP API шлюза 1.1. Тело — JSON до 64 КБ (файл идёт отдельными двоичными кадрами, до 20 МБ), ошибки — `{ "error": "…" }` по-русски. `via` в записях — `web`, `desktop`, `mobile` или `telegram`. Неизвестный путь — 404 «нет такого метода API».
223
+
224
+ Перед каждым ответом шлюз присылает своё состояние, если оно изменилось, поэтому после ответа на команду у FelisHub уже свежее состояние. Ещё шлюз присылает его через 250 мс после событий, меняющих состояние, и раз в 30 секунд, если что-то изменилось.
225
+
226
+ ### Состояние
227
+
228
+ `GET /api/status` (то же приходит кадром `status`):
229
+
230
+ ```json
231
+ {
232
+ "agent": "devops", "version": "1.2.0", "startedAt": "…", "timezone": "Europe/Moscow", "model": null, "observedModel": "claude-opus-4-1",
233
+ "paused": false, "pausedBy": null,
234
+ "maxConcurrentTurns": 1, "dailyBudgetUsd": 5, "spendTodayUsd": 0.42,
235
+ "permissionTimeoutSec": 900, "questionTimeoutSec": 1800,
236
+ "irreversiblePatterns": ["dns\\.py[\"']?\\s+(--json\\s+)?(renew|autorenew|ns|lock)\\b"],
237
+ "mailMaxHops": 4, "mailDailyLimit": 30, "mailToday": { "seo": { "sent": 3, "received": 1 } },
238
+ "skills": [{ "name": "healthcheck", "title": "Проверить серверы", "description": "Снять состояние серверов…", "icon": "server", "example": "Как дела на серверах?" }],
239
+ "conversations": [{
240
+ "id": "dm-1a2b3c4d", "kind": "dm", "title": "DevOps · Анна", "model": null, "budgetUsd": null, "maxTurns": null,
241
+ "spendTodayUsd": 0.42,
242
+ "running": { "turnId": "9f3c2a1b", "startedAt": "…", "actorName": "Анна" },
243
+ "queued": 0,
244
+ "session": { "id": "5d1e9c0a", "turns": 3, "updatedAt": "…" },
245
+ "pending": { "id": "c0ffee12", "kind": "permission", "turnId": "9f3c2a1b", "tier": "irreversible", "tool": "Bash", "input": "bin/dns.py renew …", "createdAt": "…", "expiresAt": "…" },
246
+ "lastEventAt": "…"
247
+ }],
248
+ "schedules": [{ "id": "ab12", "conversation": "dm-1a2b3c4d", "kind": "cron", "cron": "0 9 * * 1-5", "prompt": "…", "model": null, "enabled": true, "nextRun": "…", "lastRun": "…", "runs": 4, "createdAt": "…", "createdBy": { "id": "1", "name": "Анна", "role": "Администратор" } }]
249
+ }
250
+ ```
251
+
252
+ - `pausedBy` — `{ by, byId, at }`, пока агент на паузе.
253
+ - `observedModel` — модель из последнего `system/init` Agent SDK: какую модель SDK на самом деле запустил. До первого хода после установки — `null`.
254
+ - `permissionTimeoutSec`, `questionTimeoutSec`, `irreversiblePatterns`, `mailMaxHops`, `mailDailyLimit` — значения из конфигурации, только для показа. `mailToday` — по каждому коллеге запросы ему (`sent`) и от него (`received`) за сегодня в `timezone`: те самые счётчики, по которым срабатывает `mailDailyLimit`.
255
+ - `skills` — умения из `<agentDir>/.claude/skills/*/SKILL.md`, по имени каталога. Из frontmatter: `name` (иначе имя каталога), `description`, необязательные `title` (иначе `name`), `icon` и `example` (иначе `null`). Шлюз перечитывает их не чаще раза в минуту.
256
+ - `model`, `budgetUsd`, `maxTurns` разговора — его собственные значения; `null` значит умолчание агента (`model`, `dailyBudgetUsd`, `maxTurns` из конфигурации).
257
+ - `queued` — ходы в очереди без текущего. `pending` у разрешения несёт `input` так же, как событие `permission`, и `inputTruncated: true`, если вход обрезан. `pending` у вопроса — `{ id, kind: "question", turnId, questions, createdAt, expiresAt }`, где `questions` — `[{ question, header, multiSelect, options: [label] }]`.
258
+
259
+ ### Разговоры
260
+
261
+ - `PUT /api/conversations/:id` `{ kind: "dm", title, model?, budgetUsd?, maxTurns? }` → `201 { ok, created: true }` или `200 { ok, created: false }`. Замена целиком: не переданные `model`, `budgetUsd`, `maxTurns` становятся `null`. `title` — до 200 символов, переводы строк схлопываются.
262
+ - `DELETE /api/conversations/:id` `{ actor }` → `{ ok }`: удаляет разговор и его сессию; история событий и расход за день остаются. 409, пока ход идёт или ждёт в очереди; `system` и `peer-…` удалить нельзя (400). Следующий `PUT` с тем же id создаёт разговор заново, сессия начнётся с нуля.
263
+ - `POST /api/conversations/:id/messages` `{ actor, text, model?, via, allowOverBudget? }` → `202 { ok, queued, turnId }`, `queued` — сколько ходов этого разговора впереди, `turnId` — id будущего хода: его несут эхо `user` и все события хода. `text` — от 1 до 8000 символов, `model` — модель только для этого хода. 404 — нет разговора, 409 — «агент на паузе» или дневной бюджет разговора исчерпан (если не `allowOverBudget: true`). Сообщение никогда не отвечает на ожидающее решение или вопрос: оно встаёт в очередь новым ходом.
264
+ - `POST /api/conversations/:id/cancel` `{ actor }` → `{ ok, wasRunning, wasQueued }`: отклоняет ожидающее решение (`via: "cancel"`), останавливает текущий ход и снимает с очереди все ходы разговора, которые ещё не начались. У каждого снятого хода сразу пишется `turn-end` с исходом `cancelled`, `durationMs: 0` и без `turn-start`; когда до него доходит очередь, шлюз его пропускает. `wasRunning` — шёл ли ход, `wasQueued` — сняты ли ходы из очереди.
265
+ - `POST /api/conversations/:id/reset` `{ actor }` → `{ ok }`: новый контекст — следующая задача начнёт новую сессию. 409, пока идёт ход.
266
+
267
+ ### Файлы
268
+
269
+ - `PUT /api/conversations/:id/files/:name` — файл до 20 МБ (двоичные кадры, размер объявлен заранее) → `{ ok, path, size }`. Файл ложится в `<agentDir>/inbox/<id разговора>/<name>`, тот же путь заменяется; `path` — относительный путь, его API называет в тексте сообщения агенту. `name` — до 200 символов в кодировке URL, без `/`, `\` и управляющих символов, не начинается с точки. 404 — нет разговора, 413 — больше 20 МБ (шлюз отвечает сразу, не дожидаясь конца файла), 400 — пришло не столько байт, сколько объявлено. Если `inbox` ведёт за пределы каталога агента (ссылкой), запись отклоняется.
270
+ - `GET /api/files/:fileId` — артефакт, который агент отдал инструментом `felishub.attach` (событие `artifact`): ответ `{ name, mime, size }`, потом тело файла двоичными кадрами по 64 КБ. Если FelisHub отменил запрос, шлюз перестаёт читать файл. 404 — нет такого `fileId` или файл с тех пор удалён, переехал за пределы каталога агента или вырос больше 20 МБ. Шлюз помнит артефакты 14 дней.
271
+
272
+ ### Решения и вопросы
273
+
274
+ - `POST /api/approvals/:id` `{ actor, decision: "allow" | "deny", comment?, via }` → `{ ok }`. Комментарий к отказу модель получает вместе с отказом. Решение уже принято, истекло или его нет — 404 `{ error, decidedBy? }`: `decidedBy` — имя того, кто решил первым.
275
+ - `POST /api/questions/:id` `{ actor, answers: { "<текст вопроса>": "<ответ>" }, via }` → `{ ok }`. Отвечает на весь набор вопросов сразу; ответ — вариант или свой текст до 2000 символов. Нет ответа на какой-то вопрос или лишний ключ — 400. Уже отвечено — 404, как у решений.
276
+
277
+ Уровень решения шлюз считает сам: `irreversible`, если команда совпала с `irreversiblePatterns`, иначе `normal`. Кто может решать необратимое, решает FelisHub. Без ответа решение отклоняется через `permissionTimeoutSec`, вопрос — через `questionTimeoutSec` (`via: "timeout"`).
278
+
279
+ Шаблоны проверяются и по исходному тексту, и по нормализованному, чтобы кавычки не прятали команду (`bin/dns.py ren''ew` — тоже `irreversible`). Для Bash исходный текст — команда, для остальных инструментов — `<инструмент> <JSON ввода>`, а если в вводе есть строка `command`, второй вариант — тот же JSON с нормализованной `command`. Нормализация снимает кавычки и экранирование, как shell при разборе слов:
280
+
281
+ - `'…'` — содержимое как есть, кавычки убираются;
282
+ - `"…"` и `$"…"` — кавычки убираются, `\"`, `\\`, `\$`, `` \` `` дают сам символ, `\` с переводом строки исчезает, остальные `\` остаются;
283
+ - `$'…'` — кавычки убираются, `\xHH`, `\uHHHH`, `\NNN` (восьмеричное), `\n`, `\t`, `\r` декодируются, `\<символ>` даёт символ;
284
+ - вне кавычек `\<символ>` даёт символ, `\` с переводом строки исчезает;
285
+ - незакрытая кавычка тянется до конца строки.
286
+
287
+ Подстановки (`$VAR`, `$(…)`) не раскрываются. Тот же нормализатор повторяет API FelisHub для своих правил.
288
+
289
+ ### Расписания
290
+
291
+ - `POST /api/schedules` `{ actor, conversation, kind: "cron" | "once", cron? | at?, prompt, model? }` → `201 { ok, schedule }`. `cron` — 5 полей (минута час день месяц день-недели, в `timezone`), `at` — ISO 8601 не раньше чем через 30 секунд. 404 — нет разговора, 409 — агент на паузе.
292
+ - `PATCH /api/schedules/:id` `{ actor, enabled?, cron?, at?, prompt?, model? }` → `{ ok, schedule }`. `cron` меняется только у `cron`, `at` — только у `once`; при ошибке расписание не меняется. Если меняются `prompt` или `model`, `createdBy` становится `actor`: запуски идут от имени того, кто написал текст.
293
+ - `DELETE /api/schedules/:id` `{ actor }` → `{ ok }`.
294
+
295
+ Запуск идёт от имени создателя (`actor` из `POST`) с `via: "schedule"`, в отдельной сессии `job:<id>`. Рядом никого нет, поэтому решения и вопросы в нём отклоняются сразу, без событий. Запуск пропускается, если агент на паузе или дневной бюджет разговора исчерпан (тогда в разговоре событие `error`). Пропущенный больше чем на час запуск (шлюз не работал) не догоняется.
296
+
297
+ ### Пауза
298
+
299
+ `POST /api/pause` `{ actor, paused }` → `{ ok, paused }`. Пауза останавливает текущие ходы (исход `paused`), отклоняет ожидающие решения и вопросы (`via: "pause"`), отвечает 409 на новые сообщения и расписания и пропускает запуски расписаний. Ходы, которые уже стояли в очереди, ждут возобновления. Запросы коллег на паузе сразу получают отказ. Пауза переживает перезапуск шлюза. Повторная пауза или возобновление без изменения состояния — без события.
300
+
301
+ ### Коллеги и почта
302
+
303
+ - `PUT /api/peers` `{ peers: [{ name, about }] }` → `{ ok }`: коллеги агента от FelisHub, до 50, `name` — `a-z`, `0-9`, `_`, `-`, до 32 символов, не своё имя, `about` — одна строка до 300 символов. Список заменяется целиком и сохраняется в `state.json`, поэтому разговоры `peer-…` и их расписания переживают перезапуск, даже когда FelisHub недоступен.
304
+ - `POST /api/mail` `{ id, from, type, text, replyConversation, hops, re? }` → `202 { ok }`: письмо коллеги, которое переслал FelisHub. `from` ставит FelisHub; если его нет среди коллег — 403 «агент … не в коллегах — FelisHub не разрешал вам переписываться». Повтор `id` — `200 { ok, duplicate: true }`, `hops` больше `mailMaxHops` — 409, сверх `mailDailyLimit` — 429.
305
+
306
+ ### Доставка событий
307
+
308
+ Шлюз сам присылает события кадрами `events`, начиная с номера, который FelisHub назвал при подключении (`welcome.after`): по 200 событий и не больше 1 МБ в кадре, не больше 4 неподтверждённых кадров. FelisHub подтверждает каждый кадр (`ack`), после обрыва шлюз начинает снова с его номера, поэтому события доходят хотя бы раз и без пропусков. Если у FelisHub номер больше, чем в журнале шлюза (новый сервер, удалённый `events.jsonl`), шлюз продолжает нумерацию с него. Событие больше 1 МБ не отправляется, шлюз пишет об этом в журнал.
309
+
310
+ ## События
311
+
312
+ `<каталог агента>/logs/events.jsonl`, одна JSON-строка на событие: `{ id, ts, conversation, kind, ...поля }`. `id` растёт и продолжается после перезапуска.
313
+
314
+ Все события хода несут `turnId` — 8 шестнадцатеричных символов. Событие, которое ставит ход в очередь (`user`, `mail-in`), несёт `turnId` будущего хода.
315
+
316
+ | `kind` | Поля |
317
+ |---|---|
318
+ | `user` | `turnId`, `actorId`, `from`, `via`, `text`, `model?` |
319
+ | `job` | `turnId`, `jobId`, `text` |
320
+ | `turn-start` | `turnId`, `from`, `actorId`, `via` (`web` · `desktop` · `mobile` · `telegram` · `schedule` · `agent`), `model` |
321
+ | `agent` | `turnId`, `text` — Markdown, `usage` |
322
+ | `tool` | `turnId`, `toolUseId`, `tool`, `input` (команда, путь файла или JSON ввода, до 2000 символов), `target?`, `usage`, `diff?` |
323
+ | `tool-result` | `turnId`, `toolUseId`, `isError`, `durationMs`, `output` (до 2000 символов) |
324
+ | `permission` | `turnId`, `approvalId`, `toolUseId?`, `tool`, `input` (у `Bash` — команда целиком, у остальных инструментов — весь вход JSON-объектом, до 64 КБ), `inputTruncated?` (`true`, если вход длиннее 64 КБ и обрезан), `tier` (`normal` · `irreversible`), `expiresAt`, `explain?` |
325
+ | `permission-result` | `turnId`, `approvalId`, `decision`, `by`, `byId`, `via` (`web` · `desktop` · `mobile` · `telegram` · `timeout` · `cancel` · `pause`), `comment?` |
326
+ | `question` | `turnId`, `approvalId`, `questions`, `expiresAt` |
327
+ | `question-result` | `turnId`, `approvalId`, `answers`, `by`, `byId`, `via` |
328
+ | `title` | `turnId`, `title` (до 80), `short?` (до 16) |
329
+ | `task-suggestion` | `turnId`, `title`, `reason?` |
330
+ | `proposal` | `turnId`, `proposalId` (8 шестнадцатеричных), `text`, `title?` |
331
+ | `remark` | `turnId`, `text` |
332
+ | `artifact` | `turnId`, `fileId` (uuid), `name`, `size`, `mime`, `title?` |
333
+ | `turn-end` | `turnId`, `costUsd`, `turns`, `isError`, `limitReached`, `durationMs`, `outcome` (`done` · `failed` · `cancelled` · `timeout` · `limit` · `paused`), `by`, `byId` (кто остановил или отменил ход, иначе `null`) |
334
+ | `error` | `turnId?`, `text` — объяснение для человека |
335
+ | `session-reset` | `by`, `byId`, `reason` (`manual` · `idle`); у `idle` — `turnId`, `by` и `byId` равны `null` |
336
+ | `mail-out` | `turnId?` (нет у отказа на запрос коллеги, пришедший на паузе или при исчерпанном бюджете), `mailId`, `to`, `mailType`, `re`, `text`, `hops`, `failed?`, `error?`, `refusal?` |
337
+ | `mail-in` | `turnId?`, `mailId`, `from`, `mailType`, `re`, `text`, `hops` |
338
+ | `agent-paused`, `agent-resumed` | `by`, `byId`; разговор `system` |
339
+ | `schedule-changed` | `scheduleId`, `action` (`created` · `updated` · `deleted`), `by`, `byId`; разговор расписания |
340
+
341
+ - `usage` — `{ input, output, cacheRead, cacheWrite }`: токены хода накопительно к моменту события, вместе с субагентами (по `usage` сообщений ассистента Agent SDK; сообщения с одним id считаются один раз). По ним API оценивает стоимость идущего хода, настоящая — `costUsd` в `turn-end`.
342
+ - `tool` и `tool-result` связаны `toolUseId` — id блока `tool_use` в Agent SDK; `permission` несёт его же, если SDK его передал. Инструменты `mcp__felishub__*` событий `tool` и `tool-result` не дают: у каждого своё событие.
343
+ - `target` — хост, на котором выполнен шаг: строковое поле `profile` во вводе инструмента (у `mcp__ssh-mcp__*` — профиль сервера), до 64 символов. Нет `profile` — нет и `target`.
344
+ - `diff` — у `Edit`, `MultiEdit` и `Write`: `{ added, removed, lines }`. Счётчики — по полному тексту правки, `lines` — строки с префиксом `+`, `-` или пробел (до трёх строк контекста вокруг изменения), не больше 40 строк и 4000 символов. У `Write` все строки добавлены, у `MultiEdit` правки идут подряд.
345
+ - `tool-result` приходит из блоков `tool_result` сообщений Agent SDK (без субагентов). `output` — текст результата; у инструментов с командой (`input.command`) — последние 2000 символов, у остальных — первые. `durationMs` — от вызова или от решения человека, если оно было, до результата. Отказ человека — тоже результат с `isError: true`.
346
+ - `explain` у `permission` — `{ summary, changes, reason, check, tags, costUsd }` из `felishub.explain` этого хода (см. ниже): `changes` и `tags` — массивы строк (пустые, если агент их не дал), `check` — `{ summary?, lines: [{ kind: "context" | "minus" | "plus", text }] }` или `null`, `costUsd` — число или `null`. Это слова агента, не проверка шлюза.
347
+ - `refusal` у `mail-out` — `hops` или `daily`: шлюз сам не отправил запрос коллеге по `mailMaxHops` или `mailDailyLimit`. У такого события `failed: true` и свой `mailId`, но письмо никуда не уходило.
348
+
349
+ ## Инструменты felishub
350
+
351
+ В каждом ходе модели доступен встроенный MCP-сервер `felishub`. Его инструменты разрешены без решения человека и ничего не меняют в системах: они передают приложению структуру работы. Строки текста схлопываются в одну, пустые значения отклоняются, необязательное поле, которое агент не передал, в событии отсутствует.
352
+
353
+ | Инструмент | Ввод | Что делает шлюз |
354
+ |---|---|---|
355
+ | `mcp__felishub__title` | `title` (до 80), `short?` (до 16) | событие `title` — название задачи; API не применяет его, если человек переименовал задачу |
356
+ | `mcp__felishub__suggest_task` | `title` (до 80), `reason?` (до 300) | событие `task-suggestion`: быстрый вопрос оказался работой, приложение предложит вынести его в задачу |
357
+ | `mcp__felishub__explain` | `summary` (до 300), `changes?` (до 10 строк), `reason` (до 500), `check?` `{ summary?, lines: [{ kind, text }] }` (до 20 строк), `tags?` (до 5), `costUsd?` | хранит объяснение до следующего `permission` этого хода и прикладывает к нему полем `explain`; без запроса разрешения до конца хода объяснение пропадает |
358
+ | `mcp__felishub__propose` | `text` (до 4000), `title?` (до 80) | событие `proposal` со своим `proposalId`: предложение вместо действия, в запусках по расписанию — вместо всего, что требует решения |
359
+ | `mcp__felishub__remark` | `text` (до 500) | событие `remark`: работа сделана с оговорками |
360
+ | `mcp__felishub__attach` | `path` (до 1000), `title?` (до 200) | событие `artifact` и запись для `GET /api/files/:fileId`. Только обычный файл до 20 МБ внутри каталога агента и не в каталоге шлюза (там `config.json`, `state.json` и `gateway.env`); ссылки раскрываются до проверки. Иначе модель получает отказ и события нет |
361
+
362
+ ## Системный промпт
363
+
364
+ Шлюз добавляет к пресету `claude_code`: сообщения приходят из FelisHub (веб, компьютер, телефон); отвечать в GitHub Flavored Markdown (заголовки, списки, таблицы, блоки кода, без HTML); рискованные действия становятся карточками решений, которые человек нажимает в приложении; вопросы — через AskUserQuestion; инструменты `felishub`: название новой задачи, объяснение перед действием с решением, предложение вынести вопрос в задачу, замечание к итогу, предложение вместо действия в запуске по расписанию, файл-результат через `attach`; файлы от людей лежат в `inbox/<id разговора>/`; журнал сессии — `journal/YYYY-MM-DD-<id разговора>.md`. Дальше правила коллег и разговора коллеги, затем `instructions` профиля.
365
+
366
+ Первая строка каждого хода — метаданные шлюза:
367
+
368
+ ```
369
+ [felishub] от: Анна (Администратор), разговор «DevOps · Анна» #dm-1a2b3c4d, 01.10.2026, 14:20:05
370
+ ```
371
+
372
+ У запуска по расписанию к ней добавляется номер расписания и правило «рядом никого нет»: всё, что требует решения, агент предлагает через `felishub.propose`. Ход по почте коллег начинается строкой `[agent] от: <коллега> (агент), запрос #<id>, <время>` или `[agent] ответ от <коллега> на запрос #<id> («…»), <время>`. Такие метки в начале строк текста сообщения шлюз заменяет на «(ложная метка)», в том числе за невидимыми символами и в широких скобках `[]`. Название разговора в метаданных и в системном промпте шлюз очищает от кавычек «», квадратных скобок, невидимых символов и переводов строк: название задают люди и агенты, и оно не должно выдавать себя за метаданные.
373
+
374
+ ## Почта между агентами
375
+
376
+ Если у агента есть коллеги (их задаёт FelisHub), в ходах модели доступен инструмент `mcp__agents__ask` (`agent`, `text`) без решения человека: у получателя свои решения. Почта идёт только через FelisHub и только между коллегами одного пространства.
377
+
378
+ 1. Агент A вызывает инструмент. Шлюз проверяет лимиты (`mailMaxHops`, `mailDailyLimit`): если лимит исчерпан, модель получает отказ, а в журнал пишется `mail-out` с `failed: true` и `refusal`. Иначе шлюз отправляет FelisHub запрос `POST /mail` с телом `{ id, to, type: "ask", text, replyConversation, hops }`: 3 попытки с паузами 1 и 3 секунды, 15 секунд на попытку, отказ FelisHub с кодом 4xx не повторяется. Ход A не ждёт ответа. Модель получает сведения, а не указания, и их можно показать людям как есть: «Запрос #<id> передан агенту <имя>; ответ придёт отдельным сообщением в этот разговор.» или «Запрос агенту <имя> не доставлен: <причина>. Связи с агентом сейчас нет, повтор в этом ходе ничего не даст.»
379
+ 2. FelisHub проверяет, что B — коллега A в том же пространстве и на связи, ставит `from` сам и пересылает письмо шлюзу B (`POST /api/mail`). Шлюз B отвечает 202 и ставит ход в разговор `peer-A`. Если B на паузе или бюджет разговора `peer-A` исчерпан, A сразу получает отказ.
380
+ 3. Итоговый текст хода B (или отказ, или описание сбоя), не длиннее 20000 символов, уходит A тем же путём как `{ type: "answer", re, replyConversation, hops }`.
381
+ 4. Шлюз A принимает один ответ на свой запрос `re` и только от того агента, которому его отправил, остальные отбрасывает. Ответ запускает ход в разговоре, откуда ушёл запрос.
382
+
383
+ ## Безопасность
384
+
385
+ - Шлюз только подключается к FelisHub и ничего не слушает. `wss://` обязателен; `ws://` — только для `localhost`, `127.0.0.1` и `[::1]`.
386
+ - После подключения по сети не ходит ни один многоразовый секрет: каждое подключение — подпись Ed25519 над одноразовым числом FelisHub и адресом FelisHub.
387
+ - Что может тот, у кого ключ: держать сессию этого агента — получать сообщения людей, карточки решений и вопросы, загруженные файлы; присылать события в места этого агента; писать его коллегам. Решать, отвечать за людей, ставить паузу и трогать чужих агентов, чужие передачи задач и другие пространства ключ не может. Каждый новый адрес подключения попадает в журнал FelisHub и в подсказку «В сети · с …». Украденный ключ заменяют через «Переподключить шлюз».
388
+ - Никогда не запускайте два шлюза с одним ключом. Новое подключение вытесняет старое, вытесненный шлюз пишет «этот шлюз вытеснен другим процессом с тем же ключом», больше не подключается и не запускает расписания, пока его не перезапустят.
389
+ - При переезде в другое пространство разговоры и расписания прежнего откладываются в `state.workspace-<номер>.json` и `schedules.workspace-<номер>.json` рядом с `config.json`; при возврате они возвращаются. Журнал событий остаётся общим, нумерация продолжается; каждое событие помечено пространством, в котором оно произошло, и FelisHub получает только события своего пространства.
390
+ - Сервер агента — граница доверия: кто вошёл на него пользователем шлюза, может всё, что может шлюз. Держите ключ вне каталога агента и запретите агенту читать его (строки выше), а `connect` выполняйте только сами.
391
+ - Файлы: человек кладёт файл только в `inbox/<разговор>/` каталога агента, агент отдаёт только файлы из каталога агента вне каталога шлюза. Ссылки раскрываются до проверки, временный файл загрузки создаётся без перехода по ссылкам.
392
+ - Всё, что пришло из инструментов `felishub`, — слова агента: приложение помечает их так и никогда не показывает вместо команды.
393
+
394
+ ## Выпуск версии
395
+
396
+ 1. Правишь код здесь, поднимаешь `version` в `package.json`.
397
+ 2. `bin/release.sh ../devops/agent/gateway ../seo/gateway` прогоняет тесты, собирает tgz, убирает из каждого агента старый `agent-gateway` (зависимость, `node_modules/agent-gateway`, `vendor/agent-gateway-*.tgz`) и прежние `vendor/felishub-gateway-*.tgz`, сохраняет заменяемые файлы в `_dev/backups/2026-10-05-v9-GW/removed/release-…/`, кладёт новый tgz в `vendor/` и ставит его — `package.json` и `package-lock.json` агента меняются только в этой зависимости.
398
+ 3. В репозиториях агентов коммитишь `gateway/vendor/`, `gateway/package.json` и `gateway/package-lock.json`, на сервере агента — `npm ci` в `gateway/` и перезапуск шлюза.
399
+
400
+ Агент, которого не указали в `release.sh`, остаётся на своей версии, пока его не обновят.
401
+
402
+ Пакет готов к публикации в npm как `felishub-gateway`, но помечен `"private": true`: публикует его только владелец, когда решит, — тогда `npx felishub-gateway connect …` заработает у агентов без tgz.
403
+
404
+ ## Тесты
405
+
406
+ `npm test`. Для тестов `node_modules` не нужны: SDK в них не загружается.
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "felishub-gateway",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "description": "Шлюз агента FelisHub: сессии Agent SDK по разговорам, решения и вопросы, расписания, бюджеты — сам подключается к FelisHub исходящим соединением",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.4"
9
+ },
10
+ "bin": {
11
+ "felishub-gateway": "src/cli.js"
12
+ },
13
+ "exports": "./src/index.js",
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*.test.js"
19
+ },
20
+ "peerDependencies": {
21
+ "@anthropic-ai/claude-agent-sdk": ">=0.3.263",
22
+ "zod": "^4.0.0"
23
+ }
24
+ }