subscription-gateway 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # subscription-gateway — run the platform on a subscription instead of per-token billing
2
2
 
3
+ > Russian version: [README.ru.md](./README.ru.md).
4
+
3
5
  A system service: it takes a request from the platform (or from any other host),
4
6
  drives the model loop through the vendor's official SDK on **subscription**
5
7
  access, and streams the answer back.
package/README.ru.md ADDED
@@ -0,0 +1,235 @@
1
+ # subscription-gateway — работа платформы по подписке вместо поштучной оплаты
2
+
3
+ > Русская редакция. English version: [README.md](./README.md).
4
+
5
+ Системная служба: принимает запрос от платформы (или от любого другого хоста),
6
+ ведёт цикл модели через официальный SDK поставщика на **подписочном** доступе и
7
+ отдаёт ответ потоком.
8
+
9
+ Токен подписки лежит в **одном** месте на машине. Агенты его не знают — они знают
10
+ адрес точки входа.
11
+
12
+ ---
13
+
14
+ ## Что это даёт
15
+
16
+ - цикл модели идёт по подписке, а не по поштучной оплате;
17
+ - секрет не размножается по машине: один файл, права по группе, смена в одном месте;
18
+ - новый агент подключается строкой настройки, без копирования секрета;
19
+ - инструменты платформы можно подмешать в набор модели через мост (см. `dsh-tool-bridge`).
20
+
21
+ ---
22
+
23
+ ## Когда это НЕ нужно
24
+
25
+ **Если у вас обычный ключ API — не нужно вовсе.** Платформа сама дойдёт до
26
+ поставщика, а лишний процесс между нею и поставщиком только добавляет мест, где
27
+ что-то может замолчать.
28
+
29
+ Шлюз — для случая, когда доступ **подписочный**, а хост про такой доступ ничего
30
+ не знает.
31
+
32
+ **И второй случай, когда не нужно: если агент один.** Смысл выноса токена в том,
33
+ что агентов несколько. При одном проще держать секрет рядом с ним.
34
+
35
+ ---
36
+
37
+ ## 🔴 Главное перед установкой: чьими руками работает агент
38
+
39
+ SDK **агентный**. Он сам ведёт цикл и сам запускает оболочку, файлы, поиск и веб.
40
+
41
+ Значит «руки» агента — это пользователь, от которого работает **процесс шлюза**, а
42
+ не пользователь платформы. Всё остальное следует отсюда:
43
+
44
+ - служба поднимается **экземпляром на агента** (`gateway@<имя агента>.service`),
45
+ под его собственным пользователем и на своём порту;
46
+ - общий системный пользователь тут не годится по устройству: у него нет доступа к
47
+ дому агента, и все агенты машины действовали бы как одно лицо;
48
+ - **всякое право, выдаваемое рукам агента, выдаётся ЭТОМУ юниту.** Права
49
+ платформы к делу не относятся.
50
+
51
+ Последнее — не теория. Мы потеряли на этом рабочий заход: дали агенту право читать
52
+ системный журнал, вписали его в юнит платформы, проверили по живому процессу
53
+ платформы — всё сошлось, а отказ у агента остался. **Верный метод, приложенный не
54
+ к тому объекту, даёт уверенность, а не истину.** Проверять надо тот процесс,
55
+ который исполняет команды:
56
+
57
+ ```bash
58
+ grep ^Groups: /proc/$(systemctl show -p MainPID --value gateway@<агент>.service)/status
59
+ ```
60
+
61
+ 🔴 `id` и `sudo -u <агент>` на этот вопрос **не отвечают**: они порождают НОВЫЙ
62
+ процесс, который берёт группы из системного файла, и покажут желаемое вместо
63
+ действительного. Живой процесс фиксирует набор групп при запуске.
64
+
65
+ ---
66
+
67
+ ## Установка
68
+
69
+ ### 0. Код и его зависимости
70
+
71
+ ```bash
72
+ sudo mkdir -p /opt/subscription-gateway
73
+ cd /opt/subscription-gateway
74
+ npm install subscription-gateway # либо скопируйте этот пакет сюда
75
+ ```
76
+
77
+ Шаблон юнита ниже запускает `/opt/subscription-gateway/gateway.mjs`. Положите код
78
+ в другое место — придётся править три места, все названы в комментарии в шапке
79
+ файла юнита.
80
+
81
+ **Признак успеха:** `node -e "import('./gateway.mjs')"` завершается без
82
+ `ERR_MODULE_NOT_FOUND`. Две зависимости времени выполнения — агентный SDK
83
+ поставщика и библиотека схем — обязаны разрешаться из того каталога, в котором
84
+ юнит запускает файл; `NODE_PATH` тут **не помогает**, модули ES его игнорируют.
85
+
86
+ ### 1. Токен в одном месте, права по группе
87
+
88
+ ```bash
89
+ sudo groupadd -r gateway-token
90
+ sudo install -d -m 750 -o root -g gateway-token /etc/subscription-gateway
91
+ sudo install -m 640 -o root -g gateway-token /dev/null /etc/subscription-gateway/token
92
+ # положите в файл токен подписки
93
+ ```
94
+
95
+ **Признак успеха:** файл читается членом группы и не читается никем другим.
96
+
97
+ 🔴 Изоляция секрета настоящая только против агентов **без** `sudo`. Агент с `sudo`
98
+ прочтёт файл всё равно — не воображайте защиту, которой нет.
99
+
100
+ ### 2. Шаблон юнита
101
+
102
+ Поставьте `systemd/gateway@.service` (он в этом пакете) и заведите экземпляр:
103
+
104
+ ```bash
105
+ sudo systemctl enable --now gateway@<агент>.service
106
+ ```
107
+
108
+ **Признак успеха:**
109
+
110
+ ```bash
111
+ curl -s http://127.0.0.1:<порт>/health
112
+ {"ok":true,"token":"present","sdk":true}
113
+ ```
114
+
115
+ 🔴 Здоровье означает **наличие секрета**, а не «процесс жив». Без токена служба
116
+ поднята и бесполезна — это обязано быть видно снаружи, поэтому она отвечает 503,
117
+ а не 200.
118
+
119
+ ### 3. Окружение экземпляра
120
+
121
+ `/etc/subscription-gateway/instance-<агент>.env`:
122
+
123
+ ```
124
+ GATEWAY_PORT=<порт экземпляра>
125
+ GATEWAY_WORK_DIR=/home/<агент>/workspace
126
+ GATEWAY_MAX_TURNS=120
127
+ GATEWAY_MCP={"<имя сервера>":{"type":"http","url":"http://127.0.0.1:ПОРТ/mcp"}}
128
+ ```
129
+
130
+ | переменная | что задаёт | умолчание |
131
+ |---|---|---|
132
+ | `GATEWAY_PORT` | порт на петле | 8788 |
133
+ | `GATEWAY_TOKEN_FILE` | файл токена | задаётся юнитом |
134
+ | `GATEWAY_WORK_DIR` | рабочий каталог для инструментов | `$HOME`, иначе `/tmp` |
135
+ | `GATEWAY_MAX_TURNS` | верхний предел ходов внутри одного запроса | 60 |
136
+ | `GATEWAY_MCP` | внешние серверы MCP, JSON | пусто (не ошибка) |
137
+
138
+ 🔴 **Поднимайте `GATEWAY_MAX_TURNS` осознанно.** Мы упёрлись в него на 61-м ходе
139
+ длинной задачи, и SDK сообщил об этом как `exited with code N` — то есть кодом без
140
+ причины. Теперь шлюз выкапывает настоящую причину из расшифровки и печатает
141
+ `агент упёрся в предел ходов: дошёл до <N> при пороге <M>`, а не найдя её, говорит
142
+ «причина не установлена» вместо правдоподобной выдумки.
143
+
144
+ ### 4. Проверьте, что подписка отвечает
145
+
146
+ Пошлите короткий запрос на `POST /v1/agent-stream` и дождитесь ответа модели.
147
+
148
+ **Признак успеха:** пришёл поток с текстом. **Признак беды:** каждый вызов
149
+ отклоняется пределом частоты при заведомо исправной подписке — см. следующий
150
+ раздел, это не про вашу квоту.
151
+
152
+ ---
153
+
154
+ ## 🔴 Почему внутри SDK поставщика, а не собственный HTTP
155
+
156
+ Первая редакция собирала запрос к API руками: токен подписки, нужные бета-метки,
157
+ вынутые из клиентского двоичного файла. **Опознание работало** — неверный токен
158
+ получал 401, наш получал 429, — но **каждый** вызов отклонялся пределом частоты
159
+ при совершенно исправной подписке: в ту самую минуту на ней работали три агента.
160
+
161
+ Мы перебрали и отбросили четыре версии: заголовки клиента, привязка к модели,
162
+ истечение токена, набор бета-меток. Правда оказалась другой — **сырой путь просто
163
+ не обслуживается для подписочного доступа**. Тот же токен, та же машина, тот же
164
+ выход в сеть: SDK отвечает за четыре секунды, самодельный запрос получает отказ.
165
+
166
+ Общий вывод дороже самого случая: **не переизобретайте протокол поставщика.** Код
167
+ поставщика знает тонкости, которых нет в документации, и переживёт их изменение. А
168
+ отказ, выглядящий как «у вас кончилась квота», может означать «вы стучитесь не в
169
+ ту дверь».
170
+
171
+ ---
172
+
173
+ ## Чего он не делает и делать не будет
174
+
175
+ - **наружу не выставляется никогда.** Слушает только петлю. Это доступ к подписке
176
+ без пароля; вынести его на внешний адрес — то же, что опубликовать токен;
177
+ - **подтверждений не спрашивает** (`bypassPermissions`): спрашивать некого, на том
178
+ конце не человек, а хост. Настоящая граница — права пользователя экземпляра, и
179
+ задаются они в systemd, а не здесь;
180
+ - **песочница юнита намеренно не закручивается**: агенту нужны его собственные
181
+ файлы и дом, а против агента с `sudo` ограничения юнита всё равно не граница;
182
+ - **шлюз не знает, какие инструменты он передаёт.** Он получает их описание от
183
+ моста и проксирует вызовы обратно. Это нарочно: следующий агент с другим набором
184
+ подключается без правки шлюза.
185
+
186
+ ---
187
+
188
+ ## Переходник схем: место, где теряется молча
189
+
190
+ Инструменты, приходящие от хоста, описаны схемой JSON Schema, а SDK ждёт схему
191
+ своего вида. `jsonschema-to-zod.mjs` переводит между ними.
192
+
193
+ 🔴 **Незнакомая форма — это молчаливая потеря, а не отказ.** Первая редакция не
194
+ знала формы «строка ИЛИ объект» (`anyOf`/`oneOf`) и возвращала для неё «что
195
+ угодно». Инструмент при этом регистрировался, выглядел исправным — и падал в
196
+ момент вызова, при разборе доводов.
197
+
198
+ Проверяйте переходник **на настоящих схемах вашей платформы**, а не на выдуманных:
199
+
200
+ ```bash
201
+ node test-schema-adapter.mjs
202
+ ```
203
+
204
+ Стенд в этом пакете берёт схемы из файла, порождённого самой платформой, и
205
+ проверяет, что обе ветки `anyOf` проходят, а чужой тип — **нет**. Вторая половина
206
+ важнее первой: схема, выродившаяся в «что угодно», пропускает всё и тем самым
207
+ прячет ошибку.
208
+
209
+ Три исхода стенда различаются намеренно: `0` — сошлось, `1` — расхождение,
210
+ `2` — слепота (проверять нечем, например не поставлены зависимости). Слепота,
211
+ выданная кодом `0`, была бы хуже отсутствия проверки: она читается как успех.
212
+
213
+ ---
214
+
215
+ ## Приёмка своей рукой
216
+
217
+ 1. `curl /health` → `{"ok":true,"token":"present","sdk":true}`. Проверяет, что
218
+ секрет читается.
219
+ 2. `grep ^Groups: /proc/<MainPID>/status` → нужные группы **на живом процессе**.
220
+ Проверяет права рук агента, и только этот способ отвечает на такой вопрос.
221
+ 3. Короткий запрос на `/v1/agent-stream` → модель ответила. Проверяет саму подписку.
222
+ 4. `node test-schema-adapter.mjs` → всё зелёное. Проверяет переходник схем.
223
+ 5. Если рядом поставлен мост: попросите модель вызвать инструмент платформы и
224
+ найдите вызов в журнале хоста. Ответ модели признаком не является.
225
+
226
+ 🔴 **Все пять годятся как прогон после обновления** — что SDK поставщика, что
227
+ платформы. Ни одно из этих расхождений не падает с ошибкой: схема вырождается в
228
+ «что угодно», группа теряется при перезапуске, токен остаётся на месте, а путь к
229
+ нему меняется. Всё это выглядит как исправная работа.
230
+
231
+ ---
232
+
233
+ ## Лицензия
234
+
235
+ MIT.
package/gateway.mjs CHANGED
@@ -1,55 +1,53 @@
1
1
  /**
2
- * Anthropic subscription gatewaya shared system service of the machine.
2
+ * Шлюз подписки Anthropicобщий системный сервис машины.
3
3
  *
4
- * WHY. There are several agents on the machine, each under its own user. If
5
- * every one of them kept the subscription token, the secret would multiply
6
- * across the machine. Here it lives in ONE place, under its own user, and the
7
- * agents get an entry point. A new agent is connected by a line of
8
- * configuration and knows nothing about the token.
4
+ * ЗАЧЕМ. Агентов на машине несколько, каждый под своим пользователем. Если бы
5
+ * подписочный токен клал себе каждый, секрет размножался бы по машине. Здесь он
6
+ * живёт в ОДНОМ месте, под своим пользователем, а агенты получают точку входа.
7
+ * Новый агент подключается строкой в конфиге и о токене не знает.
9
8
  *
10
- * 🔴 WHY THE OFFICIAL SDK INSIDE AND NOT HAND-ROLLED HTTP (lesson of 2026-08-19).
11
- * At first the gateway assembled the API request by hand: subscription token,
12
- * two beta flags pulled out of the client binary. Authorisation worked (a wrong
13
- * token got 401, ours got 429), but EVERY call was rejected by a rate limit
14
- * while the subscription was entirely healthy: three agents were working on it
15
- * at that very moment. We went through and discarded four hypotheses — client
16
- * headers, model binding, token expiry, the set of beta flags. The truth was
17
- * something else: the raw path is simply not served to subscription access. Same
18
- * token, same machine, same network egress — the SDK answers in four seconds, a
19
- * hand-rolled request is refused.
20
- * The general conclusion: DO NOT REINVENT THE VENDOR'S PROTOCOL. Vendor code
21
- * knows subtleties that are not in the documentation, and it will survive them
22
- * changing.
9
+ * 🔴 ПОЧЕМУ ВНУТРИ ОФИЦИАЛЬНЫЙ SDK, А НЕ РУЧНОЙ HTTP (урок 19.08.2026).
10
+ * Сначала шлюз собирал запрос к API руками: подписочный токен, два бета-флага,
11
+ * вынутые из бинаря. Авторизация проходила неверным токеном приходило 401,
12
+ * с нашим 429), но КАЖДЫЙ вызов отбивался лимитом, при полностью живой
13
+ * подписке: в тот же момент на ней работали три агента. Перебрали и отбросили
14
+ * четыре версии заголовки клиента, привязку к модели, срок годности токена,
15
+ * набор бета-флагов. Верным оказалось иное: сырой путь подписке просто не
16
+ * отдают. Тот же токен, та же машина, тот же выход в сеть — SDK отвечает за
17
+ * четыре секунды, ручной запрос получает отказ.
18
+ * Вывод общего вида: НЕ ИЗОБРЕТАТЬ ПРОТОКОЛ ПОСТАВЩИКА. Вендорский код знает
19
+ * тонкости, которых нет в документации, и переживёт их смену.
23
20
  *
24
- * 🔴 INDEPENDENCE FROM ANY OTHER MACHINE. The SDK is installed HERE, the token is
25
- * HERE, and the machine has its own network egress. No central node takes part
26
- * in the chain and any such node may be switched off — a direct requirement of
27
- * the owner, verified with a live call.
21
+ * 🔴 НЕЗАВИСИМОСТЬ ОТ ГЛАВНОЙ МАШИНЫ. SDK стоит ЗДЕСЬ, токен ЗДЕСЬ, выход в
22
+ * сеть у машины свой. Главная машина в цепочке не участвует и может быть выключена —
23
+ * это прямое требование владельца, проверенное живым вызовом.
28
24
  *
29
- * BOUNDARIES. Listens on loopback only. It is never exposed outward: this is
30
- * access to our subscription without a password.
25
+ * ГРАНИЦЫ. Слушает только петлю. Наружу не выставляется никогда: это доступ к
26
+ * нашей подписке без пароля.
31
27
  *
32
- * 🔴 THE ENGINE EXECUTES THE TOOLS, NOT THE PLATFORM AND THE CHOICE OF USER
33
- * FOLLOWS FROM THAT. The SDK is agentic: it drives the loop itself and itself
34
- * runs the shell, files, search and web. So the agent's "hands" are the user
35
- * THIS process runs as. That is why the service is started as an INSTANCE PER
36
- * AGENT (`...@<agent name>.service`), under the agent's own user name and on its
37
- * own port. A shared system user does not fit here structurally: it has no
38
- * access to the agent's home, and every agent on the machine would act as one
39
- * and the same person, treading on each other.
40
- * What DOES stay shared: the token file — one per machine, no secret in the
41
- * agent's configuration, rotation in one place.
42
- * What you must NOT imagine: an agent with sudo will read the token file
43
- * anyway. Isolation of the secret is real against agents WITHOUT sudo.
28
+ * 🔴 ИНСТРУМЕНТЫ ИСПОЛНЯЕТ ДВИЖОК, А НЕ ПЛАТФОРМАИ ОТСЮДА ВЫБОР ПОЛЬЗОВАТЕЛЯ.
29
+ * SDK агентный: он сам ведёт цикл и сам выполняет оболочку, файлы, поиск, веб.
30
+ * Значит «руки» агента это тот пользователь, под которым идёт ЭТОТ процесс.
31
+ * Поэтому служба запускается ЭКЗЕМПЛЯРОМ НА АГЕНТА (`<служба>@<агент>.service`), под
32
+ * его собственным именем и на своём порту. Общий системный пользователь здесь
33
+ * не годится структурно: у него нет доступа к дому агента, а все агенты машины
34
+ * действовали бы одним лицом и топтали бы друг друга.
35
+ * Что при этом ОСТАЁТСЯ общим: файл токена — один на машину, в конфиге агента
36
+ * секрета нет, ротация в одном месте.
37
+ * Что НЕ надо себе воображать: агент с sudo прочитает файл токена в любом
38
+ * случае. Изоляция секрета реальна против агентов БЕЗ sudo.
44
39
  *
45
- * THE RIGHTS BOUNDARY. No confirmations are requested (`bypassPermissions`):
46
- * there is nobody here to ask, the far end is not a human but the platform. The
47
- * real boundary is the rights of the instance user, and it is set in systemd,
48
- * not here.
40
+ * ГРАНИЦА ПРАВ. Подтверждения не запрашиваются (`bypassPermissions`): спросить
41
+ * тут некого, на том конце не человек, а платформа. Реальная граница права
42
+ * пользователя экземпляра, и задаётся она в systemd, а не здесь.
49
43
  */
