dsh-telegram-multiagent 1.3.0 → 1.4.1

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
  // перевыпустить, поставить заново — настройка останется. Читается на лету,
@@ -217,21 +221,57 @@ export function apply(ctx, config = {}) {
217
221
  // 🔴 НИКАКИХ ТИХИХ ВЫХОДОВ: каждая ветка что-то пишет в журнал. Ровно на этом
218
222
  // обжглись сегодня — `agent.inbox?.hasPending` при недоступном inbox даёт
219
223
  // undefined, условие «всё хорошо» проходит, и отказ маскируется под успех.
224
+ // ── ЧЕТЫРЕ ИСХОДА ВМЕСТО ДВУХ (обосновано замером 01.09.2026).
225
+ // Прежняя редакция знала только «забрано» и «не забрали», и второе печаталось
226
+ // бедой. Замер за сутки: 48 писем принято, 45 строк «не забрали», побудок за всё
227
+ // время НОЛЬ, а настоящая задержка доставки — медиана 10 с. То есть беда
228
+ // объявлялась при штатной работе, 94 раза из 100.
229
+ //
230
+ // Две причины, обе исправлены здесь:
231
+ // 1) проверка «забрано ли» стояла ПОСЛЕ проверки занятости, и при занятом агенте
232
+ // очередь не смотрелась вовсе — выйти из цикла было структурно нельзя, пока
233
+ // агент работает. Теперь состояние очереди проверяется ПЕРВЫМ и от занятости
234
+ // не зависит: забрано — значит забрано, кто бы чем ни был занят;
235
+ // 2) «агент был занят весь срок» и «агент свободен, а письмо висит» — разные
236
+ // новости. Первое — сведение о штатном состоянии, второе — отказ механизма.
237
+ // Раньше обе давали одну красную строку, и настоящий отказ утонул бы в шуме.
238
+ //
239
+ // 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ И ЧЕГО НЕ ЛОВИТ:
240
+ // • это наблюдение, а не доставка: письмо доходит и без побудки, платформа
241
+ // заберёт его на следующем ходе. Молчание механизма не означает потери;
242
+ // • «занят все проверки» не доказывает, что письмо доставлено — только что
243
+ // будить было некого. Момент забора виден лишь при выходе по пустой очереди;
244
+ // • очередь, опустевшая уже на первой проверке, неотличима от «письмо вклеено
245
+ // в идущий ход» — поэтому такой случай помечается отдельно, а не молчит.
246
+ //
247
+ // 🔴 НИКАКИХ ТИХИХ ВЫХОДОВ: каждая ветка что-то пишет в журнал.
220
248
  function nudgeUntilClaimed(agent, tag, budgetMs = 120000) {
221
249
  void (async () => {
222
- const deadline = Date.now() + budgetMs;
223
- let woke = 0;
250
+ const nachato = Date.now();
251
+ const deadline = nachato + budgetMs;
252
+ let woke = 0; // сколько раз будили
253
+ let proverok = 0; // сколько проходов сделали
254
+ let zanyat = 0; // из них пришлось на занятого агента
255
+ let videliOchered = false; // видели ли очередь непустой хоть раз
224
256
  while (Date.now() < deadline) {
225
257
  await new Promise((r) => setTimeout(r, 500));
258
+ proverok += 1;
226
259
  if (typeof agent?.inbox?.hasPending !== 'boolean') {
227
260
  log(`${tag} 🔴 очередь агента недоступна — добивать побудку нечем`);
228
261
  return;
229
262
  }
230
- if (agent.status === 'running') continue;
263
+ // Забрано ли вопрос о ПИСЬМЕ, а не об агенте. Спрашиваем всегда.
231
264
  if (!agent.inbox.hasPending) {
232
- if (woke) log(`${tag} сообщение забрано после ${woke} повторных побудок`);
265
+ const sek = ((Date.now() - nachato) / 1000).toFixed(1);
266
+ const primech = videliOchered
267
+ ? ''
268
+ : ' (очередь была пуста уже на первой проверке — вероятно, письмо вклеено в идущий ход)';
269
+ log(`${tag} сообщение забрано за ${sek} с: побудок ${woke}, проверок ${proverok}${primech}`);
233
270
  return;
234
271
  }
272
+ videliOchered = true;
273
+ // А вот будить — вопрос об АГЕНТЕ: занятого будить незачем и нечем.
274
+ if (agent.status === 'running') { zanyat += 1; continue; }
235
275
  if (typeof agent.wakeDriver !== 'function') {
236
276
  log(`${tag} 🔴 сообщение висит в очереди, а wakeDriver недоступен`);
237
277
  return;
@@ -239,7 +279,11 @@ export function apply(ctx, config = {}) {
239
279
  agent.wakeDriver();
240
280
  woke += 1;
241
281
  }
242
- log(`${tag} 🔴 за ${budgetMs} мс сообщение так и не забрали (побудок: ${woke})`);
282
+ if (proverok > 0 && zanyat === proverok) {
283
+ log(`${tag} письмо ещё в очереди спустя ${budgetMs} мс: агент был занят все ${zanyat} проверок из ${proverok} — будить было некого, состояние штатное`);
284
+ } else {
285
+ log(`${tag} 🔴 за ${budgetMs} мс сообщение так и не забрали: агент был свободен ${proverok - zanyat} проверок из ${proverok}, побудок ${woke} — побудка не помогла`);
286
+ }
243
287
  })();
244
288
  }
245
289
 
@@ -271,24 +315,19 @@ export function apply(ctx, config = {}) {
271
315
  * cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
272
316
  * (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
273
317
  * Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
274
- * тем же значением, и код, написанный как `const x = ctx.get('x'); if (x)`,
275
- * тихо уходит по ветке «этой возможности просто нет». Снаружи это выглядит
276
- * штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
318
+ * тем же значением, и код вида `const x = ctx.get('x'); if (x)` тихо уходит по
319
+ * ветке «этой возможности просто нет». Снаружи это штатная работа.
277
320
  *
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
- * экземпляру, а не ко всем местам класса, гарантирует рецидив.
321
+ * Поймано трижды за три дня: 19.08 фабрика агентов (withFactoryRetry выше),
322
+ * 22.08 sessionPersistence (блок продолжения пропускался целиком, агент
323
+ * создавался поверх существующего журнала, каждый ход умирал с «id collision»),
324
+ * 22.08 agentPresets (набор инструментов у соседа упал с 33 до 3 без единой
325
+ * строки в журнале). Лечение экземпляра вместо класса гарантирует рецидив —
326
+ * поэтому функция ОДНА и форма у неё одна с опубликованным пакетом 1.2.3.
287
327
  *
288
- * ГРАНИЦА ОЖИДАНИЯ назвать обязательно, иначе защита сама станет отказом:
289
- * ждём не дольше `tries` секунд и только при заведении агента. Не дождались —
290
- * говорим ГРОМКО, называем последствие и идём дальше. Канал не запирается там,
291
- * где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
328
+ * ГРАНИЦА ОЖИДАНИЯ: не дольше `tries` секунд и только при заведении агента.
329
+ * Не дождались говорим ГРОМКО, называем последствие и идём дальше. Канал не
330
+ * запирается там, где сервиса в сборке нет вовсе: это законная сборка.
292
331
  */
293
332
  async function waitService(name, tag, consequence, tries = 30) {
294
333
  for (let i = 0; i < tries; i++) {
@@ -305,36 +344,38 @@ export function apply(ctx, config = {}) {
305
344
  }
306
345
 
307
346
  /**
308
- * 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ. Проверять надо КАЖДЫЙ, а не
309
- * тот, на котором обожглись, иначе рецидив, и рецидив будет выглядеть новой
310
- * болезнью, потому что рядом лежит доказательство «мы это уже лечили».
347
+ * 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ (23.08.2026, уточнено при
348
+ * сведении с копией соседа). Проверять надо КАЖДЫЙ, а не тот, на котором
349
+ * обожглись, иначе рецидив.
311
350
  *
312
- * ПРИЗНАК, по которому решается, а не «кажется безопасным»: ожидание нужно
313
- * там, где место может сработать ДО подъёма сервиса И отказ при этом
314
- * МОЛЧАЛИВ ИЛИ ЛОЖЕН. Одного условия мало.
351
+ * ПРИЗНАК: ожидание нужно там, где место может сработать ДО подъёма сервиса
352
+ * И отказ при этом МОЛЧАЛИВ ИЛИ ЛОЖЕН. Второе добавлено сознательно: раньше
353
+ * здесь было записано «отказ громкий — значит ждать не надо», и это было
354
+ * ошибкой. Громкий отказ «сервис не подключён» — это ДИАГНОЗ, которого код
355
+ * поставить не может: undefined одинаково означает и «нет в сборке», и «ещё
356
+ * поднимается». Механизм не вправе утверждать то, чего не в силах установить.
315
357
  *
316
- * Отдельно замечено на живом: порядок готовности волокон МЕНЯЕТСЯ от подъёма
317
- * к подъёму. В одном подъёме ждала фабрика агентов, в другом набор пресета,
318
- * причём гонка была в обоих. Значит судить о гонке по соседнему сервису
319
- * нельзя: соседний в этот раз мог успеть. Ожидание ставится по устройству
320
- * места, а не по тому, где однажды видели примету.
358
+ * И отдельно, доказано на живом 23.08: порядок готовности волокон МЕНЯЕТСЯ
359
+ * от подъёма к подъёму в одном ждала фабрика агентов, в другом набор
360
+ * пресета, а гонка была в обоих. Значит судить о гонке по соседнему сервису
361
+ * нельзя, ожидание ставится по устройству места, а не по примете.
321
362
  *
322
- * 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence.
323
- * ОЖИДАНИЕ ЕСТЬ, предел 30 с: заведение агента идёт вплотную к подъёму
324
- * платформы, а отказ здесь молчалив и разрушителен.
325
- * 2) goalCommand → goals, goalMarker, mcpBridge. ОЖИДАНИЕ ЕСТЬ, предел
326
- * короткий (GOAL_WAIT_TRIES): по undefined невозможно отличить «сервиса
327
- * нет в сборке» от «ещё поднимается», а ответ «не подключён» — это
328
- * диагноз, которого код поставить не может. Ждём мало, потому что на том
329
- * конце живой собеседник.
330
- * 3) stripGoalMarker → goalMarker. ОЖИДАНИЯ НЕТ намеренно: место
331
- * СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив —
363
+ * 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence:
364
+ * ОЖИДАНИЕ ЕСТЬ, предел 30 с заведение агента идёт вплотную к подъёму
365
+ * платформы, отказ молчалив и разрушителен.
366
+ * 2) goalCommand → goals, goalMarker, mcpBridge: ОЖИДАНИЕ ЕСТЬ, предел
367
+ * короткий (GOAL_WAIT_TRIES) на том конце живой собеседник.
368
+ * 3) stripGoalMarker goalMarker: ОЖИДАНИЯ НЕТ намеренно. Место
369
+ * СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив:
332
370
  * маркер уезжает в чат видимым текстом.
333
- * 4) обработчик goal/change → goals. ОЖИДАНИЯ НЕТ намеренно: сюда попадают
334
- * только те сессии, где цель уже поставлена через goalCommand, а тот без
335
- * сервиса целей не проходит вовсе. Значит пустота здесь означает не «ещё
336
- * не поднялся», а «сервис ПРОПАЛ», и это надо не переждать, а сказать.
371
+ * 4) обработчик goal/change → goals: ОЖИДАНИЯ НЕТ намеренно. Сюда попадают
372
+ * только сессии, где цель уже поставлена через goalCommand, а тот без
373
+ * сервиса целей не проходит. Значит пустота означает не «ещё не
374
+ * поднялся», а «сервис ПРОПАЛ», и это надо сказать, а не переждать.
337
375
  */
376
+ // Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
377
+ // Пять, а не тридцать: на том конце живой собеседник, см. goalCommand.
378
+ const GOAL_WAIT_TRIES = 5;
338
379
 
339
380
  async function agentFor(chatId) {
340
381
  const key = String(chatId);
@@ -359,8 +400,6 @@ export function apply(ctx, config = {}) {
359
400
  const setupFn = async (agentCtx) => {
360
401
  platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
361
402
  if (presets && presetId) await presets.mount(agentCtx, presetId);
362
- // Молча остаться без инструментов нельзя: снаружи это выглядит как
363
- // «агент отупел», а причина — несмонтированный пресет.
364
403
  else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
365
404
  };
366
405
 
@@ -376,6 +415,17 @@ export function apply(ctx, config = {}) {
376
415
  // события turn/end (раньше плагин печатал только тип события).
377
416
  // Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
378
417
  // продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
418
+ // 🔴 СЕРВИС ПЕРСИСТЕНЦИИ ПОЯВЛЯЕТСЯ ПОЗЖЕ КАНАЛА — та же болезнь, что у фабрики
419
+ // агентов выше, и тот же почерк: ctx.get() отдаёт undefined ТИХО, пока волокно
420
+ // поставщика не активно (cordis, ReflectService._getImpl:
421
+ // `if (strict && impl.fiber.state !== 2) return;`),
422
+ // исключения при этом нет. Без ожидания весь блок продолжения молча
423
+ // пропускался, агент создавался заново поверх существующего журнала, и КАЖДЫЙ
424
+ // ход умирал с «id collision». Поймано 22.08.2026: подъём 18:53 не напечатал
425
+ // ни строки о продолжении, ни об отказе — молчание и было ответом.
426
+ // ГРАНИЦА ОЖИДАНИЯ: ждём не дольше 30 с и только здесь, при заведении агента.
427
+ // Не дождались — говорим об этом громко и идём создавать, иначе канал запрётся
428
+ // навсегда там, где персистенции в сборке просто нет.
379
429
  const persistence = await waitService('sessionPersistence', `[${key}]`,
380
430
  'сохранённую сессию продолжить нечем, история будет потеряна');
381
431
  let handle;
@@ -384,15 +434,16 @@ export function apply(ctx, config = {}) {
384
434
  try {
385
435
  known = (await persistence.list()).map((h) => h.id);
386
436
  } catch (e) {
387
- log(`🔴 [${key}] не смог перечислить сохранённые сессии: ${e?.message ?? e}`);
437
+ log(`🔴 [${key}] не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
388
438
  }
389
- // 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ ЭТО ВТОРОЙ ТИХИЙ ПУТЬ В ЭТОЙ ЖЕ
390
- // ФУНКЦИИ. Снаружи он неотличим от честного первого запуска, а на деле
391
- // означает create() поверх существующего журнала и «id collision» на
392
- // каждом ходе. Поэтому список сообщаем ЧИСЛОМ и именами.
439
+ // 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ. Снаружи это неотличимо от штатного
440
+ // первого запуска, а на деле означает потерю истории и id collision на
441
+ // каждом ходе. Поэтому отсутствие своей сессии в списке — громкая строка
442
+ // с числом найденных и их именами, а не тишина.
393
443
  if (known !== undefined && !known.includes(sessionId)) {
394
- // Пустой список — честный первый запуск, тревожить нечем. Тревога там,
395
- // где сохранённые сессии ЕСТЬ, а нашей среди них нет.
444
+ // Пустой список — это честный первый запуск, а не беда: тревожить нечем.
445
+ // Тревога начинается там, где сохранённые сессии ЕСТЬ, а нашей среди них
446
+ // нет: значит журнал на диске остался, и create() сейчас об него разобьётся.
396
447
  if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
397
448
  else {
398
449
  // ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
@@ -476,36 +527,33 @@ export function apply(ctx, config = {}) {
476
527
  }
477
528
  }
478
529
 
479
- // ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, задача владельца).
530
+ // ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, распоряжение главной).
480
531
  //
481
- // ЗАЧЕМ. Когда цикл ведёт внешний движок (агент работает через подписку, а не
482
- // через родной цикл платформы), инструменты платформы до модели НЕ ДОХОДЯТ
483
- // вовсе — доходит только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель
484
- // изнутри разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
532
+ // ЗАЧЕМ. На подписочном маршруте цикл ведёт движок, поэтому родные
533
+ // инструменты платформы (goal/jobs) до модели НЕ ДОХОДЯТ вовсе — доходит
534
+ // только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель изнутри
535
+ // разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
485
536
  // постановку из любого канала: сервис целей доступен КОДУ модуля как
486
537
  // ctx.get('goals'), инструмент модели для этого не нужен.
487
538
  //
488
539
  // КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
489
- // goalUsers: [<числовые id>] — кто ставит цель из Telegram
490
- // goalA2ASenders: ["<имя>", …] кто вправе из служебного канала
540
+ // goalUsers: [<числовые id>] — кто ставит цель из Telegram
541
+ // goalFromA2A: true|false — вправе ли служебный канал
491
542
  // Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
492
543
  // «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
493
544
  // дорогой автономный цикл шире права поговорить.
494
- //
495
- // 🔴 ЧЕМ ЗА ЭТО ПЛАТИМ, назвать обязательно: проверки платформы
496
- // (currentInitiator, hasDirectHumanInput, roots) живут в её инструменте целей
497
- // и на ЭТОМ пути не участвуют. Мы не обходим политику — мы пишем её заново,
498
- // и две строки ниже есть вся политика целиком. Пустые списки = постановка
499
- // отключена; это умолчание выбрано нарочно.
545
+ // Проверки платформы (currentInitiator, hasDirectHumanInput, roots) живут в
546
+ // tool-goal и на этом пути НЕ участвуют: мы не обходим политику, мы пишем её
547
+ // заново, и вот эти две строки вся политика целиком.
500
548
  const goalUsers = new Set((config.goalUsers ?? []).map(Number));
501
- // 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ». Признак
502
- // источника получает ЛЮБОЕ сообщение служебного канала, а каталог обмена
503
- // бывает общим на несколько машин: правом «всем, кто пишет в канал» мы
504
- // раздали бы возможность заводить дорогие циклы кому попало.
505
- // Отправитель называет себя первой строкой файла: From: <имя>
549
+ // 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ» (21.08.2026,
550
+ // поправка главной к собственному замыслу). Вид источника 'a2a' получает ЛЮБОЕ
551
+ // сообщение служебного канала, а канал на ферме общий правом «всем, кто пишет
552
+ // в a2a» мы раздали бы возможность заводить дорогие циклы кому попало.
553
+ // Отправитель называет себя первой строкой файла: From: <инстанс>/<папка>
506
554
  // Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
507
555
  const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
508
- // sessionId → { channel, agent } — куда сообщить об исходе цели.
556
+ // sessionId → { channel, chatId } — куда сообщить об исходе цели.
509
557
  const goalOrigin = new Map();
510
558
  // канал → метки времени постановок, для счётчика ниже.
511
559
  const goalStarts = new Map();
@@ -513,26 +561,22 @@ export function apply(ctx, config = {}) {
513
561
  // 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
514
562
  //
515
563
  // ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
516
- // вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов у платформы
517
- // стоит на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей
518
- // ничем не ограничен. Каталог входящих принадлежит самому агенту, то есть в
519
- // автономном раунде он способен положить себе файл «/goal …» и продлить цикл.
564
+ // вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов стоит на
565
+ // ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не
566
+ // ограничен. Каталог входящих принадлежит самому агенту, то есть в автономном
567
+ // раунде он способен положить себе файл «/goal …» и продлить цикл.
520
568
  //
521
- // 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: счётчик НЕ защищает от намеренного действия агента,
522
- // у которого есть права администратора на этой машине, такой агент снимет
523
- // и его, и предел раундов одной командой. Технической границы против самого
524
- // агента здесь не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож
525
- // снаружи, на другой машине, вне досягаемости этого процесса.
569
+ // 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: он НЕ защищает от намеренного действия агента,
570
+ // у которого на этой машине полное sudo без пароля (проверено 21.08.2026:
571
+ // <агент> ALL NOPASSWD: ALL). Такой агент снимет и этот счётчик, и предел
572
+ // раундов одной командой. Технической границы против самого агента здесь
573
+ // не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож снаружи, на
574
+ // другой машине, вне досягаемости этого процесса.
526
575
  //
527
576
  // ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
528
577
  // механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
529
578
  // обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
530
579
  const GOAL_MAX_PER_HOUR = 3;
531
-
532
- // Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
533
- // Пять, а не тридцать: см. пояснение в goalCommand ниже.
534
- const GOAL_WAIT_TRIES = 5;
535
-
536
580
  function goalRateOk(channel) {
537
581
  const now = Date.now();
538
582
  const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
@@ -540,12 +584,12 @@ export function apply(ctx, config = {}) {
540
584
  return marks.length < GOAL_MAX_PER_HOUR;
541
585
  }
542
586
 
543
- // 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ. Права на постановку цели заголовком
544
- // запроса к модели НЕ проверяются в принципе: плагин не даёт модели ни одного
545
- // инструмента и в заголовке невидим. Печатаем ЧИСЛА, а не «загрузился»: длина
546
- // списка прав отличает верную настройку от пустого умолчания, при котором
547
- // постановка отказывает молча и выглядит поломкой. Сами идентификаторы не
548
- // печатаем — в журнал уходит устройство, а не список людей.
587
+ // 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ ПЛАГИНА (22.08.2026, распоряжение главной).
588
+ // Права на постановку цели заголовком request/header НЕ проверяются в принципе:
589
+ // плагин не даёт модели ни одного инструмента и в заголовке невидим. Печатаем
590
+ // ЧИСЛА, а не «загрузился»: длина списка прав отличает верную настройку от
591
+ // пустого умолчания, при котором постановка отказывает молча и выглядит поломкой.
592
+ // Сами идентификаторы не печатаем — в журнал уходит устройство, а не список людей.
549
593
  log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
550
594
  + `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
551
595
 
@@ -566,11 +610,25 @@ export function apply(ctx, config = {}) {
566
610
  *
567
611
  * 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
568
612
  * Отправитель называет себя сам, и подделать имя может любой, кто способен
569
- * положить файл в каталог входящих. Настоящая граница здесь — права ОС на
570
- * этот каталог: писать в него должны только сам агент и доставка. Нужна
571
- * проверка сильнее самоназвания это общий секрет в файле с правами, и
572
- * заводить его нужно отдельным решением, а не походя.
613
+ * положить файл в каталог входящих. Настоящая граница здесь — права ОС:
614
+ * дом агента открыт на проход только владельцу (drwxr-x---), поэтому писать
615
+ * во входящие могут лишь сам агент и тот, у кого есть sudo. Проверено
616
+ * опытом 21.08.2026: dsh и arg получают Permission denied и на чтение, и на
617
+ * запись. Владелец файла отправителя НЕ выдаёт вовсе — доставка кладёт файл
618
+ * от root и тут же делает chown на адресата.
619
+ * Нужна проверка сильнее самоназвания — это общий секрет в файле с правами,
620
+ * и заводить его нужно решением, а не походя.
573
621
  */
622
+ /**
623
+ * Убрать маркер цели из текста, уходящего наружу. Если плагин разбора не
624
+ * смонтирован — текст идёт как есть: маркер в чате некрасив, но безвреден,
625
+ * а молча глотать ответ из-за отсутствия служебного плагина нельзя.
626
+ */
627
+ function stripGoalMarker(raw) {
628
+ const marker = ctx.get('goalMarker');
629
+ return marker ? marker.strip(raw) : raw;
630
+ }
631
+
574
632
  function splitSender(raw) {
575
633
  const nl = raw.indexOf('\n');
576
634
  const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
@@ -579,31 +637,18 @@ export function apply(ctx, config = {}) {
579
637
  return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
580
638
  }
581
639
 
582
- /**
583
- * Убрать маркер цели из текста, уходящего наружу.
584
- *
585
- * 🔴 ЗДЕСЬ ОЖИДАНИЯ СЕРВИСА НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК. Место
586
- * СИНХРОННОЕ (зовётся из обработчика session/event, который не async), и
587
- * отказ здесь НЕ молчалив: маркер уезжает в чат видимым текстом, читатель
588
- * увидит его сам. Ждать по 30 с на каждом исходящем сообщении было бы
589
- * лечением хуже болезни — см. общее решение по ctx.get() выше.
590
- */
591
- function stripGoalMarker(raw) {
592
- const marker = ctx.get('goalMarker');
593
- return marker ? marker.strip(raw) : raw;
594
- }
595
-
596
640
  // Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
597
641
  // Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
598
642
  async function goalCommand(text, channel, agent) {
599
643
  const rest = text.slice('/goal'.length).trim();
600
- // 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ и ждём КОРОТКО. Без ожидания
601
- // ответ «сервис не подключён» был бы диагнозом, которого код поставить не
602
- // может: undefined одинаково означает и «нет в сборке», и «ещё поднимается».
603
- // Предел короткий не 30 с, как при заведении агента) потому, что на том
604
- // конце ждёт живой собеседник: полминуты молчания в чате читаются как
605
- // «бот умер», и лечение снова оказалось бы хуже болезни.
606
- const goals = await waitService('goals', '[goal]', 'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
644
+ // 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ (23.08.2026, сведение с копией
645
+ // соседа решение его, форма наша). Без ожидания ответ «не подключён» был
646
+ // бы диагнозом, которого код поставить не может: undefined одинаково
647
+ // означает и «нет в сборке», и «ещё поднимается». Предел короткий, а не
648
+ // 30 с как при заведении агента: на том конце ждёт живой собеседник, и
649
+ // полминуты молчания в чате читаются как «бот умер».
650
+ const goals = await waitService('goals', '[goal]',
651
+ 'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
607
652
  if (!goals) return 'Сервис целей на этом агенте не подключён.';
608
653
  let current;
609
654
  try { current = goals.get(agent); }
@@ -631,8 +676,8 @@ export function apply(ctx, config = {}) {
631
676
  }
632
677
 
633
678
  // Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
634
- // платформы и не должен подниматься тем же путём, каким запускается
635
- // дорогой цикл.
679
+ // (defaultMaxGoalRounds) и не должен подниматься тем же путём, каким
680
+ // запускается дорогой цикл.
636
681
  if (!goalRateOk(channel)) {
637
682
  log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
638
683
  return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
@@ -640,19 +685,19 @@ export function apply(ctx, config = {}) {
640
685
  try {
641
686
  let view = goals.create(agent, { objective: rest });
642
687
  // 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
643
- // раунда несёт только замысел и номер раунда — ни идентификатора цели, ни
644
- // ревизии в нём нет, а без них модель не сложит маркер. Идентификатор
645
- // известен только после create, поэтому edit вторым шагом; edit поднимает
646
- // ревизию на единицу, её и называем в наставлении.
688
+ // раунда (renderGoalRoundPrompt) несёт только Objective и Round — ни
689
+ // идентификатора цели, ни ревизии в нём нет, а без них модель не сложит
690
+ // маркер. Идентификатор известен только после create, поэтому edit вторым
691
+ // шагом; edit поднимает ревизию на единицу, её и называем в наставлении.
647
692
  const marker = await waitService('goalMarker', '[goal]',
648
693
  'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
649
694
  if (marker) {
650
- // 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ. Протокол
651
- // MCP добавляет к базовому имени приставку mcp__<сервер>__; наставление,
652
- // называющее инструмент БЕЗ неё, отсылает модель к несуществующему
653
- // имени, и выглядит это как «мост не работает». Нет моста — нет и имён:
654
- // тогда наставление честно говорит, что инструментов нет, и остаётся
655
- // только пометка.
695
+ // 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ (22.08.2026).
696
+ // Протокол MCP добавляет к базовому имени приставку mcp__<сервер>__;
697
+ // наставление, называющее инструмент БЕЗ неё, отсылает модель к
698
+ // несуществующему имени, и выглядит это как «мост не работает».
699
+ // Нет моста — нет и имён: тогда наставление честно говорит, что
700
+ // инструментов нет, и остаётся только пометка.
656
701
  const bridge = await waitService('mcpBridge', '[goal]',
657
702
  'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
658
703
  const goalTools = bridge?.modelToolName
@@ -681,19 +726,20 @@ export function apply(ctx, config = {}) {
681
726
  // 🔴 DEBUG 2026-08-18: все типы событий
682
727
  log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
683
728
  const sid = String(session?.id ?? '');
684
- // 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел раундов переводит
685
- // цель в blocked — и делает это молча: снаружи автономный цикл просто
686
- // перестаёт просыпаться. Молчаливый останов неотличим от поломки, поэтому
687
- // говорим в тот канал, из которого цель ставили, и ровно один раз.
688
- // Блок стоит ДО проверки chatId нарочно: у служебного канала числового чата
689
- // нет вовсе, и ранний выход съел бы событие.
729
+ // 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел переводит цель в
730
+ // blocked — и делает это молча: снаружи автономный цикл просто перестаёт
731
+ // просыпаться. Молчаливый останов неотличим от поломки, поэтому говорим в
732
+ // тот канал, из которого цель ставили, и ровно один раз.
690
733
  if (event.type === 'goal/change') {
691
734
  const origin = goalOrigin.get(sid);
692
735
  if (origin) {
693
736
  try {
694
- // Ожидания сервиса здесь нет намеренно — см. решение по ctx.get выше:
695
- // сюда попадают только сессии, где цель уже поставлена, значит пустота
696
- // означает не «ещё не поднялся», а «пропал», и это стоит строки.
737
+ // 🔴 БЫЛО МОЛЧАЛИВО: `ctx.get('goals')?.get(...)` при отсутствии
738
+ // сервиса возвращал undefined, и сообщение об исходе цели просто не
739
+ // уходило ровно то молчание, от которого этот блок и заведён.
740
+ // Сервис здесь обязан быть: goalOrigin заполняет только goalCommand,
741
+ // а он без ctx.get('goals') не проходит. Значит пустота означает, что
742
+ // сервис ПРОПАЛ, и это событие само по себе стоит строки.
697
743
  const goalsSvc = ctx.get('goals');
698
744
  if (!goalsSvc) {
699
745
  log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
@@ -742,7 +788,7 @@ export function apply(ctx, config = {}) {
742
788
  }
743
789
  } else if (event.type === 'assistant/message'
744
790
  && (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
745
- // ответ координатору — в файл, Telegram тут ни при чём
791
+ // ответ для Забавы — в файл, Telegram тут ни при чём
746
792
  const blocks = event.data?.message?.content ?? [];
747
793
  // 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
748
794
  // текста и из копий. Он служебный: его дело остановить цикл, а не
@@ -755,7 +801,7 @@ export function apply(ctx, config = {}) {
755
801
  fs.mkdirSync(A2A_OUT, { recursive: true });
756
802
  fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
757
803
  { const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
758
- log(`[a2a] ответ координатору записан (${text.length} знаков)`);
804
+ log(`[a2a] ответ Забаве записан (${text.length} знаков)`);
759
805
  } catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
760
806
  }
761
807
  } else if (event.type === 'assistant/message') {
@@ -793,7 +839,7 @@ export function apply(ctx, config = {}) {
793
839
  return tmp;
794
840
  }
795
841
 
796
- // Расшифровывает голосовое сообщение внешней командой из настройки.
842
+ // Транскрибирует голосовое сообщение через нашу локальную цепочку.
797
843
  // Возвращает строку с текстом или null при ошибке.
798
844
  async function transcribeVoice(fileId) {
799
845
  let tmpAudio = null;
@@ -901,10 +947,16 @@ export function apply(ctx, config = {}) {
901
947
  const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
902
948
  const { handle, sessionId } = await agentFor(routeKey);
903
949
  tgChatFor.set(sessionId, chatId);
904
- // 🔴 ТОЛЬКО send(), НЕ followup(). Проверено на установленной версии 0.1.0-rc.7:
905
- // followup объявлен в описании типов, но В КОДЕ ЕГО НЕТ — вызов молча не делает
906
- // ничего, и снаружи это выглядит как «бот принял сообщение и замолчал».
907
- // Правильный вызов подсмотрен в самом продукте: this.send(input, "next-turn", true).
950
+ // 🔴 ЗДЕСЬ send(), А НЕ followup() но причина СМЕНИЛАСЬ, читай внимательно.
951
+ // Было (0.1.0-rc.7): followup объявлен в описании типов, а В КОДЕ ЕГО НЕТ —
952
+ // вызов молча не делал ничего, снаружи «бот принял сообщение и замолчал».
953
+ // Стало (проверено на установленной 0.1.1-rc.2, 21.08.2026): followup в коде
954
+ // ЕСТЬ и он ровно наш вызов — dsh-agent-loop/lib/index.js:396
955
+ // followup(input) { this.send(input, "next-turn", true) }
956
+ // То есть обход больше не обход, а тот же самый путь, записанный явно.
957
+ // Оставляем send(): он работает на обеих версиях. Копии модуля разведены
958
+ // 22.08.2026 по агентам, но версии платформ у них разные (0.1.1-rc.2 и
959
+ // 0.1.0-rc.8), и код обязан работать на обеих.
908
960
  // next-turn — обычный следующий ход;
909
961
  // true — разбудить исполнителя, иначе сообщение будет ждать вечно.
910
962
  lastOrigin.set(sessionId, 'tg');
@@ -941,8 +993,8 @@ export function apply(ctx, config = {}) {
941
993
  // пользователя, а бот боту не пишет — так устроен мессенджер. Поэтому связь
942
994
  // через каталог обмена, он же работает поверх ssh с любой машины фермы.
943
995
  //
944
- // входящие мне: <a2aDir>/in/*.txt — кладёт координатор, я передаю агенту
945
- // исходящие ей: <a2aDir>/out/*.txt — пишет агент, забирает координатор
996
+ // входящие мне: <дом агента>/a2a/in/*.txt — кладёт координатор, я передаю агенту
997
+ // исходящие ей: <дом агента>/a2a/out/*.txt — пишет агент, забирает координатор
946
998
  // Каталог обмена задаётся настройкой: у каждого агента он свой. Не задан —
947
999
  // межагентского канала у этого агента просто нет, и опрос не ведётся вовсе
948
1000
  // (молчаливое создание чужих каталогов было бы хуже отсутствия связи).
@@ -951,6 +1003,54 @@ export function apply(ctx, config = {}) {
951
1003
  const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
952
1004
  const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
953
1005
 
1006
+ // ── ВТОРОЙ ИСТОЧНИК ВХОДЯЩИХ: ЯЩИК ШИНЫ A2A (31.08.2026) ────────────────────
1007
+ // Шина между агентами машины: отправитель кладёт письмо в щель
1008
+ // /var/spool/a2a/<получатель>/ (каталог 1730: опустить можно, прочесть нельзя),
1009
+ // почтальон a2a-pochtalon раз в 5 с переносит его в собственность получателя,
1010
+ // а забрать из ящика и подать агенту должен ЭТОТ модуль.
1011
+ //
1012
+ // 🔴 ЗАЧЕМ ЗАВЕДЕНО: без этого блока выход шины не подключён никуда. Замер
1013
+ // 31.08.2026: три письма соседа пролежали в ящике ДВОЕ СУТОК и дошли рукой
1014
+ // координатора, а не шиной. Снаружи это выглядело как «агент не отвечает».
1015
+ //
1016
+ // Путь — НАСТРОЙКА, не константа: у каждого агента ящик свой, и требование
1017
+ // владельца от 31.08.2026 — всё управляемое вынести из кода. Не задан — звена
1018
+ // нет вовсе, опрос не ведётся (молчаливое чтение чужого ящика было бы хуже).
1019
+ const SPOOL_IN = config.spoolDir || null;
1020
+ // Порог пачки: до него письма подаются по одному (ход на письмо сохраняет
1021
+ // структуру разговора), сверх него — ОДНИМ ходом, чтобы накопленное не
1022
+ // превратилось в лавину подряд идущих ходов. Правило координатора 31.08.2026.
1023
+ const SPOOL_BATCH = Number(config.spoolBatchCount ?? 5);
1024
+ // Старше этого — подаётся с ЯВНОЙ пометкой возраста в тексте. Письмо
1025
+ // двухсуточной давности, поданное как свежее, толкает отвечать на устаревшее.
1026
+ const SPOOL_STALE_HOURS = Number(config.spoolStaleHours ?? 12);
1027
+
1028
+ /**
1029
+ * Отправитель — из ИМЕНИ файла: <метка>-<отправитель>-<случайное>.txt.
1030
+ * Так решил почтальон: содержимое письма — сырой текст без заголовков, и
1031
+ * подделать имя отправитель не может (имя формирует a2a-send, а каталог
1032
+ * закрыт на перечисление). Не разобралось — говорим прямо, а не молчим.
1033
+ */
1034
+ function spoolSender(name) {
1035
+ const m = /^\d{8}T\d{6}Z-([^-]+)-/.exec(name);
1036
+ return m ? m[1] : '(имя не разобрано)';
1037
+ }
1038
+
1039
+ /**
1040
+ * Возраст письма в часах. Источник — метка в ИМЕНИ (её ставит отправитель),
1041
+ * а не mtime: почтальон копирует файл, и при потере метки времени mtime
1042
+ * оказался бы временем переноса, то есть свежим у двухсуточного письма.
1043
+ * Метка не разобралась — берём mtime как нижнюю оценку и говорим об этом.
1044
+ */
1045
+ function spoolAgeHours(name, full) {
1046
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(name);
1047
+ if (m) {
1048
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
1049
+ return (Date.now() - t) / 3600000;
1050
+ }
1051
+ try { return (Date.now() - fs.statSync(full).mtimeMs) / 3600000; } catch { return 0; }
1052
+ }
1053
+
954
1054
  // Берём простые текстовые форматы. Всё остальное НЕ берём — но и не
955
1055
  // проглатываем: имя отвергнутого файла называем в журнале. Поймано
956
1056
  // 21.08.2026: письмо с расширением .md пролежало во входящих 26 минут,
@@ -998,7 +1098,7 @@ export function apply(ctx, config = {}) {
998
1098
  // который нам не по правам (например 600 root:root), молча оставался
999
1099
  // лежать во входящих. Снаружи это «агент не отвечает», а на деле
1000
1100
  // мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
1001
- log(`[a2a] 🔴 не смог прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
1101
+ log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
1002
1102
  continue;
1003
1103
  }
1004
1104
  if (!text) continue;
@@ -1010,7 +1110,7 @@ export function apply(ctx, config = {}) {
1010
1110
  if (!goalA2ASenders.has(sender)) {
1011
1111
  log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
1012
1112
  await sayTo('a2a', null, sender === undefined
1013
- ? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <имя>".'
1113
+ ? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <инстанс>/<папка>".'
1014
1114
  : `Отправитель ${sender} не вправе ставить цель.`);
1015
1115
  } else {
1016
1116
  try {
@@ -1038,7 +1138,25 @@ export function apply(ctx, config = {}) {
1038
1138
  text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
1039
1139
  handle.agent.send(platform.createUserMessage({
1040
1140
  content: [{ type: 'text', text }],
1041
- source: { kind: 'user' },
1141
+ // 🔴 СВОЙ ВИД ИСТОЧНИКА ДЛЯ СЛУЖЕБНОГО КАНАЛА (21.08.2026, распоряжение
1142
+ // главной). Раньше тут стоял kind: 'user' — и это была НЕПРАВДА в слое
1143
+ // прав: платформа считала сообщение координатора человеческим, то есть
1144
+ // право ставить цель добывалось подлогом метки, а не записанным решением.
1145
+ // Теперь журнал честен: видно, что писал агент-координатор, а не человек.
1146
+ // ЧТО ЭТИМ ТЕРЯЕМ — обход ВСЕХ потребителей kind === 'user' в дереве
1147
+ // платформы (31 совпадение, разобраны все):
1148
+ // tool-goal hasDirectHumanInput -> false: из этого канала нельзя
1149
+ // закрыть цель родным инструментом (он у нас погашен);
1150
+ // tool-jobs бюджет подряд идущих пробуждений больше не обнуляется;
1151
+ // tool-skill жест /имя отсюда не подтянет тело скилла;
1152
+ // session-title заголовок сессии по такому сообщению не строится;
1153
+ // session-reference сообщение вне проекции «текущей поверхности».
1154
+ // Право ставить цель координатору вернём ЯВНЫМ списком видов в мосту —
1155
+ // решением, которое видно тому, кто откроет список.
1156
+ // Вид произвольный по устройству: createMessage кладёт source как есть,
1157
+ // схемы нет; 'goal' и 'skill-catalog' — такие же добавленные виды.
1158
+ // ⚠️ Личный чат владельца остаётся 'user' (строка 560) — он и есть человек.
1159
+ source: { kind: 'a2a' },
1042
1160
  }), 'next-turn', true);
1043
1161
  nudgeUntilClaimed(handle.agent, '[a2a]');
1044
1162
  // 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ (19.08.2026). Раньше файл
@@ -1052,10 +1170,115 @@ export function apply(ctx, config = {}) {
1052
1170
  }
1053
1171
  }
1054
1172
 
1173
+ /**
1174
+ * Забрать письма из ящика шины и подать агенту.
1175
+ *
1176
+ * 🔴 ДВА МЕСТА ОДНОЙ ДОСТАВКИ. Это короткий повтор того, что делает pollA2A;
1177
+ * общий кусок не выносила намеренно — в pollA2A он переплетён с разбором
1178
+ * /goal, и хелпер вышел бы дороже двух коротких кусков. Но перечень-копия
1179
+ * молча расходится: правишь ОДНО — проверь ВТОРОЕ. За этим следит
1180
+ * stend-yashchika-shiny.sh (сверяет, что оба места делают одно).
1181
+ *
1182
+ * Команд (/goal) из ящика НЕ принимаем: право ставить цель поимённое и живёт
1183
+ * у координатора прямым каналом. Второй путь к тому же действию всегда
1184
+ * расходится с первым, и по журналу перестаёт быть видно, кто поставил цель.
1185
+ */
1186
+ async function pollSpool() {
1187
+ if (!SPOOL_IN) return;
1188
+ let all = [];
1189
+ try {
1190
+ all = fs.readdirSync(SPOOL_IN).sort();
1191
+ } catch (e) {
1192
+ // Одна строка на причину, не на прогон: опрос идёт каждые ~25 с.
1193
+ const key = `SPOOL:${e?.code ?? 'ERR'}`;
1194
+ if (!a2aReported.has(key)) {
1195
+ a2aReported.add(key);
1196
+ log(`[ящик] \u{1F534} недоступен (${SPOOL_IN}): ${e?.message ?? e}`);
1197
+ }
1198
+ return;
1199
+ }
1200
+ const files = all.filter((f) => A2A_EXT.some((x) => f.endsWith(x)));
1201
+ for (const f of all) {
1202
+ if (files.includes(f) || a2aReported.has(`SP:${f}`)) continue;
1203
+ a2aReported.add(`SP:${f}`);
1204
+ log(`[ящик] \u{1F534} НЕ ВЗЯТ файл ${f}: беру только ${A2A_EXT.join(', ')}. `
1205
+ + `Он так и будет лежать в ящике — переименуй отправителю`);
1206
+ }
1207
+ for (const name of [...a2aReported]) {
1208
+ if (name.startsWith('SP:') && !all.includes(name.slice(3))) a2aReported.delete(name);
1209
+ }
1210
+ if (!files.length) return; // пустой ящик — тишина, а не строка «пусто»
1211
+
1212
+ // Читаем всё, что взяли, и только потом решаем: по одному или пачкой.
1213
+ const pisma = [];
1214
+ for (const f of files) {
1215
+ const full = path.join(SPOOL_IN, f);
1216
+ let text = '';
1217
+ try {
1218
+ text = fs.readFileSync(full, 'utf-8').trim();
1219
+ } catch (e) {
1220
+ log(`[ящик] \u{1F534} не смогла прочитать ${f}: ${e?.message ?? e} `
1221
+ + `(письмо должно принадлежать владельцу агента; переносит почтальон)`);
1222
+ continue;
1223
+ }
1224
+ if (!text) { try { fs.unlinkSync(full); } catch { /* пустое — убрать */ } continue; }
1225
+ const age = spoolAgeHours(f, full);
1226
+ const stale = age >= SPOOL_STALE_HOURS
1227
+ ? `\n[письмо пролежало в ящике ${Math.round(age)} ч — возможно, оно устарело]`
1228
+ : '';
1229
+ pisma.push({ f, full, who: spoolSender(f), text: stripMark(text), stale });
1230
+ }
1231
+ if (!pisma.length) return;
1232
+
1233
+ const packoj = pisma.length > SPOOL_BATCH;
1234
+ const partii = packoj ? [pisma] : pisma.map((p) => [p]);
1235
+ for (const partiya of partii) {
1236
+ const who = partiya.length === 1 ? partiya[0].who : 'агентов машины';
1237
+ const body = partiya.length === 1
1238
+ ? `[служебный канал, от агента ${partiya[0].who}]${partiya[0].stale}\n${partiya[0].text}`
1239
+ : `[служебный канал, ${partiya.length} писем из ящика одной пачкой]\n\n`
1240
+ + partiya.map((p) => `── от агента ${p.who}${p.stale}\n${p.text}`).join('\n\n');
1241
+ try {
1242
+ const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
1243
+ lastOrigin.set(a2aSessionId, 'a2a');
1244
+ pushAsk(a2aSessionId, { origin: 'a2a', who, q: partiya[0].text });
1245
+ copyAsk('a2a', who, body);
1246
+ handle.agent.send(platform.createUserMessage({
1247
+ content: [{ type: 'text', text: body }],
1248
+ // Тот же вид источника, что у служебного канала: письмо агента — не
1249
+ // слово человека. Разбор последствий kind !== 'user' — в pollA2A выше.
1250
+ source: { kind: 'a2a' },
1251
+ }), 'next-turn', true);
1252
+ nudgeUntilClaimed(handle.agent, '[ящик]');
1253
+ // 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ, но удаляем ОБЯЗАТЕЛЬНО:
1254
+ // модуль опрашивает ящик каждые ~25 с, и неудалённое письмо подавалось
1255
+ // бы снова и снова. Не удалить — не «сохранить улику», а завести лавину.
1256
+ for (const p of partiya) { try { fs.unlinkSync(p.full); } catch { /* уже убрали */ } }
1257
+ log(`[ящик] принято ${partiya.length} письмо(писем) от ${who}, ${body.length} знаков`);
1258
+ } catch (e) {
1259
+ log(`[ящик] \u{1F534} не удалось передать агенту: ${e?.message ?? e} — `
1260
+ + `${partiya.map((p) => p.f).join(', ')} ОСТАВЛЕНЫ в ящике, попробую снова`);
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ // 🔴 fetch failed СКРЫВАЕТ причину (ECONNRESET/ETIMEDOUT/ENOTFOUND выглядят
1266
+ // одинаково). Печатаем e.cause рядом с e.message. ЦЕНА: не всякая ошибка несёт
1267
+ // cause — тогда пишем «не указана», а не пустое место (пустое читается как
1268
+ // «причины нет», и это та же слепота, что и раньше).
1269
+ // Перенесено из второй живой копии модуля 01.09.2026 ДОСЛОВНО: своя редакция
1270
+ // того же лечения развела бы копии по логике при сверке.
1271
+ function errDetail(e) {
1272
+ const c = e?.cause;
1273
+ if (!c) return 'не указана';
1274
+ return c?.message ?? c?.code ?? String(c);
1275
+ }
1276
+
1055
1277
  async function loop(run) {
1056
1278
  while (run.alive) {
1057
1279
  try {
1058
1280
  await pollA2A();
1281
+ await pollSpool();
1059
1282
  const updates = await tg.poll(25);
1060
1283
  for (const u of updates) {
1061
1284
  try { await handleUpdate(u); }
@@ -1063,7 +1286,7 @@ export function apply(ctx, config = {}) {
1063
1286
  }
1064
1287
  } catch (e) {
1065
1288
  // Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
1066
- log(`опрос не удался: ${e?.message ?? e}`);
1289
+ log(`опрос не удался: ${e?.message ?? e} | причина: ${errDetail(e)}`);
1067
1290
  await new Promise((r) => setTimeout(r, 3000));
1068
1291
  }
1069
1292
  }