dsh-telegram-multiagent 1.2.3 → 1.4.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/src/index.js CHANGED
@@ -99,6 +99,10 @@ export function apply(ctx, config = {}) {
99
99
  // Имя агента — только для журнала: при нескольких ботах на машине нужно
100
100
  // видеть, чья строка. К логике отношения не имеет.
101
101
  const who = config.agentName || 'telegram';
102
+ // 🔴 Отдельная переменная, а НЕ повторное использование `who`: ниже, в ветке
103
+ // отказа чужому, `who` затенена локальной `const who = msg.from ?? {}`, и
104
+ // подстановка дала бы «[object Object]» вместо имени. Форма взята дословно
105
+ // из опубликованного 1.3.0, чтобы места не разъезжались при следующей правке.
102
106
  const who_ = config.agentName || 'агент'; // для текстов, видимых собеседнику
103
107
  const log = (m) => console.error(`[${who}] ${m}`);
104
108
 
@@ -123,7 +127,7 @@ export function apply(ctx, config = {}) {
123
127
  // улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
124
128
  const lastOrigin = new Map();
125
129
 
126
- // 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца).
130
+ // 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца машины).
127
131
  // mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
128
132
  // свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
129
133
  // говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
@@ -134,14 +138,14 @@ export function apply(ctx, config = {}) {
134
138
  // Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
135
139
  const tgChatFor = new Map();
136
140
 
137
- // 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца).
141
+ // 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца машины).
138
142
  // Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
139
143
  // одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
140
144
  // маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
141
145
  // пришёл, пока первый ещё считается, — ответ уедет не тому.
142
146
  // Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
143
147
  // входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
144
- // ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца)
148
+ // ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца машины)
145
149
  //
146
150
  // Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
147
151
  // перевыпустить, поставить заново — настройка останется. Читается на лету,
@@ -271,24 +275,19 @@ export function apply(ctx, config = {}) {
271
275
  * cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
272
276
  * (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
273
277
  * Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
274
- * тем же значением, и код, написанный как `const x = ctx.get('x'); if (x)`,
275
- * тихо уходит по ветке «этой возможности просто нет». Снаружи это выглядит
276
- * штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
278
+ * тем же значением, и код вида `const x = ctx.get('x'); if (x)` тихо уходит по
279
+ * ветке «этой возможности просто нет». Снаружи это штатная работа.
277
280
  *
278
- * Поймано трижды за три дня, каждый раз дороже предыдущего:
279
- * 19.08.2026 фабрика агентов (см. withFactoryRetry выше);
280
- * 22.08.2026 sessionPersistence: блок продолжения истории пропускался
281
- * целиком, агент создавался поверх существующего журнала, и
282
- * КАЖДЫЙ ход умирал с «id collision» за считаные миллисекунды;
283
- * 22.08.2026 agentPresets: набор инструментов у модели упал с 33 до 3,
284
- * без ошибки и без строки в журнале.
285
- * Общий вывод, ради которого функция ОДНА, а не три: лечение, применённое к
286
- * экземпляру, а не ко всем местам класса, гарантирует рецидив.
281
+ * Поймано трижды за три дня: 19.08 фабрика агентов (withFactoryRetry выше),
282
+ * 22.08 sessionPersistence (блок продолжения пропускался целиком, агент
283
+ * создавался поверх существующего журнала, каждый ход умирал с «id collision»),
284
+ * 22.08 agentPresets (набор инструментов у соседа упал с 33 до 3 без единой
285
+ * строки в журнале). Лечение экземпляра вместо класса гарантирует рецидив —
286
+ * поэтому функция ОДНА и форма у неё одна с опубликованным пакетом 1.2.3.
287
287
  *
288
- * ГРАНИЦА ОЖИДАНИЯ назвать обязательно, иначе защита сама станет отказом:
289
- * ждём не дольше `tries` секунд и только при заведении агента. Не дождались —
290
- * говорим ГРОМКО, называем последствие и идём дальше. Канал не запирается там,
291
- * где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
288
+ * ГРАНИЦА ОЖИДАНИЯ: не дольше `tries` секунд и только при заведении агента.
289
+ * Не дождались говорим ГРОМКО, называем последствие и идём дальше. Канал не
290
+ * запирается там, где сервиса в сборке нет вовсе: это законная сборка.
292
291
  */
293
292
  async function waitService(name, tag, consequence, tries = 30) {
294
293
  for (let i = 0; i < tries; i++) {
@@ -304,6 +303,40 @@ export function apply(ctx, config = {}) {
304
303
  return undefined;
305
304
  }
306
305
 
306
+ /**
307
+ * 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ (23.08.2026, уточнено при
308
+ * сведении с копией соседа). Проверять надо КАЖДЫЙ, а не тот, на котором
309
+ * обожглись, — иначе рецидив.
310
+ *
311
+ * ПРИЗНАК: ожидание нужно там, где место может сработать ДО подъёма сервиса
312
+ * И отказ при этом МОЛЧАЛИВ ИЛИ ЛОЖЕН. Второе добавлено сознательно: раньше
313
+ * здесь было записано «отказ громкий — значит ждать не надо», и это было
314
+ * ошибкой. Громкий отказ «сервис не подключён» — это ДИАГНОЗ, которого код
315
+ * поставить не может: undefined одинаково означает и «нет в сборке», и «ещё
316
+ * поднимается». Механизм не вправе утверждать то, чего не в силах установить.
317
+ *
318
+ * И отдельно, доказано на живом 23.08: порядок готовности волокон МЕНЯЕТСЯ
319
+ * от подъёма к подъёму — в одном ждала фабрика агентов, в другом набор
320
+ * пресета, а гонка была в обоих. Значит судить о гонке по соседнему сервису
321
+ * нельзя, ожидание ставится по устройству места, а не по примете.
322
+ *
323
+ * 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence:
324
+ * ОЖИДАНИЕ ЕСТЬ, предел 30 с — заведение агента идёт вплотную к подъёму
325
+ * платформы, отказ молчалив и разрушителен.
326
+ * 2) goalCommand → goals, goalMarker, mcpBridge: ОЖИДАНИЕ ЕСТЬ, предел
327
+ * короткий (GOAL_WAIT_TRIES) — на том конце живой собеседник.
328
+ * 3) stripGoalMarker → goalMarker: ОЖИДАНИЯ НЕТ намеренно. Место
329
+ * СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив:
330
+ * маркер уезжает в чат видимым текстом.
331
+ * 4) обработчик goal/change → goals: ОЖИДАНИЯ НЕТ намеренно. Сюда попадают
332
+ * только сессии, где цель уже поставлена через goalCommand, а тот без
333
+ * сервиса целей не проходит. Значит пустота означает не «ещё не
334
+ * поднялся», а «сервис ПРОПАЛ», и это надо сказать, а не переждать.
335
+ */
336
+ // Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
337
+ // Пять, а не тридцать: на том конце живой собеседник, см. goalCommand.
338
+ const GOAL_WAIT_TRIES = 5;
339
+
307
340
  async function agentFor(chatId) {
308
341
  const key = String(chatId);
309
342
  const found = chats.get(key);
@@ -327,6 +360,7 @@ export function apply(ctx, config = {}) {
327
360
  const setupFn = async (agentCtx) => {
328
361
  platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
329
362
  if (presets && presetId) await presets.mount(agentCtx, presetId);
363
+ else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
330
364
  };
331
365
 
332
366
  // 🔴 СЕССИЮ, КОТОРАЯ УЖЕ ЛЕЖИТ НА ДИСКЕ, НАДО ПРОДОЛЖАТЬ, А НЕ СОЗДАВАТЬ ЗАНОВО.
@@ -341,6 +375,17 @@ export function apply(ctx, config = {}) {
341
375
  // события turn/end (раньше плагин печатал только тип события).
342
376
  // Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
343
377
  // продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
378
+ // 🔴 СЕРВИС ПЕРСИСТЕНЦИИ ПОЯВЛЯЕТСЯ ПОЗЖЕ КАНАЛА — та же болезнь, что у фабрики
379
+ // агентов выше, и тот же почерк: ctx.get() отдаёт undefined ТИХО, пока волокно
380
+ // поставщика не активно (cordis, ReflectService._getImpl:
381
+ // `if (strict && impl.fiber.state !== 2) return;`),
382
+ // исключения при этом нет. Без ожидания весь блок продолжения молча
383
+ // пропускался, агент создавался заново поверх существующего журнала, и КАЖДЫЙ
384
+ // ход умирал с «id collision». Поймано 22.08.2026: подъём 18:53 не напечатал
385
+ // ни строки о продолжении, ни об отказе — молчание и было ответом.
386
+ // ГРАНИЦА ОЖИДАНИЯ: ждём не дольше 30 с и только здесь, при заведении агента.
387
+ // Не дождались — говорим об этом громко и идём создавать, иначе канал запрётся
388
+ // навсегда там, где персистенции в сборке просто нет.
344
389
  const persistence = await waitService('sessionPersistence', `[${key}]`,
345
390
  'сохранённую сессию продолжить нечем, история будет потеряна');
346
391
  let handle;
@@ -349,15 +394,16 @@ export function apply(ctx, config = {}) {
349
394
  try {
350
395
  known = (await persistence.list()).map((h) => h.id);
351
396
  } catch (e) {
352
- log(`🔴 [${key}] не смог перечислить сохранённые сессии: ${e?.message ?? e}`);
397
+ log(`🔴 [${key}] не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
353
398
  }
354
- // 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ ЭТО ВТОРОЙ ТИХИЙ ПУТЬ В ЭТОЙ ЖЕ
355
- // ФУНКЦИИ. Снаружи он неотличим от честного первого запуска, а на деле
356
- // означает create() поверх существующего журнала и «id collision» на
357
- // каждом ходе. Поэтому список сообщаем ЧИСЛОМ и именами.
399
+ // 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ. Снаружи это неотличимо от штатного
400
+ // первого запуска, а на деле означает потерю истории и id collision на
401
+ // каждом ходе. Поэтому отсутствие своей сессии в списке — громкая строка
402
+ // с числом найденных и их именами, а не тишина.
358
403
  if (known !== undefined && !known.includes(sessionId)) {
359
- // Пустой список — честный первый запуск, тревожить нечем. Тревога там,
360
- // где сохранённые сессии ЕСТЬ, а нашей среди них нет.
404
+ // Пустой список — это честный первый запуск, а не беда: тревожить нечем.
405
+ // Тревога начинается там, где сохранённые сессии ЕСТЬ, а нашей среди них
406
+ // нет: значит журнал на диске остался, и create() сейчас об него разобьётся.
361
407
  if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
362
408
  else {
363
409
  // ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
@@ -367,7 +413,7 @@ export function apply(ctx, config = {}) {
367
413
  // Кричать 🔴 на законном событии — это защита, которая приучает
368
414
  // не читать журнал.
369
415
  const spisok = known.slice(0, 12).join(', ') + (known.length > 12 ? `, …и ещё ${known.length - 12}` : '');
370
- log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
416
+ log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна — по одному списку не отличить. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
371
417
  }
372
418
  }
373
419
  if (known !== undefined && known.includes(sessionId)) {
@@ -441,11 +487,235 @@ export function apply(ctx, config = {}) {
441
487
  }
442
488
  }
443
489
 
490
+ // ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, распоряжение главной).
491
+ //
492
+ // ЗАЧЕМ. На подписочном маршруте цикл ведёт движок, поэтому родные
493
+ // инструменты платформы (goal/jobs) до модели НЕ ДОХОДЯТ вовсе — доходит
494
+ // только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель изнутри
495
+ // разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
496
+ // постановку из любого канала: сервис целей доступен КОДУ модуля как
497
+ // ctx.get('goals'), инструмент модели для этого не нужен.
498
+ //
499
+ // КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
500
+ // goalUsers: [<числовые id>] — кто ставит цель из Telegram
501
+ // goalFromA2A: true|false — вправе ли служебный канал
502
+ // Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
503
+ // «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
504
+ // дорогой автономный цикл шире права поговорить.
505
+ // Проверки платформы (currentInitiator, hasDirectHumanInput, roots) живут в
506
+ // tool-goal и на этом пути НЕ участвуют: мы не обходим политику, мы пишем её
507
+ // заново, и вот эти две строки — вся политика целиком.
508
+ const goalUsers = new Set((config.goalUsers ?? []).map(Number));
509
+ // 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ» (21.08.2026,
510
+ // поправка главной к собственному замыслу). Вид источника 'a2a' получает ЛЮБОЕ
511
+ // сообщение служебного канала, а канал на ферме общий — правом «всем, кто пишет
512
+ // в a2a» мы раздали бы возможность заводить дорогие циклы кому попало.
513
+ // Отправитель называет себя первой строкой файла: From: <инстанс>/<папка>
514
+ // Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
515
+ const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
516
+ // sessionId → { channel, chatId } — куда сообщить об исходе цели.
517
+ const goalOrigin = new Map();
518
+ // канал → метки времени постановок, для счётчика ниже.
519
+ const goalStarts = new Map();
520
+
521
+ // 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
522
+ //
523
+ // ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
524
+ // вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов стоит на
525
+ // ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не
526
+ // ограничен. Каталог входящих принадлежит самому агенту, то есть в автономном
527
+ // раунде он способен положить себе файл «/goal …» и продлить цикл.
528
+ //
529
+ // 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: он НЕ защищает от намеренного действия агента,
530
+ // у которого на этой машине полное sudo без пароля (проверено 21.08.2026:
531
+ // <агент> ALL NOPASSWD: ALL). Такой агент снимет и этот счётчик, и предел
532
+ // раундов одной командой. Технической границы против самого агента здесь
533
+ // не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож — снаружи, на
534
+ // другой машине, вне досягаемости этого процесса.
535
+ //
536
+ // ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
537
+ // механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
538
+ // обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
539
+ const GOAL_MAX_PER_HOUR = 3;
540
+ function goalRateOk(channel) {
541
+ const now = Date.now();
542
+ const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
543
+ goalStarts.set(channel, marks);
544
+ return marks.length < GOAL_MAX_PER_HOUR;
545
+ }
546
+
547
+ // 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ ПЛАГИНА (22.08.2026, распоряжение главной).
548
+ // Права на постановку цели заголовком request/header НЕ проверяются в принципе:
549
+ // плагин не даёт модели ни одного инструмента и в заголовке невидим. Печатаем
550
+ // ЧИСЛА, а не «загрузился»: длина списка прав отличает верную настройку от
551
+ // пустого умолчания, при котором постановка отказывает молча и выглядит поломкой.
552
+ // Сами идентификаторы не печатаем — в журнал уходит устройство, а не список людей.
553
+ log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
554
+ + `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
555
+
556
+ async function sayTo(channel, chatId, text) {
557
+ if (channel === 'a2a') {
558
+ if (!A2A_OUT) { log('[goal] 🔴 ответ в служебный канал не ушёл: каталог обмена не задан'); return; }
559
+ try {
560
+ fs.mkdirSync(A2A_OUT, { recursive: true });
561
+ fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-goal.txt`), text);
562
+ } catch (e) { log(`[goal] 🔴 ответ в служебный канал не ушёл: ${e?.message ?? e}`); }
563
+ } else {
564
+ try { await tg.send(chatId, text); } catch (e) { log(`[goal] 🔴 ответ в чат не ушёл: ${e?.message ?? e}`); }
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Отделить заголовок отправителя от текста служебного сообщения.
570
+ *
571
+ * 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
572
+ * Отправитель называет себя сам, и подделать имя может любой, кто способен
573
+ * положить файл в каталог входящих. Настоящая граница здесь — права ОС:
574
+ * дом агента открыт на проход только владельцу (drwxr-x---), поэтому писать
575
+ * во входящие могут лишь сам агент и тот, у кого есть sudo. Проверено
576
+ * опытом 21.08.2026: dsh и arg получают Permission denied и на чтение, и на
577
+ * запись. Владелец файла отправителя НЕ выдаёт вовсе — доставка кладёт файл
578
+ * от root и тут же делает chown на адресата.
579
+ * Нужна проверка сильнее самоназвания — это общий секрет в файле с правами,
580
+ * и заводить его нужно решением, а не походя.
581
+ */
582
+ /**
583
+ * Убрать маркер цели из текста, уходящего наружу. Если плагин разбора не
584
+ * смонтирован — текст идёт как есть: маркер в чате некрасив, но безвреден,
585
+ * а молча глотать ответ из-за отсутствия служебного плагина нельзя.
586
+ */
587
+ function stripGoalMarker(raw) {
588
+ const marker = ctx.get('goalMarker');
589
+ return marker ? marker.strip(raw) : raw;
590
+ }
591
+
592
+ function splitSender(raw) {
593
+ const nl = raw.indexOf('\n');
594
+ const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
595
+ const m = /^From:\s*(\S+)$/.exec(head);
596
+ if (!m) return { sender: undefined, text: raw };
597
+ return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
598
+ }
599
+
600
+ // Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
601
+ // Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
602
+ async function goalCommand(text, channel, agent) {
603
+ const rest = text.slice('/goal'.length).trim();
604
+ // 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ (23.08.2026, сведение с копией
605
+ // соседа — решение его, форма наша). Без ожидания ответ «не подключён» был
606
+ // бы диагнозом, которого код поставить не может: undefined одинаково
607
+ // означает и «нет в сборке», и «ещё поднимается». Предел короткий, а не
608
+ // 30 с как при заведении агента: на том конце ждёт живой собеседник, и
609
+ // полминуты молчания в чате читаются как «бот умер».
610
+ const goals = await waitService('goals', '[goal]',
611
+ 'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
612
+ if (!goals) return 'Сервис целей на этом агенте не подключён.';
613
+ let current;
614
+ try { current = goals.get(agent); }
615
+ catch (e) { log(`[goal] 🔴 состояние цели не прочитано: ${e?.message ?? e}`); return `Не смог прочитать состояние цели: ${e?.message ?? e}`; }
616
+
617
+ if (rest === '') {
618
+ if (!current) return 'Цели нет.';
619
+ return `Цель ${current.id}: ${current.phase}, раунд ${current.roundsStarted} из ${current.maxGoalRounds}.`
620
+ + (current.blockedReason ? `\nПричина остановки: ${current.blockedReason}` : '')
621
+ + `\nЗамысел: ${String(current.objective).slice(0, 200)}`;
622
+ }
623
+
624
+ if (rest === 'stop') {
625
+ // Снятие счётчиком НЕ ограничено — см. границы выше.
626
+ if (!current) return 'Цели нет, снимать нечего.';
627
+ try {
628
+ goals.clear(agent, { id: current.id, revision: current.revision });
629
+ goalOrigin.delete(String(agent.session?.id ?? ''));
630
+ log(`[goal] снята ${current.id} из ${channel}`);
631
+ return `Цель ${current.id} снята.`;
632
+ } catch (e) {
633
+ log(`[goal] 🔴 снятие не удалось: ${e?.message ?? e}`);
634
+ return `Не смог снять цель: ${e?.message ?? e}`;
635
+ }
636
+ }
637
+
638
+ // Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
639
+ // (defaultMaxGoalRounds) и не должен подниматься тем же путём, каким
640
+ // запускается дорогой цикл.
641
+ if (!goalRateOk(channel)) {
642
+ log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
643
+ return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
644
+ }
645
+ try {
646
+ let view = goals.create(agent, { objective: rest });
647
+ // 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
648
+ // раунда (renderGoalRoundPrompt) несёт только Objective и Round — ни
649
+ // идентификатора цели, ни ревизии в нём нет, а без них модель не сложит
650
+ // маркер. Идентификатор известен только после create, поэтому edit вторым
651
+ // шагом; edit поднимает ревизию на единицу, её и называем в наставлении.
652
+ const marker = await waitService('goalMarker', '[goal]',
653
+ 'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
654
+ if (marker) {
655
+ // 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ (22.08.2026).
656
+ // Протокол MCP добавляет к базовому имени приставку mcp__<сервер>__;
657
+ // наставление, называющее инструмент БЕЗ неё, отсылает модель к
658
+ // несуществующему имени, и выглядит это как «мост не работает».
659
+ // Нет моста — нет и имён: тогда наставление честно говорит, что
660
+ // инструментов нет, и остаётся только пометка.
661
+ const bridge = await waitService('mcpBridge', '[goal]',
662
+ 'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
663
+ const goalTools = bridge?.modelToolName
664
+ ? { update: bridge.modelToolName('update_goal'), get: bridge.modelToolName('get_goal') }
665
+ : undefined;
666
+ log(`[goal] наставление: ${goalTools ? `инструмент ${goalTools.update}, пометка запасной линией` : 'моста нет, только пометка'}`);
667
+ view = goals.edit(agent, { id: view.id, revision: view.revision },
668
+ { objective: `${rest}\n\n${marker.guidance(view.id, view.revision + 1, goalTools)}` });
669
+ } else {
670
+ log('[goal] ⚠️ сервис goalMarker не смонтирован: цель поставлена, но закрыть её сама модель не сможет — цикл дойдёт до предела раундов');
671
+ }
672
+ goalStarts.get(channel).push(Date.now());
673
+ goalOrigin.set(String(agent.session?.id ?? ''), { channel, agent });
674
+ log(`[goal] поставлена ${view.id} из ${channel}, предел ${view.maxGoalRounds} раундов: ${rest.slice(0, 60)}`);
675
+ return `Цель ${view.id} поставлена, предел ${view.maxGoalRounds} раундов.`;
676
+ } catch (e) {
677
+ const code = e?.code ?? '';
678
+ log(`[goal] 🔴 постановка не удалась (${code}): ${e?.message ?? e}`);
679
+ if (code === 'GOAL_ALREADY_EXISTS') return 'Цель уже поставлена. Сначала /goal stop.';
680
+ return `Не смог поставить цель: ${e?.message ?? e}`;
681
+ }
682
+ }
683
+
444
684
  // ── ВЫХОД: события сессии → сообщения в Telegram
445
685
  ctx.on('session/event', (session, event) => {
446
686
  // 🔴 DEBUG 2026-08-18: все типы событий
447
687
  log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
448
688
  const sid = String(session?.id ?? '');
689
+ // 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел переводит цель в
690
+ // blocked — и делает это молча: снаружи автономный цикл просто перестаёт
691
+ // просыпаться. Молчаливый останов неотличим от поломки, поэтому говорим в
692
+ // тот канал, из которого цель ставили, и ровно один раз.
693
+ if (event.type === 'goal/change') {
694
+ const origin = goalOrigin.get(sid);
695
+ if (origin) {
696
+ try {
697
+ // 🔴 БЫЛО МОЛЧАЛИВО: `ctx.get('goals')?.get(...)` при отсутствии
698
+ // сервиса возвращал undefined, и сообщение об исходе цели просто не
699
+ // уходило — ровно то молчание, от которого этот блок и заведён.
700
+ // Сервис здесь обязан быть: goalOrigin заполняет только goalCommand,
701
+ // а он без ctx.get('goals') не проходит. Значит пустота означает, что
702
+ // сервис ПРОПАЛ, и это событие само по себе стоит строки.
703
+ const goalsSvc = ctx.get('goals');
704
+ if (!goalsSvc) {
705
+ log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
706
+ return;
707
+ }
708
+ const view = goalsSvc.get(origin.agent);
709
+ if (view && view.phase !== 'active') {
710
+ goalOrigin.delete(sid);
711
+ void sayTo(origin.channel, tgChatFor.get(sid) ?? sessionToChat.get(sid),
712
+ `Цель ${view.id}: ${view.phase}`
713
+ + (view.blockedReason ? `, причина: ${view.blockedReason}` : '')
714
+ + `. Раундов пройдено ${view.roundsStarted} из ${view.maxGoalRounds}.`);
715
+ }
716
+ } catch (e) { log(`[goal] исход цели не прочитан: ${e?.message ?? e}`); }
717
+ }
718
+ }
449
719
  // 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
450
720
  // числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
451
721
  // карты, заполняемой на входе из мессенджера.
@@ -478,25 +748,30 @@ export function apply(ctx, config = {}) {
478
748
  }
479
749
  } else if (event.type === 'assistant/message'
480
750
  && (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
481
- // ответ координатору — в файл, Telegram тут ни при чём
751
+ // ответ для Забавы — в файл, Telegram тут ни при чём
482
752
  const blocks = event.data?.message?.content ?? [];
483
- const text = blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim();
753
+ // 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
754
+ // текста и из копий. Он служебный: его дело остановить цикл, а не
755
+ // попасть человеку в чат. Вырезается только маркер последней строки;
756
+ // маркер в середине оставляем видимым нарочно — он и так отвергнут,
757
+ // и пусть будет заметен читателю.
758
+ const text = stripGoalMarker(blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')).trim();
484
759
  if (text) {
485
760
  try {
486
761
  fs.mkdirSync(A2A_OUT, { recursive: true });
487
762
  fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
488
763
  { const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
489
- log(`[a2a] ответ координатору записан (${text.length} знаков)`);
764
+ log(`[a2a] ответ Забаве записан (${text.length} знаков)`);
490
765
  } catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
491
766
  }
492
767
  } else if (event.type === 'assistant/message') {
493
768
  // Берём только видимый текст: рассуждения модели наружу не отдаём.
494
769
  const blocks = event.data?.message?.content ?? [];
495
- const text = blocks
770
+ // Маркер цели наружу не отдаём — см. пояснение веткой выше.
771
+ const text = stripGoalMarker(blocks
496
772
  .filter((b) => b?.type === 'text')
497
773
  .map((b) => b.text)
498
- .join('\n')
499
- .trim();
774
+ .join('\n')).trim();
500
775
  if (text) {
501
776
  log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
502
777
  { const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
@@ -530,7 +805,6 @@ export function apply(ctx, config = {}) {
530
805
  let tmpAudio = null;
531
806
  try {
532
807
  tmpAudio = await downloadTelegramFile(fileId);
533
- // transcribe-local-shared — наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
534
808
  // 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
535
809
  // своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
536
810
  // говорится в журнал, а не молча игнорируется.
@@ -608,16 +882,41 @@ export function apply(ctx, config = {}) {
608
882
  return;
609
883
  }
610
884
 
885
+ // Постановка цели — тоже команда канала, разбираем до передачи агенту.
886
+ if (text === '/goal' || text.startsWith('/goal ')) {
887
+ // Право проверяем ДО создания агента: отказ не должен заводить сессию.
888
+ if (!goalUsers.has(Number(userId))) {
889
+ log(`[goal] ОТКАЗ ${userId}: не в списке goalUsers`);
890
+ await tg.send(chatId, 'Постановка цели из этого канала вам не разрешена.');
891
+ return;
892
+ }
893
+ const key = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
894
+ try {
895
+ const { handle: gh } = await agentFor(key);
896
+ await sayTo('tg', chatId, await goalCommand(text, 'tg', gh.agent));
897
+ } catch (e) {
898
+ log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
899
+ await tg.send(chatId, `Не смог выполнить команду: ${e?.message ?? e}`);
900
+ }
901
+ return;
902
+ }
903
+
611
904
  // При включённом слиянии владелец попадает в ту же сессию, что и служебный
612
905
  // канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
613
906
  // поэтому запоминаем настоящий чат отдельно.
614
907
  const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
615
908
  const { handle, sessionId } = await agentFor(routeKey);
616
909
  tgChatFor.set(sessionId, chatId);
617
- // 🔴 ТОЛЬКО send(), НЕ followup(). Проверено на установленной версии 0.1.0-rc.7:
618
- // followup объявлен в описании типов, но В КОДЕ ЕГО НЕТ — вызов молча не делает
619
- // ничего, и снаружи это выглядит как «бот принял сообщение и замолчал».
620
- // Правильный вызов подсмотрен в самом продукте: this.send(input, "next-turn", true).
910
+ // 🔴 ЗДЕСЬ send(), А НЕ followup() но причина СМЕНИЛАСЬ, читай внимательно.
911
+ // Было (0.1.0-rc.7): followup объявлен в описании типов, а В КОДЕ ЕГО НЕТ —
912
+ // вызов молча не делал ничего, снаружи «бот принял сообщение и замолчал».
913
+ // Стало (проверено на установленной 0.1.1-rc.2, 21.08.2026): followup в коде
914
+ // ЕСТЬ и он ровно наш вызов — dsh-agent-loop/lib/index.js:396
915
+ // followup(input) { this.send(input, "next-turn", true) }
916
+ // То есть обход больше не обход, а тот же самый путь, записанный явно.
917
+ // Оставляем send(): он работает на обеих версиях. Копии модуля разведены
918
+ // 22.08.2026 по агентам, но версии платформ у них разные (0.1.1-rc.2 и
919
+ // 0.1.0-rc.8), и код обязан работать на обеих.
621
920
  // next-turn — обычный следующий ход;
622
921
  // true — разбудить исполнителя, иначе сообщение будет ждать вечно.
623
922
  lastOrigin.set(sessionId, 'tg');
@@ -654,8 +953,8 @@ export function apply(ctx, config = {}) {
654
953
  // пользователя, а бот боту не пишет — так устроен мессенджер. Поэтому связь
655
954
  // через каталог обмена, он же работает поверх ssh с любой машины фермы.
656
955
  //
657
- // входящие мне: <a2aDir>/in/*.txt — кладёт координатор, я передаю агенту
658
- // исходящие ей: <a2aDir>/out/*.txt — пишет агент, забирает координатор
956
+ // входящие мне: <дом агента>/a2a/in/*.txt — кладёт координатор, я передаю агенту
957
+ // исходящие ей: <дом агента>/a2a/out/*.txt — пишет агент, забирает координатор
659
958
  // Каталог обмена задаётся настройкой: у каждого агента он свой. Не задан —
660
959
  // межагентского канала у этого агента просто нет, и опрос не ведётся вовсе
661
960
  // (молчаливое создание чужих каталогов было бы хуже отсутствия связи).
@@ -664,6 +963,54 @@ export function apply(ctx, config = {}) {
664
963
  const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
665
964
  const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
666
965
 
966
+ // ── ВТОРОЙ ИСТОЧНИК ВХОДЯЩИХ: ЯЩИК ШИНЫ A2A (31.08.2026) ────────────────────
967
+ // Шина между агентами машины: отправитель кладёт письмо в щель
968
+ // /var/spool/a2a/<получатель>/ (каталог 1730: опустить можно, прочесть нельзя),
969
+ // почтальон a2a-pochtalon раз в 5 с переносит его в собственность получателя,
970
+ // а забрать из ящика и подать агенту должен ЭТОТ модуль.
971
+ //
972
+ // 🔴 ЗАЧЕМ ЗАВЕДЕНО: без этого блока выход шины не подключён никуда. Замер
973
+ // 31.08.2026: три письма соседа пролежали в ящике ДВОЕ СУТОК и дошли рукой
974
+ // координатора, а не шиной. Снаружи это выглядело как «агент не отвечает».
975
+ //
976
+ // Путь — НАСТРОЙКА, не константа: у каждого агента ящик свой, и требование
977
+ // владельца от 31.08.2026 — всё управляемое вынести из кода. Не задан — звена
978
+ // нет вовсе, опрос не ведётся (молчаливое чтение чужого ящика было бы хуже).
979
+ const SPOOL_IN = config.spoolDir || null;
980
+ // Порог пачки: до него письма подаются по одному (ход на письмо сохраняет
981
+ // структуру разговора), сверх него — ОДНИМ ходом, чтобы накопленное не
982
+ // превратилось в лавину подряд идущих ходов. Правило координатора 31.08.2026.
983
+ const SPOOL_BATCH = Number(config.spoolBatchCount ?? 5);
984
+ // Старше этого — подаётся с ЯВНОЙ пометкой возраста в тексте. Письмо
985
+ // двухсуточной давности, поданное как свежее, толкает отвечать на устаревшее.
986
+ const SPOOL_STALE_HOURS = Number(config.spoolStaleHours ?? 12);
987
+
988
+ /**
989
+ * Отправитель — из ИМЕНИ файла: <метка>-<отправитель>-<случайное>.txt.
990
+ * Так решил почтальон: содержимое письма — сырой текст без заголовков, и
991
+ * подделать имя отправитель не может (имя формирует a2a-send, а каталог
992
+ * закрыт на перечисление). Не разобралось — говорим прямо, а не молчим.
993
+ */
994
+ function spoolSender(name) {
995
+ const m = /^\d{8}T\d{6}Z-([^-]+)-/.exec(name);
996
+ return m ? m[1] : '(имя не разобрано)';
997
+ }
998
+
999
+ /**
1000
+ * Возраст письма в часах. Источник — метка в ИМЕНИ (её ставит отправитель),
1001
+ * а не mtime: почтальон копирует файл, и при потере метки времени mtime
1002
+ * оказался бы временем переноса, то есть свежим у двухсуточного письма.
1003
+ * Метка не разобралась — берём mtime как нижнюю оценку и говорим об этом.
1004
+ */
1005
+ function spoolAgeHours(name, full) {
1006
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(name);
1007
+ if (m) {
1008
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
1009
+ return (Date.now() - t) / 3600000;
1010
+ }
1011
+ try { return (Date.now() - fs.statSync(full).mtimeMs) / 3600000; } catch { return 0; }
1012
+ }
1013
+
667
1014
  // Берём простые текстовые форматы. Всё остальное НЕ берём — но и не
668
1015
  // проглатываем: имя отвергнутого файла называем в журнале. Поймано
669
1016
  // 21.08.2026: письмо с расширением .md пролежало во входящих 26 минут,
@@ -711,10 +1058,32 @@ export function apply(ctx, config = {}) {
711
1058
  // который нам не по правам (например 600 root:root), молча оставался
712
1059
  // лежать во входящих. Снаружи это «агент не отвечает», а на деле
713
1060
  // мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
714
- log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: попробуй chown dsh:dsh + chmod 644)`);
1061
+ log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
715
1062
  continue;
716
1063
  }
717
1064
  if (!text) continue;
1065
+ // Заголовок отправителя отделяем ВСЕГДА: он служебный и в разговоре лишний.
1066
+ const { sender, text: body } = splitSender(text);
1067
+ if (sender !== undefined) text = body;
1068
+ if (!text) continue;
1069
+ if (text === '/goal' || text.startsWith('/goal ')) {
1070
+ if (!goalA2ASenders.has(sender)) {
1071
+ log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
1072
+ await sayTo('a2a', null, sender === undefined
1073
+ ? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <инстанс>/<папка>".'
1074
+ : `Отправитель ${sender} не вправе ставить цель.`);
1075
+ } else {
1076
+ try {
1077
+ const { handle: gh } = await agentFor(A2A_CHAT);
1078
+ await sayTo('a2a', null, await goalCommand(text, 'a2a', gh.agent));
1079
+ } catch (e) {
1080
+ log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
1081
+ await sayTo('a2a', null, `Не смог выполнить команду: ${e?.message ?? e}`);
1082
+ }
1083
+ }
1084
+ try { fs.unlinkSync(full); } catch { /* уже убрали */ }
1085
+ continue;
1086
+ }
718
1087
  try {
719
1088
  const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
720
1089
  lastOrigin.set(a2aSessionId, 'a2a');
@@ -726,10 +1095,28 @@ export function apply(ctx, config = {}) {
726
1095
  // Наш агент на этом верно упёрся — отказался выполнять просьбу,
727
1096
  // подписанную чужим именем, пришедшую не из своего канала. Значит
728
1097
  // происхождение обязан сообщать код доставки, которому подделать нечем.
729
- text = `[служебный канал, от координатора]\n${stripMark(text)}`;
1098
+ text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
730
1099
  handle.agent.send(platform.createUserMessage({
731
1100
  content: [{ type: 'text', text }],
732
- source: { kind: 'user' },
1101
+ // 🔴 СВОЙ ВИД ИСТОЧНИКА ДЛЯ СЛУЖЕБНОГО КАНАЛА (21.08.2026, распоряжение
1102
+ // главной). Раньше тут стоял kind: 'user' — и это была НЕПРАВДА в слое
1103
+ // прав: платформа считала сообщение координатора человеческим, то есть
1104
+ // право ставить цель добывалось подлогом метки, а не записанным решением.
1105
+ // Теперь журнал честен: видно, что писал агент-координатор, а не человек.
1106
+ // ЧТО ЭТИМ ТЕРЯЕМ — обход ВСЕХ потребителей kind === 'user' в дереве
1107
+ // платформы (31 совпадение, разобраны все):
1108
+ // tool-goal hasDirectHumanInput -> false: из этого канала нельзя
1109
+ // закрыть цель родным инструментом (он у нас погашен);
1110
+ // tool-jobs бюджет подряд идущих пробуждений больше не обнуляется;
1111
+ // tool-skill жест /имя отсюда не подтянет тело скилла;
1112
+ // session-title заголовок сессии по такому сообщению не строится;
1113
+ // session-reference сообщение вне проекции «текущей поверхности».
1114
+ // Право ставить цель координатору вернём ЯВНЫМ списком видов в мосту —
1115
+ // решением, которое видно тому, кто откроет список.
1116
+ // Вид произвольный по устройству: createMessage кладёт source как есть,
1117
+ // схемы нет; 'goal' и 'skill-catalog' — такие же добавленные виды.
1118
+ // ⚠️ Личный чат владельца остаётся 'user' (строка 560) — он и есть человек.
1119
+ source: { kind: 'a2a' },
733
1120
  }), 'next-turn', true);
734
1121
  nudgeUntilClaimed(handle.agent, '[a2a]');
735
1122
  // 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ (19.08.2026). Раньше файл
@@ -743,10 +1130,115 @@ export function apply(ctx, config = {}) {
743
1130
  }
744
1131
  }
745
1132
 
1133
+ /**
1134
+ * Забрать письма из ящика шины и подать агенту.
1135
+ *
1136
+ * 🔴 ДВА МЕСТА ОДНОЙ ДОСТАВКИ. Это короткий повтор того, что делает pollA2A;
1137
+ * общий кусок не выносила намеренно — в pollA2A он переплетён с разбором
1138
+ * /goal, и хелпер вышел бы дороже двух коротких кусков. Но перечень-копия
1139
+ * молча расходится: правишь ОДНО — проверь ВТОРОЕ. За этим следит
1140
+ * stend-yashchika-shiny.sh (сверяет, что оба места делают одно).
1141
+ *
1142
+ * Команд (/goal) из ящика НЕ принимаем: право ставить цель поимённое и живёт
1143
+ * у координатора прямым каналом. Второй путь к тому же действию всегда
1144
+ * расходится с первым, и по журналу перестаёт быть видно, кто поставил цель.
1145
+ */
1146
+ async function pollSpool() {
1147
+ if (!SPOOL_IN) return;
1148
+ let all = [];
1149
+ try {
1150
+ all = fs.readdirSync(SPOOL_IN).sort();
1151
+ } catch (e) {
1152
+ // Одна строка на причину, не на прогон: опрос идёт каждые ~25 с.
1153
+ const key = `SPOOL:${e?.code ?? 'ERR'}`;
1154
+ if (!a2aReported.has(key)) {
1155
+ a2aReported.add(key);
1156
+ log(`[ящик] \u{1F534} недоступен (${SPOOL_IN}): ${e?.message ?? e}`);
1157
+ }
1158
+ return;
1159
+ }
1160
+ const files = all.filter((f) => A2A_EXT.some((x) => f.endsWith(x)));
1161
+ for (const f of all) {
1162
+ if (files.includes(f) || a2aReported.has(`SP:${f}`)) continue;
1163
+ a2aReported.add(`SP:${f}`);
1164
+ log(`[ящик] \u{1F534} НЕ ВЗЯТ файл ${f}: беру только ${A2A_EXT.join(', ')}. `
1165
+ + `Он так и будет лежать в ящике — переименуй отправителю`);
1166
+ }
1167
+ for (const name of [...a2aReported]) {
1168
+ if (name.startsWith('SP:') && !all.includes(name.slice(3))) a2aReported.delete(name);
1169
+ }
1170
+ if (!files.length) return; // пустой ящик — тишина, а не строка «пусто»
1171
+
1172
+ // Читаем всё, что взяли, и только потом решаем: по одному или пачкой.
1173
+ const pisma = [];
1174
+ for (const f of files) {
1175
+ const full = path.join(SPOOL_IN, f);
1176
+ let text = '';
1177
+ try {
1178
+ text = fs.readFileSync(full, 'utf-8').trim();
1179
+ } catch (e) {
1180
+ log(`[ящик] \u{1F534} не смогла прочитать ${f}: ${e?.message ?? e} `
1181
+ + `(письмо должно принадлежать владельцу агента; переносит почтальон)`);
1182
+ continue;
1183
+ }
1184
+ if (!text) { try { fs.unlinkSync(full); } catch { /* пустое — убрать */ } continue; }
1185
+ const age = spoolAgeHours(f, full);
1186
+ const stale = age >= SPOOL_STALE_HOURS
1187
+ ? `\n[письмо пролежало в ящике ${Math.round(age)} ч — возможно, оно устарело]`
1188
+ : '';
1189
+ pisma.push({ f, full, who: spoolSender(f), text: stripMark(text), stale });
1190
+ }
1191
+ if (!pisma.length) return;
1192
+
1193
+ const packoj = pisma.length > SPOOL_BATCH;
1194
+ const partii = packoj ? [pisma] : pisma.map((p) => [p]);
1195
+ for (const partiya of partii) {
1196
+ const who = partiya.length === 1 ? partiya[0].who : 'агентов машины';
1197
+ const body = partiya.length === 1
1198
+ ? `[служебный канал, от агента ${partiya[0].who}]${partiya[0].stale}\n${partiya[0].text}`
1199
+ : `[служебный канал, ${partiya.length} писем из ящика одной пачкой]\n\n`
1200
+ + partiya.map((p) => `── от агента ${p.who}${p.stale}\n${p.text}`).join('\n\n');
1201
+ try {
1202
+ const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
1203
+ lastOrigin.set(a2aSessionId, 'a2a');
1204
+ pushAsk(a2aSessionId, { origin: 'a2a', who, q: partiya[0].text });
1205
+ copyAsk('a2a', who, body);
1206
+ handle.agent.send(platform.createUserMessage({
1207
+ content: [{ type: 'text', text: body }],
1208
+ // Тот же вид источника, что у служебного канала: письмо агента — не
1209
+ // слово человека. Разбор последствий kind !== 'user' — в pollA2A выше.
1210
+ source: { kind: 'a2a' },
1211
+ }), 'next-turn', true);
1212
+ nudgeUntilClaimed(handle.agent, '[ящик]');
1213
+ // 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ, но удаляем ОБЯЗАТЕЛЬНО:
1214
+ // модуль опрашивает ящик каждые ~25 с, и неудалённое письмо подавалось
1215
+ // бы снова и снова. Не удалить — не «сохранить улику», а завести лавину.
1216
+ for (const p of partiya) { try { fs.unlinkSync(p.full); } catch { /* уже убрали */ } }
1217
+ log(`[ящик] принято ${partiya.length} письмо(писем) от ${who}, ${body.length} знаков`);
1218
+ } catch (e) {
1219
+ log(`[ящик] \u{1F534} не удалось передать агенту: ${e?.message ?? e} — `
1220
+ + `${partiya.map((p) => p.f).join(', ')} ОСТАВЛЕНЫ в ящике, попробую снова`);
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // 🔴 fetch failed СКРЫВАЕТ причину (ECONNRESET/ETIMEDOUT/ENOTFOUND выглядят
1226
+ // одинаково). Печатаем e.cause рядом с e.message. ЦЕНА: не всякая ошибка несёт
1227
+ // cause — тогда пишем «не указана», а не пустое место (пустое читается как
1228
+ // «причины нет», и это та же слепота, что и раньше).
1229
+ // Перенесено из второй живой копии модуля 01.09.2026 ДОСЛОВНО: своя редакция
1230
+ // того же лечения развела бы копии по логике при сверке.
1231
+ function errDetail(e) {
1232
+ const c = e?.cause;
1233
+ if (!c) return 'не указана';
1234
+ return c?.message ?? c?.code ?? String(c);
1235
+ }
1236
+
746
1237
  async function loop(run) {
747
1238
  while (run.alive) {
748
1239
  try {
749
1240
  await pollA2A();
1241
+ await pollSpool();
750
1242
  const updates = await tg.poll(25);
751
1243
  for (const u of updates) {
752
1244
  try { await handleUpdate(u); }
@@ -754,7 +1246,7 @@ export function apply(ctx, config = {}) {
754
1246
  }
755
1247
  } catch (e) {
756
1248
  // Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
757
- log(`опрос не удался: ${e?.message ?? e}`);
1249
+ log(`опрос не удался: ${e?.message ?? e} | причина: ${errDetail(e)}`);
758
1250
  await new Promise((r) => setTimeout(r, 3000));
759
1251
  }
760
1252
  }