50
44
 
51
45
  import http from 'node:http';
52
46
  import fs from 'node:fs';
47
+ // 🔴 Глобальный crypto здесь — ВЕБ-версия: у неё есть randomUUID и нет
48
+ // createHash. Проверка синтаксиса это пропускает, падает первый же запрос
49
+ // (поймано пробой поведения 30.08.2026, до установки).
50
+ import nodeCrypto from 'node:crypto';
53
51
  import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
54
52
  import { shape } from './jsonschema-to-zod.mjs';
55
53
 
@@ -59,9 +57,9 @@ const TOKEN_FILE = process.env.GATEWAY_TOKEN_FILE || '/etc/subscription-gateway/
59
57
  const DEFAULT_MODEL = 'claude-opus-5';
60
58
  const DEFAULT_MAX_TURNS = Number(process.env.GATEWAY_MAX_TURNS || 60);
61
59
  /**
62
- * External tool servers (MCP) for this instance's agent. Set through the
63
- * GATEWAY_MCP variable as JSON: {"<server name>":{"type":"http","url":"..."}}.
64
- * Emptythe agent works without them, and that is not an error.
60
+ * Внешние серверы инструментов (MCP) для агента этого экземпляра. Задаются
61
+ * переменной GATEWAY_MCP как JSON: {"omega":{"type":"http","url":"..."}}.
62
+ * Пустоагент работает без них, это не ошибка.
65
63
  */
66
64
  const MCP_SERVERS = (() => {
67
65
  const raw = process.env.GATEWAY_MCP;
@@ -70,30 +68,23 @@ const MCP_SERVERS = (() => {
70
68
  const v = JSON.parse(raw);
71
69
  return v && Object.keys(v).length ? v : null;
72
70
  } catch (e) {
73
- // Ignoring this silently is not allowed: the agent would be left without
74
- // memory, and it would look like "memory does not work" rather than "I wrote
75
- // the line wrong".
76
- console.error(`[subscription-gateway] 🔴 GATEWAY_MCP could not be parsed: ${e?.message}`);
71
+ // Молча игнорировать нельзя: агент останется без памяти, и это будет
72
+ // выглядеть как «память не работает», а не как «я неверно записала строку».
73
+ console.error(`[subscription-gateway] 🔴 GATEWAY_MCP не разобран: ${e?.message}`);
77
74
  return null;
78
75
  }
79
76
  })();
80
77
 
81
- /** Default working directory for the tools the instance user's home. */
78
+ /** Рабочий каталог инструментов по умолчаниюдом пользователя экземпляра. */
82
79
  const WORK_DIR = process.env.GATEWAY_WORK_DIR || process.env.HOME || '/tmp';
83
80
 
84
81
  const log = (m) => console.error(`[subscription-gateway] ${m}`);
85
82
 
86
83
  /**
87
- * "exited with code N" from the SDK is only a code without a reason; the truth
88
- * is in the session transcript. We look for attachment.type == "max_turns_reached"
89
- * and return a human-readable line with the numbers. Not found the original
90
- * text plus an explicit "reason not established", WITHOUT inventing a plausible
91
- * one.
92
- *
93
- * 🔴 The strings matched here — 'exited with code', 'returned an error result',
94
- * 'max_turns_reached' — are the SDK'S OWN wording and protocol constants. They
95
- * are not ours to translate or prettify: change them and the match silently
96
- * stops finding anything, leaving a code without a reason again.
84
+ * «exited with code N» от SDK это только код без причины; истина в транскрипте
85
+ * сессии. Ищем attachment.type == "max_turns_reached" и возвращаем человеческую
86
+ * строку с числами. Не нашли прежний текст + явное «причина не установлена»,
87
+ * БЕЗ додумывания правдоподобной причины.
97
88
  */
98
89
  function explainExit(err, sessionId, cwd) {
99
90
  const raw = String(err?.message ?? err);
@@ -105,16 +96,16 @@ function explainExit(err, sessionId, cwd) {
105
96
  if (!line.includes('max_turns_reached')) continue;
106
97
  const a = JSON.parse(line)?.attachment;
107
98
  if (a?.type === 'max_turns_reached') {
108
- return `the agent hit the turn limit: reached ${a.turnCount} against a threshold of ${a.maxTurns} (max_turns_reached)`;
99
+ return `агент упёрся в лимит ходов: дошёл до ${a.turnCount} при пороге ${a.maxTurns} (max_turns_reached)`;
109
100
  }
110
101
  }
111
102
  } catch {
112
- return `${raw} — reason not established (the transcript could not be read)`;
103
+ return `${raw} — причина не установлена (транскрипт не прочитан)`;
113
104
  }
114
- return `${raw} — reason not established (no max_turns_reached entry in the fresh transcript)`;
105
+ return `${raw} — причина не установлена (в свежем транскрипте записи max_turns_reached нет)`;
115
106
  }
116
107
 
117
- /** The token is read on EVERY request: rotating the secret needs no restart. */
108
+ /** Токен читаем при КАЖДОМ запросе: смена секрета не требует перезапуска. */
118
109
  function readToken() {
119
110
  try {
120
111
  return fs.readFileSync(TOKEN_FILE, 'utf8').trim() || null;
@@ -123,7 +114,7 @@ function readToken() {
123
114
  }
124
115
  }
125
116
 
126
- /** A short caption for a tool call: what exactly it does, in one line. */
117
+ /** Короткая подпись к вызову инструмента: что именно он делает, одной строкой. */
127
118
  function briefOf(input) {
128
119
  if (!input || typeof input !== 'object') return '';
129
120
  const v = input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.path ?? input.query;
@@ -146,21 +137,17 @@ function readBody(req) {
146
137
  }
147
138
 
148
139
  /**
149
- * Assemble one text prompt out of the messages.
150
- *
151
- * A deliberate simplification: the SDK takes the prompt as a string. History is
152
- * glued together with role labels — the model understands them, and the platform
153
- * keeps its own history anyway. When there is time for it, real message passing
154
- * will appear here instead of gluing.
140
+ * Собираем один текстовый запрос из сообщений.
155
141
  *
156
- * 🔴 THESE ROLE LABELS ARE MODEL-FACING TEXT, NOT DISPLAY TEXT. They go into the
157
- * prompt, so changing them changes what the model reads. If your platform speaks
158
- * another language, change them deliberately and together, not one of the two.
142
+ * Осознанное упрощение: SDK принимает подсказку строкой. Историю склеиваем
143
+ * ролевыми метками модель их понимает, а платформа всё равно держит свою
144
+ * историю у себя. Когда дойдут руки до инструментов, здесь появится настоящая
145
+ * передача сообщений, а не склейка.
159
146
  */
160
147
  function buildPrompt(messages) {
161
148
  const parts = [];
162
149
  for (const m of messages ?? []) {
163
- const who = m.role === 'assistant' ? 'Assistant' : 'User';
150
+ const who = m.role === 'assistant' ? 'Ассистент' : 'Пользователь';
164
151
  const c = m.content;
165
152
  const text =
166
153
  typeof c === 'string'
@@ -174,52 +161,158 @@ function buildPrompt(messages) {
174
161
  }
175
162
 
176
163
  /**
177
- * THE PLATFORM TOOL BRIDGE.
164
+ * МЕЖХОДОВОЙ КЭШ ПОДСКАЗКИ.
165
+ *
166
+ * Беда, ради которой это написано (замер 30.08.2026): платформа присылает всю
167
+ * историю каждый ход, buildPrompt склеивает её в ОДИН блок, и этот блок
168
+ * меняется целиком — кэш подсказки не попадает ни разу. На 128 ходах: запись
169
+ * кэша 31,6 млн токенов на первых запросах ходов, то есть около 15% всей
170
+ * условной стоимости уходит на переписывание того, что уже было записано.
171
+ *
172
+ * Лечение: SDK умеет продолжать свою сессию (`resume`). Тогда история едет
173
+ * тем же байтом, что и в прошлый раз, и попадает в кэш целиком — замерено
174
+ * A/B на живом SDK: та же история прежним способом даёт запись 22 128, через
175
+ * resume — чтение 37 630 при записи 44.
176
+ *
177
+ * 🔴 ГДЕ ЭТО НЕ ДЕЙСТВУЕТ И ЧЕГО НЕ ДЕЛАЕТ:
178
+ * - не сокращает историю: это делает сжатие, отдельный рычаг;
179
+ * - не переживает перезапуск шлюза — состояние живёт в памяти процесса.
180
+ * После перезапуска первый ход каждой беседы снова холодный. Это осознанно:
181
+ * состояние на диске пришлось бы сводить с историей платформы, а расхождение
182
+ * двух источников правды дороже одного холодного хода;
183
+ * - не действует, если платформа переписала прошлые сообщения (сжатие,
184
+ * усечение, правка): префикс сверяется хэшем, не сошёлся — идём прежним
185
+ * путём с полной историей. Молчаливого расхождения быть не должно;
186
+ * - выключается целиком переменной GATEWAY_RESUME=0 без правки кода.
187
+ *
188
+ * 🔴 ИСТОЧНИК ПРАВДЫ — ПЛАТФОРМА. Транскрипт SDK здесь только кэш: его потеря
189
+ * означает холодный ход, а не потерю разговора. Поэтому отказ возобновления
190
+ * ловится и переигрывается полной историей, а не отдаётся наружу.
191
+ */
192
+ const RESUME_ON = (process.env.GATEWAY_RESUME ?? '1') !== '0';
193
+ const SESSII = new Map(); // ключ беседы -> { sessionId, otdano, hashPref }
194
+ const SESSII_MAX = 8; // бесед у агента единицы; предел от утечки памяти
195
+
196
+ function hashText(s) {
197
+ return nodeCrypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 32);
198
+ }
199
+
200
+ /** Текст сообщения в том же виде, в каком его склеивает buildPrompt. */
201
+ function tekstSoobshcheniya(m) {
202
+ const c = m?.content;
203
+ if (typeof c === 'string') return c;
204
+ if (Array.isArray(c)) return c.filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
205
+ return '';
206
+ }
207
+
208
+ /** Ключ беседы: первое сообщение. Сменилось — это другая беседа. */
209
+ function kluchBesedy(messages) {
210
+ const first = (messages ?? [])[0];
211
+ if (!first) return null;
212
+ return hashText((first.role ?? '') + '\u0000' + tekstSoobshcheniya(first));
213
+ }
214
+
215
+ /** Отпечаток первых n сообщений — сторож против молчаливой правки истории. */
216
+ function hashPrefiksa(messages, n) {
217
+ const parts = [];
218
+ for (let i = 0; i < n && i < messages.length; i++) {
219
+ parts.push((messages[i].role ?? '') + '\u0000' + tekstSoobshcheniya(messages[i]));
220
+ }
221
+ return hashText(parts.join('\u0001'));
222
+ }
223
+
224
+ /**
225
+ * Решение: продолжать сессию или начинать заново. Возвращает и то, чем потом
226
+ * обновить состояние, — чтобы обновление шло по ФАКТУ отправленного, а не по
227
+ * намерению.
228
+ */
229
+ function planZaprosa(messages) {
230
+ const msgs = messages ?? [];
231
+ const kluch = kluchBesedy(msgs);
232
+ const polnyj = { kluch, resume: null, prompt: buildPrompt(msgs), otdano: msgs.length, pochemu: 'полная история' };
233
+ if (!RESUME_ON || !kluch) return polnyj;
234
+ const st = SESSII.get(kluch);
235
+ if (!st) return polnyj;
236
+ if (msgs.length <= st.otdano) return polnyj;
237
+ if (hashPrefiksa(msgs, st.otdano) !== st.hashPref) {
238
+ // История переписана на той стороне. Говорим вслух: молчаливое расхождение
239
+ // двух картин разговора — худшее, что здесь может случиться.
240
+ log('история изменена платформой — продолжение сессии отменено, иду полной историей');
241
+ SESSII.delete(kluch);
242
+ return polnyj;
243
+ }
244
+ // Ответ прошлого хода SDK уже записал у себя; повторно его не шлём.
245
+ let i = st.otdano;
246
+ while (i < msgs.length && msgs[i]?.role === 'assistant') i++;
247
+ if (i >= msgs.length) return polnyj;
248
+ return {
249
+ kluch,
250
+ resume: st.sessionId,
251
+ prompt: buildPrompt(msgs.slice(i)),
252
+ otdano: msgs.length,
253
+ pochemu: `продолжаю сессию, новых сообщений ${msgs.length - i} из ${msgs.length}`,
254
+ };
255
+ }
256
+
257
+ /** Запоминаем ФАКТ: что именно отдано и каким был префикс. */
258
+ function zapomnit(plan, sessionId, messages) {
259
+ if (!plan.kluch) return;
260
+ if (SESSII.size >= SESSII_MAX && !SESSII.has(plan.kluch)) {
261
+ SESSII.delete(SESSII.keys().next().value); // самая старая
262
+ }
263
+ SESSII.set(plan.kluch, {
264
+ sessionId,
265
+ otdano: plan.otdano,
266
+ hashPref: hashPrefiksa(messages, plan.otdano),
267
+ });
268
+ }
269
+
270
+ /**
271
+ * МОСТ ИНСТРУМЕНТОВ ПЛАТФОРМЫ.
178
272
  *
179
- * The engine executes the tools, so its own set is the only one the model sees.
180
- * Platform plugins do not reach it at all. The bridge builds an MCP server out of
181
- * the description sent by the platform and proxies the calls back.
273
+ * Инструменты исполняет движок, поэтому его набор единственный, который
274
+ * видит модель. Плагины платформы до неё не доходят вовсе. Мост собирает из
275
+ * описания, присланного платформой, MCP-сервер и проксирует вызовы обратно.
182
276
  *
183
- * 🔴 THE GATEWAY DOES NOT KNOW WHAT THESE TOOLS ARE. Names, descriptions and
184
- * schemas arrive from the other side; there is only transport here. The next
185
- * agent with a different tool set connects without editing this file that is
186
- * what makes it a general solution rather than a patch for one case.
277
+ * 🔴 ШЛЮЗ НЕ ЗНАЕТ, ЧТО ЭТО ЗА ИНСТРУМЕНТЫ. Имена, описания и схемы приходят с
278
+ * той стороны; здесь только транспорт. Следующий агент с другим набором
279
+ * подключается без правки этого файла это и есть общее решение, а не
280
+ * заплатка под одного.
187
281
  *
188
- * TRANSPORT sdk, NOT stdio: an sdk server lives in this same process and spawns
189
- * no child. Everything the SDK passes to a child process goes as the
190
- * --mcp-config argument and is readable by any user of the machine through
191
- * /proc/<pid>/cmdline (mode 444) — verified with a live observer on 2026-08-22.
192
- * That is why the bridge TICKET does not leak with sdk: it stays in the memory of
193
- * two processes and in the request body over loopback.
282
+ * ТРАНСПОРТ sdk, А НЕ stdio: sdk-сервер живёт в этом же процессе и не
283
+ * порождает дочернего. Всё, что SDK передаёт дочернему процессу, уходит
284
+ * аргументом --mcp-config и читается любым пользователем машины через
285
+ * /proc/<pid>/cmdline (444) — проверено живым наблюдателем 22.08.2026.
286
+ * Поэтому ПРОПУСК моста при sdk не утекает: он остаётся в памяти двух
287
+ * процессов и в теле запроса по петле.
194
288
  *
195
- * 🔴 WHAT EXACTLY THE GATEWAY CARRIES. Not an identity and not a shared secret,
196
- * but a ONE-TIME TICKET issued by the platform for this turn. The gateway does
197
- * not know whose it is: there is not a single agent-identity field in this file,
198
- * neither in the code nor in the comments (we deliberately avoid writing even the
199
- * name of that field here: otherwise a grep check would find its own caveat and
200
- * take it for an occurrence). The platform retrieves the identity by the ticket
201
- * from its own table. So there is nothing here to assert somebody else's identity
202
- * with — neither for the model nor for the gateway itself.
289
+ * 🔴 ЧТО ИМЕННО НОСИТ ШЛЮЗ. Не личность и не общий секрет, а ОДНОРАЗОВЫЙ
290
+ * ПРОПУСК, выданный платформой на этот ход. Шлюз не знает, чей он: поля
291
+ * личности агента в этом файле нет ни одного ни в коде, ни в комментариях
292
+ * (нарочно не пишем здесь и само имя поля: иначе проверка грепом нашла бы
293
+ * собственную оговорку и приняла её за вхождение). Платформа сама достаёт
294
+ * личность по пропуску из своей таблицы. Значит заявить чужую личность отсюда
295
+ * нечем ни модели, ни самому шлюзу.
203
296
  */
204
297
  function buildBridgeServer(bridge) {
205
298
  if (!bridge?.url || !bridge?.ticket || !Array.isArray(bridge.tools) || !bridge.tools.length) return null;
206
299
 
207
- // 🔴 THE GATEWAY DOES NOT KNOW WHOSE REQUEST THIS IS, AND MUST NOT. It carries
208
- // an opaque one-time ticket issued by the platform for this turn and presents it
209
- // at the door. The platform retrieves the identity by that ticket from its own
210
- // table. The identity is NOT transmitted: whoever asserts it must not be the one
211
- // who assigns it. The ticket lives in the CLOSURE of this handler with the sdk
212
- // transport it goes neither into a command line nor into the environment
213
- // (measured: 0 hits across 275 inspected processes).
300
+ // 🔴 ШЛЮЗ НЕ ЗНАЕТ, ЧЕЙ ЭТО ЗАПРОС, И ЗНАТЬ НЕ ДОЛЖЕН. Он носит непрозрачный
301
+ // одноразовый пропуск, выданный платформой на этот ход, и предъявляет его на
302
+ // двери. Личность по пропуску достаёт сама платформа из своей таблицы.
303
+ // Личность НЕ передаётся: тот, кто её заявляет, не должен быть тем, кто её
304
+ // назначает. Пропуск живёт в ЗАМЫКАНИИ этого обработчикапри транспорте sdk
305
+ // он не уходит ни в командную строку, ни в окружение (замерено: 0 попаданий
306
+ // при 275 просмотренных процессах).
214
307
  const callBridge = async (toolName, args) => {
215
308
  const r = await fetch(bridge.url, {
216
309
  method: 'POST',
217
310
  headers: { 'content-type': 'application/json', 'x-bridge-ticket': bridge.ticket },
218
311
  body: JSON.stringify({ tool: toolName, args }),
219
312
  });
220
- if (!r.ok) throw new Error(`the bridge answered ${r.status}`);
313
+ if (!r.ok) throw new Error(`мост ответил ${r.status}`);
221
314
  const out = await r.json();
222
- if (out?.ok !== true) throw new Error(String(out?.error ?? 'the bridge refused without a reason'));
315
+ if (out?.ok !== true) throw new Error(String(out?.error ?? 'мост отказал без причины'));
223
316
  return out.value;
224
317
  };
225
318
 
@@ -229,10 +322,10 @@ function buildBridgeServer(bridge) {
229
322
  try {
230
323
  inputShape = shape(t.inputSchema ?? { type: 'object', properties: {} });
231
324
  } catch (e) {
232
- // A tool whose schema we cannot assemble is NOT exposed "as is": the model
233
- // would get a tool with no parameter shape and would fail at execution
234
- // time. The skip is loud, the rest keep working.
235
- log(`🔴 tool ${t.name} skipped: ${e?.message}`);
325
+ // Инструмент со схемой, которую мы не умеем собрать, НЕ выставляем
326
+ // «как есть»: модель получила бы инструмент без формы параметров и
327
+ // ошибалась бы на исполнении. Пропуск громкий, остальные работают.
328
+ log(`🔴 инструмент ${t.name} пропущен: ${e?.message}`);
236
329
  continue;
237
330
  }
238
331
  tools.push(
@@ -245,10 +338,9 @@ function buildBridgeServer(bridge) {
245
338
  const value = await callBridge(t.name, args ?? {});
246
339
  return { content: [{ type: 'text', text: JSON.stringify(value ?? null) }] };
247
340
  } catch (e) {
248
- // The refusal is returned as text, not as an exception: the model
249
- // must read the reason and decide what to do, not see the tool cut
250
- // off. 🔴 This text is model-facing.
251
- return { content: [{ type: 'text', text: `REFUSED: ${e?.message ?? e}` }], isError: true };
341
+ // Отказ отдаём текстом, а не исключением: модель должна прочитать
342
+ // причину и решить, что делать, а не увидеть обрыв инструмента.
343
+ return { content: [{ type: 'text', text: `ОТКАЗ: ${e?.message ?? e}` }], isError: true };
252
344
  }
253
345
  },
254
346
  ),
@@ -256,26 +348,24 @@ function buildBridgeServer(bridge) {
256
348
  }
257
349
  if (!tools.length) return null;
258
350
 
259
- // alwaysLoad: otherwise the tools go behind a catalogue search and are not
260
- // visible in the system header and the header is precisely our acceptance sign.
351
+ // alwaysLoad: иначе инструменты уходят за поиск по каталогу и в системном
352
+ // заголовке их не видноа именно заголовок у нас признак приёмки.
261
353
  return createSdkMcpServer({ name: bridge.name || 'dsh', version: '0.1.0', tools, alwaysLoad: true });
262
354
  }
263
355
 
264
356
  /**
265
- * Merge the tool servers WITHOUT letting the bridge overwrite somebody else's
266
- * server with its own name.
357
+ * Слить серверы инструментов, НЕ давая мосту затереть чужой сервер своим именем.
267
358
  *
268
- * 🔴 THE NAMING RULE (2026-08-22): server names must not coincide neither with
269
- * ones already connected here, nor between the root and subagent levels. The
270
- * price of a collision is silent, and it goes both ways:
271
- * * here a plain spread would overwrite a same-named server entirely, and the
272
- * model would get the bridge in its place without ever learning of it;
273
- * * for a subagent (experiment B, 2026-08-22) a server bearing THE ROOT'S NAME
274
- * comes up with its own environment yet the root's one answers anyway — from
275
- * the start-up alone it looks as if the configuration works.
276
- * Therefore a collision means refusing to connect the bridge, not a quiet
277
- * substitution: without the bridge the agent works worse, with a substituted
278
- * server it works wrongly.
359
+ * 🔴 ПРАВИЛО ИМЁН (главная, 22.08.2026): имена серверов не должны совпадать
360
+ * ни с уже подключёнными здесь, ни между уровнями корень/помощник. Цена
361
+ * совпадения молчаливая и в обе стороны:
362
+ * * здесь простой спред затёр бы одноимённый сервер (например omega) целиком,
363
+ * и модель получила бы вместо него мост, ничего об этом не узнав;
364
+ * * у помощника (опыт Б, 22.08) сервер с ИМЕНЕМ КОРНЯ поднимается со своим
365
+ * окружением, а отвечает всё равно корневой по факту старта кажется, что
366
+ * настройка работает.
367
+ * Поэтому столкновение отказ подключить мост, а не тихая замена: без моста
368
+ * агент работает хуже, с подменённым сервером неверно.
279
369
  */
280
370
  function mergeMcpServers(base, bridgeName, bridgeServer) {
281
371
  const servers = { ...(base ?? {}) };
@@ -291,28 +381,23 @@ const server = http.createServer(async (req, res) => {
291
381
  if (req.url === '/health') {
292
382
  const ok = Boolean(readToken());
293
383
  res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
294
- // Health means the PRESENCE OF THE SECRET, not "the process is alive":
295
- // without a token the service is up but useless, and that must be visible
296
- // from outside.
297
- // 🔴 The `token` field is API SURFACE, not a log line: the README documents
298
- // this exact response and acceptance step 1 compares against it. If you change
299
- // the wording, change the README and anything scripted against it in the same
300
- // pass — the HTTP status is the machine-readable part, this field is not.
301
- res.end(JSON.stringify({ ok, token: ok ? 'present' : 'MISSING', sdk: true }));
384
+ // Здоровьем считаем НАЛИЧИЕ секрета, а не «процесс жив»: без токена служба
385
+ // поднята, но бесполезна, и это должно быть видно снаружи.
386
+ res.end(JSON.stringify({ ok, token: ok ? 'есть' : 'НЕТ', sdk: true }));
302
387
  return;
303
388
  }
304
389
 
305
390
  if (req.method !== 'POST' || req.url !== '/v1/agent-stream') {
306
391
  res.writeHead(404, { 'content-type': 'application/json' });
307
- res.end(JSON.stringify({ error: 'unknown path; /v1/agent-stream and /health exist' }));
392
+ res.end(JSON.stringify({ error: 'неизвестный путь; есть /v1/agent-stream и /health' }));
308
393
  return;
309
394
  }
310
395
 
311
396
  const token = readToken();
312
397
  if (!token) {
313
398
  res.writeHead(503, { 'content-type': 'application/json' });
314
- res.end(JSON.stringify({ error: { type: 'no_credential', message: 'no subscription token' } }));
315
- log('🔴 request rejected: the token could not be read');
399
+ res.end(JSON.stringify({ error: { type: 'no_credential', message: 'нет подписочного токена' } }));
400
+ log('🔴 запрос отклонён: токен не прочитан');
316
401
  return;
317
402
  }
318
403
 
@@ -321,13 +406,13 @@ const server = http.createServer(async (req, res) => {
321
406
  body = await readBody(req);
322
407
  } catch {
323
408
  res.writeHead(400, { 'content-type': 'application/json' });
324
- res.end(JSON.stringify({ error: { type: 'bad_json', message: 'the request body could not be parsed' } }));
409
+ res.end(JSON.stringify({ error: { type: 'bad_json', message: 'тело запроса не разобрано' } }));
325
410
  return;
326
411
  }
327
412
 
328
- // A stream of events as JSON lines: one line, one event. The format is our own
329
- // and deliberately simple; translating it into the platform's protocol is the
330
- // job of a module on the agent's side.
413
+ // Поток событий строками JSON: одна строка одно событие. Формат наш
414
+ // собственный и намеренно простой; переводом в протокол платформы занимается
415
+ // модуль на стороне агента.
331
416
  res.writeHead(200, {
332
417
  'content-type': 'application/x-ndjson; charset=utf-8',
333
418
  'cache-control': 'no-cache',
@@ -336,41 +421,53 @@ const server = http.createServer(async (req, res) => {
336
421
 
337
422
  const started = Date.now();
338
423
  const cwd = body.cwd || WORK_DIR;
339
- const sessionId = crypto.randomUUID(); // Node >=19; we run v24, the global exists
424
+ let plan = planZaprosa(body.messages);
425
+ // Свой id нужен и при возобновлении: SDK его сохраняет (проверено — id после
426
+ // resume тот же), а нам он нужен для объяснения отказа в explainExit.
427
+ const sessionId = plan.resume || crypto.randomUUID(); // Node >=19, у нас v24
340
428
 
341
- // The platform tool bridge: connected ONLY when the other side has sent a
342
- // descriptor with a ticket. Without one the behaviour is exactly as before.
429
+ // Мост инструментов платформы: подключается ТОЛЬКО когда та сторона прислала
430
+ // описание с пропуском. Нет его поведение прежнее, байт в байт.
343
431
  let bridgeServer = null;
344
432
  try {
345
433
  bridgeServer = buildBridgeServer(body.bridge);
346
434
  } catch (e) {
347
- // A failure to build the bridge must not bring down the request itself:
348
- // without tools the agent works worse, but it works. Staying silent about it
349
- // is not allowed.
350
- log(`🔴 the bridge was not built: ${e?.message}`);
435
+ // Сбой сборки моста не должен рушить сам запрос: без инструментов агент
436
+ // работает хуже, но работает. Молчать при этом нельзя.
437
+ log(`🔴 мост не собран: ${e?.message}`);
351
438
  }
352
439
  const bridgeName = body.bridge?.name || 'dsh';
353
440
  const merged = mergeMcpServers(MCP_SERVERS, bridgeName, bridgeServer);
354
441
  const mcpAll = merged.servers;
355
- // The "connected" line is printed AFTER the merge and only on success: it is
356
- // our acceptance sign, and it must not lie.
357
- if (merged.mounted) log(`bridge connected: tools ${body.bridge.tools.length}, server "${bridgeName}" (the ticket carries the identity, the gateway does not know it)`);
358
- else if (merged.conflict) log(`🔴 bridge NOT connected: the server name "${merged.conflict}" is already taken by another tool server`);
442
+ // Строка «подключён» печатается ПОСЛЕ слияния и только при удаче: она у нас
443
+ // признак приёмки, и врать ей нельзя.
444
+ if (merged.mounted) log(`мост подключён: инструментов ${body.bridge.tools.length}, сервер "${bridgeName}" (личность несёт пропуск, шлюз её не знает)`);
445
+ else if (merged.conflict) log(`🔴 мост НЕ подключён: имя сервера "${merged.conflict}" уже занято другим сервером инструментов`);
359
446
  try {
447
+ let model = null;
448
+ let otdanoSobytij = 0;
449
+ // Откат возможен, только пока наружу не ушло ни одного события: после
450
+ // первого отданного куска переиграть ход уже нельзя, иначе платформа
451
+ // получит два начала одного ответа.
452
+ const otdat = (o) => { otdanoSobytij++; send(o); };
453
+
454
+ const progon = async (tekushchij) => {
360
455
  const iter = query({
361
- prompt: buildPrompt(body.messages),
456
+ prompt: tekushchij.prompt,
362
457
  options: {
363
458
  model: body.model || DEFAULT_MODEL,
364
- // A loop with tools: one turn is only enough for a conversation. We keep
365
- // a limit so that a jammed agent does not spin forever, but a generous one.
459
+ // Цикл с инструментами: одного хода хватает только на разговор. Предел
460
+ // держим, чтобы заклинивший агент не крутился вечно, но с запасом.
366
461
  maxTurns: Number(body.maxTurns) > 0 ? Number(body.maxTurns) : DEFAULT_MAX_TURNS,
367
462
  permissionMode: 'bypassPermissions',
368
- sessionId,
369
- // 🔴 EXTERNAL TOOL SERVERS ARE CONNECTED HERE, NOT IN THE PLATFORM
370
- // (2026-08-19, it cost an hour). The engine executes the tools, so its
371
- // own set is the only one the agent sees. A client plugin on the platform
372
- // side connects without errors, appears in the plugin set and does not
373
- // reach the agent at all: in this arrangement the platform is only a chassis.
463
+ // Продолжаем свою же сессию, когда префикс сошёлся: тогда история
464
+ // едет тем же байтом и попадает в кэш подсказки целиком.
465
+ ...(tekushchij.resume ? { resume: tekushchij.resume } : { sessionId }),
466
+ // 🔴 ВНЕШНИЕ СЕРВЕРЫ ИНСТРУМЕНТОВ ПОДКЛЮЧАЮТСЯ ЗДЕСЬ, А НЕ В ПЛАТФОРМЕ
467
+ // (19.08.2026, стоило часа). Инструменты исполняет движок, поэтому его
468
+ // набор единственный, который агент видит. Плагин-клиент на стороне
469
+ // платформы подключается без ошибок, числится в составе — и до агента
470
+ // не доходит вовсе: платформа в этой схеме только шасси.
374
471
  ...(Object.keys(mcpAll).length ? { mcpServers: mcpAll } : {}),
375
472
  ...(body.cwd ? { cwd: body.cwd } : { cwd: WORK_DIR }),
376
473
  ...(body.system ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: body.system } } : {}),
@@ -378,22 +475,21 @@ const server = http.createServer(async (req, res) => {
378
475
  },
379
476
  });
380
477
 
381
- let model = null;
382
478
  for await (const m of iter) {
383
479
  if (m.type === 'assistant') {
384
480
  model = m.message?.model ?? model;
385
481
  for (const b of m.message?.content ?? []) {
386
- if (b.type === 'text') send({ type: 'text', text: b.text });
387
- else if (b.type === 'thinking') send({ type: 'thinking', text: b.thinking });
388
- // Tool work is emitted outward as an EVENT rather than as silence:
389
- // otherwise a long stretch looks like a hang and the platform has
390
- // nothing to show. The tool input is not forwarded in full — there can
391
- // be secrets and megabytes in it; only the name and a short caption.
392
- else if (b.type === 'tool_use') send({ type: 'tool', name: b.name, brief: briefOf(b.input) });
482
+ if (b.type === 'text') otdat({ type: 'text', text: b.text });
483
+ else if (b.type === 'thinking') otdat({ type: 'thinking', text: b.thinking });
484
+ // Работу инструментами отдаём наружу СОБЫТИЕМ, а не молчанием: иначе
485
+ // долгий заход выглядит как зависший, и платформе нечего показать.
486
+ // Ввод инструмента не пересылаем целиком там бывают секреты и
487
+ // мегабайты; только имя и короткая подпись.
488
+ else if (b.type === 'tool_use') otdat({ type: 'tool', name: b.name, brief: briefOf(b.input) });
393
489
  }
394
490
  const u = m.message?.usage;
395
491
  if (u) {
396
- send({
492
+ otdat({
397
493
  type: 'usage',
398
494
  inputTokens: u.input_tokens ?? 0,
399
495
  outputTokens: u.output_tokens ?? 0,
@@ -403,13 +499,28 @@ const server = http.createServer(async (req, res) => {
403
499
  }
404
500
  }
405
501
  }
502
+ };
503
+
504
+ log(`подсказка: ${plan.pochemu}`);
505
+ try {
506
+ await progon(plan);
507
+ } catch (e) {
508
+ // Транскрипт SDK — кэш, а не источник правды. Его потеря (чистка,
509
+ // перезапуск, чужая рука) не должна стоить хода: переигрываем полной
510
+ // историей и говорим об этом вслух.
511
+ if (!plan.resume || otdanoSobytij > 0) throw e;
512
+ log(`🔴 продолжение сессии ${plan.resume} не удалось (${String(e?.message ?? e).slice(0, 140)}) — повторяю полной историей`);
513
+ SESSII.delete(plan.kluch);
514
+ plan = planZaprosa(body.messages);
515
+ await progon(plan);
516
+ }
517
+ zapomnit(plan, plan.resume || sessionId, body.messages);
406
518
  send({ type: 'done', model, tookMs: Date.now() - started });
407
519
  } catch (e) {
408
- // The error is returned IN THE STREAM rather than as silence: a stream cut
409
- // off without a reason reads as "the model went quiet", and the investigation
410
- // starts from nothing.
520
+ // Ошибку отдаём В ПОТОКЕ, а не молчанием: оборванный поток без причины
521
+ // читается как «модель замолчала», и разбираться приходится с нуля.
411
522
  const message = explainExit(e, sessionId, cwd);
412
- log(`🔴 call failed: ${message}`);
523
+ log(`🔴 сбой вызова: ${message}`);
413
524
  send({ type: 'error', message: message.slice(0, 500) });
414
525
  } finally {
415
526
  res.end();
@@ -417,13 +528,13 @@ const server = http.createServer(async (req, res) => {
417
528
  });
418
529
 
419
530
  server.listen(PORT, HOST, () => {
420
- log(`listening on ${HOST}:${PORT}; token from ${TOKEN_FILE}; enginethe official SDK`);
421
- if (!readToken()) log('🔴 warning: the token is NOT readable right now, requests will be rejected');
531
+ log(`слушаю ${HOST}:${PORT}; токен из ${TOKEN_FILE}; движокофициальный SDK`);
532
+ if (!readToken()) log('🔴 предупреждение: токен сейчас НЕ читается, запросы будут отклоняться');
422
533
  });
423
534
 
424
535
  for (const sig of ['SIGTERM', 'SIGINT']) {
425
536
  process.on(sig, () => {
426
- log(`${sig} received, shutting down`);
537
+ log(`получен ${sig}, закрываюсь`);
427
538
  server.close(() => process.exit(0));
428
539
  setTimeout(() => process.exit(0), 5000).unref();
429
540
  });
@@ -1,47 +1,44 @@
1
1
  /**
2
- * JSON Schema → zod raw shape. A minimal subset: exactly what zod.toJSONSchema
3
- * produces on the platform's schemas.
2
+ * JSON Schema → zod raw shape. Минимальное подмножество: ровно то, что
3
+ * порождает zod.toJSONSchema на схемах платформы.
4
4
  *
5
- * WHY. tool() from the SDK requires zod, and zod cannot travel over a wire. The
6
- * platform hands the schema over as JSON Schema, and the gateway rebuilds zod
7
- * from it.
5
+ * ЗАЧЕМ. tool() из SDK требует zod, а через провод zod не передать. Платформа
6
+ * отдаёт схему как JSON Schema, шлюз собирает из неё zod обратно.
8
7
  *
9
- * 🔴 THE BOUNDARY. An unknown type does NOT silently become "anything": such a
10
- * substitution would give the model a tool with no parameter shape, and a failure
11
- * would look like working. An unknown type is an exception, the tool is not
12
- * exposed, and the gateway writes the reason.
8
+ * 🔴 ГРАНИЦА. Незнакомый тип НЕ превращается молча в «что угодно»: такая
9
+ * подмена дала бы модели инструмент без формы параметров, и отказ выглядел бы
10
+ * как работа. Незнакомый тип исключение, инструмент не выставляется, шлюз
11
+ * пишет причину.
13
12
  */
14
13
  import { z } from 'zod'
15
14
 
16
15
  const node = (s, path) => {
17
- if (!s || typeof s !== 'object') throw new Error(`${path}: empty schema`)
16
+ if (!s || typeof s !== 'object') throw new Error(`${path}: пустая схема`)
18
17
  if (Array.isArray(s.enum)) {
19
- if (!s.enum.every((v) => typeof v === 'string')) throw new Error(`${path}: the enum is not made of strings`)
18
+ if (!s.enum.every((v) => typeof v === 'string')) throw new Error(`${path}: перечень не из строк`)
20
19
  return z.enum(s.enum)
21
20
  }
22
- // The platform's branded strings (SessionId, GoalId) are described as an
23
- // intersection of "string AND unknown", and on the wire that is an allOf with
24
- // an empty second member. We take the single typed member: zod has no empty
25
- // schema, and the type must not be lost.
26
- // A branching shape: this is how the platform describes a parameter that takes
27
- // either a string or an object (schedule_create.at). Without this branch the
28
- // adapter would not understand a node WITHOUT a type field and would refuse
29
- // and the gateway would then silently fail to expose the tool at all. The
30
- // refusal would be loud in the gateway log and invisible to the model: the tool
31
- // is simply absent.
32
- // THE BOUNDARY: there must be at least two branches, and each must assemble on
33
- // its own. One branch is not a choice but a typo; a branch that will not
34
- // assemble is a loss of shape, that is, exactly what this whole file guards
35
- // against.
36
- const branches = Array.isArray(s.oneOf) ? s.oneOf : Array.isArray(s.anyOf) ? s.anyOf : undefined
37
- if (branches) {
38
- const kw = Array.isArray(s.oneOf) ? 'oneOf' : 'anyOf'
39
- if (branches.length < 2) throw new Error(`${path}: ${kw} of ${branches.length} branch is not a choice`)
40
- return z.union(branches.map((v, i) => node(v, `${path}|${kw}[${i}]`)))
21
+ // Брендированные строки платформы (SessionId, GoalId) описаны пересечением
22
+ // «строка И неизвестное», и на проводе это allOf с пустым вторым членом.
23
+ // Берём единственный типизированный член: пустой схемы в zod нет, а
24
+ // потерять тип нельзя.
25
+ // Разветвление формы: у платформы так описан параметр, принимающий либо
26
+ // строку, либо объект (schedule_create.at). Без этой ветки конвертер не
27
+ // понимал бы узел БЕЗ поля type и отказывал а шлюз молча не выставлял бы
28
+ // инструмент целиком. Отказ был бы громким в журнале шлюза и невидимым для
29
+ // модели: инструмент просто отсутствует.
30
+ // ГРАНИЦА: ветвей обязано быть не меньше двух, и каждая обязана собираться
31
+ // сама. Одна ветвь это не выбор, а описка; несобираемая ветвь потеря
32
+ // формы, то есть ровно то, от чего защищает весь этот файл.
33
+ const vetvi = Array.isArray(s.oneOf) ? s.oneOf : Array.isArray(s.anyOf) ? s.anyOf : undefined
34
+ if (vetvi) {
35
+ const kak = Array.isArray(s.oneOf) ? 'oneOf' : 'anyOf'
36
+ if (vetvi.length < 2) throw new Error(`${path}: ${kak} из ${vetvi.length} ветви — это не выбор`)
37
+ return z.union(vetvi.map((v, i) => node(v, `${path}|${kak}[${i}]`)))
41
38
  }
42
39
  if (Array.isArray(s.allOf)) {
43
40
  const typed = s.allOf.filter((m) => m && typeof m === 'object' && m.type !== undefined)
44
- if (typed.length !== 1) throw new Error(`${path}: allOf with ${typed.length} typed members is not supported`)
41
+ if (typed.length !== 1) throw new Error(`${path}: allOf из ${typed.length} типизированных членов не поддержан`)
45
42
  return node(typed[0], path)
46
43
  }
47
44
  switch (s.type) {
@@ -51,15 +48,14 @@ const node = (s, path) => {
51
48
  case 'boolean': return z.boolean()
52
49
  case 'array': return bounds(z.array(node(s.items, `${path}[]`)), s, 'length')
53
50
  case 'object': return z.object(shape(s, path))
54
- default: throw new Error(`${path}: type ${JSON.stringify(s.type)} is not supported`)
51
+ default: throw new Error(`${path}: тип ${JSON.stringify(s.type)} не поддержан`)
55
52
  }
56
53
  }
57
54
 
58
55
  /**
59
- * Value and length bounds. Without them the schema QUIETLY weakens: a positive
60
- * number becomes any number, and the model sees a different contract from the one
61
- * the platform will enforce. It will get a refusal at execution time, and the
62
- * reason will be far from obvious.
56
+ * Пределы значения и длины. Без них схема ТИХО слабеет: положительное число
57
+ * превращается в любое, и модель видит не тот договор, который проверит
58
+ * платформа. Отказ она получит уже на исполнении, а причина будет неочевидна.
63
59
  */
64
60
  const bounds = (t, s, kind) => {
65
61
  if (kind === 'value') {
@@ -77,16 +73,15 @@ const bounds = (t, s, kind) => {
77
73
  }
78
74
 
79
75
  export const shape = (schema, path = '$') => {
80
- if (schema?.type !== 'object') throw new Error(`${path}: an object was expected`)
76
+ if (schema?.type !== 'object') throw new Error(`${path}: ожидался объект`)
81
77
  const required = new Set(schema.required ?? [])
82
78
  const out = {}
83
79
  for (const [key, sub] of Object.entries(schema.properties ?? {})) {
84
80
  let t = node(sub, `${path}.${key}`)
85
- // THE ORDER MATTERS. optional() first, describe() second: the SDK's converter
86
- // reads the description from the OUTER node, so with describe().optional() the
87
- // parameter description is SILENTLY lost the schema stays valid and the
88
- // model does not see the explanation of an optional field. Verified with
89
- // tools/list against a stub.
81
+ // ПОРЯДОК ЗНАЧИМ. Сначала optional(), потом describe(): конвертер SDK
82
+ // читает описание с ВНЕШНЕГО узла, и при describe().optional() описание
83
+ // параметра МОЛЧА теряется схема остаётся годной, а модель не видит
84
+ // пояснения к необязательному полю. Проверено tools/list на пустышке.
90
85
  if (!required.has(key)) t = t.optional()
91
86
  if (typeof sub.description === 'string') t = t.describe(sub.description)
92
87
  out[key] = t
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subscription-gateway",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Subscription gateway for DeepSeek Harness — runs the model loop through the vendor's official agent SDK, so the platform works on a subscription seat instead of a metered API key, with the token kept in one place on the machine.",
5
5
  "type": "module",
6
6
  "main": "gateway.mjs",
@@ -10,7 +10,8 @@
10
10
  "schedule.json",
11
11
  "test-schema-adapter.mjs",
12
12
  "systemd",
13
- "README.md"
13
+ "README.md",
14
+ "README.ru.md"
14
15
  ],
15
16
  "keywords": [
16
17
  "deepseek-harness",
@@ -1,7 +1,21 @@
1
1
  // A bench for the schema adapter, taken FROM THE PACKAGE FILE. It checks exactly
2
2
  // what the adapter was fixed for: shapes that occur in the platform's schemas and
3
3
  // that the first version did not know — it lost them SILENTLY, returning z.any().
4
- import { shape } from './jsonschema-to-zod.mjs'
4
+ // 🔴 ТРИ ИСХОДА, А НЕ ДВА: 0 сошлось · 1 расхождение · 2 проверить нечем.
5
+ // Прежняя редакция знала два и при отсутствующей зависимости падала голым
6
+ // стектрейсом с кодом 1 — «предмет расходится» при исправном предмете. У того,
7
+ // кто ТОЛЬКО ЧТО поставил пакет, зависимостей ещё нет, и первое, что он увидит,
8
+ // было бы ложным обвинением коду. Класс лечён в двух других наших пакетах;
9
+ // здесь остался непройденным, пока не собрали третий (01.09.2026).
10
+ let shape
11
+ try {
12
+ ({ shape } = await import('./jsonschema-to-zod.mjs'))
13
+ } catch (e) {
14
+ console.log(`СЛЕПОТА: адаптер не загрузился: ${String(e?.message ?? e).slice(0, 120)}`)
15
+ console.log(' Это не расхождение предмета: скорее всего не поставлена зависимость zod'
16
+ + ' (`npm install` в каталоге пакета) — поставьте и повторите.')
17
+ process.exit(2)
18
+ }
5
19
  import { readFileSync } from 'node:fs'
6
20
  import { fileURLToPath } from 'node:url'
7
21
  import { dirname } from 'node:path'
@@ -11,6 +25,17 @@ const t = (name, cond, got) => {
11
25
  if (cond) { ok++; console.log(` ok ${name}`) }
12
26
  else { bad++; console.log(` FAIL ${name}${got === undefined ? '' : ` — got ${JSON.stringify(got)}`}`) }
13
27
  }
28
+ // 🔴 Исключение ВНУТРИ предмета — это расхождение, а не поломка стенда. Без этой
29
+ // обёртки стенд падал голым стектрейсом: код был верный (1), но снаружи не отличить
30
+ // «предмет плох» от «стенд сломан». Проверено порчей предмета 01.09.2026.
31
+ // An exception INSIDE the subject is a discrepancy, not a broken bench: without this
32
+ // wrapper the bench died with a raw stack trace and the two cases looked alike.
33
+ const blok = (name, fn) => {
34
+ try { fn() } catch (e) {
35
+ bad++
36
+ console.log(` FAIL ${name}: предмет бросил исключение / the subject threw: ${String(e?.message ?? e).slice(0, 160)}`)
37
+ }
38
+ }
14
39
  const passes = (z, v) => { const r = z.safeParse(v); return r.success }
15
40
 
16
41
  console.log('\n=== A. Real platform schedule schemas ===')
@@ -20,12 +45,14 @@ console.log('\n=== A. Real platform schedule schemas ===')
20
45
  const SCHEDULE_FILE = process.argv[2] ?? `${dirname(fileURLToPath(import.meta.url))}/schedule.json`
21
46
  const SCHEDULE = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'))
22
47
  for (const tool of SCHEDULE) {
23
- const s = shape(tool.parameters)
24
- t(`${tool.name}: the schema assembled, fields ${Object.keys(s).length}`, Object.keys(s).length > 0 || tool.name === 'schedule_list')
48
+ blok(`${tool.name}: the schema assembled`, () => {
49
+ const s = shape(tool.parameters)
50
+ t(`${tool.name}: the schema assembled, fields ${Object.keys(s).length}`, Object.keys(s).length > 0 || tool.name === 'schedule_list')
51
+ })
25
52
  }
26
53
 
27
54
  console.log('\n=== B. The oneOf shape — the reason the adapter was fixed ===')
28
- {
55
+ blok('B: the oneOf shape', () => {
29
56
  // at: a string OR an object — exactly what the platform hands over
30
57
  const s = shape({
31
58
  type: 'object',
@@ -34,10 +61,10 @@ console.log('\n=== B. The oneOf shape — the reason the adapter was fixed ===')
34
61
  t('the string branch passes', passes(s.at, '2026-08-23T10:00:00Z'))
35
62
  t('the object branch passes', passes(s.at, { date: '2026-08-23' }))
36
63
  t('a foreign type does NOT pass (it did not degenerate into any)', !passes(s.at, 42), s.at?._def?.typeName)
37
- }
64
+ })
38
65
 
39
66
  console.log('\n=== C. Required and optional ===')
40
- {
67
+ blok('C: required and optional', () => {
41
68
  const s = shape({
42
69
  type: 'object',
43
70
  properties: { prompt: { type: 'string' }, after_seconds: { type: 'number' } },
@@ -46,16 +73,26 @@ console.log('\n=== C. Required and optional ===')
46
73
  t('required with no value — refused', !passes(s.prompt, undefined))
47
74
  t('optional with no value — passes', passes(s.after_seconds, undefined))
48
75
  t('optional with a value — passes', passes(s.after_seconds, 300))
49
- }
76
+ })
50
77
 
51
78
  console.log('\n=== D. Descriptions reach the model ===')
52
- {
79
+ blok('D: descriptions', () => {
53
80
  // 🔴 This literal appears twice on purpose — as the input and as the expected
54
81
  // output. Change one without the other and the check goes red on healthy code.
55
82
  const DESCRIPTION = 'the exact identifier'
56
83
  const s = shape({ type: 'object', properties: { id: { type: 'string', description: DESCRIPTION } }, required: ['id'] })
57
84
  t('the field description was carried over', s.id?.description === DESCRIPTION, s.id?.description)
58
- }
85
+ })
59
86
 
60
87
  console.log(`\nTOTAL: passed ${ok}, failed ${bad}`)
61
- process.exit(bad ? 1 : 0)
88
+ // 🔴 Канарейка точного числа: вырезанный раздел иначе пройдёт молча, и стенд
89
+ // будет зелен, проверив меньше. Число считается из предмета — по числу схем в
90
+ // файле платформы плюс семь постоянных случаев (B=3, C=3, D=1).
91
+ // Exact-count canary: a cut-out section would otherwise pass in silence.
92
+ const ZHDYOM = SCHEDULE.length + 7
93
+ if (ok + bad !== ZHDYOM) {
94
+ console.log(`СЛЕПОТА: проверок ${ok + bad}, а стенд состоит из ${ZHDYOM}`
95
+ + ' — раздел не исполнился / a section did not run')
96
+ process.exit(2)
97
+ }
98
+ process.exit(bad ? 1 : 0) // слепота выходит раньше, кодом 